diff --git a/.dockerignore b/.dockerignore index 9c9f3d2..07ec396 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,3 +1,5 @@ /data -src/node_modules +/scripts +backend/node_modules +frontend/node_modules docker-compose.*.yml diff --git a/.gitignore b/.gitignore index d19b595..85168ea 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,16 @@ /data +/scripts +/backend/ui +backend/http/public/favicon.ico +backend/http/public/favicon.svg +backend/http/public/_astro +backend/http/public/fonts node_modules packages -docker-compose.*.yml \ No newline at end of file +docker-compose.*.yml + +# Enterprise-only dashboard UI. Ignored so EE code is never committed to the +# public/community branches; the community build resolves these UI slots to +# empty via import.meta.glob in frontend/src/resources/ee.ts. The real +# components live only on the private EE branch (force-added there). +frontend/src/ee/ \ No newline at end of file diff --git a/Dockerfile b/Dockerfile index 4c3a3da..90561a8 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,6 +1,17 @@ -FROM node:25 -ADD src/package*.json /app/ +FROM node:25 AS builder +ADD frontend/package*.json /app/ WORKDIR /app RUN npm install -ADD src /app +ADD frontend /app +RUN npm run build + +FROM node:25 +ADD backend/package*.json /app/ +WORKDIR /app +RUN npm install +ADD backend /app +COPY --from=builder /app/dist/index.html /app/ui/ +COPY --from=builder /app/dist/favicon.* /app/http/public/ +COPY --from=builder /app/dist/_astro /app/http/public/_astro +COPY --from=builder /app/dist/fonts /app/http/public/fonts CMD ["npm", "start"] diff --git a/src/.prettierrc b/backend/.prettierrc similarity index 100% rename from src/.prettierrc rename to backend/.prettierrc diff --git a/src/apps.js b/backend/apps.js similarity index 81% rename from src/apps.js rename to backend/apps.js index 31e52a6..01abcdb 100644 --- a/src/apps.js +++ b/backend/apps.js @@ -6,6 +6,8 @@ export async function loadApps() { const config = getConfig(); const client = await getPool(); for (const type in config.appModules) { + // Internal modules (e.g. dashboard) have no `app` row / API surface. + if (config.appModules[type]?.internal) continue; await client.query(` INSERT INTO public.app ( type diff --git a/src/common/config.js b/backend/common/config.js similarity index 52% rename from src/common/config.js rename to backend/common/config.js index 5b1bb93..29da4df 100644 --- a/src/common/config.js +++ b/backend/common/config.js @@ -1,5 +1,8 @@ +const DEFAULT_KEY_CACHE = { ttlMs: 30 * 60 * 1000, max: 10_000 }; + const config = { debug: false, + dashboard: { core: {}, audit: {}, keyCache: { ...DEFAULT_KEY_CACHE } }, }; export async function loadConfig() { @@ -35,6 +38,30 @@ export async function loadConfig() { if (process.env.PDP_TYPE) config.pdpType = process.env.PDP_TYPE; if (process.env.PDP_URL) config.pdpUrl = process.env.PDP_URL; if (process.env.INSTANT_QUEUE) config.instantQueue = process.env.INSTANT_QUEUE; + + // Remote agreement-markdown caching: exponential backoff for failed fetches + // so an invalid URL isn't retried on every referencing create. Defaults: + // 1 minute → 1 day, factor 2, give up after 10 attempts. All overridable. + config.agreementCache = { + startingDelayMs: Number(process.env.AGREEMENT_CACHE_STARTING_DELAY_MS) || 60_000, + maxDelayMs: Number(process.env.AGREEMENT_CACHE_MAX_DELAY_MS) || 86_400_000, + maxRetries: Number(process.env.AGREEMENT_CACHE_MAX_RETRIES) || 10, + factor: Number(process.env.AGREEMENT_CACHE_BACKOFF_FACTOR) || 2, + }; + + // Optional federation: source dashboard core/audit data from remote servers + // (via their /api/v1/data/dashboard/* API + an API key) instead of locally. + // Unset => serve from the local DB (the single-operator default). + const keyCacheTtl = Number(process.env.DASHBOARD_KEY_CACHE_TTL_MS); + const keyCacheMax = Number(process.env.DASHBOARD_KEY_CACHE_MAX); + config.dashboard = { + core: { url: process.env.DASHBOARD_CORE_URL || null, key: process.env.DASHBOARD_CORE_KEY || null }, + audit: { url: process.env.DASHBOARD_AUDIT_URL || null, key: process.env.DASHBOARD_AUDIT_KEY || null }, + keyCache: { + ttlMs: Number.isFinite(keyCacheTtl) ? keyCacheTtl : DEFAULT_KEY_CACHE.ttlMs, + max: Number.isFinite(keyCacheMax) && keyCacheMax > 0 ? keyCacheMax : DEFAULT_KEY_CACHE.max, + }, + }; } export function getConfig() { diff --git a/backend/common/http.js b/backend/common/http.js new file mode 100644 index 0000000..9fa6051 --- /dev/null +++ b/backend/common/http.js @@ -0,0 +1,14 @@ +export const fail = (res, status, code, message) => + res.status(status).json({ error: { code, message } }); + +// Requires a logged-in user (401) and turns a throw into a 500 envelope, so a +// data-layer failure never masquerades as empty-but-200 data. +export const sessionRoute = (label, fn) => async (req, res) => { + if (!req.user) return fail(res, 401, "unauthorized", "Not logged in"); + try { + await fn(req, res); + } catch (e) { + console.error(`${label} ${req.method} ${req.path}:`, e); + fail(res, 500, "internal", "Internal error"); + } +}; diff --git a/src/common/queue.js b/backend/common/queue.js similarity index 100% rename from src/common/queue.js rename to backend/common/queue.js diff --git a/src/common/sleep.js b/backend/common/sleep.js similarity index 100% rename from src/common/sleep.js rename to backend/common/sleep.js diff --git a/src/db/agreements/00000000-0000-0000-0000-000000000000 - JLINC General Audit Agreement.json b/backend/db/agreements/00000000-0000-0000-0000-000000000000 - JLINC General Audit Agreement.json similarity index 100% rename from src/db/agreements/00000000-0000-0000-0000-000000000000 - JLINC General Audit Agreement.json rename to backend/db/agreements/00000000-0000-0000-0000-000000000000 - JLINC General Audit Agreement.json diff --git a/src/db/agreements/00000000-0000-0000-0000-000000000000 - JLINC General Audit Agreement.md b/backend/db/agreements/00000000-0000-0000-0000-000000000000 - JLINC General Audit Agreement.md similarity index 100% rename from src/db/agreements/00000000-0000-0000-0000-000000000000 - JLINC General Audit Agreement.md rename to backend/db/agreements/00000000-0000-0000-0000-000000000000 - JLINC General Audit Agreement.md diff --git a/src/db/index.js b/backend/db/index.js similarity index 91% rename from src/db/index.js rename to backend/db/index.js index fe6ad3d..c4ec236 100644 --- a/src/db/index.js +++ b/backend/db/index.js @@ -6,7 +6,12 @@ const { Pool } = pkg; import { sleep } from "../common/sleep.js"; import { getConfig } from "../common/config.js"; -import { data } from "../modules/core/data/index.js"; +import { firstNonBlankLine } from "../util/firstNonBlankLine.js"; + +// NOTE: the domain `data` layer (which pulls in @jlinc/core) is imported lazily +// inside populateAgreements() only. Keeping it out of this module's top-level +// imports lets the pool/migrate code — and anything that just needs getPool() — +// load without dragging in the crypto engine. let pool; @@ -125,6 +130,7 @@ export async function migrate() { export async function populateAgreements() { console.log(`Starting agreement population`); const config = getConfig(); + const { data } = await import("../modules/core/data/index.js"); const client = await pool.connect(); try { const files = fs.readdirSync("./db/agreements", { @@ -134,9 +140,9 @@ export async function populateAgreements() { if (file.name.endsWith('.json')) continue; const agreementUuid = file.name.slice(0, 36); - const title = file.name.slice(39, file.name.length - 3); const markdown = fs.readFileSync(path.join("./db/agreements", file.name), "utf8").trim(); const json = JSON.parse(fs.readFileSync(path.join("./db/agreements", file.name.replace('.md', '.json')), "utf8").trim()); + const title = firstNonBlankLine(markdown); const hash = createHash('sha256') .update(markdown) .digest('hex') @@ -156,6 +162,8 @@ export async function populateAgreements() { ); if (!agreementExists.rows[0].exists) { console.log(`Adding agreement '${title}' (${agreementUuid})`); + // Agreement content is immutable once inserted (it may already be + // signed), so this is INSERT-only — never an upsert. await client.query(` INSERT INTO agreement_content ( title, diff --git a/src/db/migrations/000000 - init.sql b/backend/db/migrations/000000 - init.sql similarity index 100% rename from src/db/migrations/000000 - init.sql rename to backend/db/migrations/000000 - init.sql diff --git a/src/db/migrations/000001 - archive.sql b/backend/db/migrations/000001 - archive.sql similarity index 100% rename from src/db/migrations/000001 - archive.sql rename to backend/db/migrations/000001 - archive.sql diff --git a/src/db/migrations/000002 - usage.sql b/backend/db/migrations/000002 - usage.sql similarity index 100% rename from src/db/migrations/000002 - usage.sql rename to backend/db/migrations/000002 - usage.sql diff --git a/src/db/migrations/000003 - data.sql b/backend/db/migrations/000003 - data.sql similarity index 100% rename from src/db/migrations/000003 - data.sql rename to backend/db/migrations/000003 - data.sql diff --git a/src/db/migrations/000004 - entity.sql b/backend/db/migrations/000004 - entity.sql similarity index 100% rename from src/db/migrations/000004 - entity.sql rename to backend/db/migrations/000004 - entity.sql diff --git a/src/db/migrations/000005 - agreement-content.sql b/backend/db/migrations/000005 - agreement-content.sql similarity index 100% rename from src/db/migrations/000005 - agreement-content.sql rename to backend/db/migrations/000005 - agreement-content.sql diff --git a/src/db/migrations/000006 - audit-index.sql b/backend/db/migrations/000006 - audit-index.sql similarity index 100% rename from src/db/migrations/000006 - audit-index.sql rename to backend/db/migrations/000006 - audit-index.sql diff --git a/src/db/migrations/000007 - meta.sql b/backend/db/migrations/000007 - meta.sql similarity index 100% rename from src/db/migrations/000007 - meta.sql rename to backend/db/migrations/000007 - meta.sql diff --git a/src/db/migrations/000008 - queue.sql b/backend/db/migrations/000008 - queue.sql similarity index 100% rename from src/db/migrations/000008 - queue.sql rename to backend/db/migrations/000008 - queue.sql diff --git a/src/db/migrations/000009 - null-user-agreement.sql b/backend/db/migrations/000009 - null-user-agreement.sql similarity index 100% rename from src/db/migrations/000009 - null-user-agreement.sql rename to backend/db/migrations/000009 - null-user-agreement.sql diff --git a/src/db/migrations/000010 - prohibition.sql b/backend/db/migrations/000010 - prohibition.sql similarity index 100% rename from src/db/migrations/000010 - prohibition.sql rename to backend/db/migrations/000010 - prohibition.sql diff --git a/src/db/migrations/000011 - context.sql b/backend/db/migrations/000011 - context.sql similarity index 100% rename from src/db/migrations/000011 - context.sql rename to backend/db/migrations/000011 - context.sql diff --git a/src/db/migrations/000012 - references.sql b/backend/db/migrations/000012 - references.sql similarity index 100% rename from src/db/migrations/000012 - references.sql rename to backend/db/migrations/000012 - references.sql diff --git a/src/db/migrations/000013 - public-agreement.sql b/backend/db/migrations/000013 - public-agreement.sql similarity index 100% rename from src/db/migrations/000013 - public-agreement.sql rename to backend/db/migrations/000013 - public-agreement.sql diff --git a/src/db/migrations/000014 - reference fix.sql b/backend/db/migrations/000014 - reference-fix.sql similarity index 100% rename from src/db/migrations/000014 - reference fix.sql rename to backend/db/migrations/000014 - reference-fix.sql diff --git a/backend/db/migrations/000015 - session.sql b/backend/db/migrations/000015 - session.sql new file mode 100644 index 0000000..8320629 --- /dev/null +++ b/backend/db/migrations/000015 - session.sql @@ -0,0 +1,11 @@ +-- Session store for express-session (see http/sessionStore.js). +CREATE TABLE IF NOT EXISTS public.session ( + sid VARCHAR NOT NULL PRIMARY KEY, + sess JSONB NOT NULL, + expire TIMESTAMPTZ NOT NULL, + created_ts TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_ts TIMESTAMPTZ NOT NULL DEFAULT NOW() +); +CREATE INDEX IF NOT EXISTS idx__public__session__expire ON public.session (expire); +CREATE INDEX IF NOT EXISTS idx__public__session__created_ts ON public.session (created_ts); +CREATE INDEX IF NOT EXISTS idx__public__session__updated_ts ON public.session (updated_ts); diff --git a/backend/db/migrations/000016 - cache.sql b/backend/db/migrations/000016 - cache.sql new file mode 100644 index 0000000..2c83d9d --- /dev/null +++ b/backend/db/migrations/000016 - cache.sql @@ -0,0 +1,14 @@ +-- Per-tenant read-through cache; updated_ts drives TTL freshness. +CREATE TABLE IF NOT EXISTS public.cache ( + id BIGSERIAL PRIMARY KEY, + user_id BIGINT NOT NULL REFERENCES public.user(id), + tag TEXT NOT NULL, + data JSONB NOT NULL, + created_ts TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_ts TIMESTAMPTZ NOT NULL DEFAULT NOW(), + CONSTRAINT uniq__cache__user_id_tag UNIQUE (user_id, tag) +); +CREATE INDEX IF NOT EXISTS idx__public__cache__user_id ON public.cache (user_id); +CREATE INDEX IF NOT EXISTS idx__public__cache__tag ON public.cache (tag); +CREATE INDEX IF NOT EXISTS idx__public__cache__created_ts ON public.cache (created_ts); +CREATE INDEX IF NOT EXISTS idx__public__cache__updated_ts ON public.cache (updated_ts); diff --git a/backend/db/migrations/000017 - api-key.sql b/backend/db/migrations/000017 - api-key.sql new file mode 100644 index 0000000..ef75294 --- /dev/null +++ b/backend/db/migrations/000017 - api-key.sql @@ -0,0 +1,21 @@ +-- User-managed API keys. key_hash is a salted scrypt digest, so lookups go via +-- the indexed key_prefix rather than the hash. +CREATE TABLE IF NOT EXISTS public.api_key ( + id BIGSERIAL PRIMARY KEY, + user_id BIGINT NOT NULL REFERENCES public.user(id), + app_id BIGINT NOT NULL REFERENCES public.app(id), + label TEXT, + key_hash TEXT NOT NULL, + key_prefix TEXT NOT NULL, + expires_ts TIMESTAMPTZ, + last_used_ts TIMESTAMPTZ, + created_ts TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_ts TIMESTAMPTZ NOT NULL DEFAULT NOW() +); +CREATE INDEX IF NOT EXISTS idx__public__api_key__key_prefix ON public.api_key (key_prefix); +CREATE INDEX IF NOT EXISTS idx__public__api_key__user_id ON public.api_key (user_id); +CREATE INDEX IF NOT EXISTS idx__public__api_key__app_id ON public.api_key (app_id); +CREATE INDEX IF NOT EXISTS idx__public__api_key__expires_ts ON public.api_key (expires_ts); +CREATE INDEX IF NOT EXISTS idx__public__api_key__last_used_ts ON public.api_key (last_used_ts); +CREATE INDEX IF NOT EXISTS idx__public__api_key__created_ts ON public.api_key (created_ts); +CREATE INDEX IF NOT EXISTS idx__public__api_key__updated_ts ON public.api_key (updated_ts); diff --git a/backend/db/migrations/000018 - usage-event-agreement.sql b/backend/db/migrations/000018 - usage-event-agreement.sql new file mode 100644 index 0000000..e886cdc --- /dev/null +++ b/backend/db/migrations/000018 - usage-event-agreement.sql @@ -0,0 +1,6 @@ +ALTER TABLE public.usage + ADD COLUMN IF NOT EXISTS event_id_uuid UUID, + ADD COLUMN IF NOT EXISTS agreement_id_uuid UUID; + +CREATE INDEX IF NOT EXISTS idx__public__usage__event_id_uuid ON public.usage (event_id_uuid); +CREATE INDEX IF NOT EXISTS idx__public__usage__agreement_id_uuid ON public.usage (agreement_id_uuid); diff --git a/backend/db/migrations/000019 - user-type.sql b/backend/db/migrations/000019 - user-type.sql new file mode 100644 index 0000000..636d8d1 --- /dev/null +++ b/backend/db/migrations/000019 - user-type.sql @@ -0,0 +1,30 @@ +CREATE TABLE IF NOT EXISTS public.user_type ( + id BIGSERIAL PRIMARY KEY, + value TEXT NOT NULL, + can_view_json BOOLEAN NOT NULL DEFAULT FALSE, + created_ts TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_ts TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +-- value is the natural key (the unique index also backs the ON CONFLICT below). +CREATE UNIQUE INDEX IF NOT EXISTS idx__public__user_type__value ON public.user_type (value); +CREATE INDEX IF NOT EXISTS idx__public__user_type__created_ts ON public.user_type (created_ts); +CREATE INDEX IF NOT EXISTS idx__public__user_type__updated_ts ON public.user_type (updated_ts); + +-- can_view_json is the capability the app reads; roles are defined only here. +INSERT INTO public.user_type (value, can_view_json) +VALUES ('administrator', TRUE), ('full', TRUE), ('standard', FALSE) +ON CONFLICT (value) DO NOTHING; + +ALTER TABLE public.user + ADD COLUMN IF NOT EXISTS user_type_id BIGINT REFERENCES public.user_type(id); + +CREATE INDEX IF NOT EXISTS idx__public__user__user_type_id ON public.user (user_type_id); + +-- Backfill: every existing user becomes an administrator (safe to re-run: only NULLs). +UPDATE public.user + SET user_type_id = (SELECT id FROM public.user_type WHERE value = 'administrator') + WHERE user_type_id IS NULL; + +-- All rows are populated and checkUser() always supplies it going forward. +ALTER TABLE public.user ALTER COLUMN user_type_id SET NOT NULL; diff --git a/backend/db/migrations/000020 - add-remote-url.sql b/backend/db/migrations/000020 - add-remote-url.sql new file mode 100644 index 0000000..a21b0dc --- /dev/null +++ b/backend/db/migrations/000020 - add-remote-url.sql @@ -0,0 +1,2 @@ +ALTER TABLE public.agreement_content ADD COLUMN IF NOT EXISTS remote_url TEXT; +CREATE INDEX IF NOT EXISTS idx__agreement_content__remote_url ON public.agreement_content(remote_url); \ No newline at end of file diff --git a/backend/db/migrations/000021 - agreement-fetch.sql b/backend/db/migrations/000021 - agreement-fetch.sql new file mode 100644 index 0000000..e274c07 --- /dev/null +++ b/backend/db/migrations/000021 - agreement-fetch.sql @@ -0,0 +1,15 @@ +-- Retry bookkeeping for remote agreement-markdown fetches. Kept separate from +-- agreement_content (which is immutable once inserted) because this state is +-- mutable: attempts and the next scheduled try are updated on each failure and +-- the row is deleted once the content is successfully cached. +CREATE TABLE IF NOT EXISTS public.agreement_fetch ( + agreement_uuid UUID PRIMARY KEY, + remote_url TEXT NOT NULL, + attempts INTEGER NOT NULL DEFAULT 0, + next_attempt_ts TIMESTAMPTZ NOT NULL DEFAULT NOW(), + last_error TEXT, + created_ts TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_ts TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS idx__public__agreement_fetch__next_attempt_ts ON public.agreement_fetch (next_attempt_ts); diff --git a/src/eslint.config.js b/backend/eslint.config.js similarity index 100% rename from src/eslint.config.js rename to backend/eslint.config.js diff --git a/src/http/agreements.js b/backend/http/agreements.js similarity index 100% rename from src/http/agreements.js rename to backend/http/agreements.js diff --git a/src/http/api/v1/swagger.json b/backend/http/api/v1/swagger.json similarity index 100% rename from src/http/api/v1/swagger.json rename to backend/http/api/v1/swagger.json diff --git a/backend/http/apiKeys.js b/backend/http/apiKeys.js new file mode 100644 index 0000000..2bdbb10 --- /dev/null +++ b/backend/http/apiKeys.js @@ -0,0 +1,50 @@ +import { getConfig } from "../common/config.js"; +import { fail, sessionRoute } from "../common/http.js"; +import { + listKeys as defaultList, + generateKey as defaultGenerate, + revokeKey as defaultRevoke, +} from "../modules/core/apiKey.js"; + +const requireUser = (fn) => sessionRoute("apikeys", fn); + +// Internal modules have no `app` row and issue no billable calls, so no key. +const mintableModules = () => + Object.entries(getConfig().appModules) + .filter(([, m]) => !m?.internal) + .map(([type]) => type); + +export function apiKeyHandlers({ + listKeys = defaultList, + generateKey = defaultGenerate, + revokeKey = defaultRevoke, +} = {}) { + return { + whoami: requireUser(async (req, res) => { + res.json({ username: req.user.username, canViewJson: req.user.canViewJson, modules: mintableModules() }); + }), + + list: requireUser(async (req, res) => { + res.json(await listKeys(req.user.id)); + }), + + create: requireUser(async (req, res) => { + const { type, label, expiresTs } = req.body || {}; + if (!mintableModules().includes(type)) { + return fail(res, 400, "bad_request", "unknown module"); + } + if (expiresTs != null && (typeof expiresTs !== "string" || Number.isNaN(Date.parse(expiresTs)))) { + return fail(res, 400, "bad_request", "invalid expiresTs (expected an ISO date string)"); + } + // Returns the raw key ONCE — the client must surface it immediately. + res.json(await generateKey(req.user.id, type, { label: label || null, expiresTs: expiresTs || null })); + }), + + revoke: requireUser(async (req, res) => { + const id = parseInt(req.params.id, 10); + if (!Number.isInteger(id)) return fail(res, 400, "bad_request", "invalid id"); + const ok = await revokeKey(req.user.id, id); + res.status(ok ? 200 : 404).json({ ok }); + }), + }; +} diff --git a/backend/http/apiKeys.test.js b/backend/http/apiKeys.test.js new file mode 100644 index 0000000..1cdae40 --- /dev/null +++ b/backend/http/apiKeys.test.js @@ -0,0 +1,95 @@ +import { jest } from "@jest/globals"; +import express from "express"; +import request from "supertest"; +import { getConfig } from "../common/config.js"; +import { apiKeyHandlers } from "./apiKeys.js"; + +// getConfig() is a mutable singleton — point its enabled modules at a known set. +// `dashboard` is enabled but internal, so it must NOT appear as a mintable module. +beforeAll(() => { + getConfig().appModules = { core: {}, archive: {}, dashboard: { internal: true } }; +}); + +function makeApp({ user = { id: 1, username: "dev" }, store } = {}) { + const app = express(); + app.use((req, _res, next) => { + if (user) req.user = user; + next(); + }); + const h = apiKeyHandlers(store); + app.get("/api/dashboard/auth/whoami", h.whoami); + app.get("/api/dashboard/auth/keys", h.list); + app.post("/api/dashboard/auth/keys", express.json(), h.create); + app.delete("/api/dashboard/auth/keys/:id", h.revoke); + return app; +} + +const okStore = () => ({ + listKeys: jest.fn(async () => [{ id: 1, appType: "core", prefix: "ab12cd34", label: "ci" }]), + generateKey: jest.fn(async (_uid, type) => ({ id: 9, key: "rawsecret", prefix: "rawsecre", appType: type })), + revokeKey: jest.fn(async (_uid, id) => id === 9), +}); + +describe("API key management routes", () => { + it("401s when not logged in", async () => { + const res = await request(makeApp({ user: null, store: okStore() })).get("/api/dashboard/auth/keys"); + expect(res.status).toBe(401); + expect(res.body.error.code).toBe("unauthorized"); + }); + + it("whoami returns username + mintable modules (excludes internal dashboard)", async () => { + const res = await request(makeApp({ store: okStore() })).get("/api/dashboard/auth/whoami"); + expect(res.status).toBe(200); + expect(res.body.username).toBe("dev"); + expect(res.body.modules).toEqual(expect.arrayContaining(["core", "archive"])); + expect(res.body.modules).not.toContain("dashboard"); + }); + + it("rejects generating a key for an internal module (dashboard)", async () => { + const store = okStore(); + const res = await request(makeApp({ store })).post("/api/dashboard/auth/keys").send({ type: "dashboard" }); + expect(res.status).toBe(400); + expect(store.generateKey).not.toHaveBeenCalled(); + }); + + it("lists the user's keys (metadata only)", async () => { + const store = okStore(); + const res = await request(makeApp({ store })).get("/api/dashboard/auth/keys"); + expect(res.status).toBe(200); + expect(res.body[0]).toMatchObject({ prefix: "ab12cd34" }); + expect(store.listKeys).toHaveBeenCalledWith(1); + }); + + it("generates a key for an enabled module and returns the raw key once", async () => { + const store = okStore(); + const res = await request(makeApp({ store })) + .post("/api/dashboard/auth/keys") + .send({ type: "core", label: "ci" }); + expect(res.status).toBe(200); + expect(res.body.key).toBe("rawsecret"); + expect(store.generateKey).toHaveBeenCalledWith(1, "core", { label: "ci", expiresTs: null }); + }); + + it("rejects generating a key for a module that is not enabled", async () => { + const store = okStore(); + const res = await request(makeApp({ store })).post("/api/dashboard/auth/keys").send({ type: "nope" }); + expect(res.status).toBe(400); + expect(store.generateKey).not.toHaveBeenCalled(); + }); + + it("rejects a malformed expiresTs with 400 (not a DB-level 500)", async () => { + const store = okStore(); + const res = await request(makeApp({ store })) + .post("/api/dashboard/auth/keys") + .send({ type: "core", expiresTs: "not-a-date" }); + expect(res.status).toBe(400); + expect(store.generateKey).not.toHaveBeenCalled(); + }); + + it("revokes a key (200 when deleted, 404 when not)", async () => { + const store = okStore(); + const app = makeApp({ store }); + expect((await request(app).delete("/api/dashboard/auth/keys/9")).status).toBe(200); + expect((await request(app).delete("/api/dashboard/auth/keys/5")).status).toBe(404); + }); +}); diff --git a/src/http/auth.js b/backend/http/auth.js similarity index 68% rename from src/http/auth.js rename to backend/http/auth.js index eb5f23b..eb85a3f 100644 --- a/src/http/auth.js +++ b/backend/http/auth.js @@ -1,37 +1,41 @@ import { getConfig } from "../common/config.js"; import { getPool } from "../db/index.js"; +import { verifyKey } from "../modules/core/apiKey.js"; import crypto from 'crypto'; +// Bearer-token auth for the central /api/v1/* API. On success it sets +// req.session.user_id (which the core router scopes queries by). It does NOT +// populate req.user — the browser session routes (passport) own that — so the +// session-authed dashboard/key-management routes remain login-only by design. export async function apiMiddleware(req, res, next) { let success = false; - const client = await getPool(); try { - const config = getConfig(); - const authHeader = req.headers['authorization']; - if (authHeader) { - const apiKey = authHeader.split(' ')[1]; - if (apiKey) { - const validRes = await client.query(` - SELECT - au.user_id, - ap.type - FROM public.auth au - INNER JOIN public.app ap ON ap.id = au.app_id - WHERE au.api_key = $1 - `, [ - apiKey, - ]); - if (validRes.rowCount > 0) { - // if (config.debug) console.log({apiKey}); - req.session.user_id = validRes.rows[0].user_id; - success = true + const apiKey = req.headers['authorization']?.split(' ')[1]; + if (apiKey) { + // Preferred: the hashed multi-key store (with a short in-memory cache). + const found = await verifyKey(apiKey); + if (found) { + req.session.user_id = found.user_id; + success = true; + } else { + // Fallback: the legacy single plaintext key in `auth` (issued at login). + const client = await getPool(); + try { + const r = await client.query( + `SELECT au.user_id FROM public.auth au WHERE au.api_key = $1`, + [apiKey], + ); + if (r.rowCount > 0) { + req.session.user_id = r.rows[0].user_id; + success = true; + } + } finally { + await client.release(); } } } } catch (e) { console.error(e); - } finally { - await client.release(); } if (!success) return res.status(401).json({ error: 'API key is invalid' }); @@ -48,6 +52,8 @@ export function getNewKey(user) { async function createApiKeys(client, user, issuer) { const config = getConfig(); for (const type in config.appModules) { + // Internal modules (e.g. dashboard) have no `app` row and mint no keys. + if (config.appModules[type]?.internal) continue; const apiKey = issuer !== 'https://single' ? getNewKey(user) : config.authModules.single[type] @@ -93,6 +99,7 @@ export async function getUser(client, issuer, identifier) { u.photo, u.issuer, u.identifier, + COALESCE(ut.can_view_json, FALSE) AS "canViewJson", json_agg( jsonb_build_object( 'id', a.id, @@ -103,6 +110,7 @@ export async function getUser(client, issuer, identifier) { FROM public.user u INNER JOIN public.auth au ON u.id = au.user_id INNER JOIN public.app a ON au.app_id = a.id + LEFT JOIN public.user_type ut ON ut.id = u.user_type_id WHERE u.identifier = $1 AND u.issuer = $2 GROUP BY @@ -110,7 +118,8 @@ export async function getUser(client, issuer, identifier) { u.username, u.photo, u.issuer, - u.identifier + u.identifier, + ut.can_view_json `, [ identifier, @@ -130,19 +139,24 @@ export async function checkUser(type, issuer, identifier, username, photo) { if (!username || username === '') { username = identifier; } + // New users default to the least-privileged role; an administrator + // promotes them. Existing users keep whatever the DB already holds + // (see the 000019 backfill) — checkUser never overwrites an existing type. const res = await client.query(` INSERT INTO public.user ( username, issuer, identifier, photo, - type + type, + user_type_id ) VALUES ( $1, $2, $3, $4, - $5 + $5, + (SELECT id FROM public.user_type WHERE value = 'standard') ) RETURNING id; `, [ username, diff --git a/backend/http/auth.test.js b/backend/http/auth.test.js new file mode 100644 index 0000000..c4ebfea --- /dev/null +++ b/backend/http/auth.test.js @@ -0,0 +1,61 @@ +import { jest } from "@jest/globals"; +import express from "express"; +import request from "supertest"; + +// Manual mocks: the API-key verifier and the DB pool. No real database, no scrypt +// — apiMiddleware's branching is what we exercise here. +const verifyKey = jest.fn(); +const query = jest.fn(); +const release = jest.fn(async () => {}); +jest.unstable_mockModule("../modules/core/apiKey.js", () => ({ verifyKey })); +jest.unstable_mockModule("../db/index.js", () => ({ getPool: async () => ({ query, release }) })); + +const { apiMiddleware } = await import("./auth.js"); + +function makeApp() { + const app = express(); + app.use((req, _res, next) => { req.session = {}; next(); }); + app.get("/probe", apiMiddleware, (req, res) => res.json({ userId: req.session.user_id })); + return app; +} + +beforeEach(() => { + verifyKey.mockReset(); + query.mockReset(); + release.mockClear(); +}); + +describe("apiMiddleware", () => { + it("authenticates a valid hashed key and sets req.session.user_id (no legacy lookup)", async () => { + verifyKey.mockResolvedValue({ user_id: 42, app_id: 1 }); + const res = await request(makeApp()).get("/probe").set("Authorization", "Bearer good-key"); + expect(res.status).toBe(200); + expect(res.body.userId).toBe(42); + expect(verifyKey).toHaveBeenCalledWith("good-key"); + expect(query).not.toHaveBeenCalled(); // hashed path short-circuits the legacy fallback + }); + + it("falls back to the legacy plaintext auth key when the hashed store misses", async () => { + verifyKey.mockResolvedValue(null); + query.mockResolvedValue({ rowCount: 1, rows: [{ user_id: 7 }] }); + const res = await request(makeApp()).get("/probe").set("Authorization", "Bearer legacy-key"); + expect(res.status).toBe(200); + expect(res.body.userId).toBe(7); + expect(query.mock.calls[0][1]).toEqual(["legacy-key"]); + expect(release).toHaveBeenCalledTimes(1); + }); + + it("rejects an unknown key with 401", async () => { + verifyKey.mockResolvedValue(null); + query.mockResolvedValue({ rowCount: 0, rows: [] }); + const res = await request(makeApp()).get("/probe").set("Authorization", "Bearer bogus"); + expect(res.status).toBe(401); + }); + + it("rejects a request with no Authorization header with 401 (no lookups)", async () => { + const res = await request(makeApp()).get("/probe"); + expect(res.status).toBe(401); + expect(verifyKey).not.toHaveBeenCalled(); + expect(query).not.toHaveBeenCalled(); + }); +}); diff --git a/backend/http/dashboard.js b/backend/http/dashboard.js new file mode 100644 index 0000000..9d08267 --- /dev/null +++ b/backend/http/dashboard.js @@ -0,0 +1,34 @@ +import { service as defaultService } from "../modules/dashboard/service.js"; +import { makeDashboardApi } from "../modules/dashboard/api.js"; +import { fail, sessionRoute } from "../common/http.js"; + +const requireUser = (fn) => sessionRoute("dashboard", fn); + +// The browser gets the bare payload; the { message, data } envelope is for the +// API-key router. +const send = (res, { status, data }) => + status === 200 + ? res.json(data) + : fail(res, status, status === 404 ? "not_found" : "bad_request", data?.error); + +export function dashboardHandlers({ service = defaultService } = {}) { + const api = makeDashboardApi(service); + const route = (fn) => requireUser(async (req, res) => send(res, await fn(req))); + return { + summary: route((req) => api.summary(req.query, req.user.id)), + transactions: route((req) => api.transactions(req.query, req.user.id)), + series: route((req) => api.series(req.query, req.user.id)), + verify: route((req) => api.verify(req.query, req.user.id)), + // View JSON exposes the raw payload (possibly private); the can_view_json + // capability comes from the user's role in the DB (see getUser). + event: requireUser(async (req, res) => { + if (!req.user.canViewJson) return fail(res, 403, "forbidden", "not permitted to view JSON"); + send(res, await api.event({ eventUuid: req.params.eventUuid }, req.user.id)); + }), + agreement: route((req) => api.agreement({ agreementUuid: req.params.agreementUuid }, req.user.id)), + details: requireUser(async (req, res) => { + if (!req.user.canViewJson) return fail(res, 403, "forbidden", "not permitted to view JSON"); + send(res, await api.details({ eventUuid: req.params.eventUuid }, req.user.id)); + }) + }; +} diff --git a/backend/http/dashboard.test.js b/backend/http/dashboard.test.js new file mode 100644 index 0000000..7ada4f6 --- /dev/null +++ b/backend/http/dashboard.test.js @@ -0,0 +1,169 @@ +import { jest } from "@jest/globals"; +import express from "express"; +import request from "supertest"; +import { dashboardHandlers } from "./dashboard.js"; + +// Mount the dashboard routes on a bare app with an injectable fake service and a +// settable user, so we can test auth, validation, response shaping, and the +// error envelope without a database. +function makeApp({ user = { id: 1, canViewJson: true }, service } = {}) { + const app = express(); + app.use((req, _res, next) => { + if (user) req.user = user; + next(); + }); + const h = dashboardHandlers({ service }); + app.get("/api/dashboard/summary", h.summary); + app.get("/api/dashboard/transactions", h.transactions); + app.get("/api/dashboard/series", h.series); + app.get("/api/dashboard/verify", h.verify); + app.get("/api/dashboard/transactions/:eventUuid", h.event); + return app; +} + +const okService = () => ({ + summary: jest.fn(async () => ({ apiUsage: { core: 2, audit: 1, total: 3 }, apiKeys: 4, endpoints: 2, storage: {} })), + transactions: jest.fn(async (_uid, opts) => ({ rows: [{ eventUuid: "e1" }], total: 1, limit: opts.limit, offset: opts.offset })), + series: jest.fn(async () => ({ + usage: [{ date: "2026-06-01", core: 1, audit: 0, total: 1 }], + storage: [{ date: "2026-06-01", coreDisk: 10, auditDisk: 0, totalDisk: 10 }], + })), + event: jest.fn(async () => ({ eventId: "3fa85f64-5717-4562-b3fc-2c963f66afa6", data: { a: 1 } })), + verify: jest.fn(async (_uid, { eventUuids, auditIds }) => ({ + results: [ + ...eventUuids.map((eventUuid) => ({ eventUuid, auditId: null, status: "verified", verified: true })), + ...auditIds.map((auditId) => ({ eventUuid: "e1", auditId, status: "verified", verified: true })), + ], + })), +}); + +describe("GET /api/dashboard/* auth", () => { + it("returns 401 with an error envelope when not logged in", async () => { + const res = await request(makeApp({ user: null, service: okService() })).get("/api/dashboard/summary"); + expect(res.status).toBe(401); + expect(res.body).toEqual({ error: { code: "unauthorized", message: expect.any(String) } }); + }); +}); + +describe("GET /api/dashboard/summary", () => { + it("calls the service with the user id + window and returns the payload", async () => { + const service = okService(); + const res = await request(makeApp({ service })).get("/api/dashboard/summary?from=2026-01-01&to=2026-01-31"); + expect(res.status).toBe(200); + expect(res.body.apiUsage).toEqual({ core: 2, audit: 1, total: 3 }); + expect(service.summary).toHaveBeenCalledWith(1, { from: "2026-01-01", to: "2026-01-31" }); + }); +}); + +describe("GET /api/dashboard/transactions", () => { + it("passes validated/normalized options to the service", async () => { + const service = okService(); + const res = await request(makeApp({ service })).get("/api/dashboard/transactions?type=audit&sort=size&dir=asc&limit=10"); + expect(res.status).toBe(200); + expect(res.body).toMatchObject({ total: 1, limit: 10, offset: 0 }); + expect(service.transactions).toHaveBeenCalledWith(1, expect.objectContaining({ type: "audit", sort: "size", dir: "asc", limit: 10 })); + }); + + it("returns 400 for an invalid type and does not call the service", async () => { + const service = okService(); + const res = await request(makeApp({ service })).get("/api/dashboard/transactions?type=evil"); + expect(res.status).toBe(400); + expect(res.body.error.code).toBe("bad_request"); + expect(service.transactions).not.toHaveBeenCalled(); + }); + + it("returns 400 for an unknown sort key (injection guard)", async () => { + const res = await request(makeApp({ service: okService() })).get("/api/dashboard/transactions?sort=sender;DROP"); + expect(res.status).toBe(400); + }); +}); + +describe("GET /api/dashboard/series (combined usage + storage)", () => { + it("returns both usage and storage series for the window in one response", async () => { + const service = okService(); + const res = await request(makeApp({ service })).get("/api/dashboard/series?from=2026-06-01&to=2026-06-03"); + expect(res.status).toBe(200); + expect(res.body.usage).toHaveLength(1); + expect(res.body.storage[0]).toMatchObject({ totalDisk: 10 }); + expect(service.series).toHaveBeenCalledWith(1, { from: "2026-06-01", to: "2026-06-03" }); + }); +}); + +describe("GET /api/dashboard/verify", () => { + const good = "3fa85f64-5717-4562-b3fc-2c963f66afa6"; + + it("verifies only the valid UUIDs from the ids list (core tab)", async () => { + const service = okService(); + const res = await request(makeApp({ service })).get(`/api/dashboard/verify?ids=${good},junk`); + expect(res.status).toBe(200); + expect(res.body.results).toHaveLength(1); + expect(service.verify).toHaveBeenCalledWith(1, { eventUuids: [good], auditIds: [] }); + }); + + // The audit tab targets audit RECORDS: two rows of one event must be two + // separate targets, or verifying one reports a verdict on both. + it("passes audit ids straight through as their own targets (audit tab)", async () => { + const service = okService(); + const res = await request(makeApp({ service })).get("/api/dashboard/verify?auditIds=9,10"); + expect(res.status).toBe(200); + expect(service.verify).toHaveBeenCalledWith(1, { eventUuids: [], auditIds: [9, 10] }); + expect(res.body.results).toHaveLength(2); + }); + + it("returns empty results when no valid targets are supplied", async () => { + const service = okService(); + const res = await request(makeApp({ service })).get("/api/dashboard/verify?ids=junk&auditIds=nope"); + expect(res.status).toBe(200); + expect(res.body.results).toEqual([]); + expect(service.verify).toHaveBeenCalledWith(1, { eventUuids: [], auditIds: [] }); + }); +}); + +describe("GET /api/dashboard/transactions/:eventUuid", () => { + it("400s on a non-uuid id", async () => { + const res = await request(makeApp({ service: okService() })).get("/api/dashboard/transactions/not-a-uuid"); + expect(res.status).toBe(400); + }); + + it("404s when the event is not found", async () => { + const service = okService(); + service.event = jest.fn(async () => null); + const res = await request(makeApp({ service })).get("/api/dashboard/transactions/3fa85f64-5717-4562-b3fc-2c963f66afa6"); + expect(res.status).toBe(404); + expect(res.body.error.code).toBe("not_found"); + }); + + it("returns the record when found", async () => { + const res = await request(makeApp({ service: okService() })).get("/api/dashboard/transactions/3fa85f64-5717-4562-b3fc-2c963f66afa6"); + expect(res.status).toBe(200); + expect(res.body.data).toEqual({ a: 1 }); + }); + + it("403s for a user without View JSON, before touching the service", async () => { + const service = okService(); + const res = await request(makeApp({ user: { id: 1, canViewJson: false }, service })) + .get("/api/dashboard/transactions/3fa85f64-5717-4562-b3fc-2c963f66afa6"); + expect(res.status).toBe(403); + expect(res.body.error.code).toBe("forbidden"); + expect(service.event).not.toHaveBeenCalled(); + }); + + it("allows a user with View JSON", async () => { + const res = await request(makeApp({ user: { id: 1, canViewJson: true }, service: okService() })) + .get("/api/dashboard/transactions/3fa85f64-5717-4562-b3fc-2c963f66afa6"); + expect(res.status).toBe(200); + }); +}); + +describe("error envelope on a thrown service error", () => { + it("returns 500 with the envelope and logs", async () => { + const service = okService(); + service.summary = jest.fn(async () => { throw new Error("boom"); }); + const spy = jest.spyOn(console, "error").mockImplementation(() => {}); + const res = await request(makeApp({ service })).get("/api/dashboard/summary"); + expect(res.status).toBe(500); + expect(res.body).toEqual({ error: { code: "internal", message: "Internal error" } }); + expect(spy).toHaveBeenCalled(); + spy.mockRestore(); + }); +}); diff --git a/backend/http/dashboardPassthrough.js b/backend/http/dashboardPassthrough.js new file mode 100644 index 0000000..fc673ba --- /dev/null +++ b/backend/http/dashboardPassthrough.js @@ -0,0 +1,100 @@ +import { service as defaultService } from "../modules/dashboard/service.js"; +import { getConfig } from "../common/config.js"; + +// Same interface as the local service, so it is a drop-in for it. +// +// Tenant scoping: locally, every call carries the session's own user id. +// Remotely, the API KEY is the scope — we deliberately send NO user id, since a +// local primary key is meaningless on another operator and must never be +// trusted as a cross-operator identity. +export function makePassthrough({ service = defaultService, config = getConfig(), fetchImpl = fetch } = {}) { + const sources = () => config.dashboard || { core: {}, audit: {} }; + const remote = (s) => sources()[s] && sources()[s].url; + const federated = () => Boolean(remote("core") || remote("audit")); + + async function fromSource(source, endpoint, params, localCall) { + const src = sources()[source]; + if (!src || !src.url) return localCall(); + const res = await fetchImpl(`${src.url}/api/v1/data/dashboard/${endpoint}`, { + method: "POST", + headers: { "Content-Type": "application/json", Authorization: `Bearer ${src.key}` }, + body: JSON.stringify(params), + }); + if (!res.ok) throw new Error(`federation: ${source}/${endpoint} -> ${res.status}`); + return res.json(); + } + + const num = (v) => Number(v) || 0; + + function mergeSummary(core, audit) { + const cu = core.apiUsage || {}, au = audit.apiUsage || {}; + const cs = core.storage || {}, as = audit.storage || {}; + const split = (c, a) => ({ core: num(c), audit: num(a), total: num(c) + num(a) }); + return { + apiUsage: split(cu.core, au.audit), + apiKeys: core.apiKeys, + endpoints: core.endpoints, + storage: { + onDisk: split(cs.onDisk?.core, as.onDisk?.audit), + logical: split(cs.logical?.core, as.logical?.audit), + }, + }; + } + + function mergeByDate(coreSeries = [], auditSeries = [], coreKey, auditKey, totalKey) { + const auditByDate = new Map(auditSeries.map((r) => [r.date, r])); + return coreSeries.map((r) => { + const a = auditByDate.get(r.date) || {}; + const core = num(r[coreKey]); + const audit = num(a[auditKey]); + return { date: r.date, [coreKey]: core, [auditKey]: audit, [totalKey]: core + audit }; + }); + } + + return { + async summary(userId, window) { + if (!federated()) return service.summary(userId, window); + const [core, audit] = await Promise.all([ + fromSource("core", "summary", window, () => service.summary(userId, window)), + fromSource("audit", "summary", window, () => service.summary(userId, window)), + ]); + return mergeSummary(core, audit); + }, + + async transactions(userId, opts) { + const source = opts.type === "audit" ? "audit" : "core"; + return fromSource(source, "transactions", opts, () => service.transactions(userId, opts)); + }, + + async series(userId, window) { + if (!federated()) return service.series(userId, window); + const [core, audit] = await Promise.all([ + fromSource("core", "series", window, () => service.series(userId, window)), + fromSource("audit", "series", window, () => service.series(userId, window)), + ]); + return { + usage: mergeByDate(core.usage, audit.usage, "core", "audit", "total"), + storage: mergeByDate(core.storage, audit.storage, "coreDisk", "auditDisk", "totalDisk"), + }; + }, + + // Audit ids belong to the audit source that served the listing. + async verify(userId, targets) { + const { eventUuids = [], auditIds = [] } = targets || {}; + const params = { ids: eventUuids.join(","), auditIds: auditIds.join(",") }; + return fromSource("audit", "verify", params, () => service.verify(userId, targets)); + }, + + async event(userId, eventUuid) { + return fromSource("core", "event", { eventUuid }, () => service.event(userId, eventUuid)); + }, + + async agreement(userId, agreementUuid) { + return fromSource("core", "agreement", { agreementUuid }, () => service.agreement(userId, agreementUuid)); + }, + + async details(userId, eventUuid) { + return fromSource("core", "details", { eventUuid }, () => service.details(userId, eventUuid)) + } + }; +} diff --git a/backend/http/dashboardPassthrough.test.js b/backend/http/dashboardPassthrough.test.js new file mode 100644 index 0000000..b7868f1 --- /dev/null +++ b/backend/http/dashboardPassthrough.test.js @@ -0,0 +1,97 @@ +import { jest } from "@jest/globals"; +import { makePassthrough } from "./dashboardPassthrough.js"; + +const LOCAL = { core: { url: null, key: null }, audit: { url: null, key: null } }; +const FED = { core: { url: "http://core", key: "ck" }, audit: { url: "http://audit", key: "ak" } }; + +const localService = () => ({ + summary: jest.fn(async () => ({ apiUsage: { core: 2, audit: 1, total: 3 }, apiKeys: 4, endpoints: 2, storage: {} })), + transactions: jest.fn(async () => ({ rows: [], total: 0 })), + series: jest.fn(async () => ({ usage: [], storage: [] })), + verify: jest.fn(async () => ({ results: [] })), + event: jest.fn(async () => ({ eventId: "x" })), +}); + +describe("passthrough — local mode (no remote sources)", () => { + it("serves every endpoint from the local service and never fetches", async () => { + const service = localService(); + const fetchImpl = jest.fn(); + const p = makePassthrough({ service, config: { dashboard: LOCAL }, fetchImpl }); + + expect(await p.summary(7, { from: "a", to: "b" })).toMatchObject({ apiKeys: 4 }); + await p.transactions(7, { type: "audit" }); + await p.series(7, {}); + expect(service.summary).toHaveBeenCalled(); + expect(service.transactions).toHaveBeenCalled(); + expect(fetchImpl).not.toHaveBeenCalled(); + }); + + it("scopes every local call to the caller's own user id (per-tenant isolation)", async () => { + const service = localService(); + const p = makePassthrough({ service, config: { dashboard: LOCAL }, fetchImpl: jest.fn() }); + + await p.summary(7, { from: "a", to: "b" }); + await p.transactions(9, { type: "core" }); + await p.event(9, "uuid-1"); + + // The passthrough never substitutes another user's id — each call carries the + // id it was handed (which the route sets from req.user.id), so user 7 can + // never be served user 9's rows and vice versa. + expect(service.summary).toHaveBeenCalledWith(7, { from: "a", to: "b" }); + expect(service.transactions.mock.calls[0][0]).toBe(9); + expect(service.event).toHaveBeenCalledWith(9, "uuid-1"); + }); +}); + +describe("passthrough — federated mode", () => { + const fetchImpl = jest.fn(async (url) => ({ + ok: true, + json: async () => + url.includes("//core") + ? { apiUsage: { core: 10, audit: 0, total: 10 }, apiKeys: 5, endpoints: 2, storage: { onDisk: { core: 100, audit: 0, total: 100 }, logical: { core: 100, audit: 0, total: 100 } } } + : { apiUsage: { core: 0, audit: 7, total: 7 }, apiKeys: 0, endpoints: 0, storage: { onDisk: { core: 0, audit: 50, total: 50 }, logical: { core: 0, audit: 50, total: 50 } } }, + })); + + beforeEach(() => fetchImpl.mockClear()); + + it("merges summary: core fields from core source, audit fields from audit source", async () => { + const p = makePassthrough({ service: {}, config: { dashboard: FED }, fetchImpl }); + const out = await p.summary(7, { from: "a", to: "b" }); + expect(out.apiUsage).toEqual({ core: 10, audit: 7, total: 17 }); + expect(out.storage.onDisk).toEqual({ core: 100, audit: 50, total: 150 }); + expect(out.apiKeys).toBe(5); // from the core source + expect(fetchImpl).toHaveBeenCalledTimes(2); + }); + + it("forwards with the source's API key and NO user id in the body (the key is the tenant scope)", async () => { + const p = makePassthrough({ service: {}, config: { dashboard: FED }, fetchImpl }); + await p.summary(7, { from: "a", to: "b" }); + const coreCall = fetchImpl.mock.calls.find(([u]) => u.includes("//core")); + expect(coreCall[0]).toBe("http://core/api/v1/data/dashboard/summary"); + expect(coreCall[1].headers.Authorization).toBe("Bearer ck"); + const body = JSON.parse(coreCall[1].body); + expect(body).toEqual({ from: "a", to: "b" }); // only the window — never a userId + expect(body).not.toHaveProperty("userId"); + }); + + it("routes the audit transactions tab to the audit source", async () => { + const p = makePassthrough({ service: {}, config: { dashboard: FED }, fetchImpl }); + await p.transactions(7, { type: "audit", limit: 50, offset: 0 }); + const [url] = fetchImpl.mock.calls[0]; + expect(url).toBe("http://audit/api/v1/data/dashboard/transactions"); + }); + + it("merges the combined usage+storage series by date", async () => { + const seriesFetch = jest.fn(async (url) => ({ + ok: true, + json: async () => + url.includes("//core") + ? { usage: [{ date: "2026-06-01", core: 3, audit: 0, total: 3 }], storage: [{ date: "2026-06-01", coreDisk: 100, auditDisk: 0, totalDisk: 100 }] } + : { usage: [{ date: "2026-06-01", core: 0, audit: 5, total: 5 }], storage: [{ date: "2026-06-01", coreDisk: 0, auditDisk: 50, totalDisk: 50 }] }, + })); + const p = makePassthrough({ service: {}, config: { dashboard: FED }, fetchImpl: seriesFetch }); + const out = await p.series(7, { from: "a", to: "b" }); + expect(out.usage).toEqual([{ date: "2026-06-01", core: 3, audit: 5, total: 8 }]); + expect(out.storage).toEqual([{ date: "2026-06-01", coreDisk: 100, auditDisk: 50, totalDisk: 150 }]); + }); +}); diff --git a/src/http/index.js b/backend/http/index.js similarity index 62% rename from src/http/index.js rename to backend/http/index.js index af86f14..b011fa8 100644 --- a/src/http/index.js +++ b/backend/http/index.js @@ -9,13 +9,17 @@ import { getAgreements } from "../http/agreements.js" import express from "express"; import session from "express-session"; -import MemoryStore from "memorystore"; +import { PgSessionStore } from "./sessionStore.js"; import passport from "passport"; import { refresh } from "./refresh.js"; import { logout } from "./logout.js"; import { logRequest } from "./logging.js"; import { routeAgreements } from "./agreements.js"; +import { dashboardHandlers } from "./dashboard.js"; +import { makePassthrough } from "./dashboardPassthrough.js"; +import { apiKeyHandlers } from "./apiKeys.js"; import { getUsage } from "../modules/core/usage.js"; +import path from "path"; async function render(view, res, config) { try { @@ -48,6 +52,18 @@ async function renderPrivate(view, req, res, config) { } } +async function renderPrivateUI(req, res, config) { + try { + if (!req.user) { + return res.redirect('/'); + } + const uiPath = path.join(process.cwd(), "ui", "index.html"); + res.sendFile(uiPath); + } catch (e) { + console.error(e); + } +} + export async function initHTTP(app) { const config = getConfig(); @@ -58,19 +74,16 @@ export async function initHTTP(app) { done(null, user); }); - const memoryStore = MemoryStore(session); const sess = { secret: config.secureSecret, resave: false, saveUninitialized: false, - store: new memoryStore({ - checkPeriod: 86400000 // prune expired entries every 24h - }), - cookie: {}, + store: new PgSessionStore(), + cookie: { httpOnly: true, sameSite: 'lax' }, // sameSite=lax: CSRF defense } if (app.get('env') === 'production') { app.set('trust proxy', 1) // trust first proxy - sess.cookie.secure = true // serve secure cookies + sess.cookie.secure = true // serve secure cookies over HTTPS } app.use(session(sess)); app.use(passport.initialize()); @@ -91,7 +104,27 @@ export async function initHTTP(app) { app.get("/refresh", refresh); app.post('/logout', logout); + // UI + app.get("/home", (req, res) => renderPrivateUI(req, res, config)); + + const dashboard = dashboardHandlers({ service: makePassthrough() }); + app.get("/api/dashboard/summary", dashboard.summary); + app.get("/api/dashboard/transactions", dashboard.transactions); + app.get("/api/dashboard/series", dashboard.series); + app.get("/api/dashboard/verify", dashboard.verify); + app.get("/api/dashboard/transactions/:eventUuid", dashboard.event); + app.get("/api/dashboard/agreement/:agreementUuid", dashboard.agreement); + app.get("/api/dashboard/details/:eventUuid", dashboard.details); + + + const apiKeys = apiKeyHandlers(); + app.get("/api/dashboard/auth/whoami", apiKeys.whoami); + app.get("/api/dashboard/auth/keys", apiKeys.list); + app.post("/api/dashboard/auth/keys", express.json(), apiKeys.create); + app.delete("/api/dashboard/auth/keys/:id", apiKeys.revoke); + app.post(/^\/api\/v1\/.*$/, bodyParser.json(), apiMiddleware, core.post); // app.use("/api/v1", swaggerUi.serve, swaggerUi.setup(swaggerDocument)); } + diff --git a/src/http/logging.js b/backend/http/logging.js similarity index 100% rename from src/http/logging.js rename to backend/http/logging.js diff --git a/src/http/logout.js b/backend/http/logout.js similarity index 100% rename from src/http/logout.js rename to backend/http/logout.js diff --git a/src/http/public/images/icon.svg b/backend/http/public/images/icon.svg similarity index 100% rename from src/http/public/images/icon.svg rename to backend/http/public/images/icon.svg diff --git a/src/http/refresh.js b/backend/http/refresh.js similarity index 100% rename from src/http/refresh.js rename to backend/http/refresh.js diff --git a/backend/http/sessionStore.js b/backend/http/sessionStore.js new file mode 100644 index 0000000..a9f7ee8 --- /dev/null +++ b/backend/http/sessionStore.js @@ -0,0 +1,115 @@ +import session from "express-session"; +import { getPool } from "../db/index.js"; + +const ONE_DAY_MS = 86_400_000; +const DEFAULT_PRUNE_INTERVAL_MS = 15 * 60 * 1000; // 15 minutes +// Memory is per-process: a session mutated on another worker (e.g. a logout) +// can be served stale here for at most one TTL. Keep it short. +const DEFAULT_MEMORY_TTL_MS = 60 * 1000; // 60 seconds + +export const expiryOf = (sess) => + new Date(sess?.cookie?.expires ?? Date.now() + ONE_DAY_MS); + +// In-memory cache in front of Postgres: sessions survive restarts and are +// shared across workers, while the memory map serves the read-heavy hot path. +export class PgSessionStore extends session.Store { + constructor({ pruneIntervalMs = DEFAULT_PRUNE_INTERVAL_MS, memoryTtlMs = DEFAULT_MEMORY_TTL_MS } = {}) { + super(); + // sid -> { sess, expire (ms epoch), freshUntil (ms epoch) } + this._mem = new Map(); + this._memoryTtlMs = memoryTtlMs; + // unref'd so the timer never keeps the process alive. + if (pruneIntervalMs > 0) { + this._pruneTimer = setInterval(() => this.prune(), pruneIntervalMs); + this._pruneTimer.unref?.(); + } + } + + async _withPg(fn) { + const client = await getPool(); + try { + return await fn(client); + } finally { + await client.release(); + } + } + + get(sid, cb) { + const now = Date.now(); + const hit = this._mem.get(sid); + if (hit) { + if (hit.expire <= now) { + this._mem.delete(sid); + return cb(null, null); + } + if (hit.freshUntil > now) return cb(null, hit.sess); // fast path: no DB query + } + this._withPg((c) => + c.query(`SELECT sess, expire FROM session WHERE sid = $1 AND expire > NOW()`, [sid]), + ) + .then((res) => { + const row = res.rows[0]; + if (!row) { + this._mem.delete(sid); + return cb(null, null); + } + this._mem.set(sid, { + sess: row.sess, + expire: new Date(row.expire).getTime(), + freshUntil: now + this._memoryTtlMs, + }); + cb(null, row.sess); + }) + .catch(cb); + } + + set(sid, sess, cb = () => {}) { + const expire = expiryOf(sess); + this._mem.set(sid, { sess, expire: expire.getTime(), freshUntil: Date.now() + this._memoryTtlMs }); + this._withPg((c) => + c.query( + `INSERT INTO session (sid, sess, expire) VALUES ($1, $2::jsonb, $3) + ON CONFLICT (sid) DO UPDATE SET sess = EXCLUDED.sess, expire = EXCLUDED.expire, updated_ts = NOW()`, + [sid, sess, expire], + ), + ) + .then(() => cb(null)) + .catch(cb); + } + + destroy(sid, cb = () => {}) { + this._mem.delete(sid); + this._withPg((c) => c.query(`DELETE FROM session WHERE sid = $1`, [sid])) + .then(() => cb(null)) + .catch(cb); + } + + touch(sid, sess, cb = () => {}) { + const expire = expiryOf(sess); + const hit = this._mem.get(sid); + if (hit) { + hit.expire = expire.getTime(); + hit.freshUntil = Date.now() + this._memoryTtlMs; + } + this._withPg((c) => + c.query(`UPDATE session SET expire = $2, updated_ts = NOW() WHERE sid = $1`, [sid, expire]), + ) + .then(() => cb(null)) + .catch(cb); + } + + // Errors are swallowed: runs on a timer with no caller. + async prune() { + const now = Date.now(); + for (const [sid, v] of this._mem) if (v.expire <= now) this._mem.delete(sid); + try { + await this._withPg((c) => c.query(`DELETE FROM session WHERE expire <= NOW()`)); + } catch { + /* ignore */ + } + } + + stopPruning() { + if (this._pruneTimer) clearInterval(this._pruneTimer); + } +} diff --git a/backend/http/sessionStore.test.js b/backend/http/sessionStore.test.js new file mode 100644 index 0000000..0dcb93f --- /dev/null +++ b/backend/http/sessionStore.test.js @@ -0,0 +1,160 @@ +import { jest } from "@jest/globals"; + +// Manual DB mock: getPool() returns whatever fake client the current test set. +let client; +const getPool = jest.fn(async () => client); +jest.unstable_mockModule("../db/index.js", () => ({ getPool })); + +const { PgSessionStore, expiryOf } = await import("./sessionStore.js"); + +const ONE_DAY_MS = 86_400_000; + +// A fake pooled client whose query() returns `rows` and that records release(). +function mockClient(rows = []) { + return { + query: jest.fn(async () => ({ rows })), + release: jest.fn(async () => {}), + }; +} + +// Store with the prune timer disabled; memoryTtlMs chosen per test. +const makeStore = (opts = {}) => new PgSessionStore({ pruneIntervalMs: 0, ...opts }); + +// Promisify express-session's node-style callbacks for cleaner assertions. +const call = (fn) => new Promise((resolve, reject) => + fn((err, res) => (err ? reject(err) : resolve(res)))); + +beforeEach(() => { + getPool.mockClear(); + client = mockClient(); +}); + +describe("expiryOf", () => { + it("uses the cookie's expiry when present", () => { + const when = new Date("2030-01-01T00:00:00Z"); + expect(expiryOf({ cookie: { expires: when } }).getTime()).toBe(when.getTime()); + }); + + it("defaults to ~one day out when no cookie expiry", () => { + const before = Date.now(); + const got = expiryOf({}).getTime(); + expect(got).toBeGreaterThanOrEqual(before + ONE_DAY_MS - 50); + expect(got).toBeLessThanOrEqual(Date.now() + ONE_DAY_MS + 50); + }); +}); + +describe("PgSessionStore memory + PG fallback", () => { + it("get() falls back to Postgres on a cold cache and filters expired rows in SQL", async () => { + client = mockClient([{ sess: { user_id: 7 }, expire: new Date(Date.now() + ONE_DAY_MS).toISOString() }]); + const store = makeStore(); + const sess = await call((cb) => store.get("sid-1", cb)); + + expect(sess).toEqual({ user_id: 7 }); + expect(getPool).toHaveBeenCalledTimes(1); + const [sql, params] = client.query.mock.calls[0]; + expect(sql).toContain("expire > NOW()"); + expect(params).toEqual(["sid-1"]); + expect(client.release).toHaveBeenCalledTimes(1); + }); + + it("get() serves a warm in-memory session WITHOUT querying Postgres", async () => { + const store = makeStore({ memoryTtlMs: 10_000 }); + await call((cb) => store.set("sid-2", { user_id: 1, cookie: {} }, cb)); // write-through populates memory + getPool.mockClear(); + + const sess = await call((cb) => store.get("sid-2", cb)); + expect(sess).toMatchObject({ user_id: 1 }); + expect(getPool).not.toHaveBeenCalled(); // the whole point: no per-request query + }); + + it("get() re-reads Postgres once the in-memory copy goes stale", async () => { + client = mockClient([{ sess: { user_id: 5 }, expire: new Date(Date.now() + ONE_DAY_MS).toISOString() }]); + const store = makeStore({ memoryTtlMs: 0 }); // memory never counts as fresh + await call((cb) => store.set("sid", { user_id: 5, cookie: {} }, cb)); + getPool.mockClear(); + + const sess = await call((cb) => store.get("sid", cb)); + expect(getPool).toHaveBeenCalledTimes(1); // stale -> fell back to Postgres + expect(sess).toEqual({ user_id: 5 }); + }); + + it("get() drops an expired in-memory entry without a query", async () => { + const store = makeStore({ memoryTtlMs: 10_000 }); + await call((cb) => store.set("exp", { user_id: 1, cookie: { expires: new Date(Date.now() - 1000) } }, cb)); + getPool.mockClear(); + + const sess = await call((cb) => store.get("exp", cb)); + expect(sess).toBeNull(); + expect(getPool).not.toHaveBeenCalled(); + }); + + it("get() returns null when no row matches", async () => { + client = mockClient([]); + const sess = await call((cb) => makeStore().get("missing", cb)); + expect(sess).toBeNull(); + }); + + it("set() write-through upserts to Postgres (bumping updated_ts) and populates memory", async () => { + const store = makeStore({ memoryTtlMs: 10_000 }); + const when = new Date("2030-06-01T00:00:00Z"); + await call((cb) => store.set("sid-3", { cookie: { expires: when }, user_id: 1 }, cb)); + + const [sql, params] = client.query.mock.calls[0]; + expect(sql).toContain("ON CONFLICT (sid) DO UPDATE"); + expect(sql).toContain("updated_ts = NOW()"); + expect(params[0]).toBe("sid-3"); + expect(params[2]).toEqual(when); + + getPool.mockClear(); + const sess = await call((cb) => store.get("sid-3", cb)); + expect(getPool).not.toHaveBeenCalled(); // served from memory after the write-through + expect(sess).toMatchObject({ user_id: 1 }); + }); + + it("destroy() removes the session from memory and Postgres", async () => { + const store = makeStore({ memoryTtlMs: 10_000 }); + await call((cb) => store.set("sid-4", { user_id: 1, cookie: {} }, cb)); + client.query.mockClear(); + + await call((cb) => store.destroy("sid-4", cb)); + expect(client.query.mock.calls[0][0]).toContain("DELETE FROM session WHERE sid = $1"); + expect(client.query.mock.calls[0][1]).toEqual(["sid-4"]); + + // Memory was cleared -> the next get falls back to Postgres (now empty). + client = mockClient([]); + getPool.mockClear(); + expect(await call((cb) => store.get("sid-4", cb))).toBeNull(); + expect(getPool).toHaveBeenCalledTimes(1); + }); + + it("touch() bumps only the expiry", async () => { + await call((cb) => makeStore().touch("sid-5", {}, cb)); + expect(client.query.mock.calls[0][0]).toContain("UPDATE session SET expire = $2"); + }); + + it("prune() sweeps expired rows from Postgres", async () => { + const store = makeStore(); + await store.prune(); + expect(client.query.mock.calls[0][0]).toContain("DELETE FROM session WHERE expire <= NOW()"); + expect(client.release).toHaveBeenCalledTimes(1); + }); + + it("releases the client and surfaces the error when a query fails", async () => { + client = { + query: jest.fn(async () => { throw new Error("db down"); }), + release: jest.fn(async () => {}), + }; + await expect(call((cb) => makeStore({ memoryTtlMs: 0 }).get("sid", cb))).rejects.toThrow("db down"); + expect(client.release).toHaveBeenCalledTimes(1); + }); + + it("surfaces the error and calls back exactly once when acquiring a client fails", async () => { + getPool.mockImplementationOnce(async () => { throw new Error("pool exhausted"); }); + const store = makeStore({ memoryTtlMs: 0 }); + const cb = jest.fn(); + await new Promise((resolve) => store.get("sid", (...args) => { cb(...args); resolve(); })); + expect(cb).toHaveBeenCalledTimes(1); + expect(cb.mock.calls[0][0]).toBeInstanceOf(Error); + expect(cb.mock.calls[0][0].message).toBe("pool exhausted"); + }); +}); diff --git a/src/http/views/agreement.ejs b/backend/http/views/agreement.ejs similarity index 100% rename from src/http/views/agreement.ejs rename to backend/http/views/agreement.ejs diff --git a/src/http/views/dashboard.ejs b/backend/http/views/dashboard.ejs similarity index 87% rename from src/http/views/dashboard.ejs rename to backend/http/views/dashboard.ejs index 487ac47..d2ec4ca 100644 --- a/src/http/views/dashboard.ejs +++ b/backend/http/views/dashboard.ejs @@ -4,6 +4,7 @@ <%- include('./include/header.ejs', { title: 'JLINC - Dashboard' }) %> <% for (const type in config.appModules) { %> + <% if (config.appModules[type].internal) { continue; } %> <%- include('./include/app.ejs', { app: config.appModules[type], diff --git a/src/http/views/include/agreements.ejs b/backend/http/views/include/agreements.ejs similarity index 100% rename from src/http/views/include/agreements.ejs rename to backend/http/views/include/agreements.ejs diff --git a/src/http/views/include/app.ejs b/backend/http/views/include/app.ejs similarity index 100% rename from src/http/views/include/app.ejs rename to backend/http/views/include/app.ejs diff --git a/src/http/views/include/footer.ejs b/backend/http/views/include/footer.ejs similarity index 100% rename from src/http/views/include/footer.ejs rename to backend/http/views/include/footer.ejs diff --git a/src/http/views/include/header.ejs b/backend/http/views/include/header.ejs similarity index 100% rename from src/http/views/include/header.ejs rename to backend/http/views/include/header.ejs diff --git a/src/http/views/include/logo-white.svg b/backend/http/views/include/logo-white.svg similarity index 100% rename from src/http/views/include/logo-white.svg rename to backend/http/views/include/logo-white.svg diff --git a/src/http/views/login.ejs b/backend/http/views/login.ejs similarity index 100% rename from src/http/views/login.ejs rename to backend/http/views/login.ejs diff --git a/src/index.js b/backend/index.js similarity index 78% rename from src/index.js rename to backend/index.js index 935b905..c308134 100644 --- a/src/index.js +++ b/backend/index.js @@ -3,6 +3,7 @@ import { init, close, migrate, populateAgreements } from "./db/index.js"; import { loadConfig } from "./common/config.js"; import { loadApps } from "./apps.js"; import { initHTTP } from "./http/index.js"; +import { migrateLegacyApiKeys } from "./modules/core/apiKey.js"; const app = express(); app.set("env", "development"); @@ -15,6 +16,8 @@ async function main() { await populateAgreements(); await loadApps(); await initHTTP(app); + // After initHTTP so the auth rows (incl. the single/Docker user's) exist. + await migrateLegacyApiKeys(); const server = await app.listen(9090, () => { console.log(`Listening on 0.0.0.0:9090`); }); diff --git a/backend/jest.config.js b/backend/jest.config.js new file mode 100644 index 0000000..e11ca84 --- /dev/null +++ b/backend/jest.config.js @@ -0,0 +1,8 @@ +// Native ESM: tests run under Node's experimental VM modules, no Babel. +// packages/ is excluded — @jlinc/core ships its own suite. +export default { + testEnvironment: "node", + transform: {}, + testPathIgnorePatterns: ["/node_modules/", "/packages/"], + clearMocks: true, +}; diff --git a/src/modules/app/archive.js b/backend/modules/app/archive.js similarity index 100% rename from src/modules/app/archive.js rename to backend/modules/app/archive.js diff --git a/src/modules/app/core.js b/backend/modules/app/core.js similarity index 100% rename from src/modules/app/core.js rename to backend/modules/app/core.js diff --git a/backend/modules/app/dashboard.js b/backend/modules/app/dashboard.js new file mode 100644 index 0000000..f8f1fac --- /dev/null +++ b/backend/modules/app/dashboard.js @@ -0,0 +1,10 @@ +// internal: no `app` row, not API-key mintable, excluded from the endpoint count. +const key = 'dashboard'; + +export function getModuleConfig() { + return { + title: 'JLINC Dashboard', + type: key, + internal: true, + }; +} diff --git a/src/modules/auth/github.js b/backend/modules/auth/github.js similarity index 97% rename from src/modules/auth/github.js rename to backend/modules/auth/github.js index 172200c..d51ef43 100644 --- a/src/modules/auth/github.js +++ b/backend/modules/auth/github.js @@ -40,7 +40,7 @@ export async function initModule(app, passport) { failureMessage: true }), function (req, res) { - res.redirect('/dashboard'); + res.redirect('/home'); } ); } \ No newline at end of file diff --git a/src/modules/auth/google.js b/backend/modules/auth/google.js similarity index 97% rename from src/modules/auth/google.js rename to backend/modules/auth/google.js index 76b9d9d..c5d5937 100644 --- a/src/modules/auth/google.js +++ b/backend/modules/auth/google.js @@ -40,7 +40,7 @@ export async function initModule(app, passport) { failureMessage: true }), function (req, res) { - res.redirect('/dashboard'); + res.redirect('/home'); } ); } \ No newline at end of file diff --git a/src/modules/auth/oidc.js b/backend/modules/auth/oidc.js similarity index 97% rename from src/modules/auth/oidc.js rename to backend/modules/auth/oidc.js index 9c67f81..f2bbeb4 100644 --- a/src/modules/auth/oidc.js +++ b/backend/modules/auth/oidc.js @@ -46,7 +46,7 @@ export async function initModule(app, passport) { }), function (req, res) { req.session.authStrategy = 'oidc'; - res.redirect('/dashboard'); + res.redirect('/home'); } ); } \ No newline at end of file diff --git a/src/modules/auth/single.js b/backend/modules/auth/single.js similarity index 100% rename from src/modules/auth/single.js rename to backend/modules/auth/single.js diff --git a/src/modules/core/agreement.js b/backend/modules/core/agreement.js similarity index 100% rename from src/modules/core/agreement.js rename to backend/modules/core/agreement.js diff --git a/backend/modules/core/apiKey.cache.test.js b/backend/modules/core/apiKey.cache.test.js new file mode 100644 index 0000000..6e7cfef --- /dev/null +++ b/backend/modules/core/apiKey.cache.test.js @@ -0,0 +1,96 @@ +import { jest } from "@jest/globals"; + +// The bound is read per call from config, so set it before the first verify +// rather than before the import. Own file so the tiny bound cannot leak into +// apiKey.test.js. +process.env.DASHBOARD_KEY_CACHE_MAX = "2"; + +const query = jest.fn(); +const release = jest.fn(async () => {}); +jest.unstable_mockModule("../../db/index.js", () => ({ + getPool: async () => ({ query, release }), +})); + +const { loadConfig } = await import("../../common/config.js"); +await loadConfig(); + +const { verifyKey, generateKey, _resetKeyCache } = await import("./apiKey.js"); + +beforeEach(() => { + query.mockReset(); + release.mockClear(); + _resetKeyCache(); +}); + +async function mintKey(id) { + query.mockReset(); + query + .mockResolvedValueOnce({ rows: [{ id: 2 }] }) // SELECT app + .mockResolvedValueOnce({ rows: [{ id, created_ts: "2026-01-01" }] }); // INSERT + const out = await generateKey(1, "core", { label: `k${id}` }); + return { raw: out.key, keyHash: query.mock.calls[1][1][3], id }; +} + +async function verifyWithDb({ raw, keyHash, id }) { + query.mockReset(); + query + .mockResolvedValueOnce({ rows: [{ id, user_id: 1, app_id: 2, key_hash: keyHash }] }) + .mockResolvedValue({ rows: [] }); + const result = await verifyKey(raw); + return { result, selects: query.mock.calls.filter((c) => /SELECT/.test(c[0])) }; +} + +async function servedFromCache({ raw }) { + query.mockReset(); + query.mockResolvedValue({ rows: [] }); // if it does hit the DB, it finds nothing + const result = await verifyKey(raw); + return result !== null && query.mock.calls.length === 0; +} + +describe("validated-key cache bound", () => { + it("evicts the least recently used key once the cache is full", async () => { + const a = await mintKey(11); + const b = await mintKey(12); + const c = await mintKey(13); + + // Fill the cache to its bound of 2. + await verifyWithDb(a); + await verifyWithDb(b); + + // A hit counts as a use, so probes below check the EVICTED key first. + expect(await servedFromCache(a)).toBe(true); + + await verifyWithDb(c); + expect(await servedFromCache(b)).toBe(false); + expect(await servedFromCache(a)).toBe(true); + expect(await servedFromCache(c)).toBe(true); + + const { result, selects } = await verifyWithDb(b); + expect(result).toEqual({ user_id: 1, app_id: 2 }); + expect(selects).toHaveLength(1); + }); + + it("never grows past the bound, however many distinct keys verify", async () => { + const keys = []; + for (let i = 0; i < 6; i++) keys.push(await mintKey(20 + i)); + for (const k of keys) await verifyWithDb(k); + + const cached = []; + for (const k of keys) if (await servedFromCache(k)) cached.push(k.id); + expect(cached).toEqual([keys[4].id, keys[5].id]); + }); + + it("a cache hit refreshes recency without extending the entry's TTL", async () => { + const a = await mintKey(31); + await verifyWithDb(a); + + const realNow = Date.now; + try { + // Must expire on its original deadline despite being touched. + Date.now = () => realNow() + 31 * 60 * 1000; + expect(await servedFromCache(a)).toBe(false); + } finally { + Date.now = realNow; + } + }); +}); diff --git a/backend/modules/core/apiKey.js b/backend/modules/core/apiKey.js new file mode 100644 index 0000000..84abd1b --- /dev/null +++ b/backend/modules/core/apiKey.js @@ -0,0 +1,193 @@ +import crypto from "crypto"; +import { promisify } from "util"; +import { getPool } from "../../db/index.js"; +import { getConfig } from "../../common/config.js"; + +const scrypt = promisify(crypto.scrypt); +const KEYLEN = 64; + +// Read per call rather than captured at import, so loadConfig() governs these +// the same way it governs the rest of config.dashboard. +// +// Per-process, so a revoked key can linger in another worker for one TTL. +const cacheTtlMs = () => getConfig().dashboard.keyCache.ttlMs; + +// The TTL alone does not bound the cache: an expired entry is only dropped when +// that same key is looked up again, so idle keys would accumulate forever. +const cacheMaxEntries = () => getConfig().dashboard.keyCache.max; + +// Keyed by a digest of the raw key, never the raw key. Insertion order is +// recency order, so the front of the Map is the least-recently-used entry. +const cache = new Map(); +const fastDigest = (raw) => crypto.createHash("sha256").update(raw).digest("hex"); + +// `expiresAt` is deliberately untouched, so a hammered key still expires on +// time and revocation staleness stays bounded at one TTL. +function touch(dg, entry) { + cache.delete(dg); + cache.set(dg, entry); +} + +// Sweeps only when full, so the common insert stays O(1). +function remember(dg, entry) { + const max = cacheMaxEntries(); + if (cacheTtlMs() <= 0) return; + if (cache.size >= max) { + const now = Date.now(); + for (const [k, v] of cache) if (v.expiresAt <= now) cache.delete(k); + } + cache.set(dg, entry); + while (cache.size > max) { + const lru = cache.keys().next().value; + if (lru === undefined) break; + cache.delete(lru); + } +} + +export function _resetKeyCache() { + cache.clear(); +} + +// ":". Guards a DB dump, not a weak-password guess. +async function scryptHash(raw) { + const salt = crypto.randomBytes(16); + const derived = await scrypt(raw, salt, KEYLEN); + return `${salt.toString("hex")}:${derived.toString("hex")}`; +} + +async function scryptVerify(raw, stored) { + const [saltHex, derivedHex] = String(stored).split(":"); + if (!saltHex || !derivedHex) return false; + const expected = Buffer.from(derivedHex, "hex"); + const derived = await scrypt(raw, Buffer.from(saltHex, "hex"), KEYLEN); + return derived.length === expected.length && crypto.timingSafeEqual(derived, expected); +} + +const newRawKey = () => crypto.randomBytes(32).toString("hex"); // 64 hex chars +const prefixOf = (raw) => raw.slice(0, 8); + +export async function verifyKey(rawKey) { + if (!rawKey) return null; + const dg = fastDigest(rawKey); + + const cached = cache.get(dg); + if (cached && cached.expiresAt > Date.now()) { + touch(dg, cached); + return { user_id: cached.user_id, app_id: cached.app_id }; + } + if (cached) cache.delete(dg); + + const client = await getPool(); + try { + const rows = ( + await client.query( + `SELECT id, user_id, app_id, key_hash FROM api_key + WHERE key_prefix = $1 AND (expires_ts IS NULL OR expires_ts > NOW())`, + [prefixOf(rawKey)], + ) + ).rows; + for (const row of rows) { + if (await scryptVerify(rawKey, row.key_hash)) { + client.query(`UPDATE api_key SET last_used_ts = NOW() WHERE id = $1`, [row.id]).catch(() => {}); + remember(dg, { user_id: row.user_id, app_id: row.app_id, id: row.id, expiresAt: Date.now() + cacheTtlMs() }); + return { user_id: row.user_id, app_id: row.app_id }; + } + } + return null; + } finally { + await client.release(); + } +} + +// Returns the RAW key ONCE; only the salted hash + prefix are persisted. +export async function generateKey(userId, appType, { label = null, expiresTs = null } = {}) { + const client = await getPool(); + try { + const app = (await client.query(`SELECT id FROM app WHERE type = $1`, [appType])).rows[0]; + if (!app) throw new Error(`unknown app type: ${appType}`); + const raw = newRawKey(); + const keyHash = await scryptHash(raw); + const res = await client.query( + `INSERT INTO api_key (user_id, app_id, label, key_hash, key_prefix, expires_ts) + VALUES ($1, $2, $3, $4, $5, $6) RETURNING id, created_ts`, + [userId, app.id, label, keyHash, prefixOf(raw), expiresTs], + ); + return { + id: res.rows[0].id, + key: raw, + prefix: prefixOf(raw), + appType, + label, + expiresTs, + createdTs: res.rows[0].created_ts, + }; + } finally { + await client.release(); + } +} + +// Idempotent import of the legacy plaintext keys in the `auth` table into the +// hashed api_key store, so the same keys keep authenticating via verifyKey and +// now appear in the /home API-key screen. +export async function migrateLegacyApiKeys() { + const client = await getPool(); + try { + const { rows } = await client.query( + `SELECT user_id, app_id, api_key FROM public.auth WHERE api_key IS NOT NULL`, + ); + let imported = 0; + for (const { user_id, app_id, api_key } of rows) { + const prefix = prefixOf(api_key); + const exists = await client.query( + `SELECT 1 FROM api_key WHERE user_id = $1 AND app_id = $2 AND key_prefix = $3 LIMIT 1`, + [user_id, app_id, prefix], + ); + if (exists.rowCount > 0) continue; + const keyHash = await scryptHash(api_key); + await client.query( + `INSERT INTO api_key (user_id, app_id, label, key_hash, key_prefix) + VALUES ($1, $2, $3, $4, $5)`, + [user_id, app_id, "Imported from /dashboard", keyHash, prefix], + ); + imported++; + } + if (imported > 0) console.log(`Imported ${imported} legacy API key(s) into the hashed key store`); + return imported; + } finally { + await client.release(); + } +} + +export async function listKeys(userId) { + const client = await getPool(); + try { + return ( + await client.query( + `SELECT k.id, ap.type AS "appType", k.label, k.key_prefix AS "prefix", + k.expires_ts AS "expiresTs", k.last_used_ts AS "lastUsedTs", k.created_ts AS "createdTs" + FROM api_key k JOIN app ap ON ap.id = k.app_id + WHERE k.user_id = $1 + ORDER BY k.created_ts DESC`, + [userId], + ) + ).rows; + } finally { + await client.release(); + } +} + +export async function revokeKey(userId, id) { + const client = await getPool(); + try { + const res = await client.query( + `DELETE FROM api_key WHERE id = $1 AND user_id = $2 RETURNING id`, + [id, userId], + ); + if (res.rowCount > 0) { + for (const [k, v] of cache) if (v.id === id) cache.delete(k); + } + return res.rowCount > 0; + } finally { + await client.release(); + } +} diff --git a/backend/modules/core/apiKey.test.js b/backend/modules/core/apiKey.test.js new file mode 100644 index 0000000..ebea2bd --- /dev/null +++ b/backend/modules/core/apiKey.test.js @@ -0,0 +1,144 @@ +import { jest } from "@jest/globals"; + +// Manual DB mock: getPool() returns a fake pooled client whose query() the test +// drives. No real database, no injection seams — the module under test calls the +// real getPool(), which Jest replaces here. +const query = jest.fn(); +const release = jest.fn(async () => {}); +jest.unstable_mockModule("../../db/index.js", () => ({ + getPool: async () => ({ query, release }), +})); + +const { verifyKey, generateKey, listKeys, revokeKey, migrateLegacyApiKeys, _resetKeyCache } = await import("./apiKey.js"); + +beforeEach(() => { + query.mockReset(); + release.mockClear(); + _resetKeyCache(); +}); + +// Mint a key through generateKey and capture the stored (salted) hash, so the +// verifyKey tests can round-trip a real scrypt hash without a DB. +async function mintKey({ userId = 1, appId = 2 } = {}) { + query + .mockResolvedValueOnce({ rows: [{ id: appId }] }) // SELECT app + .mockResolvedValueOnce({ rows: [{ id: 9, created_ts: "2026-01-01" }] }); // INSERT + const out = await generateKey(userId, "core", { label: "ci" }); + const insertParams = query.mock.calls[1][1]; + return { raw: out.key, keyHash: insertParams[3], out }; +} + +describe("generateKey", () => { + it("stores a salted scrypt hash (not the raw key) and returns the raw key once", async () => { + const { raw, keyHash, out } = await mintKey(); + expect(raw).toMatch(/^[0-9a-f]{64}$/); + expect(out).toMatchObject({ id: 9, prefix: raw.slice(0, 8), appType: "core", label: "ci" }); + // Stored hash is ":" and never contains the raw key. + expect(keyHash).toMatch(/^[0-9a-f]{32}:[0-9a-f]{128}$/); + expect(keyHash).not.toContain(raw); + expect(query.mock.calls[1][1]).not.toContain(raw); + }); + + it("throws for an unknown app type", async () => { + query.mockResolvedValueOnce({ rows: [] }); // app lookup empty + await expect(generateKey(1, "nope", {})).rejects.toThrow("unknown app type"); + }); +}); + +describe("verifyKey", () => { + it("returns null for an empty key without touching the DB", async () => { + expect(await verifyKey("")).toBeNull(); + expect(query).not.toHaveBeenCalled(); + }); + + it("verifies a real key by prefix + scrypt, then serves the cache without a DB query", async () => { + const { raw, keyHash } = await mintKey(); + query.mockReset(); + // First verify: prefix lookup returns the candidate, UPDATE last_used is best-effort. + query + .mockResolvedValueOnce({ rows: [{ id: 9, user_id: 1, app_id: 2, key_hash: keyHash }] }) + .mockResolvedValue({ rows: [] }); + + const first = await verifyKey(raw); + expect(first).toEqual({ user_id: 1, app_id: 2 }); + expect(query.mock.calls[0][1]).toEqual([raw.slice(0, 8)]); // looked up by prefix + + // Second verify: served from the 30-min cache — no new SELECT. + query.mockClear(); + const second = await verifyKey(raw); + expect(second).toEqual({ user_id: 1, app_id: 2 }); + const selects = query.mock.calls.filter((c) => /SELECT/.test(c[0])); + expect(selects).toHaveLength(0); + }); + + it("returns null when a candidate's hash does not match the raw key", async () => { + const { keyHash } = await mintKey(); + query.mockReset(); + query.mockResolvedValueOnce({ rows: [{ id: 9, user_id: 1, app_id: 2, key_hash: keyHash }] }); + expect(await verifyKey("some-other-raw-key-that-does-not-match")).toBeNull(); + }); + + it("returns null when no candidate row matches the prefix", async () => { + query.mockResolvedValueOnce({ rows: [] }); + expect(await verifyKey("deadbeefcafefeed")).toBeNull(); + }); +}); + +describe("listKeys", () => { + it("returns metadata rows for the user", async () => { + const rows = [{ id: 1, appType: "core", prefix: "ab12cd34" }]; + query.mockResolvedValueOnce({ rows }); + expect(await listKeys(7)).toEqual(rows); + expect(query.mock.calls[0][1]).toEqual([7]); + }); +}); + +describe("revokeKey", () => { + it("deletes the user's key and evicts its cached validation", async () => { + // Prime the cache by verifying a real key (id 9). + const { raw, keyHash } = await mintKey(); + query.mockReset(); + query + .mockResolvedValueOnce({ rows: [{ id: 9, user_id: 1, app_id: 2, key_hash: keyHash }] }) + .mockResolvedValue({ rows: [] }); + await verifyKey(raw); + + // Revoke id 9 -> deletes, and the cached entry is evicted so the next verify re-hits the DB. + query.mockReset(); + query.mockResolvedValueOnce({ rowCount: 1, rows: [{ id: 9 }] }); + expect(await revokeKey(7, 9)).toBe(true); + expect(query.mock.calls[0][1]).toEqual([9, 7]); // scoped to (id, user_id) + + query.mockReset(); + query.mockResolvedValueOnce({ rows: [] }); // no candidate now + expect(await verifyKey(raw)).toBeNull(); // cache was evicted -> DB consulted again + expect(query.mock.calls[0][0]).toMatch(/SELECT/); + }); + + it("returns false when nothing was deleted", async () => { + query.mockResolvedValueOnce({ rowCount: 0, rows: [] }); + expect(await revokeKey(7, 3)).toBe(false); + }); +}); + +describe("migrateLegacyApiKeys", () => { + it("imports each legacy key once, hashed and with its prefix", async () => { + query + .mockResolvedValueOnce({ rows: [{ user_id: 1, app_id: 2, api_key: "abcdef1234567890" }] }) // SELECT auth + .mockResolvedValueOnce({ rowCount: 0, rows: [] }) // existence check -> not present + .mockResolvedValueOnce({ rows: [] }); // INSERT + expect(await migrateLegacyApiKeys()).toBe(1); + const [sql, params] = query.mock.calls[2]; + expect(sql).toMatch(/INSERT INTO api_key/); + expect(params[4]).toBe("abcdef12"); // key_prefix = first 8 chars of the raw key + expect(params[3]).toContain(":"); // salted scrypt hash ":" + }); + + it("skips a key that was already imported (idempotent)", async () => { + query + .mockResolvedValueOnce({ rows: [{ user_id: 1, app_id: 2, api_key: "abcdef1234567890" }] }) // SELECT auth + .mockResolvedValueOnce({ rowCount: 1, rows: [{ ok: 1 }] }); // existence check -> present + expect(await migrateLegacyApiKeys()).toBe(0); + expect(query).toHaveBeenCalledTimes(2); // SELECT + existence check, no INSERT + }); +}); diff --git a/src/modules/core/archive.js b/backend/modules/core/archive.js similarity index 100% rename from src/modules/core/archive.js rename to backend/modules/core/archive.js diff --git a/src/modules/core/audit.js b/backend/modules/core/audit.js similarity index 100% rename from src/modules/core/audit.js rename to backend/modules/core/audit.js diff --git a/src/modules/core/data/agreement.js b/backend/modules/core/data/agreement.js similarity index 98% rename from src/modules/core/data/agreement.js rename to backend/modules/core/data/agreement.js index 05068bd..2d298bd 100644 --- a/src/modules/core/data/agreement.js +++ b/backend/modules/core/data/agreement.js @@ -3,6 +3,7 @@ const { JlincAgreement, JlincAudit } = pkg; import { getPool } from "../../../db/index.js"; import { entity } from "./entity.js"; import { putQueue } from "../../../common/queue.js"; +import { cacheAgreementMarkdown } from "../../../util/cacheAgreement.js"; async function getAgreement(client, userId, id, key) { const whereClause = id ? `a.agreement_id_uuid = $1` : `a.agreement_id_uuid = (SELECT agreement_uuid FROM agreement_content WHERE lower(key) = lower($1))`; @@ -439,6 +440,9 @@ async function create(input, userId, _client, uuid) { agreement.agreementId = uuid; } await save(client, userId, agreement, input.public); + for (const uri of (agreement.references || [])) { + await cacheAgreementMarkdown({ userId, agreementUuid: agreement.agreementId, uri }, client); + } response = { message: `created and saved: ${agreement.agreementId}`, data: agreement, diff --git a/src/modules/core/data/audit.js b/backend/modules/core/data/audit.js similarity index 97% rename from src/modules/core/data/audit.js rename to backend/modules/core/data/audit.js index fce3ce7..9294f98 100644 --- a/src/modules/core/data/audit.js +++ b/backend/modules/core/data/audit.js @@ -134,9 +134,11 @@ async function verify(input, userId) { valid: [], invalid: [], }; - input.didDocs = []; - for (const shortName of input.shortNames) { - input.didDocs.push((await entity.getEntity(client, userId, shortName)).didDoc) + if (!input.didDocs) { + input.didDocs = []; + for (const shortName of input.shortNames || []) { + input.didDocs.push((await entity.getEntity(client, userId, shortName)).didDoc) + } } delete input.shortNames; const organizedById = []; diff --git a/src/modules/core/data/entity.js b/backend/modules/core/data/entity.js similarity index 100% rename from src/modules/core/data/entity.js rename to backend/modules/core/data/entity.js diff --git a/src/modules/core/data/event.js b/backend/modules/core/data/event.js similarity index 95% rename from src/modules/core/data/event.js rename to backend/modules/core/data/event.js index 875325d..347f464 100644 --- a/src/modules/core/data/event.js +++ b/backend/modules/core/data/event.js @@ -3,6 +3,7 @@ const { JlincEvent, JlincAudit } = pkg; import { getPool } from "../../../db/index.js"; import { entity } from "./entity.js"; import { putQueue } from "../../../common/queue.js"; +import { cacheAgreementMarkdown } from "../../../util/cacheAgreement.js"; async function getEvent(client, userId, id, includeData, meta) { const dataSql = includeData @@ -286,6 +287,12 @@ async function create(input, userId, _client, _sender) { delete input.recipientShortName const event = await JlincEvent.create(input); await save(client, userId, event, input.meta); + // Pull down any referenced agreement content now (request refs are URIs, + // permission refs are { reference, permitted }), so a get never has to. + for (const ref of (event.references || [])) { + const uri = typeof ref === "string" ? ref : ref?.reference; + await cacheAgreementMarkdown({ userId, agreementUuid: event.agreementId, uri }, client); + } response = { message: `created and saved: ${event.eventId}`, data: event, diff --git a/src/modules/core/data/index.js b/backend/modules/core/data/index.js similarity index 100% rename from src/modules/core/data/index.js rename to backend/modules/core/data/index.js diff --git a/src/modules/core/did.js b/backend/modules/core/did.js similarity index 100% rename from src/modules/core/did.js rename to backend/modules/core/did.js diff --git a/src/modules/core/event.js b/backend/modules/core/event.js similarity index 100% rename from src/modules/core/event.js rename to backend/modules/core/event.js diff --git a/src/modules/core/index.js b/backend/modules/core/index.js similarity index 82% rename from src/modules/core/index.js rename to backend/modules/core/index.js index 0686676..bcbbc69 100644 --- a/src/modules/core/index.js +++ b/backend/modules/core/index.js @@ -6,8 +6,29 @@ import { data } from "./data/index.js"; import { archive } from "./archive.js"; import { trackUsage } from "./usage.js"; import { getConfig } from "../../common/config.js"; +import { dashboard } from "../dashboard/index.js"; import { evaluate } from "../pep/index.js"; +// The event/agreement UUIDs a request touched, so a usage row can link back to +// them. Checks the known request/response shapes in priority order; non-core +// calls (DID ops, etc.) have neither and fall through to null. +function pickUsageIds(input, response) { + const eventId = + input?.eventId ?? + response?.eventId ?? + response?.event?.eventId ?? + response?.created?.eventId ?? + null; + const agreementId = + input?.agreementId ?? + response?.agreementId ?? + response?.agreement?.agreementId ?? + response?.event?.agreementId ?? + response?.created?.agreementId ?? + null; + return { eventId, agreementId }; +} + async function post(req, res) { let response = { success: false, @@ -155,11 +176,20 @@ async function post(req, res) { break; } } - if (!type) { - response.error = 'Page not found'; - errorCode = 404; + } + // Dashboard is its own app module: read-only console data, dispatched + // separately from core and typed 'dashboard' so it never counts as usage. + if (evaluation && !type && Object.keys(config.appModules).includes('dashboard')) { + const dashboardResponse = await dashboard.dispatch(req.url, input, req.session.user_id); + if (dashboardResponse) { + response = dashboardResponse; + type = 'dashboard'; } } + if (evaluation && !type) { + response.error = 'Page not found'; + errorCode = 404; + } } catch (e) { console.error(e); response.error = e.message; @@ -185,7 +215,11 @@ async function post(req, res) { req.apiMessage = response.message; res.status(200).json(response.data); } - await trackUsage(req.session.user_id, req.url, type, response?.error ? false : true); + // Dashboard calls are console reads, not billable API usage — don't track them. + if (type !== 'dashboard') { + const { eventId, agreementId } = pickUsageIds(req.body, response); + await trackUsage(req.session.user_id, req.url, type, response?.error ? false : true, eventId, agreementId); + } } } diff --git a/src/modules/core/usage.js b/backend/modules/core/usage.js similarity index 86% rename from src/modules/core/usage.js rename to backend/modules/core/usage.js index 5d6bb61..e32cf06 100644 --- a/src/modules/core/usage.js +++ b/backend/modules/core/usage.js @@ -1,6 +1,6 @@ import { getPool } from "../../db/index.js"; -export async function trackUsage(user_id, url, type, success) { +export async function trackUsage(user_id, url, type, success, eventId = null, agreementId = null) { const client = await getPool(); try { await client.query(` @@ -19,7 +19,9 @@ export async function trackUsage(user_id, url, type, success) { INSERT INTO usage ( user_id, usage_url_id, - success + success, + event_id_uuid, + agreement_id_uuid ) VALUES ( $1, ( @@ -27,12 +29,16 @@ export async function trackUsage(user_id, url, type, success) { FROM usage_url WHERE url = $2 ), - $3 + $3, + $4, + $5 ); `, [ user_id, url, success, + eventId, + agreementId, ]); } catch(e) { console.error(e) diff --git a/backend/modules/dashboard/account.js b/backend/modules/dashboard/account.js new file mode 100644 index 0000000..c43131d --- /dev/null +++ b/backend/modules/dashboard/account.js @@ -0,0 +1,12 @@ +import { getPool } from "../../db/index.js"; + +// Legacy `auth` table only; the user-managed api_key store is counted separately. +export async function countApiKeys(userId) { + const client = await getPool(); + try { + const r = await client.query(`SELECT COUNT(*)::int AS n FROM auth WHERE user_id = $1`, [userId]); + return r.rows[0].n; + } finally { + await client.release(); + } +} diff --git a/backend/modules/dashboard/api.js b/backend/modules/dashboard/api.js new file mode 100644 index 0000000..6767871 --- /dev/null +++ b/backend/modules/dashboard/api.js @@ -0,0 +1,50 @@ +import { service as defaultService } from "./service.js"; +import { parseWindow, parseTransactionsQuery, parseVerifyTargets, isUuid } from "./validate.js"; + +// `input` is the request body on the API-key path and the query string on the +// browser path; both are plain string maps, so one parser serves both. +// `status` is for the browser routes — the API-key router ignores it. +export function makeDashboardApi(service = defaultService) { + return { + async summary(input, userId) { + return { message: "dashboard summary", status: 200, data: await service.summary(userId, parseWindow(input)) }; + }, + + async transactions(input, userId) { + const parsed = parseTransactionsQuery(input); + if (!parsed.ok) return { message: "invalid request", status: 400, data: { error: parsed.error } }; + return { message: "dashboard transactions", status: 200, data: await service.transactions(userId, parsed.value) }; + }, + + async series(input, userId) { + return { message: "dashboard series", status: 200, data: await service.series(userId, parseWindow(input)) }; + }, + + async verify(input, userId) { + return { message: "dashboard verify", status: 200, data: await service.verify(userId, parseVerifyTargets(input)) }; + }, + + async event(input, userId) { + if (!isUuid(input?.eventUuid)) return { message: "invalid request", status: 400, data: { error: "invalid event id" } }; + const record = await service.event(userId, input.eventUuid); + if (!record) return { message: "dashboard event", status: 404, data: { error: "event not found" } }; + return { message: "dashboard event", status: 200, data: record }; + }, + + async agreement(input, userId) { + if (!isUuid(input?.agreementUuid)) return { message: "invalid request", status: 400, data: { error: "invalid agreement id" } }; + const record = await service.agreement(userId, input.agreementUuid); + if (!record) return { message: "dashboard agreement", status: 404, data: { error: "agreement not found" } }; + return { message: "dashboard agreement", status: 200, data: record }; + }, + + async details(input, userId) { + if (!isUuid(input?.eventUuid)) return { message: "invalid request", status: 400, data: { error: "invalid event id" } }; + const record = await service.details(userId, input.eventUuid); + if(!record) return { message: "dashboard event details", status: 404, data: { error: "event details not found" } }; + return { message: "dashboard event details", status: 200, data: record }; + }, + }; +} + +export const dashboardApi = makeDashboardApi(); diff --git a/backend/modules/dashboard/api.test.js b/backend/modules/dashboard/api.test.js new file mode 100644 index 0000000..55a89fa --- /dev/null +++ b/backend/modules/dashboard/api.test.js @@ -0,0 +1,60 @@ +import { jest } from "@jest/globals"; +import { makeDashboardApi } from "./api.js"; + +const fakeService = () => ({ + summary: jest.fn(async () => ({ apiUsage: { total: 3 } })), + transactions: jest.fn(async (_u, opts) => ({ rows: [], total: 0, limit: opts.limit, offset: opts.offset })), + series: jest.fn(async () => ({ usage: [], storage: [] })), + verify: jest.fn(async (_u, targets) => ({ results: targets })), + event: jest.fn(async () => ({ eventId: "x", data: { a: 1 } })), +}); + +describe("dashboard API adapter", () => { + it("summary wraps the service result with a message + window", async () => { + const service = fakeService(); + const out = await makeDashboardApi(service).summary({ from: "2026-01-01", to: "2026-01-31" }, 7); + expect(out).toEqual({ message: "dashboard summary", status: 200, data: { apiUsage: { total: 3 } } }); + expect(service.summary).toHaveBeenCalledWith(7, { from: "2026-01-01", to: "2026-01-31" }); + }); + + it("transactions passes validated options through", async () => { + const service = fakeService(); + const out = await makeDashboardApi(service).transactions({ type: "audit", limit: "5" }, 7); + expect(out.message).toBe("dashboard transactions"); + expect(out.status).toBe(200); + expect(service.transactions).toHaveBeenCalledWith(7, expect.objectContaining({ type: "audit", limit: 5 })); + }); + + it("series wraps the combined usage+storage result with the window", async () => { + const service = fakeService(); + const out = await makeDashboardApi(service).series({ from: "2026-01-01", to: "2026-01-31" }, 7); + expect(out).toEqual({ message: "dashboard series", status: 200, data: { usage: [], storage: [] } }); + expect(service.series).toHaveBeenCalledWith(7, { from: "2026-01-01", to: "2026-01-31" }); + }); + + it("transactions returns a 400 error payload (not a throw) on invalid input", async () => { + const service = fakeService(); + const out = await makeDashboardApi(service).transactions({ type: "evil" }, 7); + expect(out.status).toBe(400); + expect(out.data.error).toBeTruthy(); + expect(service.transactions).not.toHaveBeenCalled(); + }); + + it("verify forwards valid event uuids and audit ids, dropping junk", async () => { + const service = fakeService(); + const good = "3fa85f64-5717-4562-b3fc-2c963f66afa6"; + const out = await makeDashboardApi(service).verify({ ids: `${good},junk`, auditIds: "9,nope" }, 7); + expect(out.data.results).toEqual({ eventUuids: [good], auditIds: [9] }); + }); + + it("event rejects a non-uuid with 400 and 404s a missing record", async () => { + const service = fakeService(); + const api = makeDashboardApi(service); + const bad = await api.event({ eventUuid: "nope" }, 7); + expect(bad).toMatchObject({ status: 400, data: { error: "invalid event id" } }); + + service.event = jest.fn(async () => null); + const missing = await api.event({ eventUuid: "3fa85f64-5717-4562-b3fc-2c963f66afa6" }, 7); + expect(missing).toMatchObject({ status: 404, data: { error: "event not found" } }); + }); +}); diff --git a/backend/modules/dashboard/cache.js b/backend/modules/dashboard/cache.js new file mode 100644 index 0000000..1f4d749 --- /dev/null +++ b/backend/modules/dashboard/cache.js @@ -0,0 +1,33 @@ +import { getPool } from "../../db/index.js"; + +// Lockless: concurrent cold misses may both compute, last writer wins. +export async function getCached(userId, tag, ttlMs, compute) { + let client = await getPool(); + try { + const hit = ( + await client.query( + `SELECT data FROM cache + WHERE user_id = $1 AND tag = $2 + AND updated_ts > NOW() - ($3 * interval '1 millisecond')`, + [userId, tag, ttlMs], + ) + ).rows[0]; + if (hit) return hit.data; + } finally { + await client.release(); + } + + const data = await compute(); + + client = await getPool(); + try { + await client.query( + `INSERT INTO cache (user_id, tag, data) VALUES ($1, $2, $3::jsonb) + ON CONFLICT (user_id, tag) DO UPDATE SET data = EXCLUDED.data, updated_ts = NOW()`, + [userId, tag, data], + ); + } finally { + await client.release(); + } + return data; +} diff --git a/backend/modules/dashboard/cache.test.js b/backend/modules/dashboard/cache.test.js new file mode 100644 index 0000000..58428f1 --- /dev/null +++ b/backend/modules/dashboard/cache.test.js @@ -0,0 +1,46 @@ +import { jest } from "@jest/globals"; + +// Manual DB mock: getPool() returns a fake client whose query() the test drives. +const query = jest.fn(); +const release = jest.fn(async () => {}); +jest.unstable_mockModule("../../db/index.js", () => ({ getPool: async () => ({ query, release }) })); + +const { getCached } = await import("./cache.js"); + +beforeEach(() => { + query.mockReset(); + release.mockClear(); +}); + +describe("getCached", () => { + it("returns the cached value on a fresh hit without computing", async () => { + query.mockResolvedValueOnce({ rows: [{ data: { v: 1 } }] }); + const compute = jest.fn(async () => ({ v: 999 })); + const out = await getCached(7, "storage", 1000, compute); + + expect(out).toEqual({ v: 1 }); + expect(compute).not.toHaveBeenCalled(); + expect(query).toHaveBeenCalledTimes(1); // SELECT only + expect(query.mock.calls[0][1]).toEqual([7, "storage", 1000]); + }); + + it("computes and upserts on a miss", async () => { + query.mockResolvedValueOnce({ rows: [] }).mockResolvedValueOnce({ rows: [] }); // SELECT miss, then INSERT + const compute = jest.fn(async () => ({ v: 2 })); + const out = await getCached(7, "storage", 1000, compute); + + expect(out).toEqual({ v: 2 }); + expect(compute).toHaveBeenCalledTimes(1); + expect(query).toHaveBeenCalledTimes(2); // SELECT then INSERT + const [sql, params] = query.mock.calls[1]; + expect(sql).toContain("ON CONFLICT (user_id, tag) DO UPDATE"); + expect(params).toEqual([7, "storage", { v: 2 }]); + }); + + it("does not cache when compute throws", async () => { + query.mockResolvedValueOnce({ rows: [] }); + const compute = jest.fn(async () => { throw new Error("nope"); }); + await expect(getCached(7, "storage", 1000, compute)).rejects.toThrow("nope"); + expect(query).toHaveBeenCalledTimes(1); // SELECT only; no INSERT + }); +}); diff --git a/backend/modules/dashboard/index.js b/backend/modules/dashboard/index.js new file mode 100644 index 0000000..77885e0 --- /dev/null +++ b/backend/modules/dashboard/index.js @@ -0,0 +1,17 @@ +import { dashboardApi } from "./api.js"; + +const routes = { + '/api/v1/data/dashboard/summary': dashboardApi.summary, + '/api/v1/data/dashboard/transactions': dashboardApi.transactions, + '/api/v1/data/dashboard/series': dashboardApi.series, + '/api/v1/data/dashboard/verify': dashboardApi.verify, + '/api/v1/data/dashboard/event': dashboardApi.event, +}; + +export async function dispatch(url, input, userId) { + // hasOwn: a bare lookup resolves Object.prototype members to callables. + if (!Object.hasOwn(routes, url)) return null; + return routes[url](input, userId); +} + +export const dashboard = { dispatch }; diff --git a/backend/modules/dashboard/query.js b/backend/modules/dashboard/query.js new file mode 100644 index 0000000..f967667 --- /dev/null +++ b/backend/modules/dashboard/query.js @@ -0,0 +1,79 @@ +// The only values ever interpolated into SQL (sort column and direction) come +// from the whitelists here; everything else is a bind parameter. + +const SORT_COLUMNS = { + agreementUuid: '"agreementUuid"', + eventUuid: '"eventUuid"', + sender: "sender", + date: "date", + size: '"size"', +}; + +export const SORTABLE_KEYS = Object.keys(SORT_COLUMNS); + +export function sortClause(sort, dir) { + const col = SORT_COLUMNS[sort] || "date"; + const direction = String(dir).toLowerCase() === "asc" ? "ASC" : "DESC"; + return `ORDER BY ${col} ${direction} NULLS LAST`; +} + +export function eventSearchClause(idx) { + return ( + `(e.event_id_uuid::text ILIKE '%'||$${idx}||'%'` + + ` OR agr.agreement_id_uuid::text ILIKE '%'||$${idx}||'%'` + + ` OR e.sender_id ILIKE '%'||$${idx}||'%'` + + ` OR e.recipient_id ILIKE '%'||$${idx}||'%')` + ); +} + +export const DEFAULT_LIMIT = 50; +const MAX_LIMIT = 200; + +// Server-side bound, applied on every request regardless of what the UI sends. +export function clampLimit(v) { + const n = parseInt(v, 10); + if (!Number.isFinite(n)) return DEFAULT_LIMIT; + return Math.min(Math.max(n, 1), MAX_LIMIT); +} + +export function clampOffset(v) { + const n = parseInt(v, 10); + if (!Number.isFinite(n) || n < 0) return 0; + return n; +} + +export function pageCount(total, perPage = DEFAULT_LIMIT) { + return Math.max(1, Math.ceil((Number(total) || 0) / (perPage || DEFAULT_LIMIT))); +} + +const MAX_WINDOW_DAYS = 366; + +// Enforced server-side on every request, not in the UI. +export function windowParams(query = {}) { + const iso = (d) => d.toISOString().slice(0, 10); + const valid = (s) => typeof s === "string" && /^\d{4}-\d{2}-\d{2}$/.test(s); + + const today = new Date(); + today.setUTCHours(0, 0, 0, 0); + + const to = valid(query.to) ? query.to : iso(today); + let from = valid(query.from) ? query.from : null; + if (!from) { + const d = new Date(`${to}T00:00:00Z`); + d.setUTCDate(d.getUTCDate() - 29); + from = iso(d); + } + + let fromD = new Date(`${from}T00:00:00Z`); + const toD = new Date(`${to}T00:00:00Z`); + if (fromD > toD) { + from = to; + fromD = new Date(toD); + } + if ((toD - fromD) / 86_400_000 > MAX_WINDOW_DAYS) { + const capped = new Date(toD); + capped.setUTCDate(capped.getUTCDate() - MAX_WINDOW_DAYS); + from = iso(capped); + } + return { from, to }; +} diff --git a/backend/modules/dashboard/query.test.js b/backend/modules/dashboard/query.test.js new file mode 100644 index 0000000..74cee4c --- /dev/null +++ b/backend/modules/dashboard/query.test.js @@ -0,0 +1,107 @@ +import { + sortClause, + SORTABLE_KEYS, + eventSearchClause, + clampLimit, + clampOffset, + pageCount, + windowParams, + DEFAULT_LIMIT, +} from "./query.js"; + +describe("sortClause", () => { + it("maps known keys and directions", () => { + expect(sortClause("size", "asc")).toBe('ORDER BY "size" ASC NULLS LAST'); + expect(sortClause("sender", "desc")).toBe("ORDER BY sender DESC NULLS LAST"); + }); + + it("falls back to date DESC for unknown/missing keys", () => { + expect(sortClause(undefined, undefined)).toBe("ORDER BY date DESC NULLS LAST"); + expect(sortClause("nope", "asc")).toBe("ORDER BY date ASC NULLS LAST"); + }); + + it("never interpolates an attacker-supplied sort key (injection safety)", () => { + const clause = sortClause("date; DROP TABLE event; --", "asc"); + expect(clause).toBe("ORDER BY date ASC NULLS LAST"); + expect(clause).not.toMatch(/DROP TABLE/); + }); + + it("only honors exactly 'asc' for direction, otherwise DESC", () => { + expect(sortClause("date", "ASC")).toContain("ASC"); + expect(sortClause("date", "ascending")).toContain("DESC"); + expect(sortClause("date", "; DELETE")).toContain("DESC"); + }); + + it("exposes the sortable keys", () => { + expect(SORTABLE_KEYS).toEqual( + expect.arrayContaining(["agreementUuid", "eventUuid", "sender", "date", "size"]), + ); + }); +}); + +describe("eventSearchClause", () => { + it("references the given bind index and only that index (no interpolated value)", () => { + const c = eventSearchClause(3); + expect(c).toContain("$3"); + expect(c).toContain("ILIKE"); + // no other positional params leak in + expect(c.match(/\$\d+/g).every((p) => p === "$3")).toBe(true); + }); +}); + +describe("clampLimit / clampOffset", () => { + it("clamps limit into [1, 200] with a default", () => { + expect(clampLimit(undefined)).toBe(DEFAULT_LIMIT); + expect(clampLimit("abc")).toBe(DEFAULT_LIMIT); + expect(clampLimit("0")).toBe(1); + expect(clampLimit("-5")).toBe(1); + expect(clampLimit("9999")).toBe(200); + expect(clampLimit("75")).toBe(75); + }); + + it("clamps offset to a non-negative integer", () => { + expect(clampOffset(undefined)).toBe(0); + expect(clampOffset("-3")).toBe(0); + expect(clampOffset("abc")).toBe(0); + expect(clampOffset("40")).toBe(40); + }); +}); + +describe("pageCount", () => { + it("computes ceil(total/perPage), at least 1", () => { + expect(pageCount(0, 50)).toBe(1); + expect(pageCount(50, 50)).toBe(1); + expect(pageCount(51, 50)).toBe(2); + expect(pageCount(200, 50)).toBe(4); + }); +}); + +describe("windowParams", () => { + it("defaults to a 30-day window ending today (UTC)", () => { + const { from, to } = windowParams({}); + const days = (new Date(`${to}T00:00:00Z`) - new Date(`${from}T00:00:00Z`)) / 86_400_000; + expect(days).toBe(29); + expect(to).toMatch(/^\d{4}-\d{2}-\d{2}$/); + }); + + it("honors valid from/to", () => { + expect(windowParams({ from: "2026-01-01", to: "2026-01-31" })).toEqual({ + from: "2026-01-01", + to: "2026-01-31", + }); + }); + + it("ignores invalid dates and collapses inverted ranges", () => { + expect(windowParams({ from: "not-a-date", to: "2026-02-10" }).to).toBe("2026-02-10"); + expect(windowParams({ from: "2026-05-01", to: "2026-01-01" })).toEqual({ + from: "2026-01-01", + to: "2026-01-01", + }); + }); + + it("caps windows wider than ~a year", () => { + const { from, to } = windowParams({ from: "2000-01-01", to: "2026-01-01" }); + const days = (new Date(`${to}T00:00:00Z`) - new Date(`${from}T00:00:00Z`)) / 86_400_000; + expect(days).toBeLessThanOrEqual(366); + }); +}); diff --git a/backend/modules/dashboard/series.js b/backend/modules/dashboard/series.js new file mode 100644 index 0000000..8bdd9fb --- /dev/null +++ b/backend/modules/dashboard/series.js @@ -0,0 +1,61 @@ +import { getPool } from "../../db/index.js"; + +// Usage counts are per-day; storage totals are running cumulatives. +export async function getSeries(userId, { from, to }) { + const client = await getPool(); + try { + const res = await client.query( + `SELECT + to_char(d, 'YYYY-MM-DD') AS date, + ( + SELECT COUNT(*)::int FROM usage u + JOIN usage_url uu ON uu.id = u.usage_url_id + WHERE u.user_id = $1 AND uu.module = 'core' + AND u.created_ts >= d AND u.created_ts < d + interval '1 day' + ) AS core, + ( + SELECT COUNT(*)::int FROM usage u + JOIN usage_url uu ON uu.id = u.usage_url_id + WHERE u.user_id = $1 AND uu.module = 'archive' + AND u.created_ts >= d AND u.created_ts < d + interval '1 day' + ) AS audit, + ( + SELECT COALESCE(SUM(pg_column_size(ed.data)), 0)::bigint + FROM event e + JOIN event_data ed ON ed.event_id = e.id + WHERE e.user_id = $1 AND e.created_as_ts < d + interval '1 day' + ) AS core_disk, + ( + ( + SELECT COALESCE(SUM(pg_column_size(a.digest)), 0)::bigint + FROM audit a + JOIN event e ON e.event_id_uuid = a.event_id AND e.user_id = $1 + WHERE a.created_ts < d + interval '1 day' + ) + + + ( + SELECT COALESCE(SUM(pg_column_size(s.jws)), 0)::bigint + FROM audit_signature s + JOIN audit a ON a.audit_id = s.audit_id + JOIN event e ON e.event_id_uuid = a.event_id AND e.user_id = $1 + WHERE a.created_ts < d + interval '1 day' + ) + ) AS audit_disk + FROM generate_series($2::date, $3::date, interval '1 day') AS d + ORDER BY d`, + [userId, from, to], + ); + + const usage = []; + const storage = []; + for (const r of res.rows) { + const core = r.core, audit = r.audit; + usage.push({ date: r.date, core, audit, total: core + audit }); + const coreDisk = Number(r.core_disk), auditDisk = Number(r.audit_disk); + storage.push({ date: r.date, coreDisk, auditDisk, totalDisk: coreDisk + auditDisk }); + } + return { usage, storage }; + } finally { + await client.release(); + } +} diff --git a/backend/modules/dashboard/series.test.js b/backend/modules/dashboard/series.test.js new file mode 100644 index 0000000..d4f6bf9 --- /dev/null +++ b/backend/modules/dashboard/series.test.js @@ -0,0 +1,37 @@ +import { jest } from "@jest/globals"; + +const query = jest.fn(); +const release = jest.fn(async () => {}); +jest.unstable_mockModule("../../db/index.js", () => ({ getPool: async () => ({ query, release }) })); + +const { getSeries } = await import("./series.js"); + +beforeEach(() => { + query.mockReset(); + release.mockClear(); +}); + +describe("getSeries (combined usage + storage, one query)", () => { + it("splits one result set into usage counts and storage byte totals with computed totals", async () => { + query.mockResolvedValue({ + rows: [ + { date: "2026-06-01", core: 3, audit: 5, core_disk: "100", audit_disk: "50" }, + { date: "2026-06-02", core: 0, audit: 0, core_disk: "100", audit_disk: "50" }, + ], + }); + + const out = await getSeries(7, { from: "2026-06-01", to: "2026-06-02" }); + + expect(query).toHaveBeenCalledTimes(1); // single round-trip + expect(query.mock.calls[0][1]).toEqual([7, "2026-06-01", "2026-06-02"]); + expect(out.usage).toEqual([ + { date: "2026-06-01", core: 3, audit: 5, total: 8 }, + { date: "2026-06-02", core: 0, audit: 0, total: 0 }, + ]); + expect(out.storage).toEqual([ + { date: "2026-06-01", coreDisk: 100, auditDisk: 50, totalDisk: 150 }, + { date: "2026-06-02", coreDisk: 100, auditDisk: 50, totalDisk: 150 }, + ]); + expect(release).toHaveBeenCalledTimes(1); + }); +}); diff --git a/backend/modules/dashboard/service.js b/backend/modules/dashboard/service.js new file mode 100644 index 0000000..3e57b47 --- /dev/null +++ b/backend/modules/dashboard/service.js @@ -0,0 +1,55 @@ +import { getConfig } from "../../common/config.js"; +import { listCore, listAudit, getEvent, getAgreement, getEventDetails } from "./transactions.js"; +import { getApiUsage } from "./usage.js"; +import { getStorage } from "./storage.js"; +import { getSeries } from "./series.js"; +import { countApiKeys } from "./account.js"; +import { getCached } from "./cache.js"; +import { verifyMany } from "./verify.js"; + +// Expensive full per-tenant byte scan that changes slowly. +const STORAGE_TTL_MS = 4 * 60 * 60 * 1000; + +export async function summary(userId, { from, to }) { + const [apiUsage, apiKeys, storage] = await Promise.all([ + getApiUsage(userId, { from, to }), + countApiKeys(userId), + getCached(userId, "storage", STORAGE_TTL_MS, () => getStorage(userId)), + ]); + return { + apiUsage, + apiKeys, + // Externally-facing modules only, not internal ones like `dashboard`. + endpoints: Object.values(getConfig().appModules).filter((m) => !m?.internal).length, + storage, + }; +} + +export async function transactions(userId, opts) { + const { rows, total } = opts.type === "audit" + ? await listAudit(userId, opts) + : await listCore(userId, opts); + return { rows, total, limit: opts.limit, offset: opts.offset }; +} + +export async function series(userId, window) { + return getSeries(userId, window); +} + +export async function event(userId, eventUuid) { + return getEvent(userId, eventUuid); +} + +export async function agreement(userId, agreementUuid) { + return getAgreement(userId, agreementUuid); +} + +export async function verify(userId, targets) { + return { results: await verifyMany(userId, targets) }; +} + +export async function details(userId, eventUuid) { + return getEventDetails(userId, eventUuid); +} + +export const service = { summary, transactions, series, event, verify, agreement, details }; diff --git a/backend/modules/dashboard/storage.js b/backend/modules/dashboard/storage.js new file mode 100644 index 0000000..d32fddf --- /dev/null +++ b/backend/modules/dashboard/storage.js @@ -0,0 +1,39 @@ +import { getPool } from "../../db/index.js"; + +// Audit rows carry no user_id, so they are attributed via event OR agreement +// uuid — deliberately wider than the event-only listAudit view. +export async function getStorage(userId) { + const client = await getPool(); + try { + const row = ( + await client.query( + `WITH ua AS ( + SELECT a.audit_id, a.digest + FROM audit a + WHERE a.event_id IN (SELECT event_id_uuid FROM event WHERE user_id = $1) + OR a.agreement_id IN (SELECT agreement_id_uuid FROM agreement WHERE user_id = $1) + ) + SELECT + (SELECT COALESCE(SUM(pg_column_size(data)), 0)::bigint FROM event_data WHERE user_id = $1) AS core_on_disk, + (SELECT COALESCE(SUM(octet_length(data)), 0)::bigint FROM event_data WHERE user_id = $1) AS core_logical, + COALESCE(SUM(pg_column_size(ua.digest)), 0)::bigint + + COALESCE((SELECT SUM(pg_column_size(s.jws)) FROM audit_signature s WHERE s.audit_id IN (SELECT audit_id FROM ua)), 0)::bigint AS audit_on_disk, + COALESCE(SUM(octet_length(ua.digest)), 0)::bigint + + COALESCE((SELECT SUM(octet_length(s.jws)) FROM audit_signature s WHERE s.audit_id IN (SELECT audit_id FROM ua)), 0)::bigint AS audit_logical + FROM ua`, + [userId], + ) + ).rows[0]; + + const coreOnDisk = Number(row.core_on_disk); + const auditOnDisk = Number(row.audit_on_disk); + const coreLogical = Number(row.core_logical); + const auditLogical = Number(row.audit_logical); + return { + onDisk: { core: coreOnDisk, audit: auditOnDisk, total: coreOnDisk + auditOnDisk }, + logical: { core: coreLogical, audit: auditLogical, total: coreLogical + auditLogical }, + }; + } finally { + await client.release(); + } +} diff --git a/backend/modules/dashboard/storage.test.js b/backend/modules/dashboard/storage.test.js new file mode 100644 index 0000000..8c308f6 --- /dev/null +++ b/backend/modules/dashboard/storage.test.js @@ -0,0 +1,30 @@ +import { jest } from "@jest/globals"; + +const query = jest.fn(); +const release = jest.fn(async () => {}); +jest.unstable_mockModule("../../db/index.js", () => ({ getPool: async () => ({ query, release }) })); + +const { getStorage } = await import("./storage.js"); + +beforeEach(() => { + query.mockReset(); + release.mockClear(); +}); + +describe("getStorage (core + audit in one query)", () => { + it("runs a single query and shapes on-disk/logical core/audit/total byte totals", async () => { + query.mockResolvedValue({ + rows: [{ core_on_disk: "100", core_logical: "120", audit_on_disk: "50", audit_logical: "60" }], + }); + + const out = await getStorage(7); + + expect(query).toHaveBeenCalledTimes(1); // combined round-trip + expect(query.mock.calls[0][1]).toEqual([7]); + expect(out).toEqual({ + onDisk: { core: 100, audit: 50, total: 150 }, + logical: { core: 120, audit: 60, total: 180 }, + }); + expect(release).toHaveBeenCalledTimes(1); + }); +}); diff --git a/backend/modules/dashboard/transactions.js b/backend/modules/dashboard/transactions.js new file mode 100644 index 0000000..0e23b97 --- /dev/null +++ b/backend/modules/dashboard/transactions.js @@ -0,0 +1,248 @@ +import { marked } from "marked"; +import { getPool } from "../../db/index.js"; +import { sortClause, eventSearchClause } from "./query.js"; + +// Every query is scoped to user_id; the only interpolated SQL is the +// whitelisted sort clause. Agreements are deliberately NOT re-scoped by +// user_id — an event may reference a public (user_id IS NULL) agreement. +export async function listCore(userId, { from, to, q = "", sort, dir, limit = 50, offset = 0 } = {}) { + const client = await getPool(); + try { + const params = [userId]; + let where = `WHERE e.user_id = $1`; + if (from && to) { + params.push(from, to); + // Half-open: includes the whole `to` day. `<= to::date` would drop + // everything after midnight of that day. + where += ` AND e.created_as_ts >= $${params.length - 1}::date AND e.created_as_ts < ($${params.length}::date + 1)`; + } + if (q) { + params.push(q); + where += ` AND ${eventSearchClause(params.length)}`; + } + + const total = ( + await client.query( + `SELECT COUNT(*)::int AS total + FROM event e + INNER JOIN agreement agr ON e.agreement_id = agr.id + ${where}`, + params, + ) + ).rows[0].total; + + const rows = ( + await client.query( + `SELECT + e.event_id_uuid AS "eventUuid", + COALESCE(es.short_name, e.sender_id) AS sender, + COALESCE(er.short_name, e.recipient_id) AS receiver, + e.created_as_ts AS date, + agr.agreement_id_uuid AS "agreementUuid", + ac.title AS "agreementTitle", + octet_length(ed.data::text) AS "size" + FROM event e + INNER JOIN agreement agr ON e.agreement_id = agr.id + LEFT JOIN agreement_content ac ON ac.agreement_uuid = agr.agreement_id_uuid + LEFT JOIN event_data ed ON ed.event_id = e.id + LEFT JOIN entity es ON es.did_id = e.sender_id AND es.user_id = $1 + LEFT JOIN entity er ON er.did_id = e.recipient_id AND er.user_id = $1 + ${where} + ${sortClause(sort, dir)} + LIMIT $${params.length + 1} OFFSET $${params.length + 2}`, + [...params, limit, offset], + ) + ).rows; + + return { rows, total }; + } finally { + await client.release(); + } +} + +// One row per AUDIT RECORD, not per event: `eventUuid` repeats across rows and +// `auditId` is what identifies a row uniquely. +export async function listAudit(userId, { from, to, q = "", sort, dir, limit = 50, offset = 0 } = {}) { + const client = await getPool(); + try { + const params = [userId]; + let where = `WHERE a.event_id IS NOT NULL`; + if (from && to) { + params.push(from, to); + where += ` AND a.created_ts >= $${params.length - 1}::date AND a.created_ts < ($${params.length}::date + 1)`; + } + if (q) { + params.push(q); + where += ` AND ${eventSearchClause(params.length)}`; + } + + const baseFrom = ` + FROM audit a + INNER JOIN event e ON e.event_id_uuid = a.event_id AND e.user_id = $1 + INNER JOIN agreement agr ON agr.id = e.agreement_id`; + + const total = ( + await client.query(`SELECT COUNT(*)::int AS total ${baseFrom} ${where}`, params) + ).rows[0].total; + + const rows = ( + await client.query( + `SELECT + a.audit_id AS "auditId", + a.digest AS "auditDigest", + e.event_id_uuid AS "eventUuid", + COALESCE(es.short_name, e.sender_id) AS sender, + COALESCE(er.short_name, e.recipient_id) AS receiver, + a.created_ts AS date, + agr.agreement_id_uuid AS "agreementUuid", + ac.title AS "agreementTitle", + ( + octet_length(a.digest) + + COALESCE((SELECT SUM(octet_length(s.jws)) FROM audit_signature s WHERE s.audit_id = a.audit_id), 0) + ) AS "size" + ${baseFrom} + LEFT JOIN agreement_content ac ON ac.agreement_uuid = agr.agreement_id_uuid + LEFT JOIN entity es ON es.did_id = e.sender_id AND es.user_id = $1 + LEFT JOIN entity er ON er.did_id = e.recipient_id AND er.user_id = $1 + ${where} + ${sortClause(sort, dir)} + LIMIT $${params.length + 1} OFFSET $${params.length + 2}`, + [...params, limit, offset], + ) + ).rows; + + return { rows, total }; + } finally { + await client.release(); + } +} + +export async function getEvent(userId, eventUuid) { + const client = await getPool(); + try { + const record = + ( + await client.query( + `SELECT JSON_BUILD_OBJECT( + 'version', e.version, + 'eventId', e.event_id_uuid, + 'type', et.value, + 'senderId', e.sender_id, + 'recipientId', e.recipient_id, + 'created', e.created, + 'agreementId', agr.agreement_id_uuid, + 'data', ed.data + ) AS record + FROM event e + JOIN event_type et ON et.id = e.event_type_id + JOIN agreement agr ON agr.id = e.agreement_id + LEFT JOIN event_data ed ON ed.event_id = e.id + WHERE e.user_id = $1 AND e.event_id_uuid = $2`, + [userId, eventUuid], + ) + ).rows[0]?.record ?? null; + + if (record?.data) { + try { + record.data = JSON.parse(record.data); + } catch { + // leave the raw string if it isn't JSON + } + } + return record; + } finally { + await client.release(); + } +} + +// Cached agreement markdown rendered to HTML for the "click the agreement" modal. +// Scoped to the viewer: their own agreement or a public (user_id IS NULL) one. +export async function getAgreement(userId, agreementUuid) { + const client = await getPool(); + try { + const row = ( + await client.query( + `SELECT title, markdown FROM agreement_content + WHERE agreement_uuid = $1 AND (user_id = $2 OR user_id IS NULL) + LIMIT 1`, + [agreementUuid, userId], + ) + ).rows[0]; + if (!row) return null; + return { title: row.title, html: marked(row.markdown || "") }; + } finally { + await client.release(); + } +} + +export async function getEventDetails(userId, eventUuid) { + const client = await getPool(); + try { + const event = await getEvent(userId, eventUuid); + if (!event) return null; + + const result = await client.query(` + SELECT s.signer_id, r.value AS role + FROM signature s + JOIN agreement agr ON agr.id = s.agreement_id + LEFT JOIN role r ON r.id = s.role_id + WHERE agr.agreement_id_uuid = $1 + `, [event.agreementId]) + + const roleByDid = {}; + + for (const row of result.rows) { + roleByDid[row.signer_id] = row.role; + } + + const roles = { + sender: roleByDid[event.senderId] || null, + receiver: roleByDid[event.recipientId] || null + } + + // Resolve the sender/receiver DIDs to their entity short names (user@fedid…). + const nameResult = await client.query(` + SELECT did_id, short_name FROM entity + WHERE user_id = $1 AND did_id = ANY($2::text[]) + `, [userId, [event.senderId, event.recipientId]]) + const nameByDid = {} + for (const row of nameResult.rows) nameByDid[row.did_id] = row.short_name + const names = { + sender: nameByDid[event.senderId] || null, + receiver: nameByDid[event.recipientId] || null + } + + const permittedResult = await client.query(` + SELECT p.value FROM purpose p + INNER JOIN agreement_purpose ap + ON p.id = ap.purpose_id + INNER JOIN agreement a ON ap.agreement_id = a.id + WHERE a.agreement_id_uuid = $1 + `, [event.agreementId]) + + const permitted = permittedResult.rows.map(row => row.value); + + const prohibitedResult = await client.query(` + SELECT p.value FROM prohibition p + INNER JOIN agreement_prohibition aph + ON p.id = aph.prohibition_id + INNER JOIN agreement a ON aph.agreement_id = a.id + WHERE a.agreement_id_uuid = $1 + `, [event.agreementId]) + const prohibited = prohibitedResult.rows.map(row => row.value) + + const title = (await client.query(` + SELECT title FROM agreement_content + WHERE agreement_uuid = $1 + `, [event.agreementId])).rows[0]?.title ?? null; + + return { + event, + roles, + names, + agreement: {permitted, prohibited, title} + } + } finally { + client.release(); + } +} diff --git a/backend/modules/dashboard/transactions.test.js b/backend/modules/dashboard/transactions.test.js new file mode 100644 index 0000000..85ed738 --- /dev/null +++ b/backend/modules/dashboard/transactions.test.js @@ -0,0 +1,63 @@ +import { jest } from "@jest/globals"; + +// Manual DB mock — assert the SQL/params the query builder produces without a DB. +const query = jest.fn(); +const release = jest.fn(async () => {}); +jest.unstable_mockModule("../../db/index.js", () => ({ getPool: async () => ({ query, release }) })); + +const { listCore, listAudit } = await import("./transactions.js"); + +// listCore/listAudit each run a COUNT then a rows query. +function prime(total = 0, rows = []) { + query.mockResolvedValueOnce({ rows: [{ total }] }).mockResolvedValueOnce({ rows }); +} + +beforeEach(() => { + query.mockReset(); + release.mockClear(); +}); + +describe("listCore search guard", () => { + it("omits the ILIKE search predicate (and the q param) when there is no query term", async () => { + prime(0, []); + await listCore(7, { from: "2026-01-01", to: "2026-01-31" }); + const [countSql, countParams] = query.mock.calls[0]; + expect(countSql).not.toMatch(/ILIKE/); + expect(countParams).toEqual([7, "2026-01-01", "2026-01-31"]); // no q appended + }); + + it("adds the ILIKE search predicate + q param only when a query term is present", async () => { + prime(1, [{ eventUuid: "e1" }]); + await listCore(7, { from: "2026-01-01", to: "2026-01-31", q: "acme" }); + const [countSql, countParams] = query.mock.calls[0]; + expect(countSql).toMatch(/ILIKE/); + expect(countParams).toEqual([7, "2026-01-01", "2026-01-31", "acme"]); + }); + + it("resolves sender/receiver to entity short names via COALESCE + LEFT JOIN entity", async () => { + prime(1, [{ eventUuid: "e1", sender: "acme", receiver: "did:x" }]); + const out = await listCore(7, {}); + const rowsSql = query.mock.calls[1][0]; + expect(rowsSql).toMatch(/COALESCE\(es\.short_name, e\.sender_id\)/); + expect(rowsSql).toMatch(/COALESCE\(er\.short_name, e\.recipient_id\)/); + expect(rowsSql).toMatch(/LEFT JOIN entity/); + expect(out).toEqual({ rows: [{ eventUuid: "e1", sender: "acme", receiver: "did:x" }], total: 1 }); + }); +}); + +describe("listAudit", () => { + it("omits the ILIKE search predicate by default", async () => { + prime(0, []); + await listAudit(7, { from: "2026-01-01", to: "2026-01-31" }); + expect(query.mock.calls[0][0]).not.toMatch(/ILIKE/); + }); + + it("keeps the display-only entity joins OUT of the COUNT query but IN the rows query", async () => { + prime(2, [{ eventUuid: "e1" }]); + await listAudit(7, { q: "x" }); + const countSql = query.mock.calls[0][0]; + const rowsSql = query.mock.calls[1][0]; + expect(countSql).not.toMatch(/LEFT JOIN entity/); + expect(rowsSql).toMatch(/LEFT JOIN entity/); + }); +}); diff --git a/backend/modules/dashboard/usage.js b/backend/modules/dashboard/usage.js new file mode 100644 index 0000000..a547e6c --- /dev/null +++ b/backend/modules/dashboard/usage.js @@ -0,0 +1,25 @@ +import { getPool } from "../../db/index.js"; + +// The 'archive' module is surfaced to the UI as "audit". + +export async function getApiUsage(userId, { from, to }) { + const client = await getPool(); + try { + const r = ( + await client.query( + `SELECT + COALESCE(SUM(CASE WHEN uu.module = 'core' THEN 1 END), 0)::int AS core, + COALESCE(SUM(CASE WHEN uu.module = 'archive' THEN 1 END), 0)::int AS audit + FROM usage u + JOIN usage_url uu ON uu.id = u.usage_url_id + WHERE u.user_id = $1 + AND u.created_ts >= $2::date + AND u.created_ts < ($3::date + 1)`, + [userId, from, to], + ) + ).rows[0]; + return { core: r.core, audit: r.audit, total: r.core + r.audit }; + } finally { + await client.release(); + } +} diff --git a/backend/modules/dashboard/validate.js b/backend/modules/dashboard/validate.js new file mode 100644 index 0000000..9f02530 --- /dev/null +++ b/backend/modules/dashboard/validate.js @@ -0,0 +1,63 @@ +import { windowParams, clampLimit, clampOffset, SORTABLE_KEYS } from "./query.js"; + +// Enums are rejected when present-but-invalid (400); numeric/date params are +// leniently clamped so an odd page size still returns sensible data. + +const TYPES = ["core", "audit"]; +const DIRS = ["asc", "desc"]; +const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; + +export const isUuid = (s) => typeof s === "string" && UUID_RE.test(s); + +export function parseWindow(query = {}) { + return windowParams(query); +} + +const MAX_VERIFY_TARGETS = 200; + +const splitList = (v) => (typeof v === "string" ? v.split(",") : []).map((s) => s.trim()).filter(Boolean); + +// `ids` are event UUIDs (core tab); `auditIds` are audit records (audit tab, +// where rows share an event UUID). The cap applies to both lists combined. +export function parseVerifyTargets(query = {}) { + const eventUuids = [...new Set(splitList(query.ids).filter(isUuid))].slice(0, MAX_VERIFY_TARGETS); + const auditIds = [...new Set(splitList(query.auditIds).map(Number))] + .filter((n) => Number.isInteger(n) && n > 0) + .slice(0, Math.max(0, MAX_VERIFY_TARGETS - eventUuids.length)); + return { eventUuids, auditIds }; +} + +export function parseTransactionsQuery(query = {}) { + const type = query.type ?? "core"; + if (!TYPES.includes(type)) { + return { ok: false, error: `type must be one of ${TYPES.join(", ")}` }; + } + + let sort = query.sort; + if (sort != null && !SORTABLE_KEYS.includes(sort)) { + return { ok: false, error: `sort must be one of ${SORTABLE_KEYS.join(", ")}` }; + } + if (sort == null) sort = undefined; + + const dir = query.dir ?? "desc"; + if (!DIRS.includes(dir)) { + return { ok: false, error: `dir must be one of ${DIRS.join(", ")}` }; + } + + const { from, to } = windowParams(query); + const q = typeof query.q === "string" ? query.q.slice(0, 200) : ""; + + return { + ok: true, + value: { + type, + from, + to, + q, + sort, + dir, + limit: clampLimit(query.limit), + offset: clampOffset(query.offset), + }, + }; +} diff --git a/backend/modules/dashboard/validate.test.js b/backend/modules/dashboard/validate.test.js new file mode 100644 index 0000000..38c91d2 --- /dev/null +++ b/backend/modules/dashboard/validate.test.js @@ -0,0 +1,101 @@ +import { parseTransactionsQuery, parseWindow, parseVerifyTargets, isUuid } from "./validate.js"; + +describe("parseTransactionsQuery", () => { + it("defaults type=core, dir=desc, sort undefined, and a 30-day window", () => { + const { ok, value } = parseTransactionsQuery({}); + expect(ok).toBe(true); + expect(value.type).toBe("core"); + expect(value.dir).toBe("desc"); + expect(value.sort).toBeUndefined(); + expect(value.limit).toBe(50); + expect(value.offset).toBe(0); + expect(value.from).toMatch(/^\d{4}-\d{2}-\d{2}$/); + }); + + it("accepts valid discrete params", () => { + const { ok, value } = parseTransactionsQuery({ type: "audit", sort: "size", dir: "asc" }); + expect(ok).toBe(true); + expect(value).toMatchObject({ type: "audit", sort: "size", dir: "asc" }); + }); + + it("rejects an invalid type", () => { + expect(parseTransactionsQuery({ type: "bogus" })).toMatchObject({ ok: false }); + }); + + it("rejects an invalid sort key", () => { + expect(parseTransactionsQuery({ sort: "sender; DROP" })).toMatchObject({ ok: false }); + }); + + it("rejects an invalid dir", () => { + expect(parseTransactionsQuery({ dir: "sideways" })).toMatchObject({ ok: false }); + }); + + it("clamps limit/offset and caps search length", () => { + const { value } = parseTransactionsQuery({ limit: "99999", offset: "-3", q: "x".repeat(500) }); + expect(value.limit).toBe(200); + expect(value.offset).toBe(0); + expect(value.q).toHaveLength(200); + }); +}); + +describe("parseWindow", () => { + it("normalizes to a { from, to } window", () => { + expect(parseWindow({ from: "2026-01-01", to: "2026-01-31" })).toEqual({ + from: "2026-01-01", + to: "2026-01-31", + }); + }); +}); + +describe("parseVerifyTargets", () => { + const U1 = "3fa85f64-5717-4562-b3fc-2c963f66afa6"; + const U2 = "11111111-2222-3333-4444-555555555555"; + + it("keeps valid UUIDs, drops junk, and de-duplicates", () => { + expect(parseVerifyTargets({ ids: `${U1}, junk ,${U2},${U1}` })).toEqual({ + eventUuids: [U1, U2], + auditIds: [], + }); + }); + + it("parses audit ids as positive integers, dropping anything else", () => { + expect(parseVerifyTargets({ auditIds: "9, 10 ,9,0,-3,abc,1.5" })).toEqual({ + eventUuids: [], + auditIds: [9, 10], + }); + }); + + it("accepts both lists at once (core rows by event, audit rows by audit id)", () => { + expect(parseVerifyTargets({ ids: U1, auditIds: "42" })).toEqual({ + eventUuids: [U1], + auditIds: [42], + }); + }); + + it("returns empty lists when absent or empty", () => { + expect(parseVerifyTargets({})).toEqual({ eventUuids: [], auditIds: [] }); + expect(parseVerifyTargets({ ids: "", auditIds: "" })).toEqual({ eventUuids: [], auditIds: [] }); + }); + + it("caps the two lists at 200 targets COMBINED", () => { + const auditIds = Array.from({ length: 250 }, (_, i) => i + 1).join(","); + const one = parseVerifyTargets({ auditIds }); + expect(one.auditIds).toHaveLength(200); + + // 150 events leaves room for only 50 audit ids. + const events = Array.from({ length: 150 }, (_, i) => + `3fa85f64-5717-4562-b3fc-${String(i).padStart(12, "0")}`).join(","); + const both = parseVerifyTargets({ ids: events, auditIds }); + expect(both.eventUuids).toHaveLength(150); + expect(both.auditIds).toHaveLength(50); + }); +}); + +describe("isUuid", () => { + it("accepts canonical UUIDs and rejects junk", () => { + expect(isUuid("3fa85f64-5717-4562-b3fc-2c963f66afa6")).toBe(true); + expect(isUuid("not-a-uuid")).toBe(false); + expect(isUuid("")).toBe(false); + expect(isUuid(null)).toBe(false); + }); +}); diff --git a/backend/modules/dashboard/verify.js b/backend/modules/dashboard/verify.js new file mode 100644 index 0000000..43f9cc7 --- /dev/null +++ b/backend/modules/dashboard/verify.js @@ -0,0 +1,146 @@ +import { getPool } from "../../db/index.js"; + +// Lazy import so this module does not pull in @jlinc/core. +const defaultVerify = async (input, userId) => + (await import("../core/data/index.js")).data.audit.verify(input, userId); + +// A target is ONE ROW: { eventUuid } covers all of an event's audit records, +// { auditId } covers exactly one. Audit rows share an eventUuid, so keying them +// by event reports one record's verdict on all its siblings. +const MAX_BATCH = 200; + +// Joined through `event` on user_id: an audit id is a lookup key, never an +// authorization decision. +async function fetchAuditRecords(client, userId, { eventUuid, auditId }) { + const byAuditId = auditId != null; + const rows = ( + await client.query( + `SELECT a.audit_id, a.version, a.event_id, a.hash_type, a.digest, a.created, + e.sender_id, e.recipient_id, + COALESCE( + JSON_AGG( + JSON_BUILD_OBJECT('version', s.version, 'id', s.id, 'signedon', s.signedon, 'type', s.type, 'jws', s.jws) + ) FILTER (WHERE s.audit_id IS NOT NULL), + '[]' + ) AS signatures + FROM audit a + JOIN event e ON e.event_id_uuid = a.event_id AND e.user_id = $1 + LEFT JOIN audit_signature s ON s.audit_id = a.audit_id + WHERE ${byAuditId ? "a.audit_id = $2" : "a.event_id = $2"} + GROUP BY a.audit_id, a.version, a.event_id, a.hash_type, a.digest, a.created, + e.sender_id, e.recipient_id + ORDER BY a.audit_id + LIMIT $3`, + [userId, byAuditId ? auditId : eventUuid, byAuditId ? 1 : MAX_BATCH], + ) + ).rows; + + const dids = new Set(); + const records = rows.map((row) => { + if (row.sender_id) dids.add(row.sender_id); + if (row.recipient_id) dids.add(row.recipient_id); + return { + auditId: Number(row.audit_id), + audit: { + version: row.version, + hashType: row.hash_type, + digest: row.digest, + created: Number(row.created), + eventId: row.event_id, + }, + signatures: (row.signatures || []).map((r) => ({ + version: r.version, + id: r.id, + signedOn: Number(r.signedon), + type: r.type, + jws: r.jws, + })), + }; + }); + return { records, dids: [...dids] }; +} + +// Only the DIDs the batch actually references, and only did_doc — getEntity +// would return the control and recovery private keys, which verification never +// needs. Resolved once per batch and memoised across targets. +async function resolveDidDocs(client, userId, dids, cache) { + const missing = dids.filter((did) => !cache.has(did)); + if (missing.length > 0) { + const rows = ( + await client.query( + `SELECT did_id, did_doc FROM entity WHERE user_id = $1 AND did_id = ANY($2)`, + [userId, missing], + ) + ).rows; + for (const did of missing) cache.set(did, null); + for (const row of rows) cache.set(row.did_id, row.did_doc); + } + return dids.map((did) => cache.get(did)).filter(Boolean); +} + +export function summarizeVerification(target, records, verifierResult) { + const { eventUuid = null, auditId = null } = target; + if (records.length === 0) { + return { + eventUuid, + auditId, + status: "no-audit", + verified: false, + signatureCount: 0, + auditCount: 0, + checks: null, + reason: "no audit record", + }; + } + + const valid = verifierResult?.data?.valid || []; + const invalid = verifierResult?.data?.invalid || []; + // Matched by audit id: the verifier splits input across two buckets, so + // position is meaningless once a target has several records. + const verdicts = records.map((r) => ({ + verified: valid.some((e) => e.audit?.auditId === r.auditId), + checks: [...valid, ...invalid].find((e) => e.audit?.auditId === r.auditId)?.results || null, + })); + + const failed = verdicts.find((v) => !v.verified); + return { + eventUuid, + auditId, + status: failed ? "invalid" : "verified", + verified: !failed, + signatureCount: records.reduce((n, r) => n + r.signatures.length, 0), + auditCount: records.length, + checks: (failed || verdicts[0]).checks, + }; +} + +async function verifyTarget(acquire, verify, userId, target, cache) { + const client = await acquire(); + let records, didDocs; + try { + const fetched = await fetchAuditRecords(client, userId, target); + records = fetched.records; + didDocs = records.length === 0 ? [] : await resolveDidDocs(client, userId, fetched.dids, cache); + } finally { + await client.release(); + } + if (records.length === 0) return summarizeVerification(target, records, null); + + const result = await verify({ didDocs, audits: records }, userId); + return summarizeVerification(target, records, result); +} + +export async function verifyMany(userId, { eventUuids = [], auditIds = [] } = {}, { acquire = getPool, verify = defaultVerify } = {}) { + const targets = [ + ...eventUuids.map((eventUuid) => ({ eventUuid, auditId: null })), + ...auditIds.map((auditId) => ({ eventUuid: null, auditId })), + ].slice(0, MAX_BATCH); + if (targets.length === 0) return []; + + const cache = new Map(); + const results = []; + for (const target of targets) { + results.push(await verifyTarget(acquire, verify, userId, target, cache)); + } + return results; +} diff --git a/backend/modules/dashboard/verify.test.js b/backend/modules/dashboard/verify.test.js new file mode 100644 index 0000000..3e6ee61 --- /dev/null +++ b/backend/modules/dashboard/verify.test.js @@ -0,0 +1,225 @@ +import { jest } from "@jest/globals"; +import { summarizeVerification, verifyMany } from "./verify.js"; + +// Build the record shape fetchAuditRecords produces. +const record = (auditId, sigCount = 1) => ({ + auditId, + audit: { version: 1, hashType: "sha256", digest: `d${auditId}`, created: 100, eventId: "e1" }, + signatures: Array.from({ length: sigCount }, (_, i) => ({ id: `alice@a`, jws: `x${i}` })), +}); + +// A verifier verdict as the canonical verifier returns it: the record we passed +// in, echoed back on `.audit`, plus its per-check results. +const verdict = (rec, results) => ({ audit: rec, results }); + +describe("summarizeVerification", () => { + it("reports no audit record when nothing was found", () => { + expect(summarizeVerification({ eventUuid: "e1", auditId: null }, [], null)).toEqual({ + eventUuid: "e1", + auditId: null, + status: "no-audit", + verified: false, + signatureCount: 0, + auditCount: 0, + checks: null, + reason: "no audit record", + }); + }); + + it("marks verified when the record's verdict is in the valid bucket", () => { + const r = record(9, 2); + const result = { data: { valid: [verdict(r, { idMatch: true, auditSig: true })], invalid: [] } }; + expect(summarizeVerification({ eventUuid: null, auditId: 9 }, [r], result)).toEqual({ + eventUuid: null, + auditId: 9, + status: "verified", + verified: true, + signatureCount: 2, + auditCount: 1, + checks: { idMatch: true, auditSig: true }, + }); + }); + + it("marks invalid and surfaces the failed checks", () => { + const r = record(9, 2); + const result = { data: { valid: [], invalid: [verdict(r, { auditSig: false })] } }; + expect(summarizeVerification({ eventUuid: null, auditId: 9 }, [r], result)).toMatchObject({ + status: "invalid", + verified: false, + checks: { auditSig: false }, + }); + }); + + // The regression this whole change is about: one event, two audit records. + describe("an event with several audit records", () => { + const produce = record(9); + const process = record(10); + const target = { eventUuid: "e1", auditId: null }; + + it("matches each verdict to its own record by audit id, not by position", () => { + // Deliberately out of order: `process` is valid, `produce` is not. + const result = { + data: { + valid: [verdict(process, { auditSig: true })], + invalid: [verdict(produce, { auditSig: false })], + }, + }; + const out = summarizeVerification(target, [produce, process], result); + expect(out).toMatchObject({ status: "invalid", verified: false, auditCount: 2 }); + // The failing record's checks are what surface, not the first bucket's. + expect(out.checks).toEqual({ auditSig: false }); + }); + + it("only reports verified when EVERY audit record passed", () => { + const allGood = { + data: { valid: [verdict(produce, { auditSig: true }), verdict(process, { auditSig: true })], invalid: [] }, + }; + expect(summarizeVerification(target, [produce, process], allGood)).toMatchObject({ + status: "verified", + verified: true, + auditCount: 2, + signatureCount: 2, + }); + }); + }); +}); + +// A fake pooled client that routes by SQL so we can exercise verifyMany's +// orchestration without a DB or the crypto engine. `entity` is matched first so +// the audit query's audit_signature JOIN can't shadow it. +function routingClient(routes) { + const query = jest.fn(async (sql, params) => { + if (/FROM entity/.test(sql)) return { rows: routes.entity ?? [] }; + if (/FROM audit/.test(sql)) return { rows: routes.audit?.(sql, params) ?? [] }; + return { rows: [] }; + }); + return { query, release: jest.fn(async () => {}) }; +} + +// One audit row as it comes back from Postgres (snake_case + aggregated JSON). +const auditRow = (auditId, eventId = "e1") => ({ + audit_id: auditId, + version: 1, + event_id: eventId, + hash_type: "sha256", + digest: `d${auditId}`, + created: "100", + sender_id: "did:alice", + recipient_id: "did:bob", + signatures: [{ version: 1, id: "alice@a", signedon: "101", type: "audit", jws: "x" }], +}); + +describe("verifyMany", () => { + it("returns [] when there are no targets", async () => { + expect(await verifyMany(1, {})).toEqual([]); + expect(await verifyMany(1, { eventUuids: [], auditIds: [] })).toEqual([]); + }); + + it("verifies an event by assembling its audit records and calling the verifier", async () => { + const client = routingClient({ + entity: [{ did_id: "did:alice", did_doc: { id: "did:alice" } }], + audit: () => [auditRow(9)], + }); + const verify = jest.fn(async (input) => ({ + data: { valid: [{ audit: input.audits[0], results: { ok: true } }], invalid: [] }, + })); + + const out = await verifyMany(1, { eventUuids: ["e1"] }, { acquire: async () => client, verify }); + + expect(out).toEqual([{ + eventUuid: "e1", + auditId: null, + status: "verified", + verified: true, + signatureCount: 1, + auditCount: 1, + checks: { ok: true }, + }]); + expect(verify).toHaveBeenCalledWith( + expect.objectContaining({ + didDocs: [{ id: "did:alice" }], + audits: [expect.objectContaining({ auditId: 9, audit: expect.objectContaining({ eventId: "e1" }) })], + }), + 1, + ); + }); + + it("resolves only the DIDs the batch references, once, and never all entities", async () => { + const entityCalls = []; + const client = routingClient({ + entity: [{ did_id: "did:alice", did_doc: { id: "did:alice" } }], + audit: (_sql, params) => [auditRow(params[1])], + }); + const inner = client.query; + client.query = jest.fn(async (sql, params) => { + if (/FROM entity/.test(sql)) entityCalls.push(params); + return inner(sql, params); + }); + const verify = jest.fn(async (input) => ({ + data: { valid: [{ audit: input.audits[0], results: { ok: true } }], invalid: [] }, + })); + + await verifyMany(1, { auditIds: [9, 10] }, { acquire: async () => client, verify }); + + // One lookup for the batch, scoped to the two DIDs on those events. + expect(entityCalls).toHaveLength(1); + expect(entityCalls[0]).toEqual([1, ["did:alice", "did:bob"]]); + // Second target reused the cache rather than re-querying. + expect(verify.mock.calls[1][0].didDocs).toEqual([{ id: "did:alice" }]); + }); + + it("looks an audit-tab target up by audit_id, scoped to the user", async () => { + let seen; + const client = routingClient({ + entity: [{ did_id: "did:alice", did_doc: { id: "did:alice" } }], + audit: (sql, params) => { seen = { sql, params }; return [auditRow(42)]; }, + }); + const verify = jest.fn(async (input) => ({ + data: { valid: [{ audit: input.audits[0], results: { ok: true } }], invalid: [] }, + })); + + const out = await verifyMany(1, { auditIds: [42] }, { acquire: async () => client, verify }); + + expect(out[0]).toMatchObject({ auditId: 42, eventUuid: null, status: "verified" }); + expect(seen.sql).toMatch(/a\.audit_id = \$2/); + expect(seen.sql).not.toMatch(/a\.event_id = \$2/); + // user_id is always $1 — an audit id alone never reaches another tenant's row. + expect(seen.params.slice(0, 2)).toEqual([1, 42]); + }); + + it("gives each audit record of one event its own independent result", async () => { + // Both rows belong to event e1; only audit 9 verifies. + const client = routingClient({ + entity: [{ did_id: "did:alice", did_doc: { id: "did:alice" } }], + audit: (_sql, params) => [auditRow(params[1])], + }); + const verify = jest.fn(async (input) => { + const rec = input.audits[0]; + return rec.auditId === 9 + ? { data: { valid: [{ audit: rec, results: { ok: true } }], invalid: [] } } + : { data: { valid: [], invalid: [{ audit: rec, results: { ok: false } }] } }; + }); + + const out = await verifyMany(1, { auditIds: [9, 10] }, { acquire: async () => client, verify }); + + expect(out).toHaveLength(2); + expect(out[0]).toMatchObject({ auditId: 9, verified: true }); + expect(out[1]).toMatchObject({ auditId: 10, verified: false, status: "invalid" }); + }); + + it("reports no audit record when the target has none", async () => { + const client = routingClient({ entity: [{ short_name: "alice@a" }], audit: () => [] }); + const verify = jest.fn(); + const out = await verifyMany(1, { eventUuids: ["missing"] }, { acquire: async () => client, verify }); + expect(out[0]).toMatchObject({ status: "no-audit", verified: false, signatureCount: 0, reason: "no audit record" }); + expect(verify).not.toHaveBeenCalled(); + }); + + it("caps the batch at 200 targets regardless of input length", async () => { + const client = routingClient({ entity: [{ short_name: "a" }], audit: () => [] }); + const eventUuids = Array.from({ length: 150 }, (_, i) => `id-${i}`); + const auditIds = Array.from({ length: 150 }, (_, i) => i + 1); + const out = await verifyMany(1, { eventUuids, auditIds }, { acquire: async () => client, verify: jest.fn() }); + expect(out).toHaveLength(200); + }); +}); diff --git a/src/modules/pep/cerbos.js b/backend/modules/pep/cerbos.js similarity index 100% rename from src/modules/pep/cerbos.js rename to backend/modules/pep/cerbos.js diff --git a/src/modules/pep/index.js b/backend/modules/pep/index.js similarity index 100% rename from src/modules/pep/index.js rename to backend/modules/pep/index.js diff --git a/src/package-lock.json b/backend/package-lock.json similarity index 97% rename from src/package-lock.json rename to backend/package-lock.json index 7c70b44..4570d16 100644 --- a/src/package-lock.json +++ b/backend/package-lock.json @@ -16,7 +16,6 @@ "express": "^5.1.0", "express-session": "^1.18.2", "marked": "^16.4.1", - "memorystore": "^1.6.7", "passport": "^0.7.0", "passport-github": "^1.1.0", "passport-google-oauth20": "^2.0.0", @@ -36,7 +35,8 @@ "globals": "^16.5.0", "jest": "^30.2.0", "nodemon": "^3.1.10", - "prettier": "^3.6.2" + "prettier": "^3.6.2", + "supertest": "^7.2.2" } }, "node_modules/@babel/code-frame": { @@ -70,7 +70,6 @@ "integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.5", @@ -1368,6 +1367,29 @@ "eslint-scope": "5.1.1" } }, + "node_modules/@noble/hashes": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz", + "integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@paralleldrive/cuid2": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/@paralleldrive/cuid2/-/cuid2-2.3.1.tgz", + "integrity": "sha512-XO7cAxhnTZl0Yggq6jOgjiOHhbgcO4NqFqwSmQpjK3b6TEE6Uj/jfSk6wzYyemh3+I0sHirKSetjQwn5cZktFw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@noble/hashes": "^1.1.5" + } + }, "node_modules/@pkgjs/parseargs": { "version": "0.11.0", "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", @@ -1873,7 +1895,6 @@ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -1974,6 +1995,13 @@ "dev": true, "license": "Python-2.0" }, + "node_modules/asap": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/asap/-/asap-2.0.6.tgz", + "integrity": "sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA==", + "dev": true, + "license": "MIT" + }, "node_modules/async": { "version": "3.2.6", "resolved": "https://registry.npmjs.org/async/-/async-3.2.6.tgz", @@ -2166,7 +2194,6 @@ "resolved": "https://registry.npmjs.org/bare-url/-/bare-url-2.3.2.tgz", "integrity": "sha512-ZMq4gd9ngV5aTMa5p9+UfY0b3skwhHELaDkhEHetMdX0LRkW9kzaym4oo/Eh+Ghm0CCDuMTsRIGM/ytUc1ZYmw==", "license": "Apache-2.0", - "peer": true, "dependencies": { "bare-path": "^3.0.0" } @@ -2267,7 +2294,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.8.19", "caniuse-lite": "^1.0.30001751", @@ -2594,6 +2620,16 @@ "node": ">= 0.8" } }, + "node_modules/component-emitter": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/component-emitter/-/component-emitter-1.3.1.tgz", + "integrity": "sha512-T0+barUSQRTUQASh8bx02dl+DhF54GtIDY13Y3m9oWTklKbb3Wv974meRpeZ3lp1JpLVECWWNHC4vaG2XHXouQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/concat-map": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", @@ -2647,6 +2683,13 @@ "node": ">=6.6.0" } }, + "node_modules/cookiejar": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/cookiejar/-/cookiejar-2.1.4.tgz", + "integrity": "sha512-LDx6oHrK+PhzLKJU9j5S7/Y3jM/mUHvD/DeI1WQmJn652iPC5Y4TBzC9l+5OMOXlyTTA+SmVUPm0HQUwpD5Jqw==", + "dev": true, + "license": "MIT" + }, "node_modules/cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", @@ -2739,6 +2782,17 @@ "node": ">=8" } }, + "node_modules/dezalgo": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/dezalgo/-/dezalgo-1.0.4.tgz", + "integrity": "sha512-rXSP0bf+5n0Qonsb+SVVfNfIsimO4HEtmnIpPHY8Q1UCzKlQrDMfdobr8nJOOsRgWCyMRqeSBQzmWUMq7zvVig==", + "dev": true, + "license": "ISC", + "dependencies": { + "asap": "^2.0.0", + "wrappy": "1" + } + }, "node_modules/dotenv": { "version": "17.2.3", "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.2.3.tgz", @@ -2919,7 +2973,6 @@ "integrity": "sha512-BhHmn2yNOFA9H9JmmIVKJmd288g9hrVRDkdoIgRCRuSySRUHH7r/DI6aAXW9T1WwUuY3DFgrcaqB+deURBLR5g==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -3366,6 +3419,13 @@ "dev": true, "license": "MIT" }, + "node_modules/fast-safe-stringify": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/fast-safe-stringify/-/fast-safe-stringify-2.1.1.tgz", + "integrity": "sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==", + "dev": true, + "license": "MIT" + }, "node_modules/fb-watchman": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/fb-watchman/-/fb-watchman-2.0.2.tgz", @@ -3525,21 +3585,39 @@ } }, "node_modules/form-data": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.4.tgz", - "integrity": "sha512-KrGhL9Q4zjj0kiUt5OO4Mr/A/jlI2jDYs5eHBpYHPcBEVSiipAvn2Ko2HnPe20rmcuuvMHNdZFp+4IlGTMF0Ow==", + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.6.tgz", + "integrity": "sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==", "license": "MIT", "dependencies": { "asynckit": "^0.4.0", "combined-stream": "^1.0.8", "es-set-tostringtag": "^2.1.0", - "hasown": "^2.0.2", - "mime-types": "^2.1.12" + "hasown": "^2.0.4", + "mime-types": "^2.1.35" }, "engines": { "node": ">= 6" } }, + "node_modules/formidable": { + "version": "3.5.4", + "resolved": "https://registry.npmjs.org/formidable/-/formidable-3.5.4.tgz", + "integrity": "sha512-YikH+7CUTOtP44ZTnUhR7Ic2UASBPOqmaRkRKxRbywPTe5VxF7RRCck4af9wutiZ/QKM5nME9Bie2fFaPz5Gug==", + "dev": true, + "license": "MIT", + "dependencies": { + "@paralleldrive/cuid2": "^2.2.2", + "dezalgo": "^1.0.4", + "once": "^1.4.0" + }, + "engines": { + "node": ">=14.0.0" + }, + "funding": { + "url": "https://ko-fi.com/tunnckoCore/commissions" + } + }, "node_modules/forwarded": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", @@ -3799,9 +3877,9 @@ } }, "node_modules/hasown": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", - "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", "license": "MIT", "dependencies": { "function-bind": "^1.1.2" @@ -4985,35 +5063,6 @@ "node": ">= 0.8" } }, - "node_modules/memorystore": { - "version": "1.6.7", - "resolved": "https://registry.npmjs.org/memorystore/-/memorystore-1.6.7.tgz", - "integrity": "sha512-OZnmNY/NDrKohPQ+hxp0muBcBKrzKNtHr55DbqSx9hLsYVNnomSAMRAtI7R64t3gf3ID7tHQA7mG4oL3Hu9hdw==", - "license": "MIT", - "dependencies": { - "debug": "^4.3.0", - "lru-cache": "^4.0.3" - }, - "engines": { - "node": ">=0.10" - } - }, - "node_modules/memorystore/node_modules/lru-cache": { - "version": "4.1.5", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-4.1.5.tgz", - "integrity": "sha512-sWZlbEP2OsHNkXrMl5GYk/jKk70MBng6UU4YI/qGDYbgf6YbP4EvmqISbXCoJiRKs+1bSpFHVgQxvJ17F2li5g==", - "license": "ISC", - "dependencies": { - "pseudomap": "^1.0.2", - "yallist": "^2.1.2" - } - }, - "node_modules/memorystore/node_modules/yallist": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/yallist/-/yallist-2.1.2.tgz", - "integrity": "sha512-ncTzHV7NvsQZkYe1DW7cbDLm0YpzHmZF5r/iyP3ZnQtMiJ+pjzisCiMNI+Sj+xQF5pXhSHxSB3uDbsBTzY/c2A==", - "license": "ISC" - }, "node_modules/merge-descriptors": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz", @@ -5033,6 +5082,16 @@ "dev": true, "license": "MIT" }, + "node_modules/methods": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", + "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, "node_modules/micromatch": { "version": "4.0.8", "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", @@ -5047,6 +5106,19 @@ "node": ">=8.6" } }, + "node_modules/mime": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-2.6.0.tgz", + "integrity": "sha512-USPkMeET31rOMiarsBNIHZKLGgvKc/LrjofAnBlOttf5ajRvqiRA8QsenbcooctK6d6Ts6aqZXBA+XbkKthiQg==", + "dev": true, + "license": "MIT", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=4.0.0" + } + }, "node_modules/mime-db": { "version": "1.52.0", "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", @@ -5574,7 +5646,6 @@ "resolved": "https://registry.npmjs.org/pg/-/pg-8.16.3.tgz", "integrity": "sha512-enxc1h0jA/aq5oSDMvqyW3q89ra6XIIDZgCX9vkMrnz5DFTw/Ny3Li2lFQ+pt3L6MCgm/5o2o8HW9hiJji+xvw==", "license": "MIT", - "peer": true, "dependencies": { "pg-connection-string": "^2.9.1", "pg-pool": "^3.10.1", @@ -5869,12 +5940,6 @@ "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", "license": "MIT" }, - "node_modules/pseudomap": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/pseudomap/-/pseudomap-1.0.2.tgz", - "integrity": "sha512-b/YwNhb8lk1Zz2+bXXpS/LK9OisiZZ1SNsSLxN1x2OXVEhW2Ckr/7mWE5vrC1ZTiJlD9g19jWszTmJsB+oEpFQ==", - "license": "ISC" - }, "node_modules/pstree.remy": { "version": "1.1.8", "resolved": "https://registry.npmjs.org/pstree.remy/-/pstree.remy-1.1.8.tgz", @@ -5910,9 +5975,9 @@ "license": "MIT" }, "node_modules/qs": { - "version": "6.14.0", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.0.tgz", - "integrity": "sha512-YWWTjgABSKcvs/nWBi9PycY/JiPJqOD4JA6o9Sej2AtvSGarXxKC3OQSk4pAarbdQlKAh5D4FCQkJNkW+GAn3w==", + "version": "6.15.2", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.2.tgz", + "integrity": "sha512-Rzq0KEyX/w/tEybncDgdkZrJgVUsUMk3xjh3t5bv3S1HTAtg+uOYt72+ZfwiQwKdysThkTBdL/rTi6HDmX9Ddw==", "license": "BSD-3-Clause", "dependencies": { "side-channel": "^1.1.0" @@ -6574,6 +6639,42 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/superagent": { + "version": "10.3.0", + "resolved": "https://registry.npmjs.org/superagent/-/superagent-10.3.0.tgz", + "integrity": "sha512-B+4Ik7ROgVKrQsXTV0Jwp2u+PXYLSlqtDAhYnkkD+zn3yg8s/zjA2MeGayPoY/KICrbitwneDHrjSotxKL+0XQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "component-emitter": "^1.3.1", + "cookiejar": "^2.1.4", + "debug": "^4.3.7", + "fast-safe-stringify": "^2.1.1", + "form-data": "^4.0.5", + "formidable": "^3.5.4", + "methods": "^1.1.2", + "mime": "2.6.0", + "qs": "^6.14.1" + }, + "engines": { + "node": ">=14.18.0" + } + }, + "node_modules/supertest": { + "version": "7.2.2", + "resolved": "https://registry.npmjs.org/supertest/-/supertest-7.2.2.tgz", + "integrity": "sha512-oK8WG9diS3DlhdUkcFn4tkNIiIbBx9lI2ClF8K+b2/m8Eyv47LSawxUzZQSNKUrVb2KsqeTDCcjAAVPYaSLVTA==", + "dev": true, + "license": "MIT", + "dependencies": { + "cookie-signature": "^1.2.2", + "methods": "^1.1.2", + "superagent": "^10.3.0" + }, + "engines": { + "node": ">=14.18.0" + } + }, "node_modules/supports-color": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", diff --git a/src/package.json b/backend/package.json similarity index 96% rename from src/package.json rename to backend/package.json index 281bffd..57fa055 100644 --- a/src/package.json +++ b/backend/package.json @@ -26,7 +26,6 @@ "express": "^5.1.0", "express-session": "^1.18.2", "marked": "^16.4.1", - "memorystore": "^1.6.7", "passport": "^0.7.0", "passport-github": "^1.1.0", "passport-google-oauth20": "^2.0.0", @@ -46,6 +45,7 @@ "globals": "^16.5.0", "jest": "^30.2.0", "nodemon": "^3.1.10", - "prettier": "^3.6.2" + "prettier": "^3.6.2", + "supertest": "^7.2.2" } } diff --git a/backend/scripts/seed-data.js b/backend/scripts/seed-data.js new file mode 100644 index 0000000..18aa44f --- /dev/null +++ b/backend/scripts/seed-data.js @@ -0,0 +1,407 @@ +// Clears the user's data first, so it is safe to re-run. +// +// docker exec -ti \ +// -e SEED_USER_ID=2 -e SEED_API_KEY= -e SEED_ARCHIVE_KEY= \ +// -e SEED_COUNT=1000 \ +// jlinc-server node /app/scripts/seed-data.js + +import axios from 'axios'; +import sodium from "sodium-native"; +import { createHash } from "crypto"; +import stringify from 'safe-stable-stringify'; +import { loadConfig } from "../common/config.js"; +import { getPool, init, close } from "../db/index.js"; + +const config = { + userId: process.env.SEED_USER_ID, + apiUrl: 'http://localhost:9090', + apiKey: process.env.SEED_API_KEY, + archiveUrl: 'http://localhost:9090', + archiveKey: process.env.SEED_ARCHIVE_KEY, + fedidUrl: 'https://fedid-test.jlinc.io', + count: parseInt(process.env.SEED_COUNT, 10) || 1000, +} + +function generateRandomString(length) { + const characters = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"; + let result = ""; + const charactersLength = characters.length; + for (let i = 0; i < length; i++) { + result += characters.charAt(Math.floor(Math.random() * charactersLength)); + } + return result; +} + +function generateRandomJSON() { + const firstNames = ['James', 'Mary', 'Robert', 'Patricia', 'John', 'Jennifer', 'Michael', 'Linda', 'David', 'Elizabeth', 'William', 'Barbara', 'Richard', 'Susan', 'Joseph', 'Jessica', 'Thomas', 'Sarah', 'Christopher', 'Karen']; + const lastNames = ['Smith', 'Johnson', 'Williams', 'Brown', 'Jones', 'Garcia', 'Miller', 'Davis', 'Rodriguez', 'Martinez', 'Hernandez', 'Lopez', 'Gonzalez', 'Wilson', 'Anderson', 'Thomas', 'Taylor', 'Moore', 'Jackson', 'Martin']; + const streets = ['Maple', 'Oak', 'Cedar', 'Pine', 'Elm', 'Washington', 'Main', 'Lake', 'Park', 'Church', 'Oak', 'Washington', 'Union', 'Walnut', 'Washington']; + const cities = ['Springfield', 'Riverside', 'Greenville', 'Madison', 'Lakeside', 'Franklin', 'Clinton', 'Burlington', 'Georgetown', 'Arlington', 'Salem', 'Cambridge', 'Milton', 'Medford', 'Newport']; + const states = ['AL', 'AK', 'AZ', 'AR', 'CA', 'CO', 'CT', 'DE', 'FL', 'GA', 'HI', 'ID', 'IL', 'IN', 'IA', 'KS', 'KY', 'LA', 'ME', 'MD', 'MA', 'MI', 'MN', 'MS', 'MO', 'MT', 'NE', 'NV', 'NH', 'NJ', 'NM', 'NY', 'NC', 'ND', 'OH', 'OK', 'OR', 'PA', 'RI', 'SC', 'SD', 'TN', 'TX', 'UT', 'VT', 'VA', 'WA', 'WV', 'WI', 'WY']; + + const pick = arr => arr[Math.floor(Math.random() * arr.length)]; + const randomNum = (min, max) => Math.floor(Math.random() * (max - min + 1)) + min; + const randomDecimal = (min, max) => (Math.random() * (max - min) + min).toFixed(2); + + const firstName = pick(firstNames); + const lastName = pick(lastNames); + const streetNum = randomNum(100, 9999); + const street = pick(streets); + const streetType = pick(['Street', 'Avenue', 'Boulevard', 'Drive', 'Lane', 'Court', 'Place', 'Way']); + const zip = `${randomNum(10000, 99999)}-${randomNum(1000, 9999)}`; + + return { + name: `${firstName} ${lastName}`, + email: `${firstName.toLowerCase()}.${lastName.toLowerCase()}@${pick(['gmail', 'yahoo', 'outlook', 'hotmail', 'protonmail'])}.com`, + phone: `(${randomNum(200, 999)}) ${randomNum(200, 999)}-${randomNum(1000, 9999)}`, + address: { + street: `${streetNum} ${street} ${streetType}`, + city: pick(cities), + state: pick(states), + zip, + }, + account: { + balance: parseFloat(randomDecimal(0, 10000)), + currency: 'USD', + status: pick(['active', 'active', 'active', 'pending', 'review']), + tier: pick(['basic', 'standard', 'premium']), + }, + metadata: { + createdAt: new Date(Date.now() - randomNum(1, 365) * 86400000).toISOString().split('T')[0], + lastLogin: new Date(Date.now() - randomNum(0, 30) * 86400000).toISOString().split('T')[0], + tags: Array.from({ length: randomNum(1, 3) }, () => pick(['new', 'returning', 'premium', 'trial', 'verified', 'flagged'])), + }, + }; +} + +const sleep = (s) => new Promise((resolve) => setTimeout(resolve, (s * 1000))); + +async function api() { + const token = config.apiKey; + const archiveToken = config.archiveKey; + + + // Get a FedID domain + // ================== + const domains = (await axios.post( + `${config.apiUrl}/api/v1/data/entity/domains/get`, + { + fedidUrl: config.fedidUrl, // optional + auth: { + subject: { + type: "user", + id: "tester", + }, + action: { + name: "read", + }, + resource: { + type: "data", + id: "1234", + properties: { + ownerID: "tester@test.com", + } + } + } + }, + { + headers: { + 'Authorization': `Bearer ${token}`, + } + }, + )).data; + + // Make the provider DID + // ===================== + let provider; + provider = (await axios.post( + `${config.apiUrl}/api/v1/data/entity/create`, + { + fedidUrl: config.fedidUrl, // optional + shortName: `provider-${generateRandomString(8)}@${domains[0]}`, + }, + { + headers: { + 'Authorization': `Bearer ${token}`, + } + } + )).data; + + // Create and saves an agreement that is valid once signed by the provider (create) + // Sign it, make an audit record, sign that, and deliver it (process) + const agreement = (await axios.post( + `${config.apiUrl}/api/v1/data/agreement/produce`, + { + data: { + references: [`https://sisa.jlinc.org/v1/34020c5fb59ebc6507ebca4eb38090aec1097c6aec8d2ae2250ddfed4b4aa63c`], + permitted: ['data-sharing'], + prohibited: ['non-delegable'], + shortNames: [provider.didDoc.shortName], // Anyone required to sign before the agreement is valid + validRoles: [ + 'provider', + 'user', + 'third-party', + ], + // public: true, + }, + shortName: provider.didDoc.shortName, // Sign this agreement with this user + role: 'provider', // Sign as this role + archive: { + url: config.archiveUrl, + key: archiveToken, + }, + }, + { + headers: { + 'Authorization': `Bearer ${token}`, + } + } + )).data; + + // Make the user DID + // ================= + let user; + user = (await axios.post( + `${config.apiUrl}/api/v1/data/entity/create`, + { + fedidUrl: config.fedidUrl, // optional + shortName: `user-${generateRandomString(8)}@${domains[0]}`, + }, + { + headers: { + 'Authorization': `Bearer ${token}`, + } + } + )).data; + + // User cross-signs the provider's signed agreement + // ================================================ + // The actual signature + const processedUserAgreement = (await axios.post( + `${config.apiUrl}/api/v1/data/agreement/process`, + { + agreementId: agreement.created.agreementId, + shortName: user.didDoc.shortName, + role: 'user', + archive: { + url: config.archiveUrl, + key: archiveToken, + }, + }, + { + headers: { + 'Authorization': `Bearer ${token}`, + } + } + )).data; + + for (let x = 0; x < config.count; x++) { + // >>> At this point, the user is in the system, and ready to ask the chatbot questions. + + // User types information into a chatbot + // ===================================== + // Create and saves an event with the user's query (create) + // Sign it, make an audit record, sign that, and deliver (process) + const userEvent = (await axios.post( + `${config.apiUrl}/api/v1/data/event/produce`, + { + type: 'data', + senderShortName: user.didDoc.shortName, + recipientShortName: provider.didDoc.shortName, + agreementId: agreement.created.agreementId, + meta: { + myCustomId: 'my_custom_identifier' + }, + data: generateRandomJSON(), + archive: { + url: config.archiveUrl, + key: archiveToken, + }, + }, + { + headers: { + 'Authorization': `Bearer ${token}`, + } + } + )).data; + + // Provider indicates they have received and will use the data + // and cross-signs the event once receiving the data + const processedProviderEvent = (await axios.post( + `${config.apiUrl}/api/v1/data/event/process`, + { + eventId: userEvent.created.eventId, + shortName: provider.didDoc.shortName, + archive: { + url: config.archiveUrl, + key: archiveToken, + }, + }, + { + headers: { + 'Authorization': `Bearer ${token}`, + } + } + )).data; + } +} + +// The async archive flow lags the api() calls; poll until both counts stop +// growing, or late rows keep "now" timestamps and spike the final day. +async function waitForSettle(client) { + // Consecutive reads: one can be fooled by a pause in the archive writes. + const STABLE_NEEDED = 4; + let prevAudits = -1, prevUsage = -1, stable = 0; + for (let i = 0; i < 180; i++) { + const audits = (await client.query( + `SELECT COUNT(*)::int AS n FROM audit a JOIN event e ON e.event_id_uuid = a.event_id WHERE e.user_id = $1`, + [config.userId])).rows[0].n; + const usage = (await client.query( + `SELECT COUNT(*)::int AS n FROM usage WHERE user_id = $1`, [config.userId])).rows[0].n; + if (audits > 0 && audits === prevAudits && usage === prevUsage) { + if (++stable >= STABLE_NEEDED) return { audits, usage }; + } else { + stable = 0; + } + prevAudits = audits; prevUsage = usage; + await sleep(1); + } + return { audits: prevAudits, usage: prevUsage }; +} + +async function dbUpdate(client) { + const settled = await waitForSettle(client); + console.log(`Settled: ${settled.audits} audit, ${settled.usage} usage record(s)`); + const userId = client.escapeLiteral(config.userId); + let sql = ` + DO $$ + DECLARE + r record; + BEGIN + FOR r IN SELECT ctid FROM event WHERE user_id = ${userId} LOOP + UPDATE event + SET created_as_ts = NOW() - (random() * INTERVAL '31 days') + WHERE ctid = r.ctid; + END LOOP; + END $$; + `; + await client.query(sql); + console.log(`Updated event(s)`); + sql = ` + UPDATE event SET + created_ts = created_as_ts, + updated_ts = created_as_ts + WHERE user_id = $1 + `; + const eventRes = await client.query(sql, [config.userId]); + console.log(`Updated ${eventRes.rowCount} event record(s)`); + sql = ` + UPDATE audit + SET created_ts = e.created_ts, + updated_ts = e.created_ts + FROM event e + WHERE audit.event_id = e.event_id_uuid + AND e.user_id = $1 + `; + const auditRes = await client.query(sql, [config.userId]); + console.log(`Updated ${auditRes.rowCount} audit record(s)`); + sql = ` + WITH all_records AS ( + -- SELECT created_ts + -- FROM audit + -- WHERE event_id IN ( + -- SELECT event_id_uuid + -- FROM event + -- WHERE user_id = $1 + -- ) + -- UNION ALL + SELECT created_ts + FROM event + WHERE user_id = $1 + ), + record_counts AS ( + SELECT COUNT(*) as total FROM all_records + ), + numbered_all AS ( + SELECT created_ts, ROW_NUMBER() OVER (ORDER BY created_ts) as rn + FROM all_records + ), + usage_numbered AS ( + SELECT id, ROW_NUMBER() OVER (ORDER BY id) as rn + FROM usage + WHERE user_id = $1 + ) + UPDATE usage + SET created_ts = (SELECT na.created_ts FROM numbered_all na WHERE na.rn = ((un.rn - 1) % (SELECT total FROM record_counts)) + 1), + updated_ts = (SELECT na.created_ts FROM numbered_all na WHERE na.rn = ((un.rn - 1) % (SELECT total FROM record_counts)) + 1) + FROM usage_numbered un + WHERE usage.id = un.id + AND usage.user_id = $1 + `; + const usageRes = await client.query(sql, [config.userId]); + console.log(`Updated ${usageRes.rowCount} usage record(s)`); + +} + + + +async function dbClear(client) { + async function runClear(sql) { + await client.query(sql, [config.userId]) + } + await runClear(`DELETE FROM audit_signature WHERE audit_id IN (SELECT a.audit_id FROM audit a JOIN event e ON e.event_id_uuid=a.event_id WHERE e.user_id=$1)`); + await runClear(`DELETE FROM audit_meta WHERE audit_id IN (SELECT audit_id FROM audit WHERE event_id IN (SELECT event_id_uuid FROM event WHERE user_id=$1))`); + await runClear(`DELETE FROM audit_meta WHERE audit_id IN (SELECT audit_id FROM audit WHERE agreement_id IN (SELECT agreement_id_uuid FROM agreement WHERE user_id=$1))`); + await runClear(`DELETE FROM audit WHERE event_id IN (SELECT event_id_uuid FROM event WHERE user_id=$1)`); + await runClear(`DELETE FROM signature WHERE user_id=$1`); + await runClear(`DELETE FROM event_data WHERE user_id=$1`); + await runClear(`DELETE FROM event_meta WHERE user_id=$1`); + await runClear(`DELETE FROM event WHERE user_id=$1`); + await runClear(`DELETE FROM usage WHERE user_id=$1`); + await runClear(`DELETE FROM entity WHERE user_id=$1`); + await runClear(`DELETE FROM agreement_purpose WHERE user_id=$1`); + await runClear(`DELETE FROM agreement_prohibition WHERE user_id=$1`); + await runClear(`DELETE FROM agreement_role WHERE user_id=$1`); + await runClear(`DELETE FROM agreement_content WHERE user_id=$1`); + await runClear(`DELETE FROM agreement_reference WHERE user_id=$1`); + await runClear(`DELETE FROM agreement_required_id WHERE user_id=$1`); + await runClear(`DELETE FROM purpose WHERE user_id=$1`); + await runClear(`DELETE FROM prohibition WHERE user_id=$1`); + await runClear(`DELETE FROM role WHERE user_id=$1`); + await runClear(`DELETE FROM reference WHERE user_id=$1`); + await runClear(`DELETE FROM agreement WHERE user_id=$1`); +} + +async function dbInvalidate(client) { + const sql = ` + WITH invalidate AS ( + SELECT id + FROM event + WHERE user_id = $1 + ORDER BY created_ts DESC + LIMIT 1 + OFFSET 2 + ) + UPDATE event + SET created = EXTRACT(EPOCH FROM created_as_ts)::bigint + WHERE id = (SELECT id FROM invalidate) + + `; + await client.query(sql, [config.userId]); +} + +async function main() { + await loadConfig() + await init(); + const client = await getPool(); + + await dbClear(client); + await api(); + await dbUpdate(client); + await dbInvalidate(client) + + await client.release(); + await close(); +} + +main() diff --git a/backend/util/cacheAgreement.js b/backend/util/cacheAgreement.js new file mode 100644 index 0000000..65ea8c2 --- /dev/null +++ b/backend/util/cacheAgreement.js @@ -0,0 +1,78 @@ +import axios from "axios"; +import { createHash } from "crypto"; +import { getPool } from "../db/index.js"; +import { getConfig } from "../common/config.js"; +import { firstNonBlankLine } from "./firstNonBlankLine.js"; + +// Delay (ms) before the next attempt, given how many have already failed. +// Exponential (startingDelay * factor^priorFailures), capped at maxDelay. +function backoffMs(priorFailures, { startingDelayMs, maxDelayMs, factor }) { + return Math.min(maxDelayMs, startingDelayMs * Math.pow(factor, priorFailures)); +} + +// Best-effort remote agreement fetch. A failure is logged, never thrown. Local +// URLs and already-cached agreements are skipped. Failures are tracked in +// agreement_fetch with exponential backoff so an invalid URL isn't re-fetched on +// every referencing create; after maxRetries we stop trying. +export async function cacheAgreementMarkdown({ userId = null, agreementUuid, uri }, _client) { + const config = getConfig(); + if (typeof uri !== "string" || !uri || !agreementUuid) return; + if (uri.startsWith(config.publicCoreUrl)) return; + + const client = _client || await getPool(); + try { + const cached = await client.query( + `SELECT 1 FROM agreement_content WHERE agreement_uuid = $1 AND markdown IS NOT NULL`, + [agreementUuid], + ); + if (cached.rowCount > 0) return; + + const { maxRetries } = config.agreementCache; + const prior = await client.query( + `SELECT attempts, next_attempt_ts FROM agreement_fetch WHERE agreement_uuid = $1`, + [agreementUuid], + ); + const attempts = prior.rows[0]?.attempts ?? 0; + if (attempts >= maxRetries) return; // gave up + if (prior.rowCount > 0 && new Date(prior.rows[0].next_attempt_ts) > new Date()) return; // not due yet + + try { + const res = await axios.get(uri, { responseType: "text" }); + const markdown = String(res.data ?? "").trim(); + if (!markdown) throw new Error("empty response"); + + const title = firstNonBlankLine(markdown); + const hash = createHash("sha256").update(markdown).digest("hex"); + // Content is immutable once inserted — INSERT only, never overwrite. + await client.query( + `INSERT INTO agreement_content (user_id, title, markdown, hash, agreement_uuid, remote_url) + VALUES ($1, $2, $3, $4, $5, $6) + ON CONFLICT DO NOTHING`, + [userId, title, markdown, hash, agreementUuid, uri], + ); + await client.query(`DELETE FROM agreement_fetch WHERE agreement_uuid = $1`, [agreementUuid]); + } catch (fetchErr) { + await recordFailure(client, agreementUuid, uri, attempts, fetchErr.message, config.agreementCache); + } + } catch (e) { + console.error(`cacheAgreementMarkdown(${agreementUuid}):`, e.message); + } finally { + if (!_client) await client.release(); + } +} + +// Bump the attempt counter and schedule the next try with exponential backoff. +async function recordFailure(client, agreementUuid, uri, priorFailures, message, cfg) { + const delayMs = Math.round(backoffMs(priorFailures, cfg)); + await client.query( + `INSERT INTO agreement_fetch (agreement_uuid, remote_url, attempts, next_attempt_ts, last_error) + VALUES ($1, $2, $3, NOW() + ($4 || ' milliseconds')::interval, $5) + ON CONFLICT (agreement_uuid) DO UPDATE SET + attempts = $3, + next_attempt_ts = NOW() + ($4 || ' milliseconds')::interval, + last_error = $5, + remote_url = $2, + updated_ts = NOW()`, + [agreementUuid, uri, priorFailures + 1, String(delayMs), message], + ); +} diff --git a/backend/util/firstNonBlankLine.js b/backend/util/firstNonBlankLine.js new file mode 100644 index 0000000..c3df0aa --- /dev/null +++ b/backend/util/firstNonBlankLine.js @@ -0,0 +1,14 @@ +export function firstNonBlankLine(markdown) { + if (typeof markdown !== 'string') return; + + const lines = markdown.split(/\r?\n/); + + for (const line of lines) { + const trimmed = line.trim(); + if (trimmed) { + return trimmed.replace(/^#+\s*/, ''); + } + } + + return; +} \ No newline at end of file diff --git a/bin/build-frontend.sh b/bin/build-frontend.sh new file mode 100755 index 0000000..1da7837 --- /dev/null +++ b/bin/build-frontend.sh @@ -0,0 +1,17 @@ +#!/usr/bin/env bash + +cd frontend +if [ ! -d node_modules ]; then + npm install +fi + +set -x +npm run build +mkdir -p ../backend/ui +cp -a ./dist/index.html ../backend/ui/ +cp -a ./dist/favicon.* ../backend/http/public/ +rm -rf ../backend/http/public/_astro +cp -a ./dist/_astro ../backend/http/public/_astro +rm -rf ../backend/http/public/fonts +cp -a ./dist/fonts ../backend/http/public/fonts +{ set +x; } 2>/dev/null diff --git a/bin/build.sh b/bin/build.sh index bac1755..b511124 100755 --- a/bin/build.sh +++ b/bin/build.sh @@ -1,7 +1,10 @@ #!/usr/bin/env bash -IMAGE="registry.jlinc.io/jlinc-server" +IMAGE="jlinc-server" TAG=$(date +'%Y%m%d.%H%M%S') -set -x -docker build -t ${IMAGE}:${TAG} . +set -xe +docker build $@ -t registry.jlinc.io/${IMAGE}:${TAG} . +docker tag registry.jlinc.io/${IMAGE}:${TAG} registry.jlinc.io/${IMAGE}:latest +docker tag registry.jlinc.io/${IMAGE}:${TAG} jlinclabs/${IMAGE}:${TAG} +docker tag registry.jlinc.io/${IMAGE}:${TAG} jlinclabs/${IMAGE}:latest { set +x; } 2>/dev/null diff --git a/bin/push.sh b/bin/push.sh index 28dd98c..4872fc9 100755 --- a/bin/push.sh +++ b/bin/push.sh @@ -1,7 +1,7 @@ #!/usr/bin/env bash -IMAGE="registry.jlinc.io/jlinc-server" -LATEST=$(docker images --format={{.Tag}} ${IMAGE} |grep -v latest |sort -n |tail -n1) +IMAGE="jlinc-server" +LATEST=$(docker images --format={{.Tag}} registry.jlinc.io/${IMAGE} |grep -v latest |sort -n |tail -n1) if [ -z "${LATEST}" ]; then echo "no image found" @@ -9,7 +9,8 @@ if [ -z "${LATEST}" ]; then fi set -x -docker tag ${IMAGE}:${LATEST} ${IMAGE}:latest -docker push ${IMAGE}:${LATEST} -docker push ${IMAGE}:latest +docker push registry.jlinc.io/${IMAGE}:${LATEST} +docker push registry.jlinc.io/${IMAGE}:latest +docker push jlinclabs/${IMAGE}:${LATEST} +docker push jlinclabs/${IMAGE}:latest { set +x; } 2>/dev/null \ No newline at end of file diff --git a/custom.example.css b/custom.example.css new file mode 100644 index 0000000..b56a6e8 --- /dev/null +++ b/custom.example.css @@ -0,0 +1,28 @@ + +:root { + --color-deep-indigo: #0f172a; + --color-vibrant-purple: #2563eb; + --color-teal-accent: #0891b2; + --color-primary: #1e3a8a; + --color-secondary: #0e7490; + + --color-background: #f8fafc; + --color-surface: #ffffff; + --color-surface-container-lowest: #ffffff; + --color-surface-container-low: #f1f5f9; + --color-surface-container: #e2e8f0; + --color-surface-border: #e2e8f0; + --color-on-surface: #0f172a; + + --color-on-primary: #ffffff; + --color-primary-fixed-dim: #93c5fd; + --color-on-primary-fixed-variant: #dbeafe; +} + +.jlinc-logo { display: none !important; } + + +#sidebar { display: none !important; } +main { margin-left: 0 !important; } + +#menu-toggle { display: none !important; } diff --git a/docker-compose.yml b/docker-compose.yml index b6ef613..9460ef6 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -3,7 +3,7 @@ version: '3.8' services: jlinc-server: - image: registry.jlinc.io/jlinc-server + image: jlinclabs/jlinc-server container_name: jlinc-server environment: # BASE CONFIGURATION @@ -17,8 +17,19 @@ services: PUBLIC_CORE_URL: http://localhost:9090 PUBLIC_ARCHIVE_URL: http://localhost:9090 PUBLIC_CALLBACK_URL: http://localhost:9090 - # Enabled app modules - APP_MODULES: core, archive + # Enabled app modules (dashboard = the read-only operator console) + APP_MODULES: core, archive, dashboard + # Optional: source dashboard data from remote core/audit servers instead of + # the local DB (federation). Unset => serve from the local DB. + # DASHBOARD_CORE_URL: https://core.example.com + # DASHBOARD_CORE_KEY: + # DASHBOARD_AUDIT_URL: https://api.jlinc.io + # DASHBOARD_AUDIT_KEY: + # Validated-API-key cache (per process). TTL 0 disables caching entirely; + # MAX bounds how many validated keys are held before the least recently + # used is evicted. Defaults: 30 min / 10000 entries. + # DASHBOARD_KEY_CACHE_TTL_MS: 1800000 + # DASHBOARD_KEY_CACHE_MAX: 10000 # Enabled authentication modules AUTH_MODULES: single, oidc, github, google # Secure secret for memory store, generate with `openssl rand -hex 64` @@ -72,6 +83,9 @@ services: - jlinc-db networks: - jlinc + # White-label: mount a custom.css to re-skin the dashboard (see custom.example.css) + # volumes: + # - ./custom.example.css:/app/http/public/custom.css:ro # For dev ports: - 9090:9090 diff --git a/frontend/.gitignore b/frontend/.gitignore new file mode 100644 index 0000000..55b9dc7 --- /dev/null +++ b/frontend/.gitignore @@ -0,0 +1,28 @@ +# build output +dist/ + +# generated types +.astro/ + +# playwright +test-results/ +playwright-report/ + +# dependencies +node_modules/ + +# logs +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* + +# environment variables +.env +.env.production + +# macOS-specific files +.DS_Store + +# jetbrains setting folder +.idea/ diff --git a/frontend/.vscode/extensions.json b/frontend/.vscode/extensions.json new file mode 100644 index 0000000..22a1505 --- /dev/null +++ b/frontend/.vscode/extensions.json @@ -0,0 +1,4 @@ +{ + "recommendations": ["astro-build.astro-vscode"], + "unwantedRecommendations": [] +} diff --git a/frontend/.vscode/launch.json b/frontend/.vscode/launch.json new file mode 100644 index 0000000..d642209 --- /dev/null +++ b/frontend/.vscode/launch.json @@ -0,0 +1,11 @@ +{ + "version": "0.2.0", + "configurations": [ + { + "command": "./node_modules/.bin/astro dev", + "name": "Development server", + "request": "launch", + "type": "node-terminal" + } + ] +} diff --git a/frontend/README.md b/frontend/README.md new file mode 100644 index 0000000..e702ebb --- /dev/null +++ b/frontend/README.md @@ -0,0 +1,31 @@ +# Frontend + +## Project Structure + +```text +/ +├── public/ +│ └── favicon.svg +├── src +│   ├── assets +│   │   └── astro.svg +│   ├── components +│   │   └── Welcome.astro +│   ├── layouts +│   │   └── Layout.astro +│   └── pages +│   └── index.astro +└── package.json +``` +## 🧞 Commands + +All commands are run from the root of the project, from a terminal: + +| Command | Action | +| :------------------------ | :----------------------------------------------- | +| `npm install` | Installs dependencies | +| `npm run dev` | Starts local dev server at `localhost:4321` | +| `npm run build` | Build your production site to `./dist/` | +| `npm run preview` | Preview your build locally, before deploying | +| `npm run astro ...` | Run CLI commands like `astro add`, `astro check` | +| `npm run astro -- --help` | Get help using the Astro CLI | diff --git a/frontend/astro.config.mjs b/frontend/astro.config.mjs new file mode 100644 index 0000000..c6098d6 --- /dev/null +++ b/frontend/astro.config.mjs @@ -0,0 +1,20 @@ +// @ts-check +import { defineConfig } from 'astro/config'; + +import tailwindcss from '@tailwindcss/vite'; + +// https://astro.build/config +export default defineConfig({ + vite: { + server: { + proxy: { + '/api': { + target: "http://localhost:9090", + changeOrigin: true, + secure: false + } + } + }, + plugins: [tailwindcss()] + } +}); \ No newline at end of file diff --git a/frontend/package-lock.json b/frontend/package-lock.json new file mode 100644 index 0000000..424df19 --- /dev/null +++ b/frontend/package-lock.json @@ -0,0 +1,6539 @@ +{ + "name": "jlinc-server-ui", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "jlinc-server-ui", + "version": "1.0.0", + "dependencies": { + "@tailwindcss/vite": "^4.3.0", + "astro": "^6.3.7", + "d3": "^7.9.0", + "tailwindcss": "^4.3.0", + "xlsx-js-style": "^1.2.0" + }, + "devDependencies": { + "@playwright/test": "^1.49.0", + "vitest": "^3.0.0" + }, + "engines": { + "node": ">=22.12.0" + } + }, + "node_modules/@astrojs/compiler": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@astrojs/compiler/-/compiler-4.0.0.tgz", + "integrity": "sha512-eouss7G8ygdZqHuke033VMcVw5HTZUu+PXd/h06DGDUg/jt5btPYPqh66ENWw/mU78rBrf/oeC4oqoBwMtDMNA==", + "license": "MIT" + }, + "node_modules/@astrojs/internal-helpers": { + "version": "0.9.1", + "resolved": "https://registry.npmjs.org/@astrojs/internal-helpers/-/internal-helpers-0.9.1.tgz", + "integrity": "sha512-1pWuARqYom/TzuU3+0ZugsTrKlUydWKuULmDqSMTuonY+9IRDUEGKX/8PXQ1nBxRq3w85uGtd9q9SXfqEldMIQ==", + "license": "MIT", + "dependencies": { + "picomatch": "^4.0.4" + } + }, + "node_modules/@astrojs/markdown-remark": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/@astrojs/markdown-remark/-/markdown-remark-7.1.2.tgz", + "integrity": "sha512-caXZ4Dc2St2dW8luEg22GlP0gupLdztCTQE4EzZOxW1pqWXz9mbeJEuHUkgDYcKWW8tjIHkydYDhWLVoxJ327Q==", + "license": "MIT", + "dependencies": { + "@astrojs/internal-helpers": "0.9.1", + "@astrojs/prism": "4.0.2", + "github-slugger": "^2.0.0", + "hast-util-from-html": "^2.0.3", + "hast-util-to-text": "^4.0.2", + "js-yaml": "^4.1.1", + "mdast-util-definitions": "^6.0.0", + "rehype-raw": "^7.0.0", + "rehype-stringify": "^10.0.1", + "remark-gfm": "^4.0.1", + "remark-parse": "^11.0.0", + "remark-rehype": "^11.1.2", + "remark-smartypants": "^3.0.2", + "retext-smartypants": "^6.2.0", + "shiki": "^4.0.0", + "smol-toml": "^1.6.0", + "unified": "^11.0.5", + "unist-util-remove-position": "^5.0.0", + "unist-util-visit": "^5.1.0", + "unist-util-visit-parents": "^6.0.2", + "vfile": "^6.0.3" + } + }, + "node_modules/@astrojs/prism": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@astrojs/prism/-/prism-4.0.2.tgz", + "integrity": "sha512-KTivpmnz6lDsC6o9H4+DNm2SrE/GHzw8cNAvEJwAvUT+eoaEnn/4NtbDNfRRaxaJHdp15gf+tfHAWiXR4wB3BA==", + "license": "MIT", + "dependencies": { + "prismjs": "^1.30.0" + }, + "engines": { + "node": ">=22.12.0" + } + }, + "node_modules/@astrojs/telemetry": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/@astrojs/telemetry/-/telemetry-3.3.2.tgz", + "integrity": "sha512-j8DNruA8ors99Al39RYZPJK4DC1bKkoNm93mAMuBhY9TCNC4R8n1q7ovFnJ5qhGh5Lsh7pa1gpQVpYpsJPeTHQ==", + "license": "MIT", + "dependencies": { + "ci-info": "^4.4.0", + "dset": "^3.1.4", + "is-docker": "^4.0.0", + "is-wsl": "^3.1.1", + "which-pm-runs": "^1.1.0" + }, + "engines": { + "node": "18.20.8 || ^20.3.0 || >=22.0.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.3", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.3.tgz", + "integrity": "sha512-b3ctpQwp+PROvU/cttc4OYl4MzfJUWy6FZg+PMXfzmt/+39iHVF0sDfqay8TQM3JA2EUOyKcFZt75jWriQijsA==", + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.0" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", + "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@capsizecss/unpack": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@capsizecss/unpack/-/unpack-4.0.0.tgz", + "integrity": "sha512-VERIM64vtTP1C4mxQ5thVT9fK0apjPFobqybMtA1UdUujWka24ERHbRHFGmpbbhp73MhV+KSsHQH9C6uOTdEQA==", + "license": "MIT", + "dependencies": { + "fontkitten": "^1.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@clack/core": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@clack/core/-/core-1.3.1.tgz", + "integrity": "sha512-fT1qHVGAag4IEkrupZ6lRRbNCs1vS9P01KB/sG8zKgvUztbYtFBtQpjSITNwooDZ83tpsPzP0mRNs1/KVszCRA==", + "license": "MIT", + "dependencies": { + "fast-wrap-ansi": "^0.2.0", + "sisteransi": "^1.0.5" + }, + "engines": { + "node": ">= 20.12.0" + } + }, + "node_modules/@clack/prompts": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@clack/prompts/-/prompts-1.4.0.tgz", + "integrity": "sha512-S0My7XPGIgpRWMDG8uRqalbgT+a6FmCUdOW+HaIOVVpUPHOb7RrpvjTjiODadKp06fsrVDJZlIzc6yCTp4AnxA==", + "license": "MIT", + "dependencies": { + "@clack/core": "1.3.1", + "fast-string-width": "^3.0.2", + "fast-wrap-ansi": "^0.2.0", + "sisteransi": "^1.0.5" + }, + "engines": { + "node": ">= 20.12.0" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz", + "integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==", + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.7.tgz", + "integrity": "sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg==", + "cpu": [ + "ppc64" + ], + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.7.tgz", + "integrity": "sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.7.tgz", + "integrity": "sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.7.tgz", + "integrity": "sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.7.tgz", + "integrity": "sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.7.tgz", + "integrity": "sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.7.tgz", + "integrity": "sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.7.tgz", + "integrity": "sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.7.tgz", + "integrity": "sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.7.tgz", + "integrity": "sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.7.tgz", + "integrity": "sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.7.tgz", + "integrity": "sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q==", + "cpu": [ + "loong64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.7.tgz", + "integrity": "sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw==", + "cpu": [ + "mips64el" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.7.tgz", + "integrity": "sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ==", + "cpu": [ + "ppc64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.7.tgz", + "integrity": "sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ==", + "cpu": [ + "riscv64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.7.tgz", + "integrity": "sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw==", + "cpu": [ + "s390x" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.7.tgz", + "integrity": "sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.7.tgz", + "integrity": "sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.7.tgz", + "integrity": "sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.7.tgz", + "integrity": "sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.7.tgz", + "integrity": "sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.7.tgz", + "integrity": "sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.7.tgz", + "integrity": "sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.7.tgz", + "integrity": "sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.7.tgz", + "integrity": "sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.7.tgz", + "integrity": "sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@img/colour": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz", + "integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/@img/sharp-darwin-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.34.5.tgz", + "integrity": "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-arm64": "1.2.4" + } + }, + "node_modules/@img/sharp-darwin-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.34.5.tgz", + "integrity": "sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-x64": "1.2.4" + } + }, + "node_modules/@img/sharp-libvips-darwin-arm64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.2.4.tgz", + "integrity": "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-darwin-x64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.2.4.tgz", + "integrity": "sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.2.4.tgz", + "integrity": "sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==", + "cpu": [ + "arm" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.2.4.tgz", + "integrity": "sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-ppc64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.2.4.tgz", + "integrity": "sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==", + "cpu": [ + "ppc64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-riscv64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.2.4.tgz", + "integrity": "sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==", + "cpu": [ + "riscv64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-s390x": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.2.4.tgz", + "integrity": "sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==", + "cpu": [ + "s390x" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-x64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.2.4.tgz", + "integrity": "sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-arm64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.2.4.tgz", + "integrity": "sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-x64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.2.4.tgz", + "integrity": "sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-linux-arm": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.34.5.tgz", + "integrity": "sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==", + "cpu": [ + "arm" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.34.5.tgz", + "integrity": "sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm64": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-ppc64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.34.5.tgz", + "integrity": "sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==", + "cpu": [ + "ppc64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-ppc64": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-riscv64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.34.5.tgz", + "integrity": "sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==", + "cpu": [ + "riscv64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-riscv64": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-s390x": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.34.5.tgz", + "integrity": "sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==", + "cpu": [ + "s390x" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-s390x": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.34.5.tgz", + "integrity": "sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-x64": "1.2.4" + } + }, + "node_modules/@img/sharp-linuxmusl-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.34.5.tgz", + "integrity": "sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-arm64": "1.2.4" + } + }, + "node_modules/@img/sharp-linuxmusl-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.34.5.tgz", + "integrity": "sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-x64": "1.2.4" + } + }, + "node_modules/@img/sharp-wasm32": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.34.5.tgz", + "integrity": "sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==", + "cpu": [ + "wasm32" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", + "optional": true, + "dependencies": { + "@emnapi/runtime": "^1.7.0" + }, + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.34.5.tgz", + "integrity": "sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-ia32": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.34.5.tgz", + "integrity": "sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==", + "cpu": [ + "ia32" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.34.5.tgz", + "integrity": "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@oslojs/encoding": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@oslojs/encoding/-/encoding-1.1.0.tgz", + "integrity": "sha512-70wQhgYmndg4GCPxPPxPGevRKqTIJ2Nh4OkiMWmDAVYsTQ+Ta7Sq+rPevXyXGdzr30/qZBnyOalCszoMxlyldQ==", + "license": "MIT" + }, + "node_modules/@playwright/test": { + "version": "1.61.1", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.61.1.tgz", + "integrity": "sha512-8nKv6+0RJSL9FE4jYOEGXnPeM/Hg12qZpmqzZjRh3qM0Y7c3z1mrOTfFLids72RDQYVh9WpLEfR5WdpNX4fkig==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.61.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@rollup/pluginutils": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/@rollup/pluginutils/-/pluginutils-5.3.0.tgz", + "integrity": "sha512-5EdhGZtnu3V88ces7s53hhfK5KSASnJZv8Lulpc04cWO3REESroJXg73DFsOmgbU2BhwV0E20bu2IDZb3VKW4Q==", + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0", + "estree-walker": "^2.0.2", + "picomatch": "^4.0.2" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "rollup": "^1.20.0||^2.0.0||^3.0.0||^4.0.0" + }, + "peerDependenciesMeta": { + "rollup": { + "optional": true + } + } + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.4.tgz", + "integrity": "sha512-F5QXMSiFebS9hKZj02XhWLLnRpJ3B3AROP0tWbFBSj+6kCbg5m9j5JoHKd4mmSVy5mS/IMQloYgYxCuJC0fxEQ==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.60.4.tgz", + "integrity": "sha512-GxxTKApUpzRhof7poWvCJHRF51C67u1R7D6DiluBE8wKU1u5GWE8t+v81JvJYtbawoBFX1hLv5Ei4eVjkWokaw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.60.4.tgz", + "integrity": "sha512-tua0TaJxMOB1R0V0RS1jFZ/RpURFDJIOR2A6jWwQeawuFyS4gBW+rntLRaQd0EQ4bd6Vp44Z2rXW+YYDBsj6IA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.60.4.tgz", + "integrity": "sha512-CSKq7MsP+5PFIcydhAiR1K0UhEI1A2jWXVKHPCBZ151yOutENwvnPocgVHkivu2kviURtCEB6zUQw0vs8RrhMg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.60.4.tgz", + "integrity": "sha512-+O8OkVdyvXMtJEciu2wS/pzm1IxntEEQx3z5TAVy4l32G0etZn+RsA48ARRrFm6Ri8fvqPQfgrvNxSjKAbnd3g==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.60.4.tgz", + "integrity": "sha512-Iw3oMskH3AfNuhU0MSN7vNbdi4me/NiYo2azqPz/Le16zHSa+3RRmliCMWWQmh4lcndccU40xcJuTYJZxNo/lw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.60.4.tgz", + "integrity": "sha512-EIPRXTVQpHyF8WOo219AD2yEltPehLTcTMz2fn6JsatLYSzQf00hj3rulF+yauOlF9/FtM2WpkT/hJh/KJFGhA==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.60.4.tgz", + "integrity": "sha512-J3Yh9PzzF1Ovah2At+lHiGQdsYgArxBbXv/zHfSyaiFQEqvNv7DcW98pCrmdjCZBrqBiKrKKe2V+aaSGWuBe/w==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.60.4.tgz", + "integrity": "sha512-BFDEZMYfUvLn37ONE1yMBojPxnMlTFsdyNoqncT0qFq1mAfllL+ATMMJd8TeuVMiX84s1KbcxcZbXInmcO2mRg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.60.4.tgz", + "integrity": "sha512-pc9EYOSlOgdQ2uPl1o9PF6/kLSgaUosia7gOuS8mB69IxJvlclko1MECXysjs5ryez1/5zjYqx3+xYU0TU6R1A==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.60.4.tgz", + "integrity": "sha512-NxnomyxYerDh5n4iLrNa+sH+Z+U4BMEE46V2PgQ/hoB909i8gV1M5wPojWg9fk1jWpO3IQnOs20K4wyZuFLEFQ==", + "cpu": [ + "loong64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.60.4.tgz", + "integrity": "sha512-nbJnQ8a3z1mtmrwImCYhc6BGpThAyYVRQxw9uKSKG4wR6aAYno9sVjJ0zaZcW9BPJX1GbrDPf+SvdWjgTuDmnw==", + "cpu": [ + "loong64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.60.4.tgz", + "integrity": "sha512-2EU6acNrQLd8tYvo/LXW535wupT3m6fo7HKo6lr7ktQoItxTyOL1ZCR/GfGCuXl2vR+zmfI6eRXkSemafv+iVg==", + "cpu": [ + "ppc64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.60.4.tgz", + "integrity": "sha512-WeBtoMuaMxiiIrO2IYP3xs6GMWkJP2C0EoT8beTLkUPmzV1i/UcOSVw1d5r9KBODtHKilG5yFxsGRnBbK3wJ4A==", + "cpu": [ + "ppc64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.60.4.tgz", + "integrity": "sha512-FJHFfqpKUI3A10WrWKiFbBZ7yVbGT4q4B5o1qKFFojqpaYoh9LrQgqWCmmcxQzVSXYtyB5bzkXrYzlHTs21MYA==", + "cpu": [ + "riscv64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.60.4.tgz", + "integrity": "sha512-mcEl6CUT5IAUmQf1m9FYSmVqCJlpQ8r8eyftFUHG8i9OhY7BkBXSUdnLH5DOf0wCOjcP9v/QO93zpmF1SptCCw==", + "cpu": [ + "riscv64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.60.4.tgz", + "integrity": "sha512-ynt3JxVd2w2buzoKDWIyiV1pJW93xlQic1THVLXilz429oijRpSHivZAgp65KBu+cMcgf1eVVjdnTLvPxgCuoQ==", + "cpu": [ + "s390x" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.60.4.tgz", + "integrity": "sha512-Boiz5+MsaROEWDf+GGEwF8VMHGhlUoQMtIPjOgA5fv4osupqTVnJteQNKJwUcnUog2G55jYXH7KZFFiJe0TEzQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.60.4.tgz", + "integrity": "sha512-+qfSY27qIrFfI/Hom04KYFw3GKZSGU4lXus51wsb5EuySfFlWRwjkKWoE9emgRw/ukoT4Udsj4W/+xxG8VbPKg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.60.4.tgz", + "integrity": "sha512-VpTfOPHgVXEBeeR8hZ2O0F3aSso+JDWqTWmTmzcQKted54IAdUVbxE+j/MVxUsKa8L20HJhv3vUezVPoquqWjA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.60.4.tgz", + "integrity": "sha512-IPOsh5aRYuLv/nkU51X10Bf75Bsf6+gZdx1X+QP5QM6lIJFHHqbHLG0uJn/hWthzo13UAc2umiUorqZy3axoZg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.60.4.tgz", + "integrity": "sha512-4QzE9E81OohJ/HKzHhsqU+zcYYojVOXlFMs1DdyMT6qXl/niOH7AVElmmEdUNHHS/oRkc++d5k6Vy85zFs0DEw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.60.4.tgz", + "integrity": "sha512-zTPgT1YuHHcd+Tmx7h8aml0FWFVelV5N54oHow9SLj+GfoDy/huQ+UV396N/C7KpMDMiPspRktzM1/0r1usYEA==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.60.4.tgz", + "integrity": "sha512-DRS4G7mi9lJxqEDezIkKCaUIKCrLUUDCUaCsTPCi/rtqaC6D/jjwslMQyiDU50Ka0JKpeXeRBFBAXwArY52vBw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.60.4.tgz", + "integrity": "sha512-QVTUovf40zgTqlFVrKA1uXMVvU2QWEFWfAH8Wdc48IxLvrJMQVMBRjuQyUpzZCDkakImib9eVazbWlC6ksWtJw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@shikijs/core": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/@shikijs/core/-/core-4.1.0.tgz", + "integrity": "sha512-jLJtSJeuFffqX6/inRE1zqU5aFv2hrszvYgq3OjbAgFRZiWv7abKMDdQzYxuSDfmUPQozZvI/kuy6VMTvnvqTQ==", + "license": "MIT", + "dependencies": { + "@shikijs/primitive": "4.1.0", + "@shikijs/types": "4.1.0", + "@shikijs/vscode-textmate": "^10.0.2", + "@types/hast": "^3.0.4", + "hast-util-to-html": "^9.0.5" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@shikijs/engine-javascript": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/@shikijs/engine-javascript/-/engine-javascript-4.1.0.tgz", + "integrity": "sha512-YquhawCUgaBfhsS72e2Y/dI59gCBNPHu3fEO/tvLaXrTssxZrY5ddjtNLTwndrMgPo8b3IscE+xoICDzpTmlFQ==", + "license": "MIT", + "dependencies": { + "@shikijs/types": "4.1.0", + "@shikijs/vscode-textmate": "^10.0.2", + "oniguruma-to-es": "^4.3.6" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@shikijs/engine-oniguruma": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/@shikijs/engine-oniguruma/-/engine-oniguruma-4.1.0.tgz", + "integrity": "sha512-axLpjVs45YBvvINa+dJF+NPW+KtFkNXsFr4SDw2BMj9GdeMnGxVB9PQb2xXlJYovslt/nz6giedAyOANkfc7hg==", + "license": "MIT", + "dependencies": { + "@shikijs/types": "4.1.0", + "@shikijs/vscode-textmate": "^10.0.2" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@shikijs/langs": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/@shikijs/langs/-/langs-4.1.0.tgz", + "integrity": "sha512-nwOMruEkbgdZfQ/b8CgpNBVOpvG1k0N5tbmgiFeqsan401+x3ILqlzZJowSla4Agmq4hG2Uf2wh5jLTEhR8VSg==", + "license": "MIT", + "dependencies": { + "@shikijs/types": "4.1.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@shikijs/primitive": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/@shikijs/primitive/-/primitive-4.1.0.tgz", + "integrity": "sha512-zx2/2Uwj2q9X3KSyYREEhXO23xBw5WUhP4orK2lE4r+t9JGITmEe0JH+wPmJhqHpOT2bRRs6lAL945+LDvOAGw==", + "license": "MIT", + "dependencies": { + "@shikijs/types": "4.1.0", + "@shikijs/vscode-textmate": "^10.0.2", + "@types/hast": "^3.0.4" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@shikijs/themes": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/@shikijs/themes/-/themes-4.1.0.tgz", + "integrity": "sha512-emCcTnUM7yO2wltYbaxm+yLvcCI4+h8XBKc4KmJ7EZUXoSGjcCHifkI//R4OFit9ewpg7H2/9tjOuXrT2v/Knw==", + "license": "MIT", + "dependencies": { + "@shikijs/types": "4.1.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@shikijs/types": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/@shikijs/types/-/types-4.1.0.tgz", + "integrity": "sha512-3EQWX54fMpniOrDblzAhiwiJwpiTMW6+B9DWyUd9ska483tbayFYuw47UxwuPknI31bKnySfVQ/QW+jFL4rFdA==", + "license": "MIT", + "dependencies": { + "@shikijs/vscode-textmate": "^10.0.2", + "@types/hast": "^3.0.4" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@shikijs/vscode-textmate": { + "version": "10.0.2", + "resolved": "https://registry.npmjs.org/@shikijs/vscode-textmate/-/vscode-textmate-10.0.2.tgz", + "integrity": "sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg==", + "license": "MIT" + }, + "node_modules/@tailwindcss/node": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.3.0.tgz", + "integrity": "sha512-aFb4gUhFOgdh9AXo4IzBEOzBkkAxm9VigwDJnMIYv3lcfXCJVesNfbEaBl4BNgVRyid92AmdviqwBUBRKSeY3g==", + "license": "MIT", + "dependencies": { + "@jridgewell/remapping": "^2.3.5", + "enhanced-resolve": "^5.21.0", + "jiti": "^2.6.1", + "lightningcss": "1.32.0", + "magic-string": "^0.30.21", + "source-map-js": "^1.2.1", + "tailwindcss": "4.3.0" + } + }, + "node_modules/@tailwindcss/oxide": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.3.0.tgz", + "integrity": "sha512-F7HZGBeN9I0/AuuJS5PwcD8xayx5ri5GhjYUDBEVYUkexyA/giwbDNjRVrxSezE3T250OU2K/wp/ltWx3UOefg==", + "license": "MIT", + "engines": { + "node": ">= 20" + }, + "optionalDependencies": { + "@tailwindcss/oxide-android-arm64": "4.3.0", + "@tailwindcss/oxide-darwin-arm64": "4.3.0", + "@tailwindcss/oxide-darwin-x64": "4.3.0", + "@tailwindcss/oxide-freebsd-x64": "4.3.0", + "@tailwindcss/oxide-linux-arm-gnueabihf": "4.3.0", + "@tailwindcss/oxide-linux-arm64-gnu": "4.3.0", + "@tailwindcss/oxide-linux-arm64-musl": "4.3.0", + "@tailwindcss/oxide-linux-x64-gnu": "4.3.0", + "@tailwindcss/oxide-linux-x64-musl": "4.3.0", + "@tailwindcss/oxide-wasm32-wasi": "4.3.0", + "@tailwindcss/oxide-win32-arm64-msvc": "4.3.0", + "@tailwindcss/oxide-win32-x64-msvc": "4.3.0" + } + }, + "node_modules/@tailwindcss/oxide-android-arm64": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.3.0.tgz", + "integrity": "sha512-TJPiq67tKlLuObP6RkwvVGDoxCMBVtDgKkLfa/uyj7/FyxvQwHS+UOnVrXXgbEsfUaMgiVvC4KbJnRr26ho4Ng==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-darwin-arm64": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.3.0.tgz", + "integrity": "sha512-oMN/WZRb+SO37BmUElEgeEWuU8E/HXRkiODxJxLe1UTHVXLrdVSgfaJV7pSlhRGMSOiXLuxTIjfsF3wYvz8cgQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-darwin-x64": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.3.0.tgz", + "integrity": "sha512-N6CUmu4a6bKVADfw77p+iw6Yd9Q3OBhe0veaDX+QazfuVYlQsHfDgxBrsjQ/IW+zywL8mTrNd0SdJT/zgtvMdA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-freebsd-x64": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.3.0.tgz", + "integrity": "sha512-zDL5hBkQdH5C6MpqbK3gQAgP80tsMwSI26vjOzjJtNCMUo0lFgOItzHKBIupOZNQxt3ouPH7RPhvNhiTfCe5CQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.3.0.tgz", + "integrity": "sha512-R06HdNi7A7OEoMsf6d4tjZ71RCWnZQPHj2mnotSFURjNLdBC+cIgXQ7l81CqeoiQftjf6OOblxXMInMgN2VzMA==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-gnu": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.3.0.tgz", + "integrity": "sha512-qTJHELX8jetjhRQHCLilkVLmybpzNQAtaI/gaoVoidn/ufbNDbAo8KlK2J+yPoc8wQxvDxCmh/5lr8nC1+lTbg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-musl": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.3.0.tgz", + "integrity": "sha512-Z6sukiQsngnWO+l39X4pPbiWT81IC+PLKF+PHxIlyZbGNb9MODfYlXEVlFvej5BOZInWX01kVyzeLvHsXhfczQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-gnu": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.3.0.tgz", + "integrity": "sha512-DRNdQRpSGzRGfARVuVkxvM8Q12nh19l4BF/G7zGA1oe+9wcC6saFBHTISrpIcKzhiXtSrlSrluCfvMuledoCTQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-musl": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.3.0.tgz", + "integrity": "sha512-Z0IADbDo8bh6I7h2IQMx601AdXBLfFpEdUotft86evd/8ZPflZe9COPO8Q1vw+pfLWIUo9zN/JGZvwuAJqduqg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.3.0.tgz", + "integrity": "sha512-HNZGOUxEmElksYR7S6sC5jTeNGpobAsy9u7Gu0AskJ8/20FR9GqebUyB+HBcU/ax6BHuiuJi+Oda4B+YX6H1yA==", + "bundleDependencies": [ + "@napi-rs/wasm-runtime", + "@emnapi/core", + "@emnapi/runtime", + "@tybys/wasm-util", + "@emnapi/wasi-threads", + "tslib" + ], + "cpu": [ + "wasm32" + ], + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "^1.10.0", + "@emnapi/runtime": "^1.10.0", + "@emnapi/wasi-threads": "^1.2.1", + "@napi-rs/wasm-runtime": "^1.1.4", + "@tybys/wasm-util": "^0.10.1", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/core": { + "version": "1.10.0", + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.1", + "tslib": "^2.4.0" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/runtime": { + "version": "1.10.0", + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/wasi-threads": { + "version": "1.2.1", + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@napi-rs/wasm-runtime": { + "version": "1.1.4", + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@tybys/wasm-util": "^0.10.1" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "peerDependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@tybys/wasm-util": { + "version": "0.10.1", + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/tslib": { + "version": "2.8.1", + "inBundle": true, + "license": "0BSD", + "optional": true + }, + "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.3.0.tgz", + "integrity": "sha512-Pe+RPVTi1T+qymuuRpcdvwSVZjnll/f7n8gBxMMh3xLTctMDKqpdfGimbMyioqtLhUYZxdJ9wGNhV7MKHvgZsQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-win32-x64-msvc": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.3.0.tgz", + "integrity": "sha512-Mvrf2kXW/yeW/OTezZlCGOirXRcUuLIBx/5Y12BaPM7wJoryG6dfS/NJL8aBPqtTEx/Vm4T4vKzFUcKDT+TKUA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/vite": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.3.0.tgz", + "integrity": "sha512-t6J3OrB5Fc0ExuhohouH0fWUGMYL6PTLhW+E7zIk/pdbnJARZDCwjBznFnkh5ynRnIRSI4YjtTH0t6USjJISrw==", + "license": "MIT", + "dependencies": { + "@tailwindcss/node": "4.3.0", + "@tailwindcss/oxide": "4.3.0", + "tailwindcss": "4.3.0" + }, + "peerDependencies": { + "vite": "^5.2.0 || ^6 || ^7 || ^8" + } + }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/debug": { + "version": "4.1.13", + "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.13.tgz", + "integrity": "sha512-KSVgmQmzMwPlmtljOomayoR89W4FynCAi3E8PPs7vmDVPe84hT+vGPKkJfThkmXs0x0jAaa9U8uW8bbfyS2fWw==", + "license": "MIT", + "dependencies": { + "@types/ms": "*" + } + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "license": "MIT" + }, + "node_modules/@types/hast": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/hast/-/hast-3.0.4.tgz", + "integrity": "sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ==", + "license": "MIT", + "dependencies": { + "@types/unist": "*" + } + }, + "node_modules/@types/mdast": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/@types/mdast/-/mdast-4.0.4.tgz", + "integrity": "sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==", + "license": "MIT", + "dependencies": { + "@types/unist": "*" + } + }, + "node_modules/@types/ms": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz", + "integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==", + "license": "MIT" + }, + "node_modules/@types/nlcst": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/@types/nlcst/-/nlcst-2.0.3.tgz", + "integrity": "sha512-vSYNSDe6Ix3q+6Z7ri9lyWqgGhJTmzRjZRqyq15N0Z/1/UnVsno9G/N40NBijoYx2seFDIl0+B2mgAb9mezUCA==", + "license": "MIT", + "dependencies": { + "@types/unist": "*" + } + }, + "node_modules/@types/unist": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz", + "integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==", + "license": "MIT" + }, + "node_modules/@ungap/structured-clone": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.1.tgz", + "integrity": "sha512-mUFwbeTqrVgDQxFveS+df2yfap6iuP20NAKAsBt5jDEoOTDew+zwLAOilHCeQJOVSvmgCX4ogqIrA0mnyr08yQ==", + "license": "ISC" + }, + "node_modules/@vitest/expect": { + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.2.6.tgz", + "integrity": "sha512-1+7q9BtaKzEmO+fmNT3kYvoNn5Y71XWAx2Q5HRim4tTVRQVRv4uJFAQ5FbK0OPUeNP/WmVCpxYxoJdvuHVjzBQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/chai": "^5.2.2", + "@vitest/spy": "3.2.6", + "@vitest/utils": "3.2.6", + "chai": "^5.2.0", + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-3.2.6.tgz", + "integrity": "sha512-EZOrpDbkKotFAP7wPAQV1UIyoGOk4oX7ynWhBhLB7v+meMHbQhU16oPpIYGTTe4oFlhpryGpgpcZP/sin3hYuw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "3.2.6", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.17" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/mocker/node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/@vitest/pretty-format": { + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.2.6.tgz", + "integrity": "sha512-lb7XXXzmm2h2ASzFnRvQpDo6onT1NmMJA3tkGTWiBFtRJ9lxGY3d3mm/Apt36gej2bkkOVLL/yTOtufDaFa/jA==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-3.2.6.tgz", + "integrity": "sha512-HYcoSj1w5tcgUnzoF0HcyaAQjpA1gj9ftUJ7iSJSuipc02jW9gKkigwZbjFldAfYHA1fa8UZVRftdMY5msWM9Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "3.2.6", + "pathe": "^2.0.3", + "strip-literal": "^3.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-3.2.6.tgz", + "integrity": "sha512-H+ZjNTWGpObenh0YnlBctAPnJSI20P81PL8BPzWpx54YXLLTm8hEsWawtcYLMrwvpK48hGxLLbCS+1KRXhsKhw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "3.2.6", + "magic-string": "^0.30.17", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-3.2.6.tgz", + "integrity": "sha512-oq6BbH68WzcWmwtBrU9nqLeaXTR4XwJF7FSLkKEZo4i6eoXcrxjcwSuTvWBIRUTC6VC72nXYunzqgZA+IKdtxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyspy": "^4.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-3.2.6.tgz", + "integrity": "sha512-lI23nIs4bnT3T8NIoh+vFaz5s2/DdP0Jgt2jxwgWljvwn82cLJtyi/If+fjFyoLMGIOz0U/fKvWE0d4jsNQEfg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "3.2.6", + "loupe": "^3.1.4", + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/adler-32": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/adler-32/-/adler-32-1.2.0.tgz", + "integrity": "sha512-/vUqU/UY4MVeFsg+SsK6c+/05RZXIHZMGJA+PX5JyWI0ZRcBpupnRuPLU/NXXoFwMYCPCoxIfElM2eS+DUXCqQ==", + "license": "Apache-2.0", + "dependencies": { + "exit-on-epipe": "~1.0.1", + "printj": "~1.1.0" + }, + "bin": { + "adler32": "bin/adler32.njs" + }, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/anymatch": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", + "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", + "license": "ISC", + "dependencies": { + "normalize-path": "^3.0.0", + "picomatch": "^2.0.4" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/anymatch/node_modules/picomatch": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "license": "Python-2.0" + }, + "node_modules/aria-query": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.2.tgz", + "integrity": "sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==", + "license": "Apache-2.0", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/array-iterate": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/array-iterate/-/array-iterate-2.0.1.tgz", + "integrity": "sha512-I1jXZMjAgCMmxT4qxXfPXa6SthSoE8h6gkSI9BGGNv8mP8G/v0blc+qFnZu6K42vTOiuME596QaLO0TP3Lk0xg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/astro": { + "version": "6.3.7", + "resolved": "https://registry.npmjs.org/astro/-/astro-6.3.7.tgz", + "integrity": "sha512-zIeDRrI0qNgN1lcCjNqt6/IVCVej7VwSa326cO8uP9BOk1cg4QuffhLnOn2gCgWQr32/wxpSRFfXiLKHglu1Tw==", + "license": "MIT", + "dependencies": { + "@astrojs/compiler": "^4.0.0", + "@astrojs/internal-helpers": "0.9.1", + "@astrojs/markdown-remark": "7.1.2", + "@astrojs/telemetry": "3.3.2", + "@capsizecss/unpack": "^4.0.0", + "@clack/prompts": "^1.1.0", + "@oslojs/encoding": "^1.1.0", + "@rollup/pluginutils": "^5.3.0", + "aria-query": "^5.3.2", + "axobject-query": "^4.1.0", + "ci-info": "^4.4.0", + "clsx": "^2.1.1", + "common-ancestor-path": "^2.0.0", + "cookie": "^1.1.1", + "devalue": "^5.6.3", + "diff": "^8.0.3", + "dset": "^3.1.4", + "es-module-lexer": "^2.0.0", + "esbuild": "^0.27.3", + "flattie": "^1.1.1", + "fontace": "~0.4.1", + "get-tsconfig": "5.0.0-beta.4", + "github-slugger": "^2.0.0", + "html-escaper": "3.0.3", + "http-cache-semantics": "^4.2.0", + "js-yaml": "^4.1.1", + "jsonc-parser": "^3.3.1", + "magic-string": "^0.30.21", + "magicast": "^0.5.2", + "mrmime": "^2.0.1", + "neotraverse": "^0.6.18", + "obug": "^2.1.1", + "p-limit": "^7.3.0", + "p-queue": "^9.1.0", + "package-manager-detector": "^1.6.0", + "piccolore": "^0.1.3", + "picomatch": "^4.0.4", + "rehype": "^13.0.2", + "semver": "^7.7.4", + "shiki": "^4.0.2", + "smol-toml": "^1.6.0", + "svgo": "^4.0.1", + "tinyclip": "^0.1.12", + "tinyexec": "^1.0.4", + "tinyglobby": "^0.2.15", + "ultrahtml": "^1.6.0", + "unifont": "~0.7.4", + "unist-util-visit": "^5.1.0", + "unstorage": "^1.17.5", + "vfile": "^6.0.3", + "vite": "^7.3.2", + "vitefu": "^1.1.2", + "xxhash-wasm": "^1.1.0", + "yargs-parser": "^22.0.0", + "zod": "^4.3.6" + }, + "bin": { + "astro": "bin/astro.mjs" + }, + "engines": { + "node": ">=22.12.0", + "npm": ">=9.6.5", + "pnpm": ">=7.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/astrodotbuild" + }, + "optionalDependencies": { + "sharp": "^0.34.0" + } + }, + "node_modules/axobject-query": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz", + "integrity": "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==", + "license": "Apache-2.0", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/bail": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/bail/-/bail-2.0.2.tgz", + "integrity": "sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/boolbase": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz", + "integrity": "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==", + "license": "ISC" + }, + "node_modules/cac": { + "version": "6.7.14", + "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", + "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/ccount": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/ccount/-/ccount-2.0.1.tgz", + "integrity": "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/cfb": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/cfb/-/cfb-1.2.2.tgz", + "integrity": "sha512-KfdUZsSOw19/ObEWasvBP/Ac4reZvAGauZhs6S/gqNhXhI7cKwvlH7ulj+dOEYnca4bm4SGo8C1bTAQvnTjgQA==", + "license": "Apache-2.0", + "dependencies": { + "adler-32": "~1.3.0", + "crc-32": "~1.2.0" + }, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/cfb/node_modules/adler-32": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/adler-32/-/adler-32-1.3.1.tgz", + "integrity": "sha512-ynZ4w/nUUv5rrsR8UUGoe1VC9hZj6V5hU9Qw1HlMDJGEJw5S7TfTErWTjMys6M7vr0YWcPqs3qAr4ss0nDfP+A==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.8" + } + }, + "node_modules/chai": { + "version": "5.3.3", + "resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz", + "integrity": "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "assertion-error": "^2.0.1", + "check-error": "^2.1.1", + "deep-eql": "^5.0.1", + "loupe": "^3.1.0", + "pathval": "^2.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/character-entities": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/character-entities/-/character-entities-2.0.2.tgz", + "integrity": "sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/character-entities-html4": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/character-entities-html4/-/character-entities-html4-2.1.0.tgz", + "integrity": "sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/character-entities-legacy": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/character-entities-legacy/-/character-entities-legacy-3.0.0.tgz", + "integrity": "sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/check-error": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.3.tgz", + "integrity": "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 16" + } + }, + "node_modules/chokidar": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-5.0.0.tgz", + "integrity": "sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==", + "license": "MIT", + "dependencies": { + "readdirp": "^5.0.0" + }, + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/ci-info": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-4.4.0.tgz", + "integrity": "sha512-77PSwercCZU2Fc4sX94eF8k8Pxte6JAwL4/ICZLFjJLqegs7kCuAsqqj/70NQF6TvDpgFjkubQB2FW2ZZddvQg==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/sibiraj-s" + } + ], + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/clsx": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/codepage": { + "version": "1.14.0", + "resolved": "https://registry.npmjs.org/codepage/-/codepage-1.14.0.tgz", + "integrity": "sha512-iz3zJLhlrg37/gYRWgEPkaFTtzmnEv1h+r7NgZum2lFElYQPi0/5bnmuDfODHxfp0INEfnRqyfyeIJDbb7ahRw==", + "license": "Apache-2.0", + "dependencies": { + "commander": "~2.14.1", + "exit-on-epipe": "~1.0.1" + }, + "bin": { + "codepage": "bin/codepage.njs" + }, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/codepage/node_modules/commander": { + "version": "2.14.1", + "resolved": "https://registry.npmjs.org/commander/-/commander-2.14.1.tgz", + "integrity": "sha512-+YR16o3rK53SmWHU3rEM3tPAh2rwb1yPcQX5irVn7mb0gXbwuCCrnkbV5+PBfETdfg1vui07nM6PCG1zndcjQw==", + "license": "MIT" + }, + "node_modules/comma-separated-tokens": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/comma-separated-tokens/-/comma-separated-tokens-2.0.3.tgz", + "integrity": "sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/commander": { + "version": "11.1.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-11.1.0.tgz", + "integrity": "sha512-yPVavfyCcRhmorC7rWlkHn15b4wDVgVmBA7kV4QVBsF7kv/9TKJAbAXVTxvTnwP8HHKjRCJDClKbciiYS7p0DQ==", + "license": "MIT", + "engines": { + "node": ">=16" + } + }, + "node_modules/common-ancestor-path": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/common-ancestor-path/-/common-ancestor-path-2.0.0.tgz", + "integrity": "sha512-dnN3ibLeoRf2HNC+OlCiNc5d2zxbLJXOtiZUudNFSXZrNSydxcCsSpRzXwfu7BBWCIfHPw+xTayeBvJCP/D8Ng==", + "license": "BlueOak-1.0.0", + "engines": { + "node": ">= 18" + } + }, + "node_modules/cookie": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz", + "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/cookie-es": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/cookie-es/-/cookie-es-1.2.3.tgz", + "integrity": "sha512-lXVyvUvrNXblMqzIRrxHb57UUVmqsSWlxqt3XIjCkUP0wDAf6uicO6KMbEgYrMNtEvWgWHwe42CKxPu9MYAnWw==", + "license": "MIT" + }, + "node_modules/crc-32": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/crc-32/-/crc-32-1.2.2.tgz", + "integrity": "sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==", + "license": "Apache-2.0", + "bin": { + "crc32": "bin/crc32.njs" + }, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/crossws": { + "version": "0.3.5", + "resolved": "https://registry.npmjs.org/crossws/-/crossws-0.3.5.tgz", + "integrity": "sha512-ojKiDvcmByhwa8YYqbQI/hg7MEU0NC03+pSdEq4ZUnZR9xXpwk7E43SMNGkn+JxJGPFtNvQ48+vV2p+P1ml5PA==", + "license": "MIT", + "dependencies": { + "uncrypto": "^0.1.3" + } + }, + "node_modules/css-select": { + "version": "5.2.2", + "resolved": "https://registry.npmjs.org/css-select/-/css-select-5.2.2.tgz", + "integrity": "sha512-TizTzUddG/xYLA3NXodFM0fSbNizXjOKhqiQQwvhlspadZokn1KDy0NZFS0wuEubIYAV5/c1/lAr0TaaFXEXzw==", + "license": "BSD-2-Clause", + "dependencies": { + "boolbase": "^1.0.0", + "css-what": "^6.1.0", + "domhandler": "^5.0.2", + "domutils": "^3.0.1", + "nth-check": "^2.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/fb55" + } + }, + "node_modules/css-tree": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz", + "integrity": "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==", + "license": "MIT", + "dependencies": { + "mdn-data": "2.27.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0" + } + }, + "node_modules/css-what": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/css-what/-/css-what-6.2.2.tgz", + "integrity": "sha512-u/O3vwbptzhMs3L1fQE82ZSLHQQfto5gyZzwteVIEyeaY5Fc7R4dapF/BvRoSYFeqfBk4m0V1Vafq5Pjv25wvA==", + "license": "BSD-2-Clause", + "engines": { + "node": ">= 6" + }, + "funding": { + "url": "https://github.com/sponsors/fb55" + } + }, + "node_modules/csso": { + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/csso/-/csso-5.0.5.tgz", + "integrity": "sha512-0LrrStPOdJj+SPCCrGhzryycLjwcgUSHBtxNA8aIDxf0GLsRh1cKYhB00Gd1lDOS4yGH69+SNn13+TWbVHETFQ==", + "license": "MIT", + "dependencies": { + "css-tree": "~2.2.0" + }, + "engines": { + "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0", + "npm": ">=7.0.0" + } + }, + "node_modules/csso/node_modules/css-tree": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-2.2.1.tgz", + "integrity": "sha512-OA0mILzGc1kCOCSJerOeqDxDQ4HOh+G8NbOJFOTgOCzpw7fCBubk0fEyxp8AgOL/jvLgYA/uV0cMbe43ElF1JA==", + "license": "MIT", + "dependencies": { + "mdn-data": "2.0.28", + "source-map-js": "^1.0.1" + }, + "engines": { + "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0", + "npm": ">=7.0.0" + } + }, + "node_modules/csso/node_modules/mdn-data": { + "version": "2.0.28", + "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.0.28.tgz", + "integrity": "sha512-aylIc7Z9y4yzHYAJNuESG3hfhC+0Ibp/MAMiaOZgNv4pmEdFyfZhhhny4MNiAfWdBQ1RQ2mfDWmM1x8SvGyp8g==", + "license": "CC0-1.0" + }, + "node_modules/d3": { + "version": "7.9.0", + "resolved": "https://registry.npmjs.org/d3/-/d3-7.9.0.tgz", + "integrity": "sha512-e1U46jVP+w7Iut8Jt8ri1YsPOvFpg46k+K8TpCb0P+zjCkjkPnV7WzfDJzMHy1LnA+wj5pLT1wjO901gLXeEhA==", + "license": "ISC", + "dependencies": { + "d3-array": "3", + "d3-axis": "3", + "d3-brush": "3", + "d3-chord": "3", + "d3-color": "3", + "d3-contour": "4", + "d3-delaunay": "6", + "d3-dispatch": "3", + "d3-drag": "3", + "d3-dsv": "3", + "d3-ease": "3", + "d3-fetch": "3", + "d3-force": "3", + "d3-format": "3", + "d3-geo": "3", + "d3-hierarchy": "3", + "d3-interpolate": "3", + "d3-path": "3", + "d3-polygon": "3", + "d3-quadtree": "3", + "d3-random": "3", + "d3-scale": "4", + "d3-scale-chromatic": "3", + "d3-selection": "3", + "d3-shape": "3", + "d3-time": "3", + "d3-time-format": "4", + "d3-timer": "3", + "d3-transition": "3", + "d3-zoom": "3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-array": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz", + "integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==", + "license": "ISC", + "dependencies": { + "internmap": "1 - 2" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-axis": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-axis/-/d3-axis-3.0.0.tgz", + "integrity": "sha512-IH5tgjV4jE/GhHkRV0HiVYPDtvfjHQlQfJHs0usq7M30XcSBvOotpmH1IgkcXsO/5gEQZD43B//fc7SRT5S+xw==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-brush": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-brush/-/d3-brush-3.0.0.tgz", + "integrity": "sha512-ALnjWlVYkXsVIGlOsuWH1+3udkYFI48Ljihfnh8FZPF2QS9o+PzGLBslO0PjzVoHLZ2KCVgAM8NVkXPJB2aNnQ==", + "license": "ISC", + "dependencies": { + "d3-dispatch": "1 - 3", + "d3-drag": "2 - 3", + "d3-interpolate": "1 - 3", + "d3-selection": "3", + "d3-transition": "3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-chord": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-chord/-/d3-chord-3.0.1.tgz", + "integrity": "sha512-VE5S6TNa+j8msksl7HwjxMHDM2yNK3XCkusIlpX5kwauBfXuyLAtNg9jCp/iHH61tgI4sb6R/EIMWCqEIdjT/g==", + "license": "ISC", + "dependencies": { + "d3-path": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-color": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz", + "integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-contour": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/d3-contour/-/d3-contour-4.0.2.tgz", + "integrity": "sha512-4EzFTRIikzs47RGmdxbeUvLWtGedDUNkTcmzoeyg4sP/dvCexO47AaQL7VKy/gul85TOxw+IBgA8US2xwbToNA==", + "license": "ISC", + "dependencies": { + "d3-array": "^3.2.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-delaunay": { + "version": "6.0.4", + "resolved": "https://registry.npmjs.org/d3-delaunay/-/d3-delaunay-6.0.4.tgz", + "integrity": "sha512-mdjtIZ1XLAM8bm/hx3WwjfHt6Sggek7qH043O8KEjDXN40xi3vx/6pYSVTwLjEgiXQTbvaouWKynLBiUZ6SK6A==", + "license": "ISC", + "dependencies": { + "delaunator": "5" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-dispatch": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-dispatch/-/d3-dispatch-3.0.1.tgz", + "integrity": "sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-drag": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-drag/-/d3-drag-3.0.0.tgz", + "integrity": "sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg==", + "license": "ISC", + "dependencies": { + "d3-dispatch": "1 - 3", + "d3-selection": "3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-dsv": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-dsv/-/d3-dsv-3.0.1.tgz", + "integrity": "sha512-UG6OvdI5afDIFP9w4G0mNq50dSOsXHJaRE8arAS5o9ApWnIElp8GZw1Dun8vP8OyHOZ/QJUKUJwxiiCCnUwm+Q==", + "license": "ISC", + "dependencies": { + "commander": "7", + "iconv-lite": "0.6", + "rw": "1" + }, + "bin": { + "csv2json": "bin/dsv2json.js", + "csv2tsv": "bin/dsv2dsv.js", + "dsv2dsv": "bin/dsv2dsv.js", + "dsv2json": "bin/dsv2json.js", + "json2csv": "bin/json2dsv.js", + "json2dsv": "bin/json2dsv.js", + "json2tsv": "bin/json2dsv.js", + "tsv2csv": "bin/dsv2dsv.js", + "tsv2json": "bin/dsv2json.js" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-dsv/node_modules/commander": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-7.2.0.tgz", + "integrity": "sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw==", + "license": "MIT", + "engines": { + "node": ">= 10" + } + }, + "node_modules/d3-ease": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz", + "integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-fetch": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-fetch/-/d3-fetch-3.0.1.tgz", + "integrity": "sha512-kpkQIM20n3oLVBKGg6oHrUchHM3xODkTzjMoj7aWQFq5QEM+R6E4WkzT5+tojDY7yjez8KgCBRoj4aEr99Fdqw==", + "license": "ISC", + "dependencies": { + "d3-dsv": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-force": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-force/-/d3-force-3.0.0.tgz", + "integrity": "sha512-zxV/SsA+U4yte8051P4ECydjD/S+qeYtnaIyAs9tgHCqfguma/aAQDjo85A9Z6EKhBirHRJHXIgJUlffT4wdLg==", + "license": "ISC", + "dependencies": { + "d3-dispatch": "1 - 3", + "d3-quadtree": "1 - 3", + "d3-timer": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-format": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.2.tgz", + "integrity": "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-geo": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/d3-geo/-/d3-geo-3.1.1.tgz", + "integrity": "sha512-637ln3gXKXOwhalDzinUgY83KzNWZRKbYubaG+fGVuc/dxO64RRljtCTnf5ecMyE1RIdtqpkVcq0IbtU2S8j2Q==", + "license": "ISC", + "dependencies": { + "d3-array": "2.5.0 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-hierarchy": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/d3-hierarchy/-/d3-hierarchy-3.1.2.tgz", + "integrity": "sha512-FX/9frcub54beBdugHjDCdikxThEqjnR93Qt7PvQTOHxyiNCAlvMrHhclk3cD5VeAaq9fxmfRp+CnWw9rEMBuA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-interpolate": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz", + "integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-path": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz", + "integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-polygon": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-polygon/-/d3-polygon-3.0.1.tgz", + "integrity": "sha512-3vbA7vXYwfe1SYhED++fPUQlWSYTTGmFmQiany/gdbiWgU/iEyQzyymwL9SkJjFFuCS4902BSzewVGsHHmHtXg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-quadtree": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-quadtree/-/d3-quadtree-3.0.1.tgz", + "integrity": "sha512-04xDrxQTDTCFwP5H6hRhsRcb9xxv2RzkcsygFzmkSIOJy3PeRJP7sNk3VRIbKXcog561P9oU0/rVH6vDROAgUw==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-random": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-random/-/d3-random-3.0.1.tgz", + "integrity": "sha512-FXMe9GfxTxqd5D6jFsQ+DJ8BJS4E/fT5mqqdjovykEB2oFbTMDVdg1MGFxfQW+FBOGoB++k8swBrgwSHT1cUXQ==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-scale": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz", + "integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==", + "license": "ISC", + "dependencies": { + "d3-array": "2.10.0 - 3", + "d3-format": "1 - 3", + "d3-interpolate": "1.2.0 - 3", + "d3-time": "2.1.1 - 3", + "d3-time-format": "2 - 4" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-scale-chromatic": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-scale-chromatic/-/d3-scale-chromatic-3.1.0.tgz", + "integrity": "sha512-A3s5PWiZ9YCXFye1o246KoscMWqf8BsD9eRiJ3He7C9OBaxKhAd5TFCdEx/7VbKtxxTsu//1mMJFrEt572cEyQ==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3", + "d3-interpolate": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-selection": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz", + "integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-shape": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz", + "integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==", + "license": "ISC", + "dependencies": { + "d3-path": "^3.1.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz", + "integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==", + "license": "ISC", + "dependencies": { + "d3-array": "2 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time-format": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz", + "integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==", + "license": "ISC", + "dependencies": { + "d3-time": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-timer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz", + "integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-transition": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-transition/-/d3-transition-3.0.1.tgz", + "integrity": "sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3", + "d3-dispatch": "1 - 3", + "d3-ease": "1 - 3", + "d3-interpolate": "1 - 3", + "d3-timer": "1 - 3" + }, + "engines": { + "node": ">=12" + }, + "peerDependencies": { + "d3-selection": "2 - 3" + } + }, + "node_modules/d3-zoom": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-zoom/-/d3-zoom-3.0.0.tgz", + "integrity": "sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw==", + "license": "ISC", + "dependencies": { + "d3-dispatch": "1 - 3", + "d3-drag": "2 - 3", + "d3-interpolate": "1 - 3", + "d3-selection": "2 - 3", + "d3-transition": "2 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/decode-named-character-reference": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/decode-named-character-reference/-/decode-named-character-reference-1.3.0.tgz", + "integrity": "sha512-GtpQYB283KrPp6nRw50q3U9/VfOutZOe103qlN7BPP6Ad27xYnOIWv4lPzo8HCAL+mMZofJ9KEy30fq6MfaK6Q==", + "license": "MIT", + "dependencies": { + "character-entities": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/deep-eql": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz", + "integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/defu": { + "version": "6.1.7", + "resolved": "https://registry.npmjs.org/defu/-/defu-6.1.7.tgz", + "integrity": "sha512-7z22QmUWiQ/2d0KkdYmANbRUVABpZ9SNYyH5vx6PZ+nE5bcC0l7uFvEfHlyld/HcGBFTL536ClDt3DEcSlEJAQ==", + "license": "MIT" + }, + "node_modules/delaunator": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/delaunator/-/delaunator-5.1.0.tgz", + "integrity": "sha512-AGrQ4QSgssa1NGmWmLPqN5NY2KajF5MqxetNEO+o0n3ZwZZeTmt7bBnvzHWrmkZFxGgr4HdyFgelzgi06otLuQ==", + "license": "ISC", + "dependencies": { + "robust-predicates": "^3.0.2" + } + }, + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/destr": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/destr/-/destr-2.0.5.tgz", + "integrity": "sha512-ugFTXCtDZunbzasqBxrK93Ik/DRYsO6S/fedkWEMKqt04xZ4csmnmwGDBAb07QWNaGMAmnTIemsYZCksjATwsA==", + "license": "MIT" + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/devalue": { + "version": "5.8.1", + "resolved": "https://registry.npmjs.org/devalue/-/devalue-5.8.1.tgz", + "integrity": "sha512-4CXDYRBGqN+57wVJkuXBYmpAVUSg3L6JAQa/DFqm238G73E1wuyc/JhGQJzN7vUf/CMphYau2zXbfWzDR5aTEw==", + "license": "MIT" + }, + "node_modules/devlop": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/devlop/-/devlop-1.1.0.tgz", + "integrity": "sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==", + "license": "MIT", + "dependencies": { + "dequal": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/diff": { + "version": "8.0.4", + "resolved": "https://registry.npmjs.org/diff/-/diff-8.0.4.tgz", + "integrity": "sha512-DPi0FmjiSU5EvQV0++GFDOJ9ASQUVFh5kD+OzOnYdi7n3Wpm9hWWGfB/O2blfHcMVTL5WkQXSnRiK9makhrcnw==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.3.1" + } + }, + "node_modules/dom-serializer": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz", + "integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==", + "license": "MIT", + "dependencies": { + "domelementtype": "^2.3.0", + "domhandler": "^5.0.2", + "entities": "^4.2.0" + }, + "funding": { + "url": "https://github.com/cheeriojs/dom-serializer?sponsor=1" + } + }, + "node_modules/dom-serializer/node_modules/entities": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", + "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/domelementtype": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz", + "integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + ], + "license": "BSD-2-Clause" + }, + "node_modules/domhandler": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz", + "integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==", + "license": "BSD-2-Clause", + "dependencies": { + "domelementtype": "^2.3.0" + }, + "engines": { + "node": ">= 4" + }, + "funding": { + "url": "https://github.com/fb55/domhandler?sponsor=1" + } + }, + "node_modules/domutils": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/domutils/-/domutils-3.2.2.tgz", + "integrity": "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==", + "license": "BSD-2-Clause", + "dependencies": { + "dom-serializer": "^2.0.0", + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3" + }, + "funding": { + "url": "https://github.com/fb55/domutils?sponsor=1" + } + }, + "node_modules/dset": { + "version": "3.1.4", + "resolved": "https://registry.npmjs.org/dset/-/dset-3.1.4.tgz", + "integrity": "sha512-2QF/g9/zTaPDc3BjNcVTGoBbXBgYfMTTceLaYcFJ/W9kggFUkhxD/hMEeuLKbugyef9SqAx8cpgwlIP/jinUTA==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/enhanced-resolve": { + "version": "5.23.0", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.23.0.tgz", + "integrity": "sha512-yJN/BOOLxcOW2aQgeif9mSnaUB8KtvmMMp56oA1kx1CRfBKbhZm2pJ+NBY+3eOboHxix8lfjWpHE0Ei5U8RbSA==", + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.4", + "tapable": "^2.3.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/entities": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", + "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/es-module-lexer": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.1.0.tgz", + "integrity": "sha512-n27zTYMjYu1aj4MjCWzSP7G9r75utsaoc8m61weK+W8JMBGGQybd43GstCXZ3WNmSFtGT9wi59qQTW6mhTR5LQ==", + "license": "MIT" + }, + "node_modules/esbuild": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.7.tgz", + "integrity": "sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w==", + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.27.7", + "@esbuild/android-arm": "0.27.7", + "@esbuild/android-arm64": "0.27.7", + "@esbuild/android-x64": "0.27.7", + "@esbuild/darwin-arm64": "0.27.7", + "@esbuild/darwin-x64": "0.27.7", + "@esbuild/freebsd-arm64": "0.27.7", + "@esbuild/freebsd-x64": "0.27.7", + "@esbuild/linux-arm": "0.27.7", + "@esbuild/linux-arm64": "0.27.7", + "@esbuild/linux-ia32": "0.27.7", + "@esbuild/linux-loong64": "0.27.7", + "@esbuild/linux-mips64el": "0.27.7", + "@esbuild/linux-ppc64": "0.27.7", + "@esbuild/linux-riscv64": "0.27.7", + "@esbuild/linux-s390x": "0.27.7", + "@esbuild/linux-x64": "0.27.7", + "@esbuild/netbsd-arm64": "0.27.7", + "@esbuild/netbsd-x64": "0.27.7", + "@esbuild/openbsd-arm64": "0.27.7", + "@esbuild/openbsd-x64": "0.27.7", + "@esbuild/openharmony-arm64": "0.27.7", + "@esbuild/sunos-x64": "0.27.7", + "@esbuild/win32-arm64": "0.27.7", + "@esbuild/win32-ia32": "0.27.7", + "@esbuild/win32-x64": "0.27.7" + } + }, + "node_modules/escape-string-regexp": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-5.0.0.tgz", + "integrity": "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/estree-walker": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", + "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", + "license": "MIT" + }, + "node_modules/eventemitter3": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz", + "integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==", + "license": "MIT" + }, + "node_modules/exit-on-epipe": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/exit-on-epipe/-/exit-on-epipe-1.0.1.tgz", + "integrity": "sha512-h2z5mrROTxce56S+pnvAV890uu7ls7f1kEvVGJbw1OlFH3/mlJ5bkXu0KRyW94v37zzHPiUd55iLn3DA7TjWpw==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.8" + } + }, + "node_modules/expect-type": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", + "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/extend": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", + "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==", + "license": "MIT" + }, + "node_modules/fast-string-truncated-width": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/fast-string-truncated-width/-/fast-string-truncated-width-3.0.3.tgz", + "integrity": "sha512-0jjjIEL6+0jag3l2XWWizO64/aZVtpiGE3t0Zgqxv0DPuxiMjvB3M24fCyhZUO4KomJQPj3LTSUnDP3GpdwC0g==", + "license": "MIT" + }, + "node_modules/fast-string-width": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/fast-string-width/-/fast-string-width-3.0.2.tgz", + "integrity": "sha512-gX8LrtNEI5hq8DVUfRQMbr5lpaS4nMIWV+7XEbXk2b8kiQIizgnlr12B4dA3ZEx3308ze0O4Q1R+cHts8kyUJg==", + "license": "MIT", + "dependencies": { + "fast-string-truncated-width": "^3.0.2" + } + }, + "node_modules/fast-wrap-ansi": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/fast-wrap-ansi/-/fast-wrap-ansi-0.2.2.tgz", + "integrity": "sha512-7F2Fl+TjRSenLqlU3UjSH0iyqopqoZIu7eZVpEirP2g1GtWa2G/ecEmBdgz31+Mxr+ELclgg6sokpSFIQiZ02Q==", + "license": "MIT", + "dependencies": { + "fast-string-width": "^3.0.2" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fflate": { + "version": "0.3.11", + "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.3.11.tgz", + "integrity": "sha512-Rr5QlUeGN1mbOHlaqcSYMKVpPbgLy0AWT/W0EHxA6NGI12yO1jpoui2zBBvU2G824ltM6Ut8BFgfHSBGfkmS0A==", + "license": "MIT" + }, + "node_modules/flattie": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/flattie/-/flattie-1.1.1.tgz", + "integrity": "sha512-9UbaD6XdAL97+k/n+N7JwX46K/M6Zc6KcFYskrYL8wbBV/Uyk0CTAMY0VT+qiK5PM7AIc9aTWYtq65U7T+aCNQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/fontace": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/fontace/-/fontace-0.4.1.tgz", + "integrity": "sha512-lDMvbAzSnHmbYMTEld5qdtvNH2/pWpICOqpean9IgC7vUbUJc3k+k5Dokp85CegamqQpFbXf0rAVkbzpyTA8aw==", + "license": "MIT", + "dependencies": { + "fontkitten": "^1.0.2" + } + }, + "node_modules/fontkitten": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/fontkitten/-/fontkitten-1.0.3.tgz", + "integrity": "sha512-Wp1zXWPVUPBmfoa3Cqc9ctaKuzKAV6uLstRqlR56kSjplf5uAce+qeyYym7F+PHbGTk+tCEdkCW6RD7DX/gBZw==", + "license": "MIT", + "dependencies": { + "tiny-inflate": "^1.0.3" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/frac": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/frac/-/frac-1.1.2.tgz", + "integrity": "sha512-w/XBfkibaTl3YDqASwfDUqkna4Z2p9cFSr1aHDt0WoMTECnRfBOv2WArlZILlqgWlmdIlALXGpM2AOhEk5W3IA==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.8" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/get-tsconfig": { + "version": "5.0.0-beta.4", + "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-5.0.0-beta.4.tgz", + "integrity": "sha512-7nF7C9fIPFEMHgEMEfgIlO9wDdZ8CyHw27rWciFZfHvHDReIiPhsYuzPRXsfvBCqFy1l8RRyyWV7QLM+ZhUJsQ==", + "license": "MIT", + "dependencies": { + "resolve-pkg-maps": "^1.0.0" + }, + "engines": { + "node": ">=20.20.0" + }, + "funding": { + "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" + } + }, + "node_modules/github-slugger": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/github-slugger/-/github-slugger-2.0.0.tgz", + "integrity": "sha512-IaOQ9puYtjrkq7Y0Ygl9KDZnrf/aiUJYUpVf89y8kyaxbRG7Y1SrX/jaumrv81vc61+kiMempujsM3Yw7w5qcw==", + "license": "ISC" + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "license": "ISC" + }, + "node_modules/h3": { + "version": "1.15.11", + "resolved": "https://registry.npmjs.org/h3/-/h3-1.15.11.tgz", + "integrity": "sha512-L3THSe2MPeBwgIZVSH5zLdBBU90TOxarvhK9d04IDY2AmVS8j2Jz2LIWtwsGOU3lu2I5jCN7FNvVfY2+XyF+mg==", + "license": "MIT", + "dependencies": { + "cookie-es": "^1.2.3", + "crossws": "^0.3.5", + "defu": "^6.1.6", + "destr": "^2.0.5", + "iron-webcrypto": "^1.2.1", + "node-mock-http": "^1.0.4", + "radix3": "^1.1.2", + "ufo": "^1.6.3", + "uncrypto": "^0.1.3" + } + }, + "node_modules/hast-util-from-html": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/hast-util-from-html/-/hast-util-from-html-2.0.3.tgz", + "integrity": "sha512-CUSRHXyKjzHov8yKsQjGOElXy/3EKpyX56ELnkHH34vDVw1N1XSQ1ZcAvTyAPtGqLTuKP/uxM+aLkSPqF/EtMw==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "devlop": "^1.1.0", + "hast-util-from-parse5": "^8.0.0", + "parse5": "^7.0.0", + "vfile": "^6.0.0", + "vfile-message": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-from-parse5": { + "version": "8.0.3", + "resolved": "https://registry.npmjs.org/hast-util-from-parse5/-/hast-util-from-parse5-8.0.3.tgz", + "integrity": "sha512-3kxEVkEKt0zvcZ3hCRYI8rqrgwtlIOFMWkbclACvjlDw8Li9S2hk/d51OI0nr/gIpdMHNepwgOKqZ/sy0Clpyg==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/unist": "^3.0.0", + "devlop": "^1.0.0", + "hastscript": "^9.0.0", + "property-information": "^7.0.0", + "vfile": "^6.0.0", + "vfile-location": "^5.0.0", + "web-namespaces": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-is-element": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/hast-util-is-element/-/hast-util-is-element-3.0.0.tgz", + "integrity": "sha512-Val9mnv2IWpLbNPqc/pUem+a7Ipj2aHacCwgNfTiK0vJKl0LF+4Ba4+v1oPHFpf3bLYmreq0/l3Gud9S5OH42g==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-parse-selector": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/hast-util-parse-selector/-/hast-util-parse-selector-4.0.0.tgz", + "integrity": "sha512-wkQCkSYoOGCRKERFWcxMVMOcYE2K1AaNLU8DXS9arxnLOUEWbOXKXiJUNzEpqZ3JOKpnha3jkFrumEjVliDe7A==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-raw": { + "version": "9.1.0", + "resolved": "https://registry.npmjs.org/hast-util-raw/-/hast-util-raw-9.1.0.tgz", + "integrity": "sha512-Y8/SBAHkZGoNkpzqqfCldijcuUKh7/su31kEBp67cFY09Wy0mTRgtsLYsiIxMJxlu0f6AA5SUTbDR8K0rxnbUw==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/unist": "^3.0.0", + "@ungap/structured-clone": "^1.0.0", + "hast-util-from-parse5": "^8.0.0", + "hast-util-to-parse5": "^8.0.0", + "html-void-elements": "^3.0.0", + "mdast-util-to-hast": "^13.0.0", + "parse5": "^7.0.0", + "unist-util-position": "^5.0.0", + "unist-util-visit": "^5.0.0", + "vfile": "^6.0.0", + "web-namespaces": "^2.0.0", + "zwitch": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-to-html": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/hast-util-to-html/-/hast-util-to-html-9.0.5.tgz", + "integrity": "sha512-OguPdidb+fbHQSU4Q4ZiLKnzWo8Wwsf5bZfbvu7//a9oTYoqD/fWpe96NuHkoS9h0ccGOTe0C4NGXdtS0iObOw==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/unist": "^3.0.0", + "ccount": "^2.0.0", + "comma-separated-tokens": "^2.0.0", + "hast-util-whitespace": "^3.0.0", + "html-void-elements": "^3.0.0", + "mdast-util-to-hast": "^13.0.0", + "property-information": "^7.0.0", + "space-separated-tokens": "^2.0.0", + "stringify-entities": "^4.0.0", + "zwitch": "^2.0.4" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-to-parse5": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/hast-util-to-parse5/-/hast-util-to-parse5-8.0.1.tgz", + "integrity": "sha512-MlWT6Pjt4CG9lFCjiz4BH7l9wmrMkfkJYCxFwKQic8+RTZgWPuWxwAfjJElsXkex7DJjfSJsQIt931ilUgmwdA==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "comma-separated-tokens": "^2.0.0", + "devlop": "^1.0.0", + "property-information": "^7.0.0", + "space-separated-tokens": "^2.0.0", + "web-namespaces": "^2.0.0", + "zwitch": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-to-text": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/hast-util-to-text/-/hast-util-to-text-4.0.2.tgz", + "integrity": "sha512-KK6y/BN8lbaq654j7JgBydev7wuNMcID54lkRav1P0CaE1e47P72AWWPiGKXTJU271ooYzcvTAn/Zt0REnvc7A==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/unist": "^3.0.0", + "hast-util-is-element": "^3.0.0", + "unist-util-find-after": "^5.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-whitespace": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/hast-util-whitespace/-/hast-util-whitespace-3.0.0.tgz", + "integrity": "sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hastscript": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/hastscript/-/hastscript-9.0.1.tgz", + "integrity": "sha512-g7df9rMFX/SPi34tyGCyUBREQoKkapwdY/T04Qn9TDWfHhAYt4/I0gMVirzK5wEzeUqIjEB+LXC/ypb7Aqno5w==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "comma-separated-tokens": "^2.0.0", + "hast-util-parse-selector": "^4.0.0", + "property-information": "^7.0.0", + "space-separated-tokens": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/html-escaper": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-3.0.3.tgz", + "integrity": "sha512-RuMffC89BOWQoY0WKGpIhn5gX3iI54O6nRA0yC124NYVtzjmFWBIiFd8M0x+ZdX0P9R4lADg1mgP8C7PxGOWuQ==", + "license": "MIT" + }, + "node_modules/html-void-elements": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/html-void-elements/-/html-void-elements-3.0.0.tgz", + "integrity": "sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/http-cache-semantics": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/http-cache-semantics/-/http-cache-semantics-4.2.0.tgz", + "integrity": "sha512-dTxcvPXqPvXBQpq5dUr6mEMJX4oIEFv6bwom3FDwKRDsuIjjJGANqhBuoAn9c1RQJIdAKav33ED65E2ys+87QQ==", + "license": "BSD-2-Clause" + }, + "node_modules/iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/internmap": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz", + "integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/iron-webcrypto": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/iron-webcrypto/-/iron-webcrypto-1.2.1.tgz", + "integrity": "sha512-feOM6FaSr6rEABp/eDfVseKyTMDt+KGpeB35SkVn9Tyn0CqvVsY3EwI0v5i8nMHyJnzCIQf7nsy3p41TPkJZhg==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/brc-dd" + } + }, + "node_modules/is-docker": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/is-docker/-/is-docker-4.0.0.tgz", + "integrity": "sha512-LHE+wROyG/Y/0ZnbktRCoTix2c1RhgWaZraMZ8o1Q7zCh0VSrICJQO5oqIIISrcSBtrXv0o233w1IYwsWCjTzA==", + "license": "MIT", + "bin": { + "is-docker": "cli.js" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-inside-container": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-inside-container/-/is-inside-container-1.0.0.tgz", + "integrity": "sha512-KIYLCCJghfHZxqjYBE7rEy0OBuTd5xCHS7tHVgvCLkx7StIoaxwNW3hCALgEUjFfeRk+MG/Qxmp/vtETEF3tRA==", + "license": "MIT", + "dependencies": { + "is-docker": "^3.0.0" + }, + "bin": { + "is-inside-container": "cli.js" + }, + "engines": { + "node": ">=14.16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-inside-container/node_modules/is-docker": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-docker/-/is-docker-3.0.0.tgz", + "integrity": "sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ==", + "license": "MIT", + "bin": { + "is-docker": "cli.js" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-plain-obj": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-4.1.0.tgz", + "integrity": "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-wsl": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-3.1.1.tgz", + "integrity": "sha512-e6rvdUCiQCAuumZslxRJWR/Doq4VpPR82kqclvcS0efgt430SlGIk05vdCN58+VrzgtIcfNODjozVielycD4Sw==", + "license": "MIT", + "dependencies": { + "is-inside-container": "^1.0.0" + }, + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/jiti": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz", + "integrity": "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==", + "license": "MIT", + "bin": { + "jiti": "lib/jiti-cli.mjs" + } + }, + "node_modules/js-tokens": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-9.0.1.tgz", + "integrity": "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/js-yaml": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", + "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/jsonc-parser": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/jsonc-parser/-/jsonc-parser-3.3.1.tgz", + "integrity": "sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ==", + "license": "MIT" + }, + "node_modules/lightningcss": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", + "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.32.0", + "lightningcss-darwin-arm64": "1.32.0", + "lightningcss-darwin-x64": "1.32.0", + "lightningcss-freebsd-x64": "1.32.0", + "lightningcss-linux-arm-gnueabihf": "1.32.0", + "lightningcss-linux-arm64-gnu": "1.32.0", + "lightningcss-linux-arm64-musl": "1.32.0", + "lightningcss-linux-x64-gnu": "1.32.0", + "lightningcss-linux-x64-musl": "1.32.0", + "lightningcss-win32-arm64-msvc": "1.32.0", + "lightningcss-win32-x64-msvc": "1.32.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", + "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", + "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", + "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", + "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", + "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", + "cpu": [ + "arm" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", + "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", + "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", + "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", + "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", + "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", + "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/longest-streak": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/longest-streak/-/longest-streak-3.1.0.tgz", + "integrity": "sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/loupe": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz", + "integrity": "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/lru-cache": { + "version": "11.5.0", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.0.tgz", + "integrity": "sha512-5YgH9UJd7wVb9hIouI2adWpgqrrICkt070Dnj8EUY1+B4B2P9eRLPAkAAo6NICA7CEhOIeBHl46u9zSNpNu7zA==", + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/magicast": { + "version": "0.5.3", + "resolved": "https://registry.npmjs.org/magicast/-/magicast-0.5.3.tgz", + "integrity": "sha512-pVKE4UdSQ7DvHzivsCIFx2BJn1mHG6KsyrFcaxFx6tONdneEuThrDx0Cj3AMg58KyN4pzYT+LHOotxDQDjNvkw==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.3", + "@babel/types": "^7.29.0", + "source-map-js": "^1.2.1" + } + }, + "node_modules/markdown-table": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/markdown-table/-/markdown-table-3.0.4.tgz", + "integrity": "sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/mdast-util-definitions": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-definitions/-/mdast-util-definitions-6.0.0.tgz", + "integrity": "sha512-scTllyX6pnYNZH/AIp/0ePz6s4cZtARxImwoPJ7kS42n+MnVsI4XbnG6d4ibehRIldYMWM2LD7ImQblVhUejVQ==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "@types/unist": "^3.0.0", + "unist-util-visit": "^5.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-find-and-replace": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mdast-util-find-and-replace/-/mdast-util-find-and-replace-3.0.2.tgz", + "integrity": "sha512-Tmd1Vg/m3Xz43afeNxDIhWRtFZgM2VLyaf4vSTYwudTyeuTneoL3qtWMA5jeLyz/O1vDJmmV4QuScFCA2tBPwg==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "escape-string-regexp": "^5.0.0", + "unist-util-is": "^6.0.0", + "unist-util-visit-parents": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-from-markdown": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/mdast-util-from-markdown/-/mdast-util-from-markdown-2.0.3.tgz", + "integrity": "sha512-W4mAWTvSlKvf8L6J+VN9yLSqQ9AOAAvHuoDAmPkz4dHf553m5gVj2ejadHJhoJmcmxEnOv6Pa8XJhpxE93kb8Q==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "@types/unist": "^3.0.0", + "decode-named-character-reference": "^1.0.0", + "devlop": "^1.0.0", + "mdast-util-to-string": "^4.0.0", + "micromark": "^4.0.0", + "micromark-util-decode-numeric-character-reference": "^2.0.0", + "micromark-util-decode-string": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0", + "unist-util-stringify-position": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm/-/mdast-util-gfm-3.1.0.tgz", + "integrity": "sha512-0ulfdQOM3ysHhCJ1p06l0b0VKlhU0wuQs3thxZQagjcjPrlFRqY215uZGHHJan9GEAXd9MbfPjFJz+qMkVR6zQ==", + "license": "MIT", + "dependencies": { + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-gfm-autolink-literal": "^2.0.0", + "mdast-util-gfm-footnote": "^2.0.0", + "mdast-util-gfm-strikethrough": "^2.0.0", + "mdast-util-gfm-table": "^2.0.0", + "mdast-util-gfm-task-list-item": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-autolink-literal": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-autolink-literal/-/mdast-util-gfm-autolink-literal-2.0.1.tgz", + "integrity": "sha512-5HVP2MKaP6L+G6YaxPNjuL0BPrq9orG3TsrZ9YXbA3vDw/ACI4MEsnoDpn6ZNm7GnZgtAcONJyPhOP8tNJQavQ==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "ccount": "^2.0.0", + "devlop": "^1.0.0", + "mdast-util-find-and-replace": "^3.0.0", + "micromark-util-character": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-footnote": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-footnote/-/mdast-util-gfm-footnote-2.1.0.tgz", + "integrity": "sha512-sqpDWlsHn7Ac9GNZQMeUzPQSMzR6Wv0WKRNvQRg0KqHh02fpTz69Qc1QSseNX29bhz1ROIyNyxExfawVKTm1GQ==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "devlop": "^1.1.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-strikethrough": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-strikethrough/-/mdast-util-gfm-strikethrough-2.0.0.tgz", + "integrity": "sha512-mKKb915TF+OC5ptj5bJ7WFRPdYtuHv0yTRxK2tJvi+BDqbkiG7h7u/9SI89nRAYcmap2xHQL9D+QG/6wSrTtXg==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-table": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-table/-/mdast-util-gfm-table-2.0.0.tgz", + "integrity": "sha512-78UEvebzz/rJIxLvE7ZtDd/vIQ0RHv+3Mh5DR96p7cS7HsBhYIICDBCu8csTNWNO6tBWfqXPWekRuj2FNOGOZg==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "markdown-table": "^3.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-task-list-item": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-task-list-item/-/mdast-util-gfm-task-list-item-2.0.0.tgz", + "integrity": "sha512-IrtvNvjxC1o06taBAVJznEnkiHxLFTzgonUdy8hzFVeDun0uTjxxrRGVaNFqkU1wJR3RBPEfsxmU6jDWPofrTQ==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-phrasing": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/mdast-util-phrasing/-/mdast-util-phrasing-4.1.0.tgz", + "integrity": "sha512-TqICwyvJJpBwvGAMZjj4J2n0X8QWp21b9l0o7eXyVJ25YNWYbJDVIyD1bZXE6WtV6RmKJVYmQAKWa0zWOABz2w==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "unist-util-is": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-to-hast": { + "version": "13.2.1", + "resolved": "https://registry.npmjs.org/mdast-util-to-hast/-/mdast-util-to-hast-13.2.1.tgz", + "integrity": "sha512-cctsq2wp5vTsLIcaymblUriiTcZd0CwWtCbLvrOzYCDZoWyMNV8sZ7krj09FSnsiJi3WVsHLM4k6Dq/yaPyCXA==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "@ungap/structured-clone": "^1.0.0", + "devlop": "^1.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "trim-lines": "^3.0.0", + "unist-util-position": "^5.0.0", + "unist-util-visit": "^5.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-to-markdown": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/mdast-util-to-markdown/-/mdast-util-to-markdown-2.1.2.tgz", + "integrity": "sha512-xj68wMTvGXVOKonmog6LwyJKrYXZPvlwabaryTjLh9LuvovB/KAH+kvi8Gjj+7rJjsFi23nkUxRQv1KqSroMqA==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "@types/unist": "^3.0.0", + "longest-streak": "^3.0.0", + "mdast-util-phrasing": "^4.0.0", + "mdast-util-to-string": "^4.0.0", + "micromark-util-classify-character": "^2.0.0", + "micromark-util-decode-string": "^2.0.0", + "unist-util-visit": "^5.0.0", + "zwitch": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-to-string": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-to-string/-/mdast-util-to-string-4.0.0.tgz", + "integrity": "sha512-0H44vDimn51F0YwvxSJSm0eCDOJTRlmN0R1yBh4HLj9wiV1Dn0QoXGbvFAWj2hSItVTlCmBF1hqKlIyUBVFLPg==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdn-data": { + "version": "2.27.1", + "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz", + "integrity": "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==", + "license": "CC0-1.0" + }, + "node_modules/micromark": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/micromark/-/micromark-4.0.2.tgz", + "integrity": "sha512-zpe98Q6kvavpCr1NPVSCMebCKfD7CA2NqZ+rykeNhONIJBpc1tFKt9hucLGwha3jNTNI8lHpctWJWoimVF4PfA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "@types/debug": "^4.0.0", + "debug": "^4.0.0", + "decode-named-character-reference": "^1.0.0", + "devlop": "^1.0.0", + "micromark-core-commonmark": "^2.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-combine-extensions": "^2.0.0", + "micromark-util-decode-numeric-character-reference": "^2.0.0", + "micromark-util-encode": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-resolve-all": "^2.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "micromark-util-subtokenize": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-core-commonmark": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/micromark-core-commonmark/-/micromark-core-commonmark-2.0.3.tgz", + "integrity": "sha512-RDBrHEMSxVFLg6xvnXmb1Ayr2WzLAWjeSATAoxwKYJV94TeNavgoIdA0a9ytzDSVzBy2YKFK+emCPOEibLeCrg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "decode-named-character-reference": "^1.0.0", + "devlop": "^1.0.0", + "micromark-factory-destination": "^2.0.0", + "micromark-factory-label": "^2.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-factory-title": "^2.0.0", + "micromark-factory-whitespace": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-classify-character": "^2.0.0", + "micromark-util-html-tag-name": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-resolve-all": "^2.0.0", + "micromark-util-subtokenize": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-extension-gfm": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm/-/micromark-extension-gfm-3.0.0.tgz", + "integrity": "sha512-vsKArQsicm7t0z2GugkCKtZehqUm31oeGBV/KVSorWSy8ZlNAv7ytjFhvaryUiCUJYqs+NoE6AFhpQvBTM6Q4w==", + "license": "MIT", + "dependencies": { + "micromark-extension-gfm-autolink-literal": "^2.0.0", + "micromark-extension-gfm-footnote": "^2.0.0", + "micromark-extension-gfm-strikethrough": "^2.0.0", + "micromark-extension-gfm-table": "^2.0.0", + "micromark-extension-gfm-tagfilter": "^2.0.0", + "micromark-extension-gfm-task-list-item": "^2.0.0", + "micromark-util-combine-extensions": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-autolink-literal": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-autolink-literal/-/micromark-extension-gfm-autolink-literal-2.1.0.tgz", + "integrity": "sha512-oOg7knzhicgQ3t4QCjCWgTmfNhvQbDDnJeVu9v81r7NltNCVmhPy1fJRX27pISafdjL+SVc4d3l48Gb6pbRypw==", + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-footnote": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-footnote/-/micromark-extension-gfm-footnote-2.1.0.tgz", + "integrity": "sha512-/yPhxI1ntnDNsiHtzLKYnE3vf9JZ6cAisqVDauhp4CEHxlb4uoOTxOCJ+9s51bIB8U1N1FJ1RXOKTIlD5B/gqw==", + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-core-commonmark": "^2.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-strikethrough": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-strikethrough/-/micromark-extension-gfm-strikethrough-2.1.0.tgz", + "integrity": "sha512-ADVjpOOkjz1hhkZLlBiYA9cR2Anf8F4HqZUO6e5eDcPQd0Txw5fxLzzxnEkSkfnD0wziSGiv7sYhk/ktvbf1uw==", + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-classify-character": "^2.0.0", + "micromark-util-resolve-all": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-table": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-table/-/micromark-extension-gfm-table-2.1.1.tgz", + "integrity": "sha512-t2OU/dXXioARrC6yWfJ4hqB7rct14e8f7m0cbI5hUmDyyIlwv5vEtooptH8INkbLzOatzKuVbQmAYcbWoyz6Dg==", + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-tagfilter": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-tagfilter/-/micromark-extension-gfm-tagfilter-2.0.0.tgz", + "integrity": "sha512-xHlTOmuCSotIA8TW1mDIM6X2O1SiX5P9IuDtqGonFhEK0qgRI4yeC6vMxEV2dgyr2TiD+2PQ10o+cOhdVAcwfg==", + "license": "MIT", + "dependencies": { + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-task-list-item": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-task-list-item/-/micromark-extension-gfm-task-list-item-2.1.0.tgz", + "integrity": "sha512-qIBZhqxqI6fjLDYFTBIa4eivDMnP+OZqsNwmQ3xNLE4Cxwc+zfQEfbs6tzAo2Hjq+bh6q5F+Z8/cksrLFYWQQw==", + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-factory-destination": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-destination/-/micromark-factory-destination-2.0.1.tgz", + "integrity": "sha512-Xe6rDdJlkmbFRExpTOmRj9N3MaWmbAgdpSrBQvCFqhezUn4AHqJHbaEnfbVYYiexVSs//tqOdY/DxhjdCiJnIA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-label": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-label/-/micromark-factory-label-2.0.1.tgz", + "integrity": "sha512-VFMekyQExqIW7xIChcXn4ok29YE3rnuyveW3wZQWWqF4Nv9Wk5rgJ99KzPvHjkmPXF93FXIbBp6YdW3t71/7Vg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-space": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-space/-/micromark-factory-space-2.0.1.tgz", + "integrity": "sha512-zRkxjtBxxLd2Sc0d+fbnEunsTj46SWXgXciZmHq0kDYGnck/ZSGj9/wULTV95uoeYiK5hRXP2mJ98Uo4cq/LQg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-title": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-title/-/micromark-factory-title-2.0.1.tgz", + "integrity": "sha512-5bZ+3CjhAd9eChYTHsjy6TGxpOFSKgKKJPJxr293jTbfry2KDoWkhBb6TcPVB4NmzaPhMs1Frm9AZH7OD4Cjzw==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-whitespace": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-whitespace/-/micromark-factory-whitespace-2.0.1.tgz", + "integrity": "sha512-Ob0nuZ3PKt/n0hORHyvoD9uZhr+Za8sFoP+OnMcnWK5lngSzALgQYKMr9RJVOWLqQYuyn6ulqGWSXdwf6F80lQ==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-character": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/micromark-util-character/-/micromark-util-character-2.1.1.tgz", + "integrity": "sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-chunked": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-chunked/-/micromark-util-chunked-2.0.1.tgz", + "integrity": "sha512-QUNFEOPELfmvv+4xiNg2sRYeS/P84pTW0TCgP5zc9FpXetHY0ab7SxKyAQCNCc1eK0459uoLI1y5oO5Vc1dbhA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-classify-character": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-classify-character/-/micromark-util-classify-character-2.0.1.tgz", + "integrity": "sha512-K0kHzM6afW/MbeWYWLjoHQv1sgg2Q9EccHEDzSkxiP/EaagNzCm7T/WMKZ3rjMbvIpvBiZgwR3dKMygtA4mG1Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-combine-extensions": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-combine-extensions/-/micromark-util-combine-extensions-2.0.1.tgz", + "integrity": "sha512-OnAnH8Ujmy59JcyZw8JSbK9cGpdVY44NKgSM7E9Eh7DiLS2E9RNQf0dONaGDzEG9yjEl5hcqeIsj4hfRkLH/Bg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-chunked": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-decode-numeric-character-reference": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/micromark-util-decode-numeric-character-reference/-/micromark-util-decode-numeric-character-reference-2.0.2.tgz", + "integrity": "sha512-ccUbYk6CwVdkmCQMyr64dXz42EfHGkPQlBj5p7YVGzq8I7CtjXZJrubAYezf7Rp+bjPseiROqe7G6foFd+lEuw==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-decode-string": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-decode-string/-/micromark-util-decode-string-2.0.1.tgz", + "integrity": "sha512-nDV/77Fj6eH1ynwscYTOsbK7rR//Uj0bZXBwJZRfaLEJ1iGBR6kIfNmlNqaqJf649EP0F3NWNdeJi03elllNUQ==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "decode-named-character-reference": "^1.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-decode-numeric-character-reference": "^2.0.0", + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-encode": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-encode/-/micromark-util-encode-2.0.1.tgz", + "integrity": "sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-util-html-tag-name": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-html-tag-name/-/micromark-util-html-tag-name-2.0.1.tgz", + "integrity": "sha512-2cNEiYDhCWKI+Gs9T0Tiysk136SnR13hhO8yW6BGNyhOC4qYFnwF1nKfD3HFAIXA5c45RrIG1ub11GiXeYd1xA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-util-normalize-identifier": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-normalize-identifier/-/micromark-util-normalize-identifier-2.0.1.tgz", + "integrity": "sha512-sxPqmo70LyARJs0w2UclACPUUEqltCkJ6PhKdMIDuJ3gSf/Q+/GIe3WKl0Ijb/GyH9lOpUkRAO2wp0GVkLvS9Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-resolve-all": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-resolve-all/-/micromark-util-resolve-all-2.0.1.tgz", + "integrity": "sha512-VdQyxFWFT2/FGJgwQnJYbe1jjQoNTS4RjglmSjTUlpUMa95Htx9NHeYW4rGDJzbjvCsl9eLjMQwGeElsqmzcHg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-sanitize-uri": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-sanitize-uri/-/micromark-util-sanitize-uri-2.0.1.tgz", + "integrity": "sha512-9N9IomZ/YuGGZZmQec1MbgxtlgougxTodVwDzzEouPKo3qFWvymFHWcnDi2vzV1ff6kas9ucW+o3yzJK9YB1AQ==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-encode": "^2.0.0", + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-subtokenize": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-util-subtokenize/-/micromark-util-subtokenize-2.1.0.tgz", + "integrity": "sha512-XQLu552iSctvnEcgXw6+Sx75GflAPNED1qx7eBJ+wydBb2KCbRZe+NwvIEEMM83uml1+2WSXpBAcp9IUCgCYWA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-symbol": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-symbol/-/micromark-util-symbol-2.0.1.tgz", + "integrity": "sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-util-types": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/micromark-util-types/-/micromark-util-types-2.0.2.tgz", + "integrity": "sha512-Yw0ECSpJoViF1qTU4DC6NwtC4aWGt1EkzaQB8KPPyCRR8z9TWeV0HbEFGTO+ZY1wB22zmxnJqhPyTpOVCpeHTA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/mrmime": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/mrmime/-/mrmime-2.0.1.tgz", + "integrity": "sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.12", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz", + "integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/neotraverse": { + "version": "0.6.18", + "resolved": "https://registry.npmjs.org/neotraverse/-/neotraverse-0.6.18.tgz", + "integrity": "sha512-Z4SmBUweYa09+o6pG+eASabEpP6QkQ70yHj351pQoEXIs8uHbaU2DWVmzBANKgflPa47A50PtB2+NgRpQvr7vA==", + "license": "MIT", + "engines": { + "node": ">= 10" + } + }, + "node_modules/nlcst-to-string": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/nlcst-to-string/-/nlcst-to-string-4.0.0.tgz", + "integrity": "sha512-YKLBCcUYKAg0FNlOBT6aI91qFmSiFKiluk655WzPF+DDMA02qIyy8uiRqI8QXtcFpEvll12LpL5MXqEmAZ+dcA==", + "license": "MIT", + "dependencies": { + "@types/nlcst": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/node-fetch-native": { + "version": "1.6.7", + "resolved": "https://registry.npmjs.org/node-fetch-native/-/node-fetch-native-1.6.7.tgz", + "integrity": "sha512-g9yhqoedzIUm0nTnTqAQvueMPVOuIY16bqgAJJC8XOOubYFNwz6IER9qs0Gq2Xd0+CecCKFjtdDTMA4u4xG06Q==", + "license": "MIT" + }, + "node_modules/node-mock-http": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/node-mock-http/-/node-mock-http-1.0.4.tgz", + "integrity": "sha512-8DY+kFsDkNXy1sJglUfuODx1/opAGJGyrTuFqEoN90oRc2Vk0ZbD4K2qmKXBBEhZQzdKHIVfEJpDU8Ak2NJEvQ==", + "license": "MIT" + }, + "node_modules/normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/nth-check": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/nth-check/-/nth-check-2.1.1.tgz", + "integrity": "sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==", + "license": "BSD-2-Clause", + "dependencies": { + "boolbase": "^1.0.0" + }, + "funding": { + "url": "https://github.com/fb55/nth-check?sponsor=1" + } + }, + "node_modules/obug": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz", + "integrity": "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==", + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT" + }, + "node_modules/ofetch": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/ofetch/-/ofetch-1.5.1.tgz", + "integrity": "sha512-2W4oUZlVaqAPAil6FUg/difl6YhqhUR7x2eZY4bQCko22UXg3hptq9KLQdqFClV+Wu85UX7hNtdGTngi/1BxcA==", + "license": "MIT", + "dependencies": { + "destr": "^2.0.5", + "node-fetch-native": "^1.6.7", + "ufo": "^1.6.1" + } + }, + "node_modules/ohash": { + "version": "2.0.11", + "resolved": "https://registry.npmjs.org/ohash/-/ohash-2.0.11.tgz", + "integrity": "sha512-RdR9FQrFwNBNXAr4GixM8YaRZRJ5PUWbKYbE5eOsrwAjJW0q2REGcf79oYPsLyskQCZG1PLN+S/K1V00joZAoQ==", + "license": "MIT" + }, + "node_modules/oniguruma-parser": { + "version": "0.12.2", + "resolved": "https://registry.npmjs.org/oniguruma-parser/-/oniguruma-parser-0.12.2.tgz", + "integrity": "sha512-6HVa5oIrgMC6aA6WF6XyyqbhRPJrKR02L20+2+zpDtO5QAzGHAUGw5TKQvwi5vctNnRHkJYmjAhRVQF2EKdTQw==", + "license": "MIT" + }, + "node_modules/oniguruma-to-es": { + "version": "4.3.6", + "resolved": "https://registry.npmjs.org/oniguruma-to-es/-/oniguruma-to-es-4.3.6.tgz", + "integrity": "sha512-csuQ9x3Yr0cEIs/Zgx/OEt9iBw9vqIunAPQkx19R/fiMq2oGVTgcMqO/V3Ybqefr1TBvosI6jU539ksaBULJyA==", + "license": "MIT", + "dependencies": { + "oniguruma-parser": "^0.12.2", + "regex": "^6.1.0", + "regex-recursion": "^6.0.2" + } + }, + "node_modules/p-limit": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-7.3.0.tgz", + "integrity": "sha512-7cIXg/Z0M5WZRblrsOla88S4wAK+zOQQWeBYfV3qJuJXMr+LnbYjaadrFaS0JILfEDPVqHyKnZ1Z/1d6J9VVUw==", + "license": "MIT", + "dependencies": { + "yocto-queue": "^1.2.1" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-queue": { + "version": "9.3.0", + "resolved": "https://registry.npmjs.org/p-queue/-/p-queue-9.3.0.tgz", + "integrity": "sha512-7NED7xhQ74Ngp4JP/2e0VZHp7vSWfJfqeiR92jPgxsz6m0Se4P03YoTKa9dDXyZ3r6P616gUXttrB6nnHYKang==", + "license": "MIT", + "dependencies": { + "eventemitter3": "^5.0.4", + "p-timeout": "^7.0.0" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-timeout": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/p-timeout/-/p-timeout-7.0.1.tgz", + "integrity": "sha512-AxTM2wDGORHGEkPCt8yqxOTMgpfbEHqF51f/5fJCmwFC3C/zNcGT63SymH2ttOAaiIws2zVg4+izQCjrakcwHg==", + "license": "MIT", + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/package-manager-detector": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/package-manager-detector/-/package-manager-detector-1.6.0.tgz", + "integrity": "sha512-61A5ThoTiDG/C8s8UMZwSorAGwMJ0ERVGj2OjoW5pAalsNOg15+iQiPzrLJ4jhZ1HJzmC2PIHT2oEiH3R5fzNA==", + "license": "MIT" + }, + "node_modules/parse-latin": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/parse-latin/-/parse-latin-7.0.0.tgz", + "integrity": "sha512-mhHgobPPua5kZ98EF4HWiH167JWBfl4pvAIXXdbaVohtK7a6YBOy56kvhCqduqyo/f3yrHFWmqmiMg/BkBkYYQ==", + "license": "MIT", + "dependencies": { + "@types/nlcst": "^2.0.0", + "@types/unist": "^3.0.0", + "nlcst-to-string": "^4.0.0", + "unist-util-modify-children": "^4.0.0", + "unist-util-visit-children": "^3.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/parse5": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz", + "integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==", + "license": "MIT", + "dependencies": { + "entities": "^6.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, + "node_modules/pathval": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.1.tgz", + "integrity": "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.16" + } + }, + "node_modules/piccolore": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/piccolore/-/piccolore-0.1.3.tgz", + "integrity": "sha512-o8bTeDWjE086iwKrROaDf31K0qC/BENdm15/uH9usSC/uZjJOKb2YGiVHfLY4GhwsERiPI1jmwI2XrA7ACOxVw==", + "license": "ISC" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/playwright": { + "version": "1.61.1", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.61.1.tgz", + "integrity": "sha512-DWnY5o3YbLWK4GovuAVwpqL+1VwGNdUGrRr++8j8PtQQzvAVZUIMjKQ90fY689sEJZJBbZVw1rXaOKSTitkzPQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.61.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.61.1", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.61.1.tgz", + "integrity": "sha512-h7Qlt6m4REp25qvIdvbDtVmD4LqVXfpRxhORv9L0jzETM05p4fuPJ3dKyuSXQxDSbXnmS79HAgi9589lGSpLkg==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/playwright/node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/postcss": { + "version": "8.5.15", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz", + "integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.12", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/printj": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/printj/-/printj-1.1.2.tgz", + "integrity": "sha512-zA2SmoLaxZyArQTOPj5LXecR+RagfPSU5Kw1qP+jkWeNlrq+eJZyY2oS68SU1Z/7/myXM4lo9716laOFAVStCQ==", + "license": "Apache-2.0", + "bin": { + "printj": "bin/printj.njs" + }, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/prismjs": { + "version": "1.30.0", + "resolved": "https://registry.npmjs.org/prismjs/-/prismjs-1.30.0.tgz", + "integrity": "sha512-DEvV2ZF2r2/63V+tK8hQvrR2ZGn10srHbXviTlcv7Kpzw8jWiNTqbVgjO3IY8RxrrOUF8VPMQQFysYYYv0YZxw==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/property-information": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/property-information/-/property-information-7.1.0.tgz", + "integrity": "sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/radix3": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/radix3/-/radix3-1.1.2.tgz", + "integrity": "sha512-b484I/7b8rDEdSDKckSSBA8knMpcdsXudlE/LNL639wFoHKwLbEkQFZHWEYwDC0wa0FKUcCY+GAF73Z7wxNVFA==", + "license": "MIT" + }, + "node_modules/readdirp": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-5.0.0.tgz", + "integrity": "sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ==", + "license": "MIT", + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "type": "individual", + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/regex": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/regex/-/regex-6.1.0.tgz", + "integrity": "sha512-6VwtthbV4o/7+OaAF9I5L5V3llLEsoPyq9P1JVXkedTP33c7MfCG0/5NOPcSJn0TzXcG9YUrR0gQSWioew3LDg==", + "license": "MIT", + "dependencies": { + "regex-utilities": "^2.3.0" + } + }, + "node_modules/regex-recursion": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/regex-recursion/-/regex-recursion-6.0.2.tgz", + "integrity": "sha512-0YCaSCq2VRIebiaUviZNs0cBz1kg5kVS2UKUfNIx8YVs1cN3AV7NTctO5FOKBA+UT2BPJIWZauYHPqJODG50cg==", + "license": "MIT", + "dependencies": { + "regex-utilities": "^2.3.0" + } + }, + "node_modules/regex-utilities": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/regex-utilities/-/regex-utilities-2.3.0.tgz", + "integrity": "sha512-8VhliFJAWRaUiVvREIiW2NXXTmHs4vMNnSzuJVhscgmGav3g9VDxLrQndI3dZZVVdp0ZO/5v0xmX516/7M9cng==", + "license": "MIT" + }, + "node_modules/rehype": { + "version": "13.0.2", + "resolved": "https://registry.npmjs.org/rehype/-/rehype-13.0.2.tgz", + "integrity": "sha512-j31mdaRFrwFRUIlxGeuPXXKWQxet52RBQRvCmzl5eCefn/KGbomK5GMHNMsOJf55fgo3qw5tST5neDuarDYR2A==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "rehype-parse": "^9.0.0", + "rehype-stringify": "^10.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/rehype-parse": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/rehype-parse/-/rehype-parse-9.0.1.tgz", + "integrity": "sha512-ksCzCD0Fgfh7trPDxr2rSylbwq9iYDkSn8TCDmEJ49ljEUBxDVCzCHv7QNzZOfODanX4+bWQ4WZqLCRWYLfhag==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "hast-util-from-html": "^2.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/rehype-raw": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/rehype-raw/-/rehype-raw-7.0.0.tgz", + "integrity": "sha512-/aE8hCfKlQeA8LmyeyQvQF3eBiLRGNlfBJEvWH7ivp9sBqs7TNqBL5X3v157rM4IFETqDnIOO+z5M/biZbo9Ww==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "hast-util-raw": "^9.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/rehype-stringify": { + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/rehype-stringify/-/rehype-stringify-10.0.1.tgz", + "integrity": "sha512-k9ecfXHmIPuFVI61B9DeLPN0qFHfawM6RsuX48hoqlaKSF61RskNjSm1lI8PhBEM0MRdLxVVm4WmTqJQccH9mA==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "hast-util-to-html": "^9.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/remark-gfm": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/remark-gfm/-/remark-gfm-4.0.1.tgz", + "integrity": "sha512-1quofZ2RQ9EWdeN34S79+KExV1764+wCUGop5CPL1WGdD0ocPpu91lzPGbwWMECpEpd42kJGQwzRfyov9j4yNg==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-gfm": "^3.0.0", + "micromark-extension-gfm": "^3.0.0", + "remark-parse": "^11.0.0", + "remark-stringify": "^11.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/remark-parse": { + "version": "11.0.0", + "resolved": "https://registry.npmjs.org/remark-parse/-/remark-parse-11.0.0.tgz", + "integrity": "sha512-FCxlKLNGknS5ba/1lmpYijMUzX2esxW5xQqjWxw2eHFfS2MSdaHVINFmhjo+qN1WhZhNimq0dZATN9pH0IDrpA==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-from-markdown": "^2.0.0", + "micromark-util-types": "^2.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/remark-rehype": { + "version": "11.1.2", + "resolved": "https://registry.npmjs.org/remark-rehype/-/remark-rehype-11.1.2.tgz", + "integrity": "sha512-Dh7l57ianaEoIpzbp0PC9UKAdCSVklD8E5Rpw7ETfbTl3FqcOOgq5q2LVDhgGCkaBv7p24JXikPdvhhmHvKMsw==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "mdast-util-to-hast": "^13.0.0", + "unified": "^11.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/remark-smartypants": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/remark-smartypants/-/remark-smartypants-3.0.2.tgz", + "integrity": "sha512-ILTWeOriIluwEvPjv67v7Blgrcx+LZOkAUVtKI3putuhlZm84FnqDORNXPPm+HY3NdZOMhyDwZ1E+eZB/Df5dA==", + "license": "MIT", + "dependencies": { + "retext": "^9.0.0", + "retext-smartypants": "^6.0.0", + "unified": "^11.0.4", + "unist-util-visit": "^5.0.0" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/remark-stringify": { + "version": "11.0.0", + "resolved": "https://registry.npmjs.org/remark-stringify/-/remark-stringify-11.0.0.tgz", + "integrity": "sha512-1OSmLd3awB/t8qdoEOMazZkNsfVTeY4fTsgzcQFdXNq8ToTN4ZGwrMnlda4K6smTFKD+GRV6O48i6Z4iKgPPpw==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-to-markdown": "^2.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/resolve-pkg-maps": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", + "integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==", + "license": "MIT", + "funding": { + "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" + } + }, + "node_modules/retext": { + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/retext/-/retext-9.0.0.tgz", + "integrity": "sha512-sbMDcpHCNjvlheSgMfEcVrZko3cDzdbe1x/e7G66dFp0Ff7Mldvi2uv6JkJQzdRcvLYE8CA8Oe8siQx8ZOgTcA==", + "license": "MIT", + "dependencies": { + "@types/nlcst": "^2.0.0", + "retext-latin": "^4.0.0", + "retext-stringify": "^4.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/retext-latin": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/retext-latin/-/retext-latin-4.0.0.tgz", + "integrity": "sha512-hv9woG7Fy0M9IlRQloq/N6atV82NxLGveq+3H2WOi79dtIYWN8OaxogDm77f8YnVXJL2VD3bbqowu5E3EMhBYA==", + "license": "MIT", + "dependencies": { + "@types/nlcst": "^2.0.0", + "parse-latin": "^7.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/retext-smartypants": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/retext-smartypants/-/retext-smartypants-6.2.0.tgz", + "integrity": "sha512-kk0jOU7+zGv//kfjXEBjdIryL1Acl4i9XNkHxtM7Tm5lFiCog576fjNC9hjoR7LTKQ0DsPWy09JummSsH1uqfQ==", + "license": "MIT", + "dependencies": { + "@types/nlcst": "^2.0.0", + "nlcst-to-string": "^4.0.0", + "unist-util-visit": "^5.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/retext-stringify": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/retext-stringify/-/retext-stringify-4.0.0.tgz", + "integrity": "sha512-rtfN/0o8kL1e+78+uxPTqu1Klt0yPzKuQ2BfWwwfgIUSayyzxpM1PJzkKt4V8803uB9qSy32MvI7Xep9khTpiA==", + "license": "MIT", + "dependencies": { + "@types/nlcst": "^2.0.0", + "nlcst-to-string": "^4.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/robust-predicates": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/robust-predicates/-/robust-predicates-3.0.3.tgz", + "integrity": "sha512-NS3levdsRIUOmiJ8FZWCP7LG3QpJyrs/TE0Zpf1yvZu8cAJJ6QMW92H1c7kWpdIHo8RvmLxN/o2JXTKHp74lUA==", + "license": "Unlicense" + }, + "node_modules/rollup": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.4.tgz", + "integrity": "sha512-WHeFSbZYsPu3+bLoNRUuAO+wavNlocOPf3wSHTP7hcFKVnJeWsYlCDbr3mTS14FCizf9ccIxXA8sGL8zKeQN3g==", + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.60.4", + "@rollup/rollup-android-arm64": "4.60.4", + "@rollup/rollup-darwin-arm64": "4.60.4", + "@rollup/rollup-darwin-x64": "4.60.4", + "@rollup/rollup-freebsd-arm64": "4.60.4", + "@rollup/rollup-freebsd-x64": "4.60.4", + "@rollup/rollup-linux-arm-gnueabihf": "4.60.4", + "@rollup/rollup-linux-arm-musleabihf": "4.60.4", + "@rollup/rollup-linux-arm64-gnu": "4.60.4", + "@rollup/rollup-linux-arm64-musl": "4.60.4", + "@rollup/rollup-linux-loong64-gnu": "4.60.4", + "@rollup/rollup-linux-loong64-musl": "4.60.4", + "@rollup/rollup-linux-ppc64-gnu": "4.60.4", + "@rollup/rollup-linux-ppc64-musl": "4.60.4", + "@rollup/rollup-linux-riscv64-gnu": "4.60.4", + "@rollup/rollup-linux-riscv64-musl": "4.60.4", + "@rollup/rollup-linux-s390x-gnu": "4.60.4", + "@rollup/rollup-linux-x64-gnu": "4.60.4", + "@rollup/rollup-linux-x64-musl": "4.60.4", + "@rollup/rollup-openbsd-x64": "4.60.4", + "@rollup/rollup-openharmony-arm64": "4.60.4", + "@rollup/rollup-win32-arm64-msvc": "4.60.4", + "@rollup/rollup-win32-ia32-msvc": "4.60.4", + "@rollup/rollup-win32-x64-gnu": "4.60.4", + "@rollup/rollup-win32-x64-msvc": "4.60.4", + "fsevents": "~2.3.2" + } + }, + "node_modules/rollup/node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "license": "MIT" + }, + "node_modules/rw": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/rw/-/rw-1.3.3.tgz", + "integrity": "sha512-PdhdWy89SiZogBLaw42zdeqtRJ//zFd2PgQavcICDUgJT5oW10QCRKbJ6bg4r0/UY2M6BWd5tkxuGFRvCkgfHQ==", + "license": "BSD-3-Clause" + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, + "node_modules/sax": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/sax/-/sax-1.6.0.tgz", + "integrity": "sha512-6R3J5M4AcbtLUdZmRv2SygeVaM7IhrLXu9BmnOGmmACak8fiUtOsYNWUS4uK7upbmHIBbLBeFeI//477BKLBzA==", + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=11.0.0" + } + }, + "node_modules/semver": { + "version": "7.8.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.1.tgz", + "integrity": "sha512-rkVq3IXh+4FDGch+KwzX3aV9W3kO54GyEgpvBzSyctDA6Xtd7RJQV1xmXbeQp5v7+VzLOfVqiutSE6GICgPFvg==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/sharp": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.34.5.tgz", + "integrity": "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==", + "hasInstallScript": true, + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "@img/colour": "^1.0.0", + "detect-libc": "^2.1.2", + "semver": "^7.7.3" + }, + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-darwin-arm64": "0.34.5", + "@img/sharp-darwin-x64": "0.34.5", + "@img/sharp-libvips-darwin-arm64": "1.2.4", + "@img/sharp-libvips-darwin-x64": "1.2.4", + "@img/sharp-libvips-linux-arm": "1.2.4", + "@img/sharp-libvips-linux-arm64": "1.2.4", + "@img/sharp-libvips-linux-ppc64": "1.2.4", + "@img/sharp-libvips-linux-riscv64": "1.2.4", + "@img/sharp-libvips-linux-s390x": "1.2.4", + "@img/sharp-libvips-linux-x64": "1.2.4", + "@img/sharp-libvips-linuxmusl-arm64": "1.2.4", + "@img/sharp-libvips-linuxmusl-x64": "1.2.4", + "@img/sharp-linux-arm": "0.34.5", + "@img/sharp-linux-arm64": "0.34.5", + "@img/sharp-linux-ppc64": "0.34.5", + "@img/sharp-linux-riscv64": "0.34.5", + "@img/sharp-linux-s390x": "0.34.5", + "@img/sharp-linux-x64": "0.34.5", + "@img/sharp-linuxmusl-arm64": "0.34.5", + "@img/sharp-linuxmusl-x64": "0.34.5", + "@img/sharp-wasm32": "0.34.5", + "@img/sharp-win32-arm64": "0.34.5", + "@img/sharp-win32-ia32": "0.34.5", + "@img/sharp-win32-x64": "0.34.5" + } + }, + "node_modules/shiki": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/shiki/-/shiki-4.1.0.tgz", + "integrity": "sha512-l/ABZPUR5v70jI10EzqfMS/I96vjSGv2y0ihUV+WYFzv0EfvW4s54m0Lg8wCrrL+2IkwBzFTuxkZjPf8b2NX9Q==", + "license": "MIT", + "dependencies": { + "@shikijs/core": "4.1.0", + "@shikijs/engine-javascript": "4.1.0", + "@shikijs/engine-oniguruma": "4.1.0", + "@shikijs/langs": "4.1.0", + "@shikijs/themes": "4.1.0", + "@shikijs/types": "4.1.0", + "@shikijs/vscode-textmate": "^10.0.2", + "@types/hast": "^3.0.4" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, + "node_modules/sisteransi": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/sisteransi/-/sisteransi-1.0.5.tgz", + "integrity": "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==", + "license": "MIT" + }, + "node_modules/smol-toml": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/smol-toml/-/smol-toml-1.6.1.tgz", + "integrity": "sha512-dWUG8F5sIIARXih1DTaQAX4SsiTXhInKf1buxdY9DIg4ZYPZK5nGM1VRIYmEbDbsHt7USo99xSLFu5Q1IqTmsg==", + "license": "BSD-3-Clause", + "engines": { + "node": ">= 18" + }, + "funding": { + "url": "https://github.com/sponsors/cyyynthia" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/space-separated-tokens": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/space-separated-tokens/-/space-separated-tokens-2.0.2.tgz", + "integrity": "sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/ssf": { + "version": "0.11.2", + "resolved": "https://registry.npmjs.org/ssf/-/ssf-0.11.2.tgz", + "integrity": "sha512-+idbmIXoYET47hH+d7dfm2epdOMUDjqcB4648sTZ+t2JwoyBFL/insLfB/racrDmsKB3diwsDA696pZMieAC5g==", + "license": "Apache-2.0", + "dependencies": { + "frac": "~1.1.2" + }, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "3.10.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", + "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", + "dev": true, + "license": "MIT" + }, + "node_modules/stringify-entities": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/stringify-entities/-/stringify-entities-4.0.4.tgz", + "integrity": "sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg==", + "license": "MIT", + "dependencies": { + "character-entities-html4": "^2.0.0", + "character-entities-legacy": "^3.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/strip-literal": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/strip-literal/-/strip-literal-3.1.0.tgz", + "integrity": "sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "js-tokens": "^9.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/svgo": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/svgo/-/svgo-4.0.1.tgz", + "integrity": "sha512-XDpWUOPC6FEibaLzjfe0ucaV0YrOjYotGJO1WpF0Zd+n6ZGEQUsSugaoLq9QkEZtAfQIxT42UChcssDVPP3+/w==", + "license": "MIT", + "dependencies": { + "commander": "^11.1.0", + "css-select": "^5.1.0", + "css-tree": "^3.0.1", + "css-what": "^6.1.0", + "csso": "^5.0.5", + "picocolors": "^1.1.1", + "sax": "^1.5.0" + }, + "bin": { + "svgo": "bin/svgo.js" + }, + "engines": { + "node": ">=16" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/svgo" + } + }, + "node_modules/tailwindcss": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.3.0.tgz", + "integrity": "sha512-y6nxMGB1nMW9R6k96e5gdIFzcfL/gTJRNaqGes1YvkLnPVXzWgbqFF2yLC0T8G774n24cx3Pe8XrKoniCOAH+Q==", + "license": "MIT" + }, + "node_modules/tapable": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.3.tgz", + "integrity": "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==", + "license": "MIT", + "engines": { + "node": ">=6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/tiny-inflate": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/tiny-inflate/-/tiny-inflate-1.0.3.tgz", + "integrity": "sha512-pkY1fj1cKHb2seWDy0B16HeWyczlJA9/WW3u3c4z/NiWDsO3DOU5D7nhTLE9CF0yXv/QZFY7sEJmj24dK+Rrqw==", + "license": "MIT" + }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyclip": { + "version": "0.1.12", + "resolved": "https://registry.npmjs.org/tinyclip/-/tinyclip-0.1.12.tgz", + "integrity": "sha512-Ae3OVUqifDw0wBriIBS7yVaW44Dp6eSHQcyq4Igc7eN2TJH/2YsicswaW+J/OuMvhpDPOKEgpAZCjkb4hpoyeA==", + "license": "MIT", + "engines": { + "node": "^16.14.0 || >= 17.3.0" + } + }, + "node_modules/tinyexec": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.1.2.tgz", + "integrity": "sha512-dAqSqE/RabpBKI8+h26GfLq6Vb3JVXs30XYQjdMjaj/c2tS8IYYMbIzP599KtRj7c57/wYApb3QjgRgXmrCukA==", + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.16", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz", + "integrity": "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==", + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinypool": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.1.1.tgz", + "integrity": "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.0.0 || >=20.0.0" + } + }, + "node_modules/tinyrainbow": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-2.0.0.tgz", + "integrity": "sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tinyspy": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-4.0.4.tgz", + "integrity": "sha512-azl+t0z7pw/z958Gy9svOTuzqIk6xq+NSheJzn5MMWtWTFywIacg2wUlzKFGtt3cthx0r2SxMK0yzJOR0IES7Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/trim-lines": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/trim-lines/-/trim-lines-3.0.1.tgz", + "integrity": "sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/trough": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/trough/-/trough-2.2.0.tgz", + "integrity": "sha512-tmMpK00BjZiUyVyvrBK7knerNgmgvcV/KLVyuma/SC+TQN167GrMRciANTz09+k3zW8L8t60jWO1GpfkZdjTaw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD", + "optional": true + }, + "node_modules/ufo": { + "version": "1.6.4", + "resolved": "https://registry.npmjs.org/ufo/-/ufo-1.6.4.tgz", + "integrity": "sha512-JFNbkD1Svwe0KvGi8GOeLcP4kAWQ609twvCdcHxq1oSL8svv39ZuSvajcD8B+5D0eL4+s1Is2D/O6KN3qcTeRA==", + "license": "MIT" + }, + "node_modules/ultrahtml": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/ultrahtml/-/ultrahtml-1.6.0.tgz", + "integrity": "sha512-R9fBn90VTJrqqLDwyMph+HGne8eqY1iPfYhPzZrvKpIfwkWZbcYlfpsb8B9dTvBfpy1/hqAD7Wi8EKfP9e8zdw==", + "license": "MIT" + }, + "node_modules/uncrypto": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/uncrypto/-/uncrypto-0.1.3.tgz", + "integrity": "sha512-Ql87qFHB3s/De2ClA9e0gsnS6zXG27SkTiSJwjCc9MebbfapQfuPzumMIUMi38ezPZVNFcHI9sUIepeQfw8J8Q==", + "license": "MIT" + }, + "node_modules/unified": { + "version": "11.0.5", + "resolved": "https://registry.npmjs.org/unified/-/unified-11.0.5.tgz", + "integrity": "sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "bail": "^2.0.0", + "devlop": "^1.0.0", + "extend": "^3.0.0", + "is-plain-obj": "^4.0.0", + "trough": "^2.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unifont": { + "version": "0.7.4", + "resolved": "https://registry.npmjs.org/unifont/-/unifont-0.7.4.tgz", + "integrity": "sha512-oHeis4/xl42HUIeHuNZRGEvxj5AaIKR+bHPNegRq5LV1gdc3jundpONbjglKpihmJf+dswygdMJn3eftGIMemg==", + "license": "MIT", + "dependencies": { + "css-tree": "^3.1.0", + "ofetch": "^1.5.1", + "ohash": "^2.0.11" + } + }, + "node_modules/unist-util-find-after": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/unist-util-find-after/-/unist-util-find-after-5.0.0.tgz", + "integrity": "sha512-amQa0Ep2m6hE2g72AugUItjbuM8X8cGQnFoHk0pGfrFeT9GZhzN5SW8nRsiGKK7Aif4CrACPENkA6P/Lw6fHGQ==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-is": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-is": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/unist-util-is/-/unist-util-is-6.0.1.tgz", + "integrity": "sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-modify-children": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/unist-util-modify-children/-/unist-util-modify-children-4.0.0.tgz", + "integrity": "sha512-+tdN5fGNddvsQdIzUF3Xx82CU9sMM+fA0dLgR9vOmT0oPT2jH+P1nd5lSqfCfXAw+93NhcXNY2qqvTUtE4cQkw==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "array-iterate": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-position": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/unist-util-position/-/unist-util-position-5.0.0.tgz", + "integrity": "sha512-fucsC7HjXvkB5R3kTCO7kUjRdrS0BJt3M/FPxmHMBOm8JQi2BsHAHFsy27E0EolP8rp0NzXsJ+jNPyDWvOJZPA==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-remove-position": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/unist-util-remove-position/-/unist-util-remove-position-5.0.0.tgz", + "integrity": "sha512-Hp5Kh3wLxv0PHj9m2yZhhLt58KzPtEYKQQ4yxfYFEO7EvHwzyDYnduhHnY1mDxoqr7VUwVuHXk9RXKIiYS1N8Q==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-visit": "^5.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-stringify-position": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/unist-util-stringify-position/-/unist-util-stringify-position-4.0.0.tgz", + "integrity": "sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-visit": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/unist-util-visit/-/unist-util-visit-5.1.0.tgz", + "integrity": "sha512-m+vIdyeCOpdr/QeQCu2EzxX/ohgS8KbnPDgFni4dQsfSCtpz8UqDyY5GjRru8PDKuYn7Fq19j1CQ+nJSsGKOzg==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-is": "^6.0.0", + "unist-util-visit-parents": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-visit-children": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/unist-util-visit-children/-/unist-util-visit-children-3.0.0.tgz", + "integrity": "sha512-RgmdTfSBOg04sdPcpTSD1jzoNBjt9a80/ZCzp5cI9n1qPzLZWF9YdvWGN2zmTumP1HWhXKdUWexjy/Wy/lJ7tA==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-visit-parents": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/unist-util-visit-parents/-/unist-util-visit-parents-6.0.2.tgz", + "integrity": "sha512-goh1s1TBrqSqukSc8wrjwWhL0hiJxgA8m4kFxGlQ+8FYQ3C/m11FcTs4YYem7V664AhHVvgoQLk890Ssdsr2IQ==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-is": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unstorage": { + "version": "1.17.5", + "resolved": "https://registry.npmjs.org/unstorage/-/unstorage-1.17.5.tgz", + "integrity": "sha512-0i3iqvRfx29hkNntHyQvJTpf5W9dQ9ZadSoRU8+xVlhVtT7jAX57fazYO9EHvcRCfBCyi5YRya7XCDOsbTgkPg==", + "license": "MIT", + "dependencies": { + "anymatch": "^3.1.3", + "chokidar": "^5.0.0", + "destr": "^2.0.5", + "h3": "^1.15.10", + "lru-cache": "^11.2.7", + "node-fetch-native": "^1.6.7", + "ofetch": "^1.5.1", + "ufo": "^1.6.3" + }, + "peerDependencies": { + "@azure/app-configuration": "^1.8.0", + "@azure/cosmos": "^4.2.0", + "@azure/data-tables": "^13.3.0", + "@azure/identity": "^4.6.0", + "@azure/keyvault-secrets": "^4.9.0", + "@azure/storage-blob": "^12.26.0", + "@capacitor/preferences": "^6 || ^7 || ^8", + "@deno/kv": ">=0.9.0", + "@netlify/blobs": "^6.5.0 || ^7.0.0 || ^8.1.0 || ^9.0.0 || ^10.0.0", + "@planetscale/database": "^1.19.0", + "@upstash/redis": "^1.34.3", + "@vercel/blob": ">=0.27.1", + "@vercel/functions": "^2.2.12 || ^3.0.0", + "@vercel/kv": "^1 || ^2 || ^3", + "aws4fetch": "^1.0.20", + "db0": ">=0.2.1", + "idb-keyval": "^6.2.1", + "ioredis": "^5.4.2", + "uploadthing": "^7.4.4" + }, + "peerDependenciesMeta": { + "@azure/app-configuration": { + "optional": true + }, + "@azure/cosmos": { + "optional": true + }, + "@azure/data-tables": { + "optional": true + }, + "@azure/identity": { + "optional": true + }, + "@azure/keyvault-secrets": { + "optional": true + }, + "@azure/storage-blob": { + "optional": true + }, + "@capacitor/preferences": { + "optional": true + }, + "@deno/kv": { + "optional": true + }, + "@netlify/blobs": { + "optional": true + }, + "@planetscale/database": { + "optional": true + }, + "@upstash/redis": { + "optional": true + }, + "@vercel/blob": { + "optional": true + }, + "@vercel/functions": { + "optional": true + }, + "@vercel/kv": { + "optional": true + }, + "aws4fetch": { + "optional": true + }, + "db0": { + "optional": true + }, + "idb-keyval": { + "optional": true + }, + "ioredis": { + "optional": true + }, + "uploadthing": { + "optional": true + } + } + }, + "node_modules/vfile": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/vfile/-/vfile-6.0.3.tgz", + "integrity": "sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "vfile-message": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/vfile-location": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/vfile-location/-/vfile-location-5.0.3.tgz", + "integrity": "sha512-5yXvWDEgqeiYiBe1lbxYF7UMAIm/IcopxMHrMQDq3nvKcjPKIhZklUKL+AE7J7uApI4kwe2snsK+eI6UTj9EHg==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/vfile-message": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/vfile-message/-/vfile-message-4.0.3.tgz", + "integrity": "sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-stringify-position": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/vite": { + "version": "7.3.3", + "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.3.tgz", + "integrity": "sha512-/4XH147Ui7OGTjg3HbdWe5arnZQSbfuRzdr9Ec7TQi5I7R+ir0Rlc9GIvD4v0XZurELqA035KVXJXpR61xhiTA==", + "license": "MIT", + "dependencies": { + "esbuild": "^0.27.0", + "fdir": "^6.5.0", + "picomatch": "^4.0.3", + "postcss": "^8.5.6", + "rollup": "^4.43.0", + "tinyglobby": "^0.2.15" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "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 + } + } + }, + "node_modules/vite-node": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-3.2.4.tgz", + "integrity": "sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cac": "^6.7.14", + "debug": "^4.4.1", + "es-module-lexer": "^1.7.0", + "pathe": "^2.0.3", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" + }, + "bin": { + "vite-node": "vite-node.mjs" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/vite-node/node_modules/es-module-lexer": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", + "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", + "dev": true, + "license": "MIT" + }, + "node_modules/vitefu": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/vitefu/-/vitefu-1.1.3.tgz", + "integrity": "sha512-ub4okH7Z5KLjb6hDyjqrGXqWtWvoYdU3IGm/NorpgHncKoLTCfRIbvlhBm7r0YstIaQRYlp4yEbFqDcKSzXSSg==", + "license": "MIT", + "workspaces": [ + "tests/deps/*", + "tests/projects/*", + "tests/projects/workspace/packages/*" + ], + "peerDependencies": { + "vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "vite": { + "optional": true + } + } + }, + "node_modules/vitest": { + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-3.2.6.tgz", + "integrity": "sha512-xejya+bT/j/+R/AGa1XOfRxLmNUlLtlwjRsFUILF+xHfzElmGcmFydy2gqqIrd62ptIEfwVMofd19uNWD9L7Nw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/chai": "^5.2.2", + "@vitest/expect": "3.2.6", + "@vitest/mocker": "3.2.6", + "@vitest/pretty-format": "^3.2.6", + "@vitest/runner": "3.2.6", + "@vitest/snapshot": "3.2.6", + "@vitest/spy": "3.2.6", + "@vitest/utils": "3.2.6", + "chai": "^5.2.0", + "debug": "^4.4.1", + "expect-type": "^1.2.1", + "magic-string": "^0.30.17", + "pathe": "^2.0.3", + "picomatch": "^4.0.2", + "std-env": "^3.9.0", + "tinybench": "^2.9.0", + "tinyexec": "^0.3.2", + "tinyglobby": "^0.2.14", + "tinypool": "^1.1.1", + "tinyrainbow": "^2.0.0", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0", + "vite-node": "3.2.4", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@types/debug": "^4.1.12", + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "@vitest/browser": "3.2.6", + "@vitest/ui": "3.2.6", + "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 + } + } + }, + "node_modules/vitest/node_modules/tinyexec": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz", + "integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/web-namespaces": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/web-namespaces/-/web-namespaces-2.0.1.tgz", + "integrity": "sha512-bKr1DkiNa2krS7qxNtdrtHAmzuYGFQLiQ13TsorsdT6ULTkPLKuu5+GsFpDlg6JFjUTwX2DyhMPG2be8uPrqsQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/which-pm-runs": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/which-pm-runs/-/which-pm-runs-1.1.0.tgz", + "integrity": "sha512-n1brCuqClxfFfq/Rb0ICg9giSZqCS+pLtccdag6C2HyufBrh3fBOiy9nb6ggRMvWOVH5GrdJskj5iGTZNxd7SA==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/wmf": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wmf/-/wmf-1.0.2.tgz", + "integrity": "sha512-/p9K7bEh0Dj6WbXg4JG0xvLQmIadrner1bi45VMJTfnbVHsc7yIajZyoSoK60/dtVBs12Fm6WkUI5/3WAVsNMw==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.8" + } + }, + "node_modules/word": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/word/-/word-0.3.0.tgz", + "integrity": "sha512-OELeY0Q61OXpdUfTp+oweA/vtLVg5VDOXh+3he3PNzLGG/y0oylSOC1xRVj0+l4vQ3tj/bB1HVHv1ocXkQceFA==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.8" + } + }, + "node_modules/xlsx-js-style": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/xlsx-js-style/-/xlsx-js-style-1.2.0.tgz", + "integrity": "sha512-DDT4FXFSWfT4DXMSok/m3TvmP1gvO3dn0Eu/c+eXHW5Kzmp7IczNkxg/iEPnImbG9X0Vb8QhROda5eatSR/97Q==", + "license": "Apache-2.0", + "dependencies": { + "adler-32": "~1.2.0", + "cfb": "^1.1.4", + "codepage": "~1.14.0", + "commander": "~2.17.1", + "crc-32": "~1.2.0", + "exit-on-epipe": "~1.0.1", + "fflate": "^0.3.8", + "ssf": "~0.11.2", + "wmf": "~1.0.1", + "word": "~0.3.0" + }, + "bin": { + "xlsx": "bin/xlsx.njs" + }, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/xlsx-js-style/node_modules/commander": { + "version": "2.17.1", + "resolved": "https://registry.npmjs.org/commander/-/commander-2.17.1.tgz", + "integrity": "sha512-wPMUt6FnH2yzG95SA6mzjQOEKUU3aLaDEmzs1ti+1E9h+CsrZghRlqEM/EJ4KscsQVG8uNN4uVreUeT8+drlgg==", + "license": "MIT" + }, + "node_modules/xxhash-wasm": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/xxhash-wasm/-/xxhash-wasm-1.1.0.tgz", + "integrity": "sha512-147y/6YNh+tlp6nd/2pWq38i9h6mz/EuQ6njIrmW8D1BS5nCqs0P6DG+m6zTGnNz5I+uhZ0SHxBs9BsPrwcKDA==", + "license": "MIT" + }, + "node_modules/yargs-parser": { + "version": "22.0.0", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-22.0.0.tgz", + "integrity": "sha512-rwu/ClNdSMpkSrUb+d6BRsSkLUq1fmfsY6TOpYzTwvwkg1/NRG85KBy3kq++A8LKQwX6lsu+aWad+2khvuXrqw==", + "license": "ISC", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=23" + } + }, + "node_modules/yocto-queue": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-1.2.2.tgz", + "integrity": "sha512-4LCcse/U2MHZ63HAJVE+v71o7yOdIe4cZ70Wpf8D/IyjDKYQLV5GD46B+hSTjJsvV5PztjvHoU580EftxjDZFQ==", + "license": "MIT", + "engines": { + "node": ">=12.20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/zod": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz", + "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/zwitch": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/zwitch/-/zwitch-2.0.4.tgz", + "integrity": "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + } + } +} diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..f22aaa8 --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,28 @@ +{ + "name": "jlinc-server-ui", + "version": "1.0.0", + "description": "JLINC Server", + "type": "module", + "engines": { + "node": ">=22.12.0" + }, + "scripts": { + "dev": "astro dev", + "build": "astro build", + "preview": "astro preview", + "astro": "astro", + "test": "vitest run", + "test:e2e": "playwright test" + }, + "dependencies": { + "@tailwindcss/vite": "^4.3.0", + "astro": "^6.3.7", + "d3": "^7.9.0", + "tailwindcss": "^4.3.0", + "xlsx-js-style": "^1.2.0" + }, + "devDependencies": { + "@playwright/test": "^1.49.0", + "vitest": "^3.0.0" + } +} diff --git a/frontend/playwright.config.js b/frontend/playwright.config.js new file mode 100644 index 0000000..eec73b9 --- /dev/null +++ b/frontend/playwright.config.js @@ -0,0 +1,14 @@ +import { defineConfig } from "@playwright/test"; + +// npm run test:e2e (needs `npx playwright install chromium` once). +export default defineConfig({ + testDir: "./tests/e2e", + timeout: 30_000, + use: { baseURL: "http://localhost:4321" }, + webServer: { + command: "npm run build && npm run preview -- --port 4321", + url: "http://localhost:4321", + reuseExistingServer: true, + timeout: 120_000, + }, +}); diff --git a/frontend/public/favicon.ico b/frontend/public/favicon.ico new file mode 100644 index 0000000..7f48a94 Binary files /dev/null and b/frontend/public/favicon.ico differ diff --git a/frontend/public/favicon.svg b/frontend/public/favicon.svg new file mode 100644 index 0000000..f157bd1 --- /dev/null +++ b/frontend/public/favicon.svg @@ -0,0 +1,9 @@ + + + + diff --git a/frontend/public/fonts/UcC73FwrK3iLTeHuS_nVMrMxCp50SjIa1ZL7.woff2 b/frontend/public/fonts/UcC73FwrK3iLTeHuS_nVMrMxCp50SjIa1ZL7.woff2 new file mode 100644 index 0000000..d15208d Binary files /dev/null and b/frontend/public/fonts/UcC73FwrK3iLTeHuS_nVMrMxCp50SjIa1ZL7.woff2 differ diff --git a/frontend/public/fonts/UcC73FwrK3iLTeHuS_nVMrMxCp50SjIa25L7SUc.woff2 b/frontend/public/fonts/UcC73FwrK3iLTeHuS_nVMrMxCp50SjIa25L7SUc.woff2 new file mode 100644 index 0000000..479d010 Binary files /dev/null and b/frontend/public/fonts/UcC73FwrK3iLTeHuS_nVMrMxCp50SjIa25L7SUc.woff2 differ diff --git a/frontend/public/fonts/kJEhBvYX7BgnkSrUwT8OhrdQw4oELdPIeeII9v6oFsI.woff2 b/frontend/public/fonts/kJEhBvYX7BgnkSrUwT8OhrdQw4oELdPIeeII9v6oFsI.woff2 new file mode 100644 index 0000000..7fb5e80 Binary files /dev/null and b/frontend/public/fonts/kJEhBvYX7BgnkSrUwT8OhrdQw4oELdPIeeII9v6oFsI.woff2 differ diff --git a/frontend/public/fonts/tDbv2o-flEEny0FZhsfKu5WU4zr3E_BX0PnT8RD8yKwBNntkaToggR7BYRbKPx7cwhsk.woff2 b/frontend/public/fonts/tDbv2o-flEEny0FZhsfKu5WU4zr3E_BX0PnT8RD8yKwBNntkaToggR7BYRbKPx7cwhsk.woff2 new file mode 100644 index 0000000..82f9668 Binary files /dev/null and b/frontend/public/fonts/tDbv2o-flEEny0FZhsfKu5WU4zr3E_BX0PnT8RD8yKwBNntkaToggR7BYRbKPx7cwhsk.woff2 differ diff --git a/frontend/public/fonts/tDbv2o-flEEny0FZhsfKu5WU4zr3E_BX0PnT8RD8yKwBNntkaToggR7BYRbKPxDcwg.woff2 b/frontend/public/fonts/tDbv2o-flEEny0FZhsfKu5WU4zr3E_BX0PnT8RD8yKwBNntkaToggR7BYRbKPxDcwg.woff2 new file mode 100644 index 0000000..4d09cda Binary files /dev/null and b/frontend/public/fonts/tDbv2o-flEEny0FZhsfKu5WU4zr3E_BX0PnT8RD8yKwBNntkaToggR7BYRbKPxDcwg.woff2 differ diff --git a/frontend/public/fonts/zYXzKVElMYYaJe8bpLHnCwDKr932-G7dytD-Dmu1syxQKYbABA.woff2 b/frontend/public/fonts/zYXzKVElMYYaJe8bpLHnCwDKr932-G7dytD-Dmu1syxQKYbABA.woff2 new file mode 100644 index 0000000..394ebbb Binary files /dev/null and b/frontend/public/fonts/zYXzKVElMYYaJe8bpLHnCwDKr932-G7dytD-Dmu1syxQKYbABA.woff2 differ diff --git a/frontend/public/fonts/zYXzKVElMYYaJe8bpLHnCwDKr932-G7dytD-Dmu1syxeKYY.woff2 b/frontend/public/fonts/zYXzKVElMYYaJe8bpLHnCwDKr932-G7dytD-Dmu1syxeKYY.woff2 new file mode 100644 index 0000000..b757bc5 Binary files /dev/null and b/frontend/public/fonts/zYXzKVElMYYaJe8bpLHnCwDKr932-G7dytD-Dmu1syxeKYY.woff2 differ diff --git a/frontend/src/assets/astro.svg b/frontend/src/assets/astro.svg new file mode 100644 index 0000000..8cf8fb0 --- /dev/null +++ b/frontend/src/assets/astro.svg @@ -0,0 +1 @@ + diff --git a/frontend/src/assets/background.svg b/frontend/src/assets/background.svg new file mode 100644 index 0000000..4b2be0a --- /dev/null +++ b/frontend/src/assets/background.svg @@ -0,0 +1 @@ + diff --git a/frontend/src/components/ApiKeys.astro b/frontend/src/components/ApiKeys.astro new file mode 100644 index 0000000..5cec24d --- /dev/null +++ b/frontend/src/components/ApiKeys.astro @@ -0,0 +1,125 @@ +--- +--- + + + + diff --git a/frontend/src/components/AppBar.astro b/frontend/src/components/AppBar.astro new file mode 100644 index 0000000..c8f428e --- /dev/null +++ b/frontend/src/components/AppBar.astro @@ -0,0 +1,63 @@ +--- + +--- + +
+
+ menu + + +
+
+
+ notifications + 0 +
+ help_outline +
+
+ + +
+
+
+ + \ No newline at end of file diff --git a/frontend/src/components/FilterBar.astro b/frontend/src/components/FilterBar.astro new file mode 100644 index 0000000..5ab62e4 --- /dev/null +++ b/frontend/src/components/FilterBar.astro @@ -0,0 +1,74 @@ +--- +--- + +
+
+
+ + +
+
+
+ calendar_today + + – + +
+
+
+ Saved ✓ + +
+
+ + diff --git a/frontend/src/components/Sidebar.astro b/frontend/src/components/Sidebar.astro new file mode 100644 index 0000000..d6b1110 --- /dev/null +++ b/frontend/src/components/Sidebar.astro @@ -0,0 +1,54 @@ +--- +import { eeSlot } from '../resources/ee'; + +const eeFooter = eeSlot('sidebar'); +--- + + + + + + \ No newline at end of file diff --git a/frontend/src/components/SummaryCards.astro b/frontend/src/components/SummaryCards.astro new file mode 100644 index 0000000..430736b --- /dev/null +++ b/frontend/src/components/SummaryCards.astro @@ -0,0 +1,121 @@ +--- +import { eeSlot } from '../resources/ee'; + +const eeCards = eeSlot('cards'); +const columns = eeCards.length ? 'lg:grid-cols-4' : 'lg:grid-cols-3'; +--- + +
+ +
+
+ API Usage + hub +
+
+

+

+
+
+
+
+
+
+ +
+
+ Disk Storage + database +
+
+

+

+
+
+
+
+
+
+ {/* EE cards (Billing); empty in the community build */} + {eeCards.map((Card) => )} + +
+
+ Config + settings_input_component +
+
+
+ API Keys + +
+
+ Endpoints + +
+
+
+
+ + \ No newline at end of file diff --git a/frontend/src/components/TransactionsTable.astro b/frontend/src/components/TransactionsTable.astro new file mode 100644 index 0000000..36b2984 --- /dev/null +++ b/frontend/src/components/TransactionsTable.astro @@ -0,0 +1,449 @@ +--- +import { eeSlot } from '../resources/ee'; + +const eeToolbar = eeSlot('toolbar'); +--- + +
+
+ + +
+ +
+ Loading… + + + +
+ {eeToolbar.map((Item) => )} + +
+
+ +
+ + + + + + + + + + + + + + + +
+ + + Agreement arrow_drop_down + + Event UUID arrow_drop_down + + Sender / Receiver arrow_drop_down + + Event Date arrow_drop_down + + Size arrow_drop_down + Action
+
+ +
+
+ + + diff --git a/frontend/src/components/UsageChart.astro b/frontend/src/components/UsageChart.astro new file mode 100644 index 0000000..8324da7 --- /dev/null +++ b/frontend/src/components/UsageChart.astro @@ -0,0 +1,108 @@ +--- +--- + +
+
+

API Usage & Storage

+
+
+
+
+ + diff --git a/frontend/src/components/Welcome.astro b/frontend/src/components/Welcome.astro new file mode 100644 index 0000000..1b2cf9c --- /dev/null +++ b/frontend/src/components/Welcome.astro @@ -0,0 +1,210 @@ +--- +import astroLogo from '../assets/astro.svg'; +import background from '../assets/background.svg'; +--- + + + + diff --git a/frontend/src/layouts/Layout.astro b/frontend/src/layouts/Layout.astro new file mode 100644 index 0000000..13e01a4 --- /dev/null +++ b/frontend/src/layouts/Layout.astro @@ -0,0 +1,27 @@ +--- +import '../styles/global.css'; +import '../styles/fonts.css'; +--- + + + + + + + JLINC Dashboard + + + + + + + + + diff --git a/frontend/src/pages/index.astro b/frontend/src/pages/index.astro new file mode 100644 index 0000000..9d69882 --- /dev/null +++ b/frontend/src/pages/index.astro @@ -0,0 +1,69 @@ +--- +import Layout from '../layouts/Layout.astro'; +import Sidebar from '../components/Sidebar.astro'; +import AppBar from '../components/AppBar.astro'; +import SummaryCards from '../components/SummaryCards.astro'; +import FilterBar from '../components/FilterBar.astro'; +import UsageChart from '../components/UsageChart.astro'; +import TransactionsTable from '../components/TransactionsTable.astro'; +import ApiKeys from '../components/ApiKeys.astro'; +import { eeSlot } from '../resources/ee'; + +const eeNav = eeSlot('nav'); +--- + + + + + + +
+ + + +
+ + + + +
+ + + + +
+ + + +
diff --git a/frontend/src/resources/agreementModal.js b/frontend/src/resources/agreementModal.js new file mode 100644 index 0000000..87962de --- /dev/null +++ b/frontend/src/resources/agreementModal.js @@ -0,0 +1,126 @@ +function close() { + document.getElementById('agreement-modal')?.remove(); + document.removeEventListener('keydown', onKey); +} +function onKey(e) { if (e.key === 'Escape') close(); } + +const TIMEOUT_MS = 15000; + +const MD_CSS = ` +.agreement-md{color:#191c1e;line-height:1.5} +.agreement-md h1{font-size:1.5rem;font-weight:700;margin:0 0 .5rem} +.agreement-md h2{font-size:1.15rem;font-weight:600;margin:1rem 0 .35rem} +.agreement-md p{margin:.5rem 0} +.agreement-md ul,.agreement-md ol{margin:.5rem 0;padding-left:1.25rem} +.agreement-md li{margin:.15rem 0} +.agreement-md a{color:#4F378B;text-decoration:underline} +.agreement-md table{border-collapse:collapse;margin:.5rem 0} +.agreement-md td,.agreement-md th{border:1px solid #cbc4d2;padding:4px 8px} +.agreement-md code{background:#eee;padding:1px 4px;border-radius:3px}`; + +function ensureStyle() { + if (document.getElementById('agreement-md-style')) return; + const s = document.createElement('style'); + s.id = 'agreement-md-style'; + s.textContent = MD_CSS; + document.head.appendChild(s); +} + +// Strip anything executable before the markdown HTML is inserted: dangerous +// elements, inline event handlers, and javascript: URLs. The content is +// markdown rendered server-side, but a cached remote agreement is only +// semi-trusted, so we never hand it raw to innerHTML. +function sanitize(html) { + const tpl = document.createElement('template'); + tpl.innerHTML = html; + tpl.content.querySelectorAll('script,style,iframe,object,embed,link,meta,form,svg,math').forEach((el) => el.remove()); + tpl.content.querySelectorAll('*').forEach((el) => { + for (const attr of [...el.attributes]) { + const name = attr.name.toLowerCase(); + const val = attr.value.replace(/\s+/g, '').toLowerCase(); + if (name.startsWith('on') || ((name === 'href' || name === 'src') && val.startsWith('javascript:'))) { + el.removeAttribute(attr.name); + } + } + }); + return tpl.innerHTML; +} + +export async function openAgreementModal(agreementUuid) { + close(); + + const overlay = document.createElement('div'); + overlay.id = 'agreement-modal'; + overlay.className = 'fixed inset-0 z-[100] flex items-center justify-center bg-black/50 p-lg'; + overlay.addEventListener('click', (e) => { if (e.target === overlay) close(); }); + + const panel = document.createElement('div'); + panel.className = + 'bg-white w-full max-w-3xl max-h-[80vh] rounded-lg border ' + + 'border-surface-border shadow-xl flex flex-col overflow-hidden'; + + const header = document.createElement('div'); + header.className = 'flex items-center justify-between px-lg py-md border-b border-surface-border'; + const heading = document.createElement('h3'); + heading.className = 'font-headline-sm text-headline-sm text-deep-indigo'; + heading.textContent = 'Agreement'; + const actions = document.createElement('div'); + actions.className = 'flex items-center gap-md'; + const retryBtn = document.createElement('button'); + retryBtn.className = 'hidden text-vibrant-purple hover:underline font-label-md text-label-md'; + retryBtn.textContent = 'Retry'; + const closeBtn = document.createElement('button'); + closeBtn.className = 'material-symbols-outlined text-on-surface-variant hover:text-primary cursor-pointer'; + closeBtn.textContent = 'close'; + closeBtn.addEventListener('click', close); + actions.append(retryBtn, closeBtn); + header.append(heading, actions); + + const body = document.createElement('div'); + body.className = 'flex-1 overflow-auto p-lg'; + + panel.append(header, body); + overlay.appendChild(panel); + document.addEventListener('keydown', onKey); + document.body.appendChild(overlay); + + const message = (text, isError) => + `

${text}

`; + + // Always leaves the body in a visible state — loading, content, empty, or a + // clear error with Retry — so a slow/failed request can never show a blank. + async function load() { + retryBtn.classList.add('hidden'); + body.innerHTML = message('Loading…'); + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), TIMEOUT_MS); + try { + const res = await fetch(`/api/dashboard/agreement/${encodeURIComponent(agreementUuid)}`, { + credentials: 'include', + signal: controller.signal, + }); + if (!res.ok) throw new Error(`status ${res.status}`); + const { title, html } = await res.json(); + if (title) heading.textContent = title; + const clean = sanitize(html || ''); + if (clean.trim()) { + ensureStyle(); + body.innerHTML = `
${clean}
`; + } else { + body.innerHTML = message('This agreement has no content.'); + } + } catch (e) { + console.error('agreement', e); + const msg = e.name === 'AbortError' + ? 'This is taking longer than expected.' + : 'Could not load the agreement.'; + body.innerHTML = message(msg, true); + retryBtn.classList.remove('hidden'); + } finally { + clearTimeout(timer); + } + } + + retryBtn.addEventListener('click', load); + await load(); +} diff --git a/frontend/src/resources/detailsModal.js b/frontend/src/resources/detailsModal.js new file mode 100644 index 0000000..42ef8b4 --- /dev/null +++ b/frontend/src/resources/detailsModal.js @@ -0,0 +1,140 @@ +import { openJsonModal } from './jsonModal.js'; +import { openAgreementModal } from './agreementModal.js'; +import { truncateText } from './formatters.js'; +import { toast } from './toast.js'; + +function close() { + document.getElementById('details-modal')?.remove(); + document.removeEventListener('keydown', onKey); +} +function onKey(e) { if (e.key === 'Escape') close(); } + +// A sender/receiver box: role label, full short name, faded DID underneath. +function party(who, accent, roleColor) { + return ` +
+
+ ${who} + +
+
+
+
`; +} + +function shell() { + return ` +
+

Details

+ +
+
+ ${party('sender', 'border-vibrant-purple', 'text-vibrant-purple')} +

+      ${party('receiver', 'border-teal-accent', 'text-teal-accent')}
+
+      
+
+ +
+ +
+
+
Permitted
+
    +
    +
    +
    Prohibited
    +
      +
      +
      + +
      + +
      +
      `; +} + +function fillList(ul, items) { + ul.replaceChildren(); + if (!items || !items.length) { + const li = document.createElement('li'); + li.className = 'text-on-surface-variant/60 italic'; + li.textContent = 'None'; + ul.appendChild(li); + return; + } + for (const item of items) { + const li = document.createElement('li'); + li.textContent = `• ${item}`; + ul.appendChild(li); + } +} + +// Primary line is the short name; the DID sits underneath, faded. With no known +// short name the DID becomes the primary line (no duplicate underneath). +function setParty(panel, who, role, name, did) { + panel.querySelector(`#d-${who}-role`).textContent = role || 'no role'; + const nameEl = panel.querySelector(`#d-${who}-name`); + const didEl = panel.querySelector(`#d-${who}-did`); + if (name) { + nameEl.textContent = name; + didEl.textContent = did || ''; + didEl.title = did || ''; + didEl.classList.toggle('hidden', !did); + } else { + nameEl.textContent = did || '—'; + didEl.classList.add('hidden'); + } +} + +export async function openDetailsModal(eventUuid) { + close(); + + const overlay = document.createElement('div'); + overlay.id = 'details-modal'; + overlay.className = 'fixed inset-0 z-[100] flex items-center justify-center bg-black/50 p-lg'; + overlay.addEventListener('click', (e) => { if (e.target === overlay) close(); }); + + const panel = document.createElement('div'); + panel.className = + 'bg-surface-container-lowest w-[80vw] max-w-none max-h-[80vh] rounded-lg border ' + + 'border-surface-border shadow-xl flex flex-col overflow-hidden'; + panel.innerHTML = shell(); + + overlay.appendChild(panel); + document.addEventListener('keydown', onKey); + document.body.appendChild(overlay); + panel.querySelector('#d-close').addEventListener('click', close); + + let data; + try { + const res = await fetch(`/api/dashboard/details/${encodeURIComponent(eventUuid)}`, { credentials: 'include' }); + if (!res.ok) throw new Error(`status ${res.status}`); + data = await res.json(); + } catch (e) { + console.error('details', e); + close(); + toast('Could not load details'); + return; + } + + const { event = {}, roles = {}, names = {}, agreement = {} } = data; + + setParty(panel, 'sender', roles.sender, names.sender, event.senderId); + setParty(panel, 'receiver', roles.receiver, names.receiver, event.recipientId); + panel.querySelector('#d-data').textContent = JSON.stringify(event.data ?? {}, null, 2); + panel.querySelector('#d-title').textContent = + agreement.title || (event.agreementId ? `Agreement ${truncateText(event.agreementId, 12)}` : 'No agreement'); + + const read = panel.querySelector('#d-read'); + if (agreement.title) { + read.classList.remove('hidden'); + read.addEventListener('click', () => openAgreementModal(event.agreementId)); + } + + fillList(panel.querySelector('#d-permitted'), agreement.permitted); + fillList(panel.querySelector('#d-prohibited'), agreement.prohibited); + + panel.querySelector('#d-raw').addEventListener('click', () => openJsonModal(`Event ${truncateText(event.eventId, 18)}`, event)); +} diff --git a/frontend/src/resources/ee.ts b/frontend/src/resources/ee.ts new file mode 100644 index 0000000..f5d6ca6 --- /dev/null +++ b/frontend/src/resources/ee.ts @@ -0,0 +1,14 @@ +export type EeSlot = + | 'cards' + | 'sidebar' + | 'nav' + | 'toolbar'; + +const modules = import.meta.glob('../ee/*/*.astro', { eager: true }); + +export function eeSlot(slot: EeSlot) { + return Object.entries(modules) + .filter(([path]) => path.includes(`/ee/${slot}/`)) + .sort(([a], [b]) => a.localeCompare(b)) + .map(([, mod]) => (mod as { default: any }).default); +} diff --git a/frontend/src/resources/filters.js b/frontend/src/resources/filters.js new file mode 100644 index 0000000..6de8f95 --- /dev/null +++ b/frontend/src/resources/filters.js @@ -0,0 +1,85 @@ +// Shared filter state that every panel reads from and reacts to. + +const KEY = 'dashboard.filters'; +const EVENT = 'dashboard:filters'; + +function isoDay(d) { + return d.toISOString().slice(0, 10); +} + +function defaults() { + const to = new Date(); + const from = new Date(); + from.setDate(from.getDate() - 29); + return { from: isoDay(from), to: isoDay(to), core: true, audit: true }; +} + +function load() { + try { + const saved = JSON.parse(localStorage.getItem(KEY) || 'null'); + return saved ? { ...defaults(), ...saved } : defaults(); + } catch { + return defaults(); + } +} + +let state = load(); + +export function getFilters() { + return { ...state }; +} + +export function setFilters(patch, { persist = false } = {}) { + state = { ...state, ...patch }; + if (persist) { + try { + localStorage.setItem(KEY, JSON.stringify(state)); + } catch { + /* ignore quota/availability errors */ + } + } + window.dispatchEvent(new CustomEvent(EVENT, { detail: getFilters() })); +} + +export function onFilters(cb) { + window.addEventListener(EVENT, (e) => cb(e.detail)); +} + +export function activeModules() { + const modules = []; + if (state.core) modules.push('core'); + if (state.audit) modules.push('audit'); + return modules; +} + +// Split out from toQuery() so it unit-tests without DOM/localStorage state. +export function buildQuery({ from, to, modules }, extra = {}) { + return new URLSearchParams({ + from, + to, + modules: (modules && modules.length ? modules.join(',') : 'none'), + ...extra, + }).toString(); +} + +export function toQuery(extra = {}) { + const f = getFilters(); + return buildQuery({ from: f.from, to: f.to, modules: activeModules() }, extra); +} + +// Transient and separate, so a keystroke doesn't refetch the chart/summary. +const SEARCH_EVENT = 'dashboard:search'; +let search = ''; + +export function getSearch() { + return search; +} + +export function setSearch(q) { + search = q || ''; + window.dispatchEvent(new CustomEvent(SEARCH_EVENT, { detail: search })); +} + +export function onSearch(cb) { + window.addEventListener(SEARCH_EVENT, (e) => cb(e.detail)); +} diff --git a/frontend/src/resources/filters.test.js b/frontend/src/resources/filters.test.js new file mode 100644 index 0000000..b5ea07a --- /dev/null +++ b/frontend/src/resources/filters.test.js @@ -0,0 +1,21 @@ +import { buildQuery } from "./filters.js"; + +describe("buildQuery", () => { + it("serializes the window + active modules", () => { + const q = buildQuery({ from: "2026-01-01", to: "2026-01-31", modules: ["core", "audit"] }); + const p = new URLSearchParams(q); + expect(p.get("from")).toBe("2026-01-01"); + expect(p.get("to")).toBe("2026-01-31"); + expect(p.get("modules")).toBe("core,audit"); + }); + + it("emits modules=none when nothing is active (server still gets a valid value)", () => { + expect(new URLSearchParams(buildQuery({ from: "a", to: "b", modules: [] })).get("modules")).toBe("none"); + }); + + it("merges extra params (e.g. paging/sort) into the query", () => { + const p = new URLSearchParams(buildQuery({ from: "a", to: "b", modules: ["core"] }, { sort: "date", limit: "50" })); + expect(p.get("sort")).toBe("date"); + expect(p.get("limit")).toBe("50"); + }); +}); diff --git a/frontend/src/resources/formatters.js b/frontend/src/resources/formatters.js new file mode 100644 index 0000000..62e4070 --- /dev/null +++ b/frontend/src/resources/formatters.js @@ -0,0 +1,38 @@ + +export function formatCompact(n) { + return new Intl.NumberFormat('en', { notation: 'compact', maximumFractionDigits: 1 }).format(n || 0); +} + +export function formatBytes(bytes) { + if (!bytes || bytes <= 0) return '0 Bytes'; // negatives would yield "NaN undefined" + const k = 1024; + const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB']; + const i = Math.floor(Math.log(bytes) / Math.log(k)); + return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i]; +} + +export function truncateUuid(uuid) { + if (!uuid) return ''; + return uuid.substring(0, 10) + '...'; +} + +export function truncateText(text, max = 28) { + if (!text) return ''; + return text.length > max ? text.slice(0, max) + '…' : text; +} + +export function formatDate(dateString) { + if (!dateString) return '—'; + const d = new Date(dateString); + if (isNaN(d.getTime())) return '—'; + const pad = (num) => String(num).padStart(2, '0'); + + const year = d.getFullYear(); + const month = pad(d.getMonth() + 1); + const day = pad(d.getDate()); + const hours = pad(d.getHours()); + const minutes = pad(d.getMinutes()); + const seconds = pad(d.getSeconds()); + + return `${year}/${month}/${day} ${hours}:${minutes}:${seconds}`; +} diff --git a/frontend/src/resources/formatters.test.js b/frontend/src/resources/formatters.test.js new file mode 100644 index 0000000..4304ff2 --- /dev/null +++ b/frontend/src/resources/formatters.test.js @@ -0,0 +1,43 @@ +import { formatCompact, formatBytes, truncateUuid, formatDate } from "./formatters.js"; + +describe("formatBytes", () => { + it("formats zero, falsy, and negatives as '0 Bytes' (never 'NaN undefined')", () => { + expect(formatBytes(0)).toBe("0 Bytes"); + expect(formatBytes(undefined)).toBe("0 Bytes"); + expect(formatBytes(-500)).toBe("0 Bytes"); + }); + + it("scales to the right unit", () => { + expect(formatBytes(1024)).toBe("1 KB"); + expect(formatBytes(1536)).toBe("1.5 KB"); + expect(formatBytes(1048576)).toBe("1 MB"); + }); +}); + +describe("truncateUuid", () => { + it("returns '' for empty and a 10-char prefix otherwise", () => { + expect(truncateUuid("")).toBe(""); + expect(truncateUuid(null)).toBe(""); + expect(truncateUuid("19c3680c-aaaa-bbbb-cccc-dddddddddddd")).toBe("19c3680c-a..."); + }); +}); + +describe("formatCompact", () => { + it("compacts large numbers", () => { + expect(formatCompact(0)).toBe("0"); + expect(formatCompact(2591)).toBe("2.6K"); + expect(formatCompact(1_300_000)).toBe("1.3M"); + }); +}); + +describe("formatDate", () => { + it("renders YYYY/MM/DD HH:MM:SS", () => { + expect(formatDate("2026-06-01T12:34:56Z")).toMatch(/^\d{4}\/\d{2}\/\d{2} \d{2}:\d{2}:\d{2}$/); + }); + + it("renders an em dash for missing or unparseable input (never NaN)", () => { + expect(formatDate(null)).toBe("—"); + expect(formatDate(undefined)).toBe("—"); + expect(formatDate("not-a-date")).toBe("—"); + }); +}); diff --git a/frontend/src/resources/jsonModal.js b/frontend/src/resources/jsonModal.js new file mode 100644 index 0000000..ecc78d8 --- /dev/null +++ b/frontend/src/resources/jsonModal.js @@ -0,0 +1,63 @@ +// Reusable modal for inspecting raw JSON (e.g. the "View JSON" row action). +// Renders the payload with textContent only — never innerHTML — so untrusted +// event data can't inject markup. + +function close() { + document.getElementById('json-modal')?.remove(); + document.removeEventListener('keydown', onKey); +} + +function onKey(e) { + if (e.key === 'Escape') close(); +} + +/** + * Open a modal showing pretty-printed JSON. + * @param {string} title + * @param {unknown} data Any JSON-serializable value (or a raw string). + */ +export function openJsonModal(title, data) { + close(); // keep a single instance + + const overlay = document.createElement('div'); + overlay.id = 'json-modal'; + overlay.className = 'fixed inset-0 z-[100] flex items-center justify-center bg-black/50 p-lg'; + overlay.addEventListener('click', (e) => { + if (e.target === overlay) close(); + }); + + const panel = document.createElement('div'); + panel.className = + 'bg-surface-container-lowest w-full max-w-2xl max-h-[80vh] rounded-lg border ' + + 'border-surface-border shadow-xl flex flex-col overflow-hidden'; + + const header = document.createElement('div'); + header.className = 'flex items-center justify-between px-lg py-md border-b border-surface-border'; + + const heading = document.createElement('h3'); + heading.className = 'font-headline-sm text-headline-sm text-deep-indigo'; + heading.textContent = title; + + const closeBtn = document.createElement('button'); + closeBtn.className = 'material-symbols-outlined text-on-surface-variant hover:text-primary cursor-pointer'; + closeBtn.textContent = 'close'; + closeBtn.addEventListener('click', close); + + header.append(heading, closeBtn); + + // `whitespace-pre` (not pre-wrap): wrapping a multi-MB single-line payload + // freezes the main thread. Also cap the rendered text so a huge event_data + // blob can't lock up the page — show a truncation note instead. + const RENDER_LIMIT = 200_000; + const full = typeof data === 'string' ? data : JSON.stringify(data, null, 2); + const body = document.createElement('pre'); + body.className = 'overflow-auto p-lg text-label-sm font-label-sm text-on-surface whitespace-pre'; + body.textContent = full.length > RENDER_LIMIT + ? `${full.slice(0, RENDER_LIMIT)}\n\n… truncated — ${Math.round(full.length / 1024)} KB total` + : full; + + panel.append(header, body); + overlay.appendChild(panel); + document.addEventListener('keydown', onKey); + document.body.appendChild(overlay); +} diff --git a/frontend/src/resources/lineChart.js b/frontend/src/resources/lineChart.js new file mode 100644 index 0000000..7808509 --- /dev/null +++ b/frontend/src/resources/lineChart.js @@ -0,0 +1,163 @@ +import * as d3 from 'd3'; + +// Reusable primitive: treat labels/colors as untrusted in the tooltip HTML. +const esc = (s) => String(s ?? '').replace(/[&<>"']/g, (c) => + ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c])); + +/** + * Reusable, responsive multi-series time-series line chart with optional dual + * Y-axes and a hover tooltip. The one chart primitive the dashboard reuses. + * + * @param {HTMLElement} el Container element (gets `position: relative`). + * @param {object} opts + * @param {Array<{date: string} & Record>} opts.data + * Rows oldest→newest; `date` is `YYYY-MM-DD`, plus one numeric field per series key. + * @param {Array<{key: string, label: string, color: string, dashed?: boolean, axis?: 'left'|'right'}>} opts.series + * @param {(n: number) => string} [opts.formatLeft] Left-axis / tooltip formatter (default SI). + * @param {(n: number) => string} [opts.formatRight] Right-axis / tooltip formatter (default SI). + * @returns {{ destroy: () => void }} + */ +export function timeSeriesChart(el, { data, series, formatLeft = d3.format('~s'), formatRight = d3.format('~s') }) { + const hasRight = series.some((s) => s.axis === 'right'); + const margin = { top: 16, right: hasRight ? 56 : 24, bottom: 28, left: 48 }; + const parseDate = d3.utcParse('%Y-%m-%d'); + const rows = data.map((d) => ({ ...d, _date: parseDate(d.date) })); + const fmt = (s) => (s.axis === 'right' ? formatRight : formatLeft); + + el.style.position = 'relative'; + + const tip = document.createElement('div'); + tip.className = + 'pointer-events-none absolute z-10 hidden rounded-lg border border-surface-border ' + + 'bg-surface-container-lowest px-sm py-xs shadow-lg space-y-0.5'; + el.appendChild(tip); + + const svg = d3.select(el).append('svg').attr('width', '100%'); + const bisect = d3.bisector((d) => d._date).center; + let observer; + + function scaleFor(axis, height) { + const keys = series.filter((s) => (s.axis === 'right') === (axis === 'right')).map((s) => s.key); + const max = d3.max(rows, (d) => d3.max(keys, (k) => d[k])) || 1; + return d3.scaleLinear().domain([0, max]).nice().range([height - margin.bottom, margin.top]); + } + + function styleTicks(g) { + g.selectAll('.tick text').attr('fill', '#494551').attr('font-family', 'JetBrains Mono').attr('font-size', 11); + } + + function render() { + const width = el.clientWidth || 640; + const height = el.clientHeight || 320; + svg.attr('height', height).selectAll('*').remove(); + + const x = d3.scaleUtc().domain(d3.extent(rows, (d) => d._date)).range([margin.left, width - margin.right]); + const yLeft = scaleFor('left', height); + const yRight = hasRight ? scaleFor('right', height) : null; + const yFor = (s) => (s.axis === 'right' ? yRight : yLeft); + + // Left axis + horizontal grid + svg + .append('g') + .attr('transform', `translate(${margin.left},0)`) + .call(d3.axisLeft(yLeft).ticks(5).tickSize(-(width - margin.left - margin.right)).tickFormat(formatLeft)) + .call((g) => g.select('.domain').remove()) + .call((g) => g.selectAll('.tick line').attr('stroke', '#E2E8F0').attr('stroke-dasharray', '2,2')) + .call(styleTicks); + + // Right axis (no grid) + if (hasRight) { + svg + .append('g') + .attr('transform', `translate(${width - margin.right},0)`) + .call(d3.axisRight(yRight).ticks(5).tickFormat(formatRight)) + .call((g) => g.select('.domain').remove()) + .call((g) => g.selectAll('.tick line').remove()) + .call(styleTicks); + } + + // X axis + svg + .append('g') + .attr('transform', `translate(0,${height - margin.bottom})`) + .call(d3.axisBottom(x).ticks(6).tickFormat(d3.utcFormat('%b %d')).tickSizeOuter(0)) + .call((g) => g.select('.domain').attr('stroke', '#E2E8F0')) + .call((g) => g.selectAll('.tick line').attr('stroke', '#E2E8F0')) + .call(styleTicks); + + // Lines + for (const s of series) { + const y = yFor(s); + svg + .append('path') + .datum(rows) + .attr('fill', 'none') + .attr('stroke', s.color) + .attr('stroke-width', s.key === 'total' ? 3 : 2) + .attr('stroke-dasharray', s.dashed ? '5,4' : null) + .attr('d', d3.line().x((d) => x(d._date)).y((d) => y(d[s.key])).curve(d3.curveMonotoneX)); + } + + // Hover layer + const focus = svg.append('g').style('display', 'none'); + focus + .append('line') + .attr('y1', margin.top) + .attr('y2', height - margin.bottom) + .attr('stroke', '#cbc4d2') + .attr('stroke-dasharray', '3,3'); + const dots = series.map((s) => + focus.append('circle').attr('r', 4).attr('fill', s.color).attr('stroke', '#fff').attr('stroke-width', 1.5) + ); + + svg + .append('rect') + .attr('x', margin.left) + .attr('y', margin.top) + .attr('width', Math.max(0, width - margin.left - margin.right)) + .attr('height', Math.max(0, height - margin.top - margin.bottom)) + .attr('fill', 'transparent') + .on('mouseenter', () => { + focus.style('display', null); + tip.classList.remove('hidden'); + }) + .on('mouseleave', () => { + focus.style('display', 'none'); + tip.classList.add('hidden'); + }) + .on('mousemove', (event) => { + const mx = d3.pointer(event)[0]; + const d = rows[bisect(rows, x.invert(mx))]; + if (!d) return; + focus.select('line').attr('x1', x(d._date)).attr('x2', x(d._date)); + series.forEach((s, i) => dots[i].attr('cx', x(d._date)).attr('cy', yFor(s)(d[s.key]))); + tip.innerHTML = + `
      ${d3.utcFormat('%b %d, %Y')(d._date)}
      ` + + series + .map( + (s) => + `
      ` + + `` + + `${esc(s.label)}: ${fmt(s)(d[s.key])}
      ` + ) + .join(''); + const left = Math.min(x(d._date) + 12, width - tip.offsetWidth - 8); + tip.style.left = `${Math.max(margin.left, left)}px`; + tip.style.top = `${margin.top}px`; + }); + } + + render(); + if (typeof ResizeObserver !== 'undefined') { + observer = new ResizeObserver(render); + observer.observe(el); + } + + return { + destroy() { + observer?.disconnect(); + svg.remove(); + tip.remove(); + }, + }; +} diff --git a/frontend/src/resources/toast.js b/frontend/src/resources/toast.js new file mode 100644 index 0000000..c21b700 --- /dev/null +++ b/frontend/src/resources/toast.js @@ -0,0 +1,26 @@ +// Minimal toast notifications — used to surface fetch/load errors instead of +// failing silently to the console. + +function host() { + let el = document.getElementById('toast-host'); + if (!el) { + el = document.createElement('div'); + el.id = 'toast-host'; + el.className = 'fixed bottom-4 right-4 z-[200] flex flex-col gap-2 pointer-events-none'; + document.body.appendChild(el); + } + return el; +} + +/** + * @param {string} message + * @param {'error'|'info'} [type] + */ +export function toast(message, type = 'error') { + const colors = type === 'error' ? 'border-error text-error' : 'border-surface-border text-on-surface'; + const el = document.createElement('div'); + el.className = `bg-surface-container-lowest border ${colors} rounded-lg shadow-lg px-md py-sm text-label-md font-label-md`; + el.textContent = message; + host().appendChild(el); + setTimeout(() => el.remove(), 4000); +} diff --git a/frontend/src/resources/verifyModal.js b/frontend/src/resources/verifyModal.js new file mode 100644 index 0000000..0c4e168 --- /dev/null +++ b/frontend/src/resources/verifyModal.js @@ -0,0 +1,104 @@ +const CHECKS = [ + ['validId', 'The IDs match between the audit and originating record'], + ['validSignature', 'The signature on the original record is valid'], + ['validAuditHash', 'The hash from the audit record matches the originating signed record'], + ['validAuditSignature', 'The signature on the audit record is valid'], + ['validMatchingDids', 'The DIDs that signed match on the original and audit records'], + ['validEventAgreement', 'If an event, it contains a valid agreement'], + ['validEventAgreementSignature', 'The agreement has been signed by the DID that signed the originating record'], +]; + +function close() { + document.getElementById('verify-modal')?.remove(); + document.removeEventListener('keydown', onKey); +} + +function onKey(e) { + if (e.key === 'Escape') close(); +} + +// One check row: status icon + description. `value` is true / false / undefined +// (undefined = the verifier didn't report this check, e.g. agreement checks on a +// non-event record) — shown as a muted "N/A" so it reads differently from a fail. +function checkRow(description, value) { + const row = document.createElement('div'); + row.className = 'flex items-start gap-sm px-lg py-sm border-b border-surface-border last:border-b-0'; + + const icon = document.createElement('span'); + icon.className = 'material-symbols-outlined text-[20px] shrink-0'; + if (value === true) { + icon.textContent = 'check_circle'; + icon.classList.add('text-teal-accent'); + } else if (value === false) { + icon.textContent = 'cancel'; + icon.classList.add('text-error'); + } else { + icon.textContent = 'remove'; + icon.classList.add('text-on-surface-variant'); + } + + const text = document.createElement('span'); + text.className = 'font-label-md text-label-md text-on-surface'; + text.textContent = description; + + const state = document.createElement('span'); + state.className = 'ml-auto pl-md font-label-sm text-label-sm shrink-0 ' + + (value === true ? 'text-teal-accent' : value === false ? 'text-error' : 'text-on-surface-variant'); + state.textContent = value === true ? 'Pass' : value === false ? 'Fail' : 'N/A'; + + row.append(icon, text, state); + return row; +} + +/** + * Open the verification-detail modal for one event. + * @param {string} title + * @param {{ verified?: boolean, signatureCount?: number, checks?: Record | null, reason?: string }} result + */ +export function openVerifyModal(title, result) { + close(); // single instance + + const overlay = document.createElement('div'); + overlay.id = 'verify-modal'; + overlay.className = 'fixed inset-0 z-[100] flex items-center justify-center bg-black/50 p-lg'; + overlay.addEventListener('click', (e) => { + if (e.target === overlay) close(); + }); + + const panel = document.createElement('div'); + panel.className = + 'bg-surface-container-lowest w-full max-w-2xl max-h-[80vh] rounded-lg border ' + + 'border-surface-border shadow-xl flex flex-col overflow-hidden'; + + const header = document.createElement('div'); + header.className = 'flex items-center justify-between px-lg py-md border-b border-surface-border'; + + const heading = document.createElement('h3'); + heading.className = 'font-headline-sm text-headline-sm text-deep-indigo'; + heading.textContent = title; + + const closeBtn = document.createElement('button'); + closeBtn.className = 'material-symbols-outlined text-on-surface-variant hover:text-primary cursor-pointer'; + closeBtn.textContent = 'close'; + closeBtn.addEventListener('click', close); + + header.append(heading, closeBtn); + + const body = document.createElement('div'); + body.className = 'overflow-auto'; + + const checks = result?.checks; + if (!checks) { + const empty = document.createElement('p'); + empty.className = 'px-lg py-xl text-center font-label-md text-label-md text-on-surface-variant'; + empty.textContent = result?.reason || 'No audit record — nothing to verify.'; + body.appendChild(empty); + } else { + for (const [key, description] of CHECKS) body.appendChild(checkRow(description, checks[key])); + } + + panel.append(header, body); + overlay.appendChild(panel); + document.addEventListener('keydown', onKey); + document.body.appendChild(overlay); +} diff --git a/frontend/src/styles/fonts.css b/frontend/src/styles/fonts.css new file mode 100644 index 0000000..d6e134e --- /dev/null +++ b/frontend/src/styles/fonts.css @@ -0,0 +1,179 @@ +/* Self-hosted fonts (downloaded from Google Fonts, served locally). */ +/* latin-ext */ +@font-face { + font-family: 'IBM Plex Sans'; + font-style: normal; + font-weight: 400; + font-stretch: 100%; + font-display: swap; + src: url(/fonts/zYXzKVElMYYaJe8bpLHnCwDKr932-G7dytD-Dmu1syxQKYbABA.woff2) format('woff2'); + unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF; +} +/* latin */ +@font-face { + font-family: 'IBM Plex Sans'; + font-style: normal; + font-weight: 400; + font-stretch: 100%; + font-display: swap; + src: url(/fonts/zYXzKVElMYYaJe8bpLHnCwDKr932-G7dytD-Dmu1syxeKYY.woff2) format('woff2'); + unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD; +} +/* latin-ext */ +@font-face { + font-family: 'IBM Plex Sans'; + font-style: normal; + font-weight: 500; + font-stretch: 100%; + font-display: swap; + src: url(/fonts/zYXzKVElMYYaJe8bpLHnCwDKr932-G7dytD-Dmu1syxQKYbABA.woff2) format('woff2'); + unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF; +} +/* latin */ +@font-face { + font-family: 'IBM Plex Sans'; + font-style: normal; + font-weight: 500; + font-stretch: 100%; + font-display: swap; + src: url(/fonts/zYXzKVElMYYaJe8bpLHnCwDKr932-G7dytD-Dmu1syxeKYY.woff2) format('woff2'); + unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD; +} +/* latin-ext */ +@font-face { + font-family: 'IBM Plex Sans'; + font-style: normal; + font-weight: 600; + font-stretch: 100%; + font-display: swap; + src: url(/fonts/zYXzKVElMYYaJe8bpLHnCwDKr932-G7dytD-Dmu1syxQKYbABA.woff2) format('woff2'); + unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF; +} +/* latin */ +@font-face { + font-family: 'IBM Plex Sans'; + font-style: normal; + font-weight: 600; + font-stretch: 100%; + font-display: swap; + src: url(/fonts/zYXzKVElMYYaJe8bpLHnCwDKr932-G7dytD-Dmu1syxeKYY.woff2) format('woff2'); + unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD; +} +/* latin-ext */ +@font-face { + font-family: 'IBM Plex Sans'; + font-style: normal; + font-weight: 700; + font-stretch: 100%; + font-display: swap; + src: url(/fonts/zYXzKVElMYYaJe8bpLHnCwDKr932-G7dytD-Dmu1syxQKYbABA.woff2) format('woff2'); + unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF; +} +/* latin */ +@font-face { + font-family: 'IBM Plex Sans'; + font-style: normal; + font-weight: 700; + font-stretch: 100%; + font-display: swap; + src: url(/fonts/zYXzKVElMYYaJe8bpLHnCwDKr932-G7dytD-Dmu1syxeKYY.woff2) format('woff2'); + unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD; +} +/* latin-ext */ +@font-face { + font-family: 'Inter'; + font-style: normal; + font-weight: 400; + font-display: swap; + src: url(/fonts/UcC73FwrK3iLTeHuS_nVMrMxCp50SjIa25L7SUc.woff2) format('woff2'); + unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF; +} +/* latin */ +@font-face { + font-family: 'Inter'; + font-style: normal; + font-weight: 400; + font-display: swap; + src: url(/fonts/UcC73FwrK3iLTeHuS_nVMrMxCp50SjIa1ZL7.woff2) format('woff2'); + unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD; +} +/* latin-ext */ +@font-face { + font-family: 'Inter'; + font-style: normal; + font-weight: 500; + font-display: swap; + src: url(/fonts/UcC73FwrK3iLTeHuS_nVMrMxCp50SjIa25L7SUc.woff2) format('woff2'); + unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF; +} +/* latin */ +@font-face { + font-family: 'Inter'; + font-style: normal; + font-weight: 500; + font-display: swap; + src: url(/fonts/UcC73FwrK3iLTeHuS_nVMrMxCp50SjIa1ZL7.woff2) format('woff2'); + unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD; +} +/* latin-ext */ +@font-face { + font-family: 'Inter'; + font-style: normal; + font-weight: 600; + font-display: swap; + src: url(/fonts/UcC73FwrK3iLTeHuS_nVMrMxCp50SjIa25L7SUc.woff2) format('woff2'); + unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF; +} +/* latin */ +@font-face { + font-family: 'Inter'; + font-style: normal; + font-weight: 600; + font-display: swap; + src: url(/fonts/UcC73FwrK3iLTeHuS_nVMrMxCp50SjIa1ZL7.woff2) format('woff2'); + unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD; +} +/* latin-ext */ +@font-face { + font-family: 'JetBrains Mono'; + font-style: normal; + font-weight: 400; + font-display: swap; + src: url(/fonts/tDbv2o-flEEny0FZhsfKu5WU4zr3E_BX0PnT8RD8yKwBNntkaToggR7BYRbKPx7cwhsk.woff2) format('woff2'); + unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF; +} +/* latin */ +@font-face { + font-family: 'JetBrains Mono'; + font-style: normal; + font-weight: 400; + font-display: swap; + src: url(/fonts/tDbv2o-flEEny0FZhsfKu5WU4zr3E_BX0PnT8RD8yKwBNntkaToggR7BYRbKPxDcwg.woff2) format('woff2'); + unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD; +} +/* latin-ext */ +@font-face { + font-family: 'JetBrains Mono'; + font-style: normal; + font-weight: 500; + font-display: swap; + src: url(/fonts/tDbv2o-flEEny0FZhsfKu5WU4zr3E_BX0PnT8RD8yKwBNntkaToggR7BYRbKPx7cwhsk.woff2) format('woff2'); + unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF; +} +/* latin */ +@font-face { + font-family: 'JetBrains Mono'; + font-style: normal; + font-weight: 500; + font-display: swap; + src: url(/fonts/tDbv2o-flEEny0FZhsfKu5WU4zr3E_BX0PnT8RD8yKwBNntkaToggR7BYRbKPxDcwg.woff2) format('woff2'); + unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD; +} +/* fallback */ +@font-face { + font-family: 'Material Symbols Outlined'; + font-style: normal; + font-weight: 100 700; + font-display: swap; + src: url(/fonts/kJEhBvYX7BgnkSrUwT8OhrdQw4oELdPIeeII9v6oFsI.woff2) format('woff2'); +} diff --git a/frontend/src/styles/global.css b/frontend/src/styles/global.css new file mode 100644 index 0000000..6683b20 --- /dev/null +++ b/frontend/src/styles/global.css @@ -0,0 +1,145 @@ +@import "tailwindcss"; + +@theme { + --color-primary-fixed-dim: #cfbcff; + --color-error-container: #ffdad6; + --color-on-tertiary-container: #bdade2; + --color-tertiary-container: #4c3f6c; + --color-on-background: #191c1e; + --color-outline: #7a7582; + --color-surface-dim: #d9dadc; + --color-on-primary-container: #c0a8ff; + --color-secondary-fixed-dim: #68d6e7; + --color-secondary: #006874; + --color-on-secondary-fixed-variant: #004f58; + --color-tertiary: #362954; + --color-inverse-primary: #cfbcff; + --color-secondary-container: #80ecfe; + --color-primary: #381e73; + --color-vibrant-purple: #4F378B; + --color-primary-container: #4f378b; + --color-on-tertiary-fixed-variant: #4c3f6c; + --color-teal-accent: #31A9BA; + --color-on-surface: #191c1e; + --color-primary-fixed: #e9ddff; + --color-error: #ba1a1a; + --color-deep-indigo: #231641; + --color-outline-variant: #cbc4d2; + --color-on-secondary-container: #006b77; + --color-on-error: #ffffff; + --color-inverse-surface: #2e3132; + --color-surface-container-low: #f2f4f6; + --color-on-tertiary: #ffffff; + --color-surface-container-highest: #e1e2e4; + --color-on-secondary: #ffffff; + --color-on-surface-variant: #494551; + --color-on-primary-fixed-variant: #4f378b; + --color-secondary-fixed: #9af0ff; + --color-surface-container-high: #e7e8ea; + --color-surface-tint: #674fa5; + --color-surface-border: #E2E8F0; + --color-on-primary: #ffffff; + --color-on-error-container: #93000a; + --color-surface-variant: #e1e2e4; + --color-inverse-on-surface: #f0f1f3; + --color-on-tertiary-fixed: #20133e; + --color-background: #f8f9fb; + --color-on-primary-fixed: #22005d; + --color-on-secondary-fixed: #001f24; + --color-tertiary-fixed: #e9ddff; + --color-surface: #f8f9fb; + --color-surface-container-lowest: #ffffff; + --color-surface-bright: #f8f9fb; + --color-tertiary-fixed-dim: #cfbef4; + --color-surface-container: #edeef0; + + --spacing-md: 16px; + --spacing-container-max: 1440px; + --spacing-gutter: 24px; + --spacing-sm: 8px; + --spacing-base: 4px; + --spacing-lg: 24px; + --spacing-xs: 4px; + --spacing-xl: 32px; + + --font-label-md: "JetBrains Mono"; + --font-body-md: "Inter"; + --font-headline-lg: "IBM Plex Sans"; + --font-body-lg: "Inter"; + --font-headline-sm: "IBM Plex Sans"; + --font-headline-lg-mobile: "IBM Plex Sans"; + --font-headline-md: "IBM Plex Sans"; + --font-label-sm: "JetBrains Mono"; + + /* Font Size Customization with Line Height, Weight, and Letter Spacing */ + --text-label-md: 13px; + --text-label-md--line-height: 16px; + --text-label-md--font-weight: 500; + + --text-body-md: 14px; + --text-body-md--line-height: 20px; + --text-body-md--font-weight: 400; + + --text-headline-lg: 32px; + --text-headline-lg--line-height: 40px; + --text-headline-lg--letter-spacing: -0.02em; + --text-headline-lg--font-weight: 600; + + --text-body-lg: 16px; + --text-body-lg--line-height: 24px; + --text-body-lg--font-weight: 400; + + --text-headline-sm: 18px; + --text-headline-sm--line-height: 24px; + --text-headline-sm--font-weight: 600; + + --text-headline-lg-mobile: 26px; + --text-headline-lg-mobile--line-height: 32px; + --text-headline-lg-mobile--font-weight: 600; + + --text-headline-md: 24px; + --text-headline-md--line-height: 32px; + --text-headline-md--letter-spacing: -0.01em; + --text-headline-md--font-weight: 600; + + --text-label-sm: 11px; + --text-label-sm--line-height: 14px; + --text-label-sm--letter-spacing: 0.05em; + --text-label-sm--font-weight: 500; + + /* borderRadius: "lg": "0.25rem" → */ + --radius-default: 0.125rem; + --radius-lg: 0.25rem; + --radius-xl: 0.5rem; + --radius-full: 0.75rem; +} + +.tab-active { border-bottom: 2px solid #4F378B; color: #4F378B; font-weight: 600; } + +.material-symbols-outlined { + font-family: 'Material Symbols Outlined'; + font-weight: normal; + font-style: normal; + line-height: 1; + letter-spacing: normal; + text-transform: none; + display: inline-block; + white-space: nowrap; + word-wrap: normal; + direction: ltr; + /* render the icon-name ligatures (e.g. "add" -> the + glyph) */ + font-feature-settings: 'liga'; + -webkit-font-feature-settings: 'liga'; + -webkit-font-smoothing: antialiased; + font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 24; +} + +.chart-grid { + background-image: linear-gradient(to right, rgba(226, 232, 240, 0.5) 1px, transparent 1px), + linear-gradient(to bottom, rgba(226, 232, 240, 0.5) 1px, transparent 1px); + background-size: 40px 40px; +} + +::-webkit-scrollbar { width: 6px; height: 6px; } +::-webkit-scrollbar-track { background: #f8f9fb; } +::-webkit-scrollbar-thumb { background: #cbc4d2; border-radius: 3px; } diff --git a/frontend/tests/e2e/smoke.spec.js b/frontend/tests/e2e/smoke.spec.js new file mode 100644 index 0000000..d723be0 --- /dev/null +++ b/frontend/tests/e2e/smoke.spec.js @@ -0,0 +1,131 @@ +import { test, expect } from "@playwright/test"; + +// API responses mocked in the SAME envelope shapes the real backend returns +// (verified against /api/v1/data/dashboard/*). The smoke asserts each panel +// consumes the contract and renders DATA — and that interactions (verify) work — +// not just that containers exist. +const EVENT_ID = "f6a042ca-efbf-4c4d-b99c-dac41373eb63"; + +const MOCKS = { + "/api/dashboard/whoami": { username: "dev", modules: ["core", "archive"] }, + "/api/dashboard/summary": { + apiUsage: { core: 2005, audit: 2002, total: 4007 }, + apiKeys: 2, + endpoints: 2, + storage: { + onDisk: { core: 353712, audit: 1321330, total: 1675042 }, + logical: { core: 349712, audit: 1311320, total: 1661032 }, + }, + }, + "/api/dashboard/usage-series": { + series: [ + { date: "2026-06-20", core: 76, audit: 0, total: 76 }, + { date: "2026-06-21", core: 64, audit: 0, total: 64 }, + ], + }, + "/api/dashboard/storage-series": { + series: [ + { date: "2026-06-20", coreDisk: 322617, auditDisk: 0, totalDisk: 322617 }, + { date: "2026-06-21", coreDisk: 333904, auditDisk: 0, totalDisk: 333904 }, + ], + }, + "/api/dashboard/keys": [], +}; + +const coreRow = { + eventUuid: EVENT_ID, + sender: "did:jlinc:sender", + receiver: "did:jlinc:receiver", + date: "2026-06-23T11:37:40.664Z", + agreementUuid: "9ab250b9-b3e3-48aa-1111-222233334444", + size: 655, +}; +// Two audit records for ONE event (produce + process): same eventUuid, distinct +// auditId. On the audit tab it is auditId — not eventUuid — that identifies a row. +const auditRows = [ + { ...coreRow, auditId: 9, auditDigest: "572b052a11aa" }, + { ...coreRow, auditId: 10, auditDigest: "4029d4a6da22" }, +]; + +test.beforeEach(async ({ page }) => { + await page.route("**/api/dashboard/**", async (route) => { + const url = new URL(route.request().url()); + const path = url.pathname; + if (path === "/api/dashboard/transactions") { + const audit = url.searchParams.get("type") === "audit"; + return route.fulfill({ + json: audit + ? { rows: auditRows, total: 2, limit: 50, offset: 0 } + : { rows: [coreRow], total: 1, limit: 50, offset: 0 }, + }); + } + if (path === "/api/dashboard/verify") { + const list = (k) => (url.searchParams.get(k) || "").split(",").filter(Boolean); + const pass = (extra) => ({ status: "verified", verified: true, signatureCount: 1, auditCount: 1, checks: { validId: true }, ...extra }); + return route.fulfill({ + json: { + results: [ + ...list("ids").map((eventUuid) => pass({ eventUuid, auditId: null })), + // Audit 9 passes, audit 10 fails. Two rows of the SAME event getting + // different verdicts is only expressible if each row is its own + // verification target. + ...list("auditIds").map(Number).map((auditId) => + auditId === 9 + ? pass({ eventUuid: EVENT_ID, auditId }) + : { eventUuid: EVENT_ID, auditId, status: "invalid", verified: false, signatureCount: 1, auditCount: 1, checks: { validAuditHash: false } }), + ], + }, + }); + } + if (Object.prototype.hasOwnProperty.call(MOCKS, path)) return route.fulfill({ json: MOCKS[path] }); + return route.fulfill({ json: {} }); + }); +}); + +test("dashboard panels render real data from the API contract", async ({ page }) => { + await page.goto("/"); + await expect(page.locator("#api-usage-total")).toHaveText(/\d/, { timeout: 10_000 }); + await expect(page.locator("#storage-total")).toHaveText(/\d/); + await expect(page.locator("#usage-chart svg")).toBeAttached({ timeout: 10_000 }); + await expect(page.locator("#transaction-rows tr[data-event-id]")).toHaveCount(1); +}); + +test("Audit tab: verifying one row badges ONLY that row", async ({ page }) => { + await page.goto("/"); + await page.locator("#tab-audit").click(); + // Two audit records share one event UUID; each is its own row. + const rows = page.locator("#transaction-rows tr[data-event-id]"); + await expect(rows).toHaveCount(2); + + // The bug: clicking Verify on one row badged BOTH, because rows were keyed by + // the event UUID they share. The request must target the row's audit id. + const [request] = await Promise.all([ + page.waitForRequest((r) => r.url().includes("/api/dashboard/verify")), + rows.nth(0).locator(".btn-verify").click(), + ]); + expect(new URL(request.url()).searchParams.get("auditIds")).toBe("9"); + + await expect(rows.nth(0).locator(".verify-badge")).toContainText("Verified"); + await expect(rows.nth(1).locator(".verify-badge")).toBeHidden(); +}); + +test("Audit tab: Validate All gives each audit row its own verdict", async ({ page }) => { + await page.goto("/"); + await page.locator("#tab-audit").click(); + await expect(page.locator("#transaction-rows tr[data-event-id]")).toHaveCount(2); + + await page.locator("#validate-all").click(); + + // Same event, different audit records -> independent results. A per-event + // verification could not produce two different badges here. + const rows = page.locator("#transaction-rows tr[data-event-id]"); + await expect(rows.nth(0).locator(".verify-badge")).toContainText("Verified"); + await expect(rows.nth(1).locator(".verify-badge")).toContainText("Invalid"); +}); + +test("sidebar switches to the API Keys view", async ({ page }) => { + await page.goto("/"); + await expect(page.locator("#api-keys-view")).toBeAttached(); + await page.locator("#nav-api-keys").click(); + await expect(page.locator("#api-keys-view")).toBeVisible(); +}); diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json new file mode 100644 index 0000000..8bf91d3 --- /dev/null +++ b/frontend/tsconfig.json @@ -0,0 +1,5 @@ +{ + "extends": "astro/tsconfigs/strict", + "include": [".astro/types.d.ts", "**/*"], + "exclude": ["dist"] +} diff --git a/frontend/vitest.config.js b/frontend/vitest.config.js new file mode 100644 index 0000000..4554eba --- /dev/null +++ b/frontend/vitest.config.js @@ -0,0 +1,11 @@ +import { defineConfig } from "vitest/config"; + +// Unit tests for the pure resource modules (formatters, filter-state query +// building). Astro components and DOM glue are covered by the Playwright smoke. +export default defineConfig({ + test: { + globals: true, + environment: "node", + include: ["src/**/*.test.js"], + }, +});