From 3668a6b71fbceaf7d0fceb52014ef799cb39c267 Mon Sep 17 00:00:00 2001 From: Travis Rich Date: Sun, 10 May 2026 10:42:37 -0400 Subject: [PATCH 01/23] First pass refactor --- .github/workflows/deploy-mirror.yml | 14 +- .github/workflows/deploy.yml | 12 +- .gitignore | 21 +- Caddyfile | 30 +- Dockerfile | 48 +- astro.config.mjs | 23 - .../2024-04-27-institutional-repositories.md | 1 - .../blog/2024-04-27-underlay-revived.md | 1 - .../blog/2026-04-28-atproto-integration.md | 1 - .../blog/2026-04-30-schema-evolution.md | 1 - dev.sh | 52 +- docker-compose.local.yml | 61 +- docker-compose.mirror.yml | 144 - docker-compose.yml | 33 +- docker-entrypoint.dev.sh | 15 - dprint.json | 13 + index.html | 20 + oxlintrc.json | 8 + package.json | 47 +- pnpm-lock.yaml | 4516 +++++++++++++++++ pnpm-workspace.yaml | 5 + server.ts | 157 + src/App.tsx | 76 + src/api/accounts.ts | 1180 +++++ src/api/admin.ts | 132 + src/api/ark-middleware.server.ts | 101 + src/api/ark.ts | 514 ++ src/api/auth.server.ts | 131 + src/api/collections.ts | 526 ++ src/api/files.ts | 262 + src/api/health.ts | 9 + src/api/plugins/auth.ts | 117 - src/api/query.ts | 394 ++ src/api/routes/accounts.ts | 1318 ----- src/api/routes/admin.ts | 116 - src/api/routes/ark.ts | 529 -- src/api/routes/collections.ts | 515 -- src/api/routes/files.ts | 279 - src/api/routes/health.ts | 7 - src/api/routes/query.ts | 394 -- src/api/routes/schemas.ts | 387 -- src/api/routes/uploads.ts | 911 ---- src/api/routes/versions.ts | 1017 ---- src/api/schemas.ts | 382 ++ src/api/server.ts | 92 - src/api/uploads.ts | 910 ++++ src/api/versions.ts | 1036 ++++ src/components/BaseLayout.tsx | 70 + src/components/BlogLayout.tsx | 31 + src/components/CollectionNav.astro | 41 - src/components/DocsLayout.tsx | 74 + src/components/QueryExplorer.tsx | 2 +- src/db/client.server.ts | 10 + src/db/index.ts | 10 - src/db/migrate.ts | 20 +- src/db/seed.ts | 2 +- src/entry-client.tsx | 16 + src/entry-server.tsx | 101 + src/env.d.ts | 7 + src/global.css | 260 + src/layouts/Base.astro | 102 - src/layouts/BlogPost.astro | 86 - src/layouts/Docs.astro | 306 -- src/lib/ark.ts | 2 +- src/lib/auth.server.ts | 85 + src/lib/mirror-sync.ts | 2 +- src/lib/page-utils.ts | 46 - src/lib/ssr-data.tsx | 27 + src/loaders.server.ts | 404 ++ src/middleware.ts | 103 - src/pages/[owner]/[collection]/diff.astro | 319 -- src/pages/[owner]/[collection]/index.astro | 182 - src/pages/[owner]/[collection]/schemas.astro | 195 - src/pages/[owner]/[collection]/settings.astro | 270 - src/pages/[owner]/[collection]/v/[n].astro | 474 -- src/pages/[owner]/[collection]/versions.astro | 73 - src/pages/[owner]/index.astro | 181 - src/pages/[owner]/settings/index.astro | 238 - src/pages/[owner]/settings/keys.astro | 244 - src/pages/[owner]/settings/members.astro | 287 -- src/pages/admin/mirror.astro | 46 - src/pages/blog/index.astro | 40 - src/pages/dashboard.astro | 200 - src/pages/docs/api/accounts.astro | 155 - src/pages/docs/api/index.astro | 97 - src/pages/docs/index.astro | 36 - src/pages/docs/integration.astro | 175 - src/pages/explore.astro | 13 - src/pages/forgot-password.astro | 67 - src/pages/index.astro | 198 - src/pages/invitations/accept.astro | 89 - src/pages/login.astro | 86 - src/pages/logout.astro | 15 - src/pages/query.astro | 75 - src/pages/reset-password.astro | 97 - src/pages/schemas/[id].astro | 130 - src/pages/schemas/index.astro | 13 - src/pages/settings/avatar.astro | 97 - src/pages/settings/index.astro | 313 -- src/pages/settings/keys.astro | 210 - src/pages/settings/sessions.astro | 105 - src/pages/signup.astro | 104 - src/routes.ts | 59 + src/routes/admin/mirror.tsx | 40 + src/routes/blog/index.tsx | 61 + src/routes/blog/post.tsx | 62 + src/routes/collection/diff.tsx | 430 ++ src/routes/collection/index.tsx | 439 ++ src/routes/collection/schemas.tsx | 310 ++ src/routes/collection/settings.tsx | 362 ++ src/routes/collection/version.tsx | 739 +++ src/routes/collection/versions.tsx | 135 + src/routes/dashboard.tsx | 242 + src/routes/docs/api/accounts.tsx | 157 + .../docs/api/collections.tsx} | 98 +- .../files.astro => routes/docs/api/files.tsx} | 84 +- src/routes/docs/api/index.tsx | 99 + .../docs/api/versions.tsx} | 172 +- .../docs/concepts.tsx} | 22 +- src/routes/docs/index.tsx | 38 + src/routes/docs/integration.tsx | 179 + .../docs/quickstart.tsx} | 44 +- .../docs/self-host.tsx} | 49 +- src/routes/explore.tsx | 15 + src/routes/forgot-password.tsx | 76 + src/routes/home.tsx | 208 + src/routes/invitations/accept.tsx | 102 + src/routes/login.tsx | 89 + src/routes/logout.tsx | 18 + src/routes/owner/index.tsx | 257 + src/routes/owner/settings-keys.tsx | 334 ++ src/routes/owner/settings-members.tsx | 413 ++ src/routes/owner/settings.tsx | 365 ++ src/routes/query.tsx | 49 + src/routes/reset-password.tsx | 115 + src/routes/schemas/detail.tsx | 174 + src/routes/schemas/index.tsx | 15 + src/routes/settings/avatar.tsx | 111 + src/routes/settings/index.tsx | 443 ++ src/routes/settings/keys.tsx | 245 + src/routes/settings/sessions.tsx | 108 + src/routes/signup.tsx | 107 + src/sql-js.d.ts | 1 - src/styles/global.css | 46 - tools/seedMirror.ts | 2 +- tsconfig.json | 24 +- vite.config.ts | 40 + 147 files changed, 18487 insertions(+), 11880 deletions(-) delete mode 100644 astro.config.mjs rename {src/pages => content}/blog/2024-04-27-institutional-repositories.md (99%) rename {src/pages => content}/blog/2024-04-27-underlay-revived.md (99%) rename {src/pages => content}/blog/2026-04-28-atproto-integration.md (99%) rename {src/pages => content}/blog/2026-04-30-schema-evolution.md (99%) delete mode 100644 docker-compose.mirror.yml delete mode 100755 docker-entrypoint.dev.sh create mode 100644 dprint.json create mode 100644 index.html create mode 100644 oxlintrc.json create mode 100644 pnpm-lock.yaml create mode 100644 pnpm-workspace.yaml create mode 100644 server.ts create mode 100644 src/App.tsx create mode 100644 src/api/accounts.ts create mode 100644 src/api/admin.ts create mode 100644 src/api/ark-middleware.server.ts create mode 100644 src/api/ark.ts create mode 100644 src/api/auth.server.ts create mode 100644 src/api/collections.ts create mode 100644 src/api/files.ts create mode 100644 src/api/health.ts delete mode 100644 src/api/plugins/auth.ts create mode 100644 src/api/query.ts delete mode 100644 src/api/routes/accounts.ts delete mode 100644 src/api/routes/admin.ts delete mode 100644 src/api/routes/ark.ts delete mode 100644 src/api/routes/collections.ts delete mode 100644 src/api/routes/files.ts delete mode 100644 src/api/routes/health.ts delete mode 100644 src/api/routes/query.ts delete mode 100644 src/api/routes/schemas.ts delete mode 100644 src/api/routes/uploads.ts delete mode 100644 src/api/routes/versions.ts create mode 100644 src/api/schemas.ts delete mode 100644 src/api/server.ts create mode 100644 src/api/uploads.ts create mode 100644 src/api/versions.ts create mode 100644 src/components/BaseLayout.tsx create mode 100644 src/components/BlogLayout.tsx delete mode 100644 src/components/CollectionNav.astro create mode 100644 src/components/DocsLayout.tsx create mode 100644 src/db/client.server.ts delete mode 100644 src/db/index.ts create mode 100644 src/entry-client.tsx create mode 100644 src/entry-server.tsx create mode 100644 src/env.d.ts create mode 100644 src/global.css delete mode 100644 src/layouts/Base.astro delete mode 100644 src/layouts/BlogPost.astro delete mode 100644 src/layouts/Docs.astro create mode 100644 src/lib/auth.server.ts delete mode 100644 src/lib/page-utils.ts create mode 100644 src/lib/ssr-data.tsx create mode 100644 src/loaders.server.ts delete mode 100644 src/middleware.ts delete mode 100644 src/pages/[owner]/[collection]/diff.astro delete mode 100644 src/pages/[owner]/[collection]/index.astro delete mode 100644 src/pages/[owner]/[collection]/schemas.astro delete mode 100644 src/pages/[owner]/[collection]/settings.astro delete mode 100644 src/pages/[owner]/[collection]/v/[n].astro delete mode 100644 src/pages/[owner]/[collection]/versions.astro delete mode 100644 src/pages/[owner]/index.astro delete mode 100644 src/pages/[owner]/settings/index.astro delete mode 100644 src/pages/[owner]/settings/keys.astro delete mode 100644 src/pages/[owner]/settings/members.astro delete mode 100644 src/pages/admin/mirror.astro delete mode 100644 src/pages/blog/index.astro delete mode 100644 src/pages/dashboard.astro delete mode 100644 src/pages/docs/api/accounts.astro delete mode 100644 src/pages/docs/api/index.astro delete mode 100644 src/pages/docs/index.astro delete mode 100644 src/pages/docs/integration.astro delete mode 100644 src/pages/explore.astro delete mode 100644 src/pages/forgot-password.astro delete mode 100644 src/pages/index.astro delete mode 100644 src/pages/invitations/accept.astro delete mode 100644 src/pages/login.astro delete mode 100644 src/pages/logout.astro delete mode 100644 src/pages/query.astro delete mode 100644 src/pages/reset-password.astro delete mode 100644 src/pages/schemas/[id].astro delete mode 100644 src/pages/schemas/index.astro delete mode 100644 src/pages/settings/avatar.astro delete mode 100644 src/pages/settings/index.astro delete mode 100644 src/pages/settings/keys.astro delete mode 100644 src/pages/settings/sessions.astro delete mode 100644 src/pages/signup.astro create mode 100644 src/routes.ts create mode 100644 src/routes/admin/mirror.tsx create mode 100644 src/routes/blog/index.tsx create mode 100644 src/routes/blog/post.tsx create mode 100644 src/routes/collection/diff.tsx create mode 100644 src/routes/collection/index.tsx create mode 100644 src/routes/collection/schemas.tsx create mode 100644 src/routes/collection/settings.tsx create mode 100644 src/routes/collection/version.tsx create mode 100644 src/routes/collection/versions.tsx create mode 100644 src/routes/dashboard.tsx create mode 100644 src/routes/docs/api/accounts.tsx rename src/{pages/docs/api/collections.astro => routes/docs/api/collections.tsx} (50%) rename src/{pages/docs/api/files.astro => routes/docs/api/files.tsx} (51%) create mode 100644 src/routes/docs/api/index.tsx rename src/{pages/docs/api/versions.astro => routes/docs/api/versions.tsx} (54%) rename src/{pages/docs/concepts.astro => routes/docs/concepts.tsx} (90%) create mode 100644 src/routes/docs/index.tsx create mode 100644 src/routes/docs/integration.tsx rename src/{pages/docs/quickstart.astro => routes/docs/quickstart.tsx} (79%) rename src/{pages/docs/self-host.astro => routes/docs/self-host.tsx} (63%) create mode 100644 src/routes/explore.tsx create mode 100644 src/routes/forgot-password.tsx create mode 100644 src/routes/home.tsx create mode 100644 src/routes/invitations/accept.tsx create mode 100644 src/routes/login.tsx create mode 100644 src/routes/logout.tsx create mode 100644 src/routes/owner/index.tsx create mode 100644 src/routes/owner/settings-keys.tsx create mode 100644 src/routes/owner/settings-members.tsx create mode 100644 src/routes/owner/settings.tsx create mode 100644 src/routes/query.tsx create mode 100644 src/routes/reset-password.tsx create mode 100644 src/routes/schemas/detail.tsx create mode 100644 src/routes/schemas/index.tsx create mode 100644 src/routes/settings/avatar.tsx create mode 100644 src/routes/settings/index.tsx create mode 100644 src/routes/settings/keys.tsx create mode 100644 src/routes/settings/sessions.tsx create mode 100644 src/routes/signup.tsx delete mode 100644 src/sql-js.d.ts delete mode 100644 src/styles/global.css create mode 100644 vite.config.ts diff --git a/.github/workflows/deploy-mirror.yml b/.github/workflows/deploy-mirror.yml index 2b1e802..911ec0b 100644 --- a/.github/workflows/deploy-mirror.yml +++ b/.github/workflows/deploy-mirror.yml @@ -45,13 +45,15 @@ jobs: - name: Set up Node.js uses: actions/setup-node@v4 with: - node-version: 22 + node-version: 24 + + - name: Install pnpm + run: corepack enable && corepack prepare pnpm@latest --activate - name: Install and typecheck run: | - npm ci - npx astro sync - npm run typecheck + pnpm install --frozen-lockfile + pnpm typecheck - name: Build and push uses: docker/build-push-action@v6 @@ -134,7 +136,7 @@ jobs: sudo docker pull "ghcr.io/${REPO}:${IMAGE_TAG}" - # Deploy using docker-compose.mirror.yml — Postgres is self-contained. + # Deploy using docker-compose.yml with mirror config. # S3 creds and API key are sourced from prod .env above. sudo env \ IMAGE="ghcr.io/${REPO}" IMAGE_TAG="$IMAGE_TAG" \ @@ -143,7 +145,7 @@ jobs: S3_BUCKET="${S3_BUCKET:-underlay}" \ UNDERLAY_UPSTREAM_API_KEY="${UNDERLAY_UPSTREAM_API_KEY:-}" \ SESSION_SECRET="$(openssl rand -hex 32)" \ - docker stack deploy -c docker-compose.mirror.yml \ + docker stack deploy -c docker-compose.yml \ --with-registry-auth --resolve-image always --prune "${STACK_NAME}" sudo docker stack services "${STACK_NAME}" diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index d8b843e..5f78cce 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -75,16 +75,18 @@ jobs: with: images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} - - name: Set up Node.js + - name: Set up Node.js and pnpm uses: actions/setup-node@v4 with: - node-version: 22 + node-version: 24 + + - name: Install pnpm + run: corepack enable && corepack prepare pnpm@latest --activate - name: Install and typecheck run: | - npm ci - npx astro sync - npm run typecheck + pnpm install --frozen-lockfile + pnpm typecheck - name: Build and push uses: docker/build-push-action@v6 diff --git a/.gitignore b/.gitignore index 9cf3fb3..c5aa268 100644 --- a/.gitignore +++ b/.gitignore @@ -1,20 +1,19 @@ -node_modules/ -dist/ +node_modules +dist +.astro .env -.env.dev -.env.local +.env.* +!.env.enc +!.env.*.enc +.DS_Store +coverage data/ -planning/ -.astro/ *.db *.db-journal *.db-wal -_scriptsRepo/ -pubstarExample/ -pubstarExample2/ +planning/ # OS files -.DS_Store Thumbs.db # Editor files @@ -26,6 +25,4 @@ Thumbs.db # Logs *.log npm-debug.log* - -# Coverage coverage/ diff --git a/Caddyfile b/Caddyfile index ff9c699..8b9e504 100644 --- a/Caddyfile +++ b/Caddyfile @@ -3,36 +3,14 @@ # Reload with: systemctl reload caddy # NOTE: Use 127.0.0.1 (not localhost) — Docker Swarm publishes on IPv4 only. +# Single Hono server handles both SSR and API on one port. + www.underlay.org { tls internal - - # API routes → Fastify (prod) - handle /api/* { - reverse_proxy 127.0.0.1:3001 - } - handle /uploads/* { - reverse_proxy 127.0.0.1:3001 - } - - # Everything else → Astro SSR (prod) - handle { - reverse_proxy 127.0.0.1:4322 - } + reverse_proxy 127.0.0.1:3001 } dev.underlay.org { tls internal - - # API routes → Fastify (dev) - handle /api/* { - reverse_proxy 127.0.0.1:3000 - } - handle /uploads/* { - reverse_proxy 127.0.0.1:3000 - } - - # Everything else → Astro SSR (dev) - handle { - reverse_proxy 127.0.0.1:4321 - } + reverse_proxy 127.0.0.1:3000 } diff --git a/Dockerfile b/Dockerfile index 643591a..3423167 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,39 +1,41 @@ -# --- Build stage --- -FROM node:24-alpine AS build -RUN apk add --no-cache python3 make g++ +# --- Dev stage --- +FROM node:24-slim AS dev WORKDIR /app -COPY package.json package-lock.json* ./ -RUN npm install +RUN corepack enable && corepack prepare pnpm@latest --activate +RUN apt-get update && apt-get install -y python3 make g++ && rm -rf /var/lib/apt/lists/* +COPY package.json pnpm-lock.yaml* pnpm-workspace.yaml ./ +RUN pnpm install COPY . . -RUN npm run build +CMD ["pnpm", "dev:app"] -# --- Dev stage (used by docker-compose.dev.yml) --- -FROM node:24-alpine AS dev -RUN apk add --no-cache python3 make g++ +# --- Build stage --- +FROM node:24-slim AS build WORKDIR /app -COPY package.json package-lock.json* ./ -RUN npm install +RUN corepack enable && corepack prepare pnpm@latest --activate +RUN apt-get update && apt-get install -y python3 make g++ && rm -rf /var/lib/apt/lists/* +COPY package.json pnpm-lock.yaml* pnpm-workspace.yaml ./ +RUN pnpm install --frozen-lockfile COPY . . -CMD ["sh", "-c", "npm run db:migrate && npm run db:seed && npm run dev:server"] +RUN pnpm build # --- Production stage --- -FROM node:24-alpine AS production +FROM node:24-slim AS production WORKDIR /app -RUN apk add --no-cache curl python3 make g++ -COPY package.json package-lock.json* ./ -RUN npm install --omit=dev && apk del python3 make g++ +RUN corepack enable && corepack prepare pnpm@latest --activate +RUN apt-get update && apt-get install -y python3 make g++ curl && rm -rf /var/lib/apt/lists/* +COPY package.json pnpm-lock.yaml* ./ +RUN pnpm install --frozen-lockfile --prod && apt-get purge -y python3 make g++ && apt-get autoremove -y COPY --from=build /app/dist ./dist -COPY --from=build /app/public ./public -COPY --from=build /app/src/api ./src/api +COPY --from=build /app/server.ts ./server.ts COPY --from=build /app/src/db ./src/db COPY --from=build /app/src/lib ./src/lib +COPY --from=build /app/src/api ./src/api +COPY --from=build /app/content ./content COPY --from=build /app/tools ./tools COPY --from=build /app/tsconfig.json ./tsconfig.json COPY --from=build /app/drizzle.config.ts ./drizzle.config.ts +COPY --from=build /app/public ./public ENV NODE_ENV=production -ENV HOST=0.0.0.0 -EXPOSE 4321 3000 - -# Run migrations then start both Astro SSR and Fastify API -CMD ["sh", "-c", "npx tsx src/db/migrate.ts && node ./dist/server/entry.mjs & npx tsx src/api/server.ts & wait"] +EXPOSE ${PORT:-3000} +CMD ["node", "dist/server/entry-server.js"] diff --git a/astro.config.mjs b/astro.config.mjs deleted file mode 100644 index 43f1b45..0000000 --- a/astro.config.mjs +++ /dev/null @@ -1,23 +0,0 @@ -import { defineConfig } from "astro/config"; -import node from "@astrojs/node"; -import react from "@astrojs/react"; -import tailwindcss from "@tailwindcss/vite"; - -export default defineConfig({ - output: "server", - site: "https://underlay.org", - adapter: node({ mode: "standalone" }), - integrations: [react()], - security: { - checkOrigin: false, - }, - server: { port: 4321, host: "0.0.0.0" }, - vite: { - plugins: [tailwindcss()], - server: { - proxy: { - "/api": "http://localhost:3000", - }, - }, - }, -}); diff --git a/src/pages/blog/2024-04-27-institutional-repositories.md b/content/blog/2024-04-27-institutional-repositories.md similarity index 99% rename from src/pages/blog/2024-04-27-institutional-repositories.md rename to content/blog/2024-04-27-institutional-repositories.md index 4a47e76..5bf50de 100644 --- a/src/pages/blog/2024-04-27-institutional-repositories.md +++ b/content/blog/2024-04-27-institutional-repositories.md @@ -1,5 +1,4 @@ --- -layout: ../../layouts/BlogPost.astro title: "The IR of the Future Is a Reading List" subtitle: "Institutional repositories don't need to be monoliths. They need to be views." date: 2024-04-27 diff --git a/src/pages/blog/2024-04-27-underlay-revived.md b/content/blog/2024-04-27-underlay-revived.md similarity index 99% rename from src/pages/blog/2024-04-27-underlay-revived.md rename to content/blog/2024-04-27-underlay-revived.md index 06cc6b3..027f15e 100644 --- a/src/pages/blog/2024-04-27-underlay-revived.md +++ b/content/blog/2024-04-27-underlay-revived.md @@ -1,5 +1,4 @@ --- -layout: ../../layouts/BlogPost.astro title: "Underlay, Revived" subtitle: "The landscape changed. The project can finally be simple." date: 2024-04-27 diff --git a/src/pages/blog/2026-04-28-atproto-integration.md b/content/blog/2026-04-28-atproto-integration.md similarity index 99% rename from src/pages/blog/2026-04-28-atproto-integration.md rename to content/blog/2026-04-28-atproto-integration.md index 593d54f..e7b7ee5 100644 --- a/src/pages/blog/2026-04-28-atproto-integration.md +++ b/content/blog/2026-04-28-atproto-integration.md @@ -1,5 +1,4 @@ --- -layout: ../../layouts/BlogPost.astro title: "Underlay Meets AT Protocol" subtitle: "Every collection is a feed. Every push is an event." date: 2026-04-28 diff --git a/src/pages/blog/2026-04-30-schema-evolution.md b/content/blog/2026-04-30-schema-evolution.md similarity index 99% rename from src/pages/blog/2026-04-30-schema-evolution.md rename to content/blog/2026-04-30-schema-evolution.md index 631e132..037ecb5 100644 --- a/src/pages/blog/2026-04-30-schema-evolution.md +++ b/content/blog/2026-04-30-schema-evolution.md @@ -1,5 +1,4 @@ --- -layout: ../../layouts/BlogPost.astro title: "From Monolithic Schemas to Content-Addressed Types" subtitle: "How we made interoperability automatic by treating schemas like files." date: 2026-04-30 diff --git a/dev.sh b/dev.sh index 618200e..94e9b3f 100755 --- a/dev.sh +++ b/dev.sh @@ -1,43 +1,23 @@ #!/usr/bin/env bash set -euo pipefail - cd "$(dirname "$0")" -MODE="${1:-dev}" - -case "$MODE" in - dev) - COMPOSE_FILE=docker-compose.local.yml - - # Only create .env.local if it doesn't exist yet - if [[ ! -f .env.local ]]; then - if [[ -f .env.test ]]; then - echo "Creating .env.local from .env.test defaults (with Docker hostnames)" - sed -e 's|@localhost:5432|@postgres:5432|' \ - -e 's|http://localhost:9000|http://minio:9000|' \ - .env.test > .env.local - else - echo "No .env.test found — create .env.local manually" - exit 1 - fi - echo "Edit .env.local to customize." - fi +# Load env vars (prefer .env.local for local dev) +set -a +[[ -f .env.local ]] && source .env.local || [[ -f .env ]] && source .env +set +a - echo "Starting local development environment (source mounted, fast reload)..." - ;; - prod|build) - COMPOSE_FILE=docker-compose.yml - echo "Starting production-like environment (built image)..." - echo "Make sure you've built the image first: docker build -t underlay ." - ;; - *) - echo "Usage: $0 [dev|prod]" - echo " dev - Fast development with source mounting (default)" - echo " prod - Production-like testing with built image" - exit 1 - ;; -esac +# Find an available port, incrementing from PORT (default 3000) +BASE_PORT="${PORT:-3000}" +PORT="$BASE_PORT" +while lsof -iTCP:"$PORT" -sTCP:LISTEN -t &>/dev/null; do + ((PORT++)) +done +if [[ "$PORT" -ne "$BASE_PORT" ]]; then + echo "Port $BASE_PORT in use, using $PORT" +fi +export PORT -trap "docker compose -f $COMPOSE_FILE down --remove-orphans" EXIT +trap "docker compose -f docker-compose.local.yml down" EXIT -docker compose -f "$COMPOSE_FILE" up --build --attach app +docker compose -f docker-compose.local.yml up --build --attach app diff --git a/docker-compose.local.yml b/docker-compose.local.yml index 531a6b4..7eac908 100644 --- a/docker-compose.local.yml +++ b/docker-compose.local.yml @@ -1,81 +1,50 @@ # Local development compose — source-mounted for fast reload. # Start with: ./dev.sh -# Access directly at localhost:4321 (Astro) and localhost:3000 (API). +# Access at localhost:${PORT:-3000} name: underlay-local services: - postgres: - image: postgres:16-alpine + db: + image: postgres:17 environment: POSTGRES_USER: underlay POSTGRES_PASSWORD: underlay POSTGRES_DB: underlay command: > + postgres -c shared_buffers=256MB -c effective_cache_size=512MB -c work_mem=8MB -c maintenance_work_mem=64MB -c max_connections=50 ports: - - "5433:5432" + - '5433:5432' volumes: - pgdata-dev:/var/lib/postgresql/data - networks: - - dev - - dbaccess-dev healthcheck: - test: ["CMD-SHELL", "pg_isready -U underlay"] + test: ['CMD-SHELL', 'pg_isready -U underlay'] interval: 5s timeout: 3s retries: 5 - deploy: - resources: - limits: - memory: 1g - cpus: "1.0" app: build: context: . dockerfile: Dockerfile target: dev - env_file: - - .env.local - environment: - NODE_ENV: development ports: - - "4321:4321" - - "3000:3000" + - '${PORT:-3000}:${PORT:-3000}' + volumes: + - .:/app + - /app/node_modules + environment: + PORT: ${PORT:-3000} + DATABASE_URL: postgresql://underlay:underlay@db:5432/underlay + command: sh -c "pnpm db:migrate && pnpm db:seed && pnpm dev:app" depends_on: - postgres: + db: condition: service_healthy - volumes: - - ./src:/app/src - - ./public:/app/public - - ./astro.config.mjs:/app/astro.config.mjs - - ./tsconfig.json:/app/tsconfig.json - - ./drizzle.config.ts:/app/drizzle.config.ts - - ./tools:/app/tools - - ./docker-entrypoint.dev.sh:/app/docker-entrypoint.dev.sh - - app_node_modules:/app/node_modules - networks: - - dev - deploy: - resources: - limits: - memory: 512m - cpus: "1.0" - command: ["sh", "-c", "npm run db:migrate && npm run db:seed && npm run dev:server"] - entrypoint: ["sh", "/app/docker-entrypoint.dev.sh"] - restart: unless-stopped - -networks: - dev: - driver: bridge - dbaccess-dev: - driver: bridge volumes: pgdata-dev: - app_node_modules: diff --git a/docker-compose.mirror.yml b/docker-compose.mirror.yml deleted file mode 100644 index 02415a6..0000000 --- a/docker-compose.mirror.yml +++ /dev/null @@ -1,144 +0,0 @@ -# Mirror stack — deploy with: docker stack deploy -c docker-compose.mirror.yml underlay-mirror -# Self-contained: spins up its own Postgres, no .env file needed. -# Triggered manually via .github/workflows/deploy-mirror.yml - -services: - app: - image: ${IMAGE:-ghcr.io/knowledgefutures/underlay}:${IMAGE_TAG:-latest} - environment: - NODE_ENV: production - NODE_OPTIONS: "--max-old-space-size=448" - # Mirror-specific - UNDERLAY_MODE: mirror - UNDERLAY_UPSTREAM: https://www.underlay.org - UNDERLAY_NODE_NAME: IUA Mirror - UNDERLAY_SYNC_SCHEDULE: "0 0 * * 0" - # Database (internal to this stack) - DATABASE_URL: postgresql://mirror:mirror@postgres:5432/mirror - # S3 — shared bucket with prod (content-addressed = free dedup) - S3_ENDPOINT: ${S3_ENDPOINT:-} - S3_REGION: ${S3_REGION:-auto} - S3_ACCESS_KEY: ${S3_ACCESS_KEY:-} - S3_SECRET_KEY: ${S3_SECRET_KEY:-} - S3_BUCKET: ${S3_BUCKET:-underlay} - # Upstream API key (for authenticated sync — higher rate limit) - UNDERLAY_UPSTREAM_API_KEY: ${UNDERLAY_UPSTREAM_API_KEY:-} - # Session (for admin login) - SESSION_SECRET: ${SESSION_SECRET:-mirror-secret-change-me} - ports: - - "4323:4321" - - "3002:3000" - networks: - - mirrornet - depends_on: - - postgres - deploy: - replicas: 1 - resources: - limits: - memory: 512m - cpus: "1.0" - reservations: - memory: 256m - cpus: "0.25" - update_config: - parallelism: 1 - delay: 10s - order: start-first - failure_action: rollback - restart_policy: - condition: on-failure - healthcheck: - test: ["CMD-SHELL", "node -e \"const h=require('http'),check=(port,path)=>new Promise((res,rej)=>{h.get('http://127.0.0.1:'+port+path,r=>r.statusCode<400?res():rej()).on('error',rej)});Promise.all([check(3000,'/api/health'),check(4321,'/')]).then(()=>process.exit(0)).catch(()=>process.exit(1));\""] - interval: 30s - timeout: 5s - retries: 3 - start_period: 30s - logging: - driver: json-file - options: - max-size: "10m" - max-file: "3" - - cron: - image: ${IMAGE:-ghcr.io/knowledgefutures/underlay}:${IMAGE_TAG:-latest} - environment: - NODE_ENV: production - NODE_OPTIONS: "--max-old-space-size=128" - UNDERLAY_MODE: mirror - UNDERLAY_UPSTREAM: https://www.underlay.org - UNDERLAY_NODE_NAME: UK Mirror - UNDERLAY_SYNC_SCHEDULE: "0 0 * * 0" - DATABASE_URL: postgresql://mirror:mirror@postgres:5432/mirror - S3_ENDPOINT: ${S3_ENDPOINT:-} - S3_REGION: ${S3_REGION:-auto} - S3_ACCESS_KEY: ${S3_ACCESS_KEY:-} - S3_SECRET_KEY: ${S3_SECRET_KEY:-} - S3_BUCKET: ${S3_BUCKET:-underlay} - UNDERLAY_UPSTREAM_API_KEY: ${UNDERLAY_UPSTREAM_API_KEY:-} - command: ["node", "--import", "tsx/esm", "tools/cron.ts"] - networks: - - mirrornet - depends_on: - - postgres - deploy: - replicas: 1 - resources: - limits: - memory: 192m - cpus: "0.5" - reservations: - memory: 64m - cpus: "0.1" - restart_policy: - condition: any - logging: - driver: json-file - options: - max-size: "5m" - max-file: "3" - - postgres: - image: postgres:16-alpine - environment: - POSTGRES_USER: mirror - POSTGRES_PASSWORD: mirror - POSTGRES_DB: mirror - command: > - -c shared_buffers=256MB - -c effective_cache_size=512MB - -c work_mem=16MB - -c maintenance_work_mem=64MB - -c max_connections=50 - volumes: - - mirror-pgdata:/var/lib/postgresql/data - networks: - - mirrornet - deploy: - replicas: 1 - resources: - limits: - memory: 512m - cpus: "0.5" - reservations: - memory: 128m - cpus: "0.1" - restart_policy: - condition: any - healthcheck: - test: ["CMD-SHELL", "pg_isready -U mirror"] - interval: 10s - timeout: 5s - retries: 5 - logging: - driver: json-file - options: - max-size: "5m" - max-file: "3" - -networks: - mirrornet: - driver: overlay - -volumes: - mirror-pgdata: diff --git a/docker-compose.yml b/docker-compose.yml index 56d6688..6311f58 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,6 +1,5 @@ # Stack file — deploy with: docker stack deploy -c docker-compose.yml # SOPS decrypts .env.enc → .env before this runs (see deploy workflow). -# Caddy runs on the host (not in Docker) — see infra/Caddyfile. services: app: @@ -9,22 +8,18 @@ services: - .env environment: NODE_ENV: production - NODE_OPTIONS: "--max-old-space-size=448" - # Host ports (left) are set via .env; container ports (right) are fixed + NODE_OPTIONS: '--max-old-space-size=448' ports: - - "${APP_PORT:-4322}:4321" - - "${API_PORT:-3001}:3000" - networks: - - appnet + - '${PORT:-3000}:${PORT:-3000}' deploy: replicas: ${APP_REPLICAS:-2} resources: limits: memory: 640m - cpus: "1.0" + cpus: '1.0' reservations: memory: 384m - cpus: "0.25" + cpus: '0.25' update_config: parallelism: 1 delay: 10s @@ -38,7 +33,7 @@ services: tmpfs: - /tmp:size=64m healthcheck: - test: ["CMD-SHELL", "node -e \"const h=require('http'),check=(port,path)=>new Promise((res,rej)=>{h.get('http://127.0.0.1:'+port+path,r=>r.statusCode<400?res():rej()).on('error',rej)});Promise.all([check(3000,'/api/health'),check(4321,'/')]).then(()=>process.exit(0)).catch(()=>process.exit(1));\""] + test: ['CMD-SHELL', 'curl -sf http://127.0.0.1:${PORT:-3000}/api/health || exit 1'] interval: 30s timeout: 5s retries: 3 @@ -46,8 +41,8 @@ services: logging: driver: json-file options: - max-size: "10m" - max-file: "3" + max-size: '10m' + max-file: '3' cron: image: ${IMAGE:-ghcr.io/knowledgefutures/underlay}:${IMAGE_TAG:-latest} @@ -55,19 +50,17 @@ services: - .env environment: NODE_ENV: production - NODE_OPTIONS: "--max-old-space-size=128" - command: ["node", "--import", "tsx/esm", "tools/cron.ts"] - networks: - - appnet + NODE_OPTIONS: '--max-old-space-size=128' + command: ['node', '--import', 'tsx/esm', 'tools/cron.ts'] deploy: replicas: 1 resources: limits: memory: 192m - cpus: "0.5" + cpus: '0.5' reservations: memory: 64m - cpus: "0.1" + cpus: '0.1' restart_policy: condition: any tmpfs: @@ -75,8 +68,8 @@ services: logging: driver: json-file options: - max-size: "5m" - max-file: "3" + max-size: '5m' + max-file: '3' postgres: image: postgres:16-alpine diff --git a/docker-entrypoint.dev.sh b/docker-entrypoint.dev.sh deleted file mode 100755 index f6421bf..0000000 --- a/docker-entrypoint.dev.sh +++ /dev/null @@ -1,15 +0,0 @@ -#!/bin/sh -set -e - -# Auto-install deps if package-lock.json changed since last install -LOCK_HASH=$(md5sum package-lock.json 2>/dev/null | cut -d' ' -f1) -STORED_HASH="" -[ -f node_modules/.lock-hash ] && STORED_HASH=$(cat node_modules/.lock-hash) - -if [ "$LOCK_HASH" != "$STORED_HASH" ]; then - echo "[dev-entrypoint] package-lock.json changed — running npm install..." - npm install - echo "$LOCK_HASH" > node_modules/.lock-hash -fi - -exec "$@" diff --git a/dprint.json b/dprint.json new file mode 100644 index 0000000..c4a6fad --- /dev/null +++ b/dprint.json @@ -0,0 +1,13 @@ +{ + "typescript": { + "indentWidth": 2, + "semiColons": "asi", + "quoteStyle": "preferSingle", + "trailingCommas": "always" + }, + "json": { + "indentWidth": 2 + }, + "includes": ["src/**/*.{ts,tsx}", "server.ts", "vite.config.ts", "drizzle.config.ts"], + "excludes": ["node_modules", "dist"] +} diff --git a/index.html b/index.html new file mode 100644 index 0000000..cf39362 --- /dev/null +++ b/index.html @@ -0,0 +1,20 @@ + + + + + + + + + + Underlay + + +
+ + + + diff --git a/oxlintrc.json b/oxlintrc.json new file mode 100644 index 0000000..d9e8061 --- /dev/null +++ b/oxlintrc.json @@ -0,0 +1,8 @@ +{ + "rules": { + "no-unused-vars": "warn", + "no-console": "off", + "eqeqeq": "error" + }, + "ignorePatterns": ["node_modules", "dist"] +} diff --git a/package.json b/package.json index 3e48b4b..e2caba0 100644 --- a/package.json +++ b/package.json @@ -4,13 +4,14 @@ "private": true, "type": "module", "scripts": { - "dev": "bash dev.sh", - "dev:server": "astro dev --host 0.0.0.0 & tsx --watch src/api/server.ts & wait", - "dev:api": "tsx --watch src/api/server.ts", - "build": "astro build", + "dev": "./dev.sh", + "dev:app": "tsx --env-file=.env.local server.ts", + "build": "vite build && vite build --ssr src/entry-server.tsx", + "start": "NODE_ENV=production node dist/server/entry-server.js", "typecheck": "tsc --noEmit", - "preview": "astro preview", - "start": "node ./dist/server/entry.mjs", + "lint": "oxlint .", + "fmt": "dprint fmt", + "fmt:check": "dprint check", "db:generate": "drizzle-kit generate", "db:migrate": "tsx src/db/migrate.ts", "db:seed": "tsx src/db/seed.ts", @@ -21,15 +22,9 @@ "secrets:encrypt": "sops -e --input-type dotenv --output-type dotenv --output .env.enc .env", "secrets:encrypt:dev": "sops -e --input-type dotenv --output-type dotenv --output .env.dev.enc .env.dev", "secrets:decrypt": "sops -d --input-type dotenv --output-type dotenv --output .env .env.enc", - "secrets:decrypt:dev": "sops -d --input-type dotenv --output-type dotenv --output .env.dev .env.dev.enc", - "lint": "eslint .", - "lint:fix": "eslint . --fix", - "format": "prettier --write .", - "format:check": "prettier --check ." + "secrets:decrypt:dev": "sops -d --input-type dotenv --output-type dotenv --output .env.dev .env.dev.enc" }, "dependencies": { - "@astrojs/node": "^10.0.0", - "@astrojs/react": "^5.0.0", "@aws-sdk/client-s3": "^3.750.0", "@codemirror/autocomplete": "^6.20.1", "@codemirror/commands": "^6.10.3", @@ -37,20 +32,15 @@ "@codemirror/language": "^6.12.3", "@codemirror/state": "^6.6.0", "@codemirror/view": "^6.41.1", - "@fastify/cookie": "^11.0.0", - "@fastify/cors": "^11.0.0", - "@fastify/multipart": "^10.0.0", - "@fastify/rate-limit": "^10.3.0", - "@lucide/astro": "^1.11.0", + "@hono/node-server": "^1", "@sinclair/typebox": "^0.34.0", "ajv": "^8.17.0", "ajv-formats": "^3.0.0", - "astro": "^6.0.0", "bcrypt": "^6.0.0", "better-sqlite3": "^12.9.0", "codemirror": "^6.0.2", "drizzle-orm": "^0.45.0", - "fastify": "^5.2.0", + "hono": "^4", "lucide-react": "^1.11.0", "marked": "^18.0.0", "node-cron": "^4.0.0", @@ -58,9 +48,11 @@ "postgres": "^3.4.0", "react": "^19.1.0", "react-dom": "^19.1.0", + "react-router": "^7", "sql.js": "^1.14.1", "tar-stream": "^3.1.8", - "uuid": "^14.0.0" + "uuid": "^14.0.0", + "zod": "^3" }, "devDependencies": { "@tailwindcss/vite": "^4.1.0", @@ -71,16 +63,13 @@ "@types/react": "^19.1.0", "@types/react-dom": "^19.1.0", "@types/tar-stream": "^3.1.4", + "@vitejs/plugin-react": "^4", "drizzle-kit": "^0.31.0", - "eslint": "^10.0.0", - "prettier": "^3.5.0", + "dprint": "latest", + "oxlint": "latest", "tailwindcss": "^4.1.0", "tsx": "^4.19.0", - "typescript": "^6.0.0" - }, - "overrides": { - "@astrojs/react": { - "@vitejs/plugin-react": "^4.7.0" - } + "typescript": "^6.0.0", + "vite": "^6" } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml new file mode 100644 index 0000000..90100a1 --- /dev/null +++ b/pnpm-lock.yaml @@ -0,0 +1,4516 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + dependencies: + '@aws-sdk/client-s3': + specifier: ^3.750.0 + version: 3.1045.0 + '@codemirror/autocomplete': + specifier: ^6.20.1 + version: 6.20.2 + '@codemirror/commands': + specifier: ^6.10.3 + version: 6.10.3 + '@codemirror/lang-sql': + specifier: ^6.10.0 + version: 6.10.0 + '@codemirror/language': + specifier: ^6.12.3 + version: 6.12.3 + '@codemirror/state': + specifier: ^6.6.0 + version: 6.6.0 + '@codemirror/view': + specifier: ^6.41.1 + version: 6.42.1 + '@hono/node-server': + specifier: ^1 + version: 1.19.14(hono@4.12.18) + '@sinclair/typebox': + specifier: ^0.34.0 + version: 0.34.49 + ajv: + specifier: ^8.17.0 + version: 8.20.0 + ajv-formats: + specifier: ^3.0.0 + version: 3.0.1(ajv@8.20.0) + bcrypt: + specifier: ^6.0.0 + version: 6.0.0 + better-sqlite3: + specifier: ^12.9.0 + version: 12.9.0 + codemirror: + specifier: ^6.0.2 + version: 6.0.2 + drizzle-orm: + specifier: ^0.45.0 + version: 0.45.2(@types/better-sqlite3@7.6.13)(better-sqlite3@12.9.0)(postgres@3.4.9)(sql.js@1.14.1) + hono: + specifier: ^4 + version: 4.12.18 + lucide-react: + specifier: ^1.11.0 + version: 1.14.0(react@19.2.6) + marked: + specifier: ^18.0.0 + version: 18.0.3 + node-cron: + specifier: ^4.0.0 + version: 4.2.1 + nodemailer: + specifier: ^8.0.7 + version: 8.0.7 + postgres: + specifier: ^3.4.0 + version: 3.4.9 + react: + specifier: ^19.1.0 + version: 19.2.6 + react-dom: + specifier: ^19.1.0 + version: 19.2.6(react@19.2.6) + react-router: + specifier: ^7 + version: 7.15.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + sql.js: + specifier: ^1.14.1 + version: 1.14.1 + tar-stream: + specifier: ^3.1.8 + version: 3.2.0 + uuid: + specifier: ^14.0.0 + version: 14.0.0 + zod: + specifier: ^3 + version: 3.25.76 + devDependencies: + '@tailwindcss/vite': + specifier: ^4.1.0 + version: 4.3.0(vite@6.4.2(@types/node@25.6.2)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.21.0)) + '@types/bcrypt': + specifier: ^6.0.0 + version: 6.0.0 + '@types/better-sqlite3': + specifier: ^7.6.13 + version: 7.6.13 + '@types/node': + specifier: ^25.0.0 + version: 25.6.2 + '@types/nodemailer': + specifier: ^8.0.0 + version: 8.0.0 + '@types/react': + specifier: ^19.1.0 + version: 19.2.14 + '@types/react-dom': + specifier: ^19.1.0 + version: 19.2.3(@types/react@19.2.14) + '@types/tar-stream': + specifier: ^3.1.4 + version: 3.1.4 + '@vitejs/plugin-react': + specifier: ^4 + version: 4.7.0(vite@6.4.2(@types/node@25.6.2)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.21.0)) + dprint: + specifier: latest + version: 0.54.0 + drizzle-kit: + specifier: ^0.31.0 + version: 0.31.10 + oxlint: + specifier: latest + version: 1.63.0 + tailwindcss: + specifier: ^4.1.0 + version: 4.3.0 + tsx: + specifier: ^4.19.0 + version: 4.21.0 + typescript: + specifier: ^6.0.0 + version: 6.0.3 + vite: + specifier: ^6 + version: 6.4.2(@types/node@25.6.2)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.21.0) + +packages: + + '@aws-crypto/crc32@5.2.0': + resolution: {integrity: sha512-nLbCWqQNgUiwwtFsen1AdzAtvuLRsQS8rYgMuxCrdKf9kOssamGLuPwyTY9wyYblNr9+1XM8v6zoDTPPSIeANg==} + engines: {node: '>=16.0.0'} + + '@aws-crypto/crc32c@5.2.0': + resolution: {integrity: sha512-+iWb8qaHLYKrNvGRbiYRHSdKRWhto5XlZUEBwDjYNf+ly5SVYG6zEoYIdxvf5R3zyeP16w4PLBn3rH1xc74Rag==} + + '@aws-crypto/sha1-browser@5.2.0': + resolution: {integrity: sha512-OH6lveCFfcDjX4dbAvCFSYUjJZjDr/3XJ3xHtjn3Oj5b9RjojQo8npoLeA/bNwkOkrSQ0wgrHzXk4tDRxGKJeg==} + + '@aws-crypto/sha256-browser@5.2.0': + resolution: {integrity: sha512-AXfN/lGotSQwu6HNcEsIASo7kWXZ5HYWvfOmSNKDsEqC4OashTp8alTmaz+F7TC2L083SFv5RdB+qU3Vs1kZqw==} + + '@aws-crypto/sha256-js@5.2.0': + resolution: {integrity: sha512-FFQQyu7edu4ufvIZ+OadFpHHOt+eSTBaYaki44c+akjg7qZg9oOQeLlk77F6tSYqjDAFClrHJk9tMf0HdVyOvA==} + engines: {node: '>=16.0.0'} + + '@aws-crypto/supports-web-crypto@5.2.0': + resolution: {integrity: sha512-iAvUotm021kM33eCdNfwIN//F77/IADDSs58i+MDaOqFrVjZo9bAal0NK7HurRuWLLpF1iLX7gbWrjHjeo+YFg==} + + '@aws-crypto/util@5.2.0': + resolution: {integrity: sha512-4RkU9EsI6ZpBve5fseQlGNUWKMa1RLPQ1dnjnQoe07ldfIzcsGb5hC5W0Dm7u423KWzawlrpbjXBrXCEv9zazQ==} + + '@aws-sdk/client-s3@3.1045.0': + resolution: {integrity: sha512-fsuO3Y6t+3Ro9Bsg41DKj4Sfy53CGSrhnMldNplWmG8Tx0UbYk+YDa4RD1hVlJpERw4JBmPkl0+J9qlxMh1pcA==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/core@3.974.8': + resolution: {integrity: sha512-njR2qoG6ZuB0kvAS2FyICsFZJ6gmCcf2X/7JcD14sUvGDm26wiZ5BrA6LOiUxKFEF+IVe7kdroxyE00YlkiYsw==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/crc64-nvme@3.972.7': + resolution: {integrity: sha512-QUagVVBbC8gODCF6e1aV0mE2TXWB9Opz4k8EJFdNrujUVQm5R4AjJa1mpOqzwOuROBzqJU9zawzig7M96L8Ejg==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/credential-provider-env@3.972.34': + resolution: {integrity: sha512-XT0jtf8Fw9JE6ppsQeoNnZRiG+jqRixMT1v1ZR17G60UvVdsQmTG8nbEyHuEPfMxDXEhfdARaM/XiEhca4lGHQ==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/credential-provider-http@3.972.36': + resolution: {integrity: sha512-DPoGWfy7J7RKxvbf5kOKIGQkD2ek3dbKgzKIGrnLuvZBz5myU+Im/H6pmc14QcnFbqHMqxvtWSgRDSJW3qXLQg==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/credential-provider-ini@3.972.38': + resolution: {integrity: sha512-oDzUBu2MGJFgoar05sPMCwSrhw44ASyccrHzj66vO69OZqi7I6hZZxXfuPLC8OCzW7C+sU+bI73XHij41yekgQ==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/credential-provider-login@3.972.38': + resolution: {integrity: sha512-g1NosS8qe4OF++G2UFCM5ovSkgipC7YYor5KCWatG0UoMSO5YFj9C8muePlyVmOBV/WTI16Jo3/s1NUo/o1Bww==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/credential-provider-node@3.972.39': + resolution: {integrity: sha512-HEswDQyxUtadoZ/bJsPPENHg7R0Lzym5LuMksJeHvqhCOpP+rtkDLKI4/ZChH4w3cf5kG8n6bZuI8PzajoiqMg==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/credential-provider-process@3.972.34': + resolution: {integrity: sha512-T3IFs4EVmVi1dVN5RciFnklCANSzvrQd/VuHY9ThHSQmYkTogjcGkoJEr+oNUPQZnso52183088NqysMPji1/Q==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/credential-provider-sso@3.972.38': + resolution: {integrity: sha512-5ZxG+t0+3Q3QPh8KEjX6syskhgNf7I0MN7oGioTf6Lm1NTjfP7sIcYGNsthXC2qR8vcD3edNZwCr2ovfSSWuRA==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/credential-provider-web-identity@3.972.38': + resolution: {integrity: sha512-lYHFF30DGI20jZcYX8cm6Ns0V7f1dDN6g/MBDLTyD/5iw+bXs3yBr2iAiHDkx4RFU5JgsnZvCHYKiRVPRdmOgw==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/middleware-bucket-endpoint@3.972.10': + resolution: {integrity: sha512-Vbc2frZH7wXlMNd+ZZSXUEs/l1Sv8Jj4zUnIfwrYF5lwaLdXHZ9xx4U3rjUcaye3HRhFVc+E5DbBxpRAbB16BA==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/middleware-expect-continue@3.972.10': + resolution: {integrity: sha512-2Yn0f1Qiq/DjxYR3wfI3LokXnjOhFM7Ssn4LTdFDIxRMCE6I32MAsVnhPX1cUZsuVA9tiZtwwhlSLAtFGxAZlQ==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/middleware-flexible-checksums@3.974.16': + resolution: {integrity: sha512-6ru8doI0/XzszqLIPXf0E/V7HhAw1Pu94010XCKYtBUfD0LxF0BuOzrUf8OQGR6j2o6wgKTHUniOmndQycHwCA==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/middleware-host-header@3.972.10': + resolution: {integrity: sha512-IJSsIMeVQ8MMCPbuh1AbltkFhLBLXn7aejzfX5YKT/VLDHn++Dcz8886tXckE+wQssyPUhaXrJhdakO2VilRhg==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/middleware-location-constraint@3.972.10': + resolution: {integrity: sha512-rI3NZvJcEvjoD0+0PI0iUAwlPw2IlSlhyvgBK/3WkKJQE/YiKFedd9dMN2lVacdNxPNhxL/jzQaKQdrGtQagjQ==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/middleware-logger@3.972.10': + resolution: {integrity: sha512-OOuGvvz1Dm20SjZo5oEBePFqxt5nf8AwkNDSyUHvD9/bfNASmstcYxFAHUowy4n6Io7mWUZ04JURZwSBvyQanQ==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/middleware-recursion-detection@3.972.11': + resolution: {integrity: sha512-+zz6f79Kj9V5qFK2P+D8Ehjnw4AhphAlCAsPjUqEcInA9umtSSKMrHbSagEeOIsDNuvVrH98bjRHcyQukTrhaQ==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/middleware-sdk-s3@3.972.37': + resolution: {integrity: sha512-Km7M+i8DrLArVzrid1gfxeGhYHBd3uxvE77g0s5a52zPSVosxzQBnJ0gwWb6NIp/DOk8gsBMhi7V+cpJG0ndTA==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/middleware-ssec@3.972.10': + resolution: {integrity: sha512-Gli9A0u8EVVb+5bFDGS/QbSVg28w/wpEidg1ggVcSj65BDTdGR6punsOcVjqdiu1i42WHWo51MCvARPIIz9juw==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/middleware-user-agent@3.972.38': + resolution: {integrity: sha512-iz+B29TXcAZsJpwB+AwG/TTGA5l/VnmMZ2UxtiySOZjI6gCdmviXPwdgzcmuazMy16rXoPY4mYCGe7zdNKfx5A==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/nested-clients@3.997.6': + resolution: {integrity: sha512-WBDnqatJl+kGObpfmfSxqnXeYTu3Me8wx8WCtvoxX3pfWrrTv8I4WTMSSs7PZqcRcVh8WeUKMgGFjMG+52SR1w==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/region-config-resolver@3.972.13': + resolution: {integrity: sha512-CvJ2ZIjK/jVD/lbOpowBVElJyC1YxLTIJ13yM0AEo0t2v7swOzGjSA6lJGH+DwZXQhcjUjoYwc8bVYCX5MDr1A==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/signature-v4-multi-region@3.996.25': + resolution: {integrity: sha512-+CMIt3e1VzlklAECmG+DtP1sV8iKq25FuA0OKpnJ4KA0kxUtd7CgClY7/RU6VzJBQwbN4EJ9Ue6plvqx1qGadw==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/token-providers@3.1041.0': + resolution: {integrity: sha512-Th7kPI6YPtvJUcdznooXJMy+9rQWjmEF81LxaJssngBzuysK4a/x+l8kjm1zb7nYsUPbndnBdUnwng/3PLvtGw==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/types@3.973.8': + resolution: {integrity: sha512-gjlAdtHMbtR9X5iIhVUvbVcy55KnznpC6bkDUWW9z915bi0ckdUr5cjf16Kp6xq0bP5HBD2xzgbL9F9Quv5vUw==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/util-arn-parser@3.972.3': + resolution: {integrity: sha512-HzSD8PMFrvgi2Kserxuff5VitNq2sgf3w9qxmskKDiDTThWfVteJxuCS9JXiPIPtmCrp+7N9asfIaVhBFORllA==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/util-endpoints@3.996.8': + resolution: {integrity: sha512-oOZHcRDihk5iEe5V25NVWg45b3qEA8OpHWVdU/XQh8Zj4heVPAJqWvMphQnU7LkufmUo10EpvFPZuQMiFLJK3g==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/util-locate-window@3.965.5': + resolution: {integrity: sha512-WhlJNNINQB+9qtLtZJcpQdgZw3SCDCpXdUJP7cToGwHbCWCnRckGlc6Bx/OhWwIYFNAn+FIydY8SZ0QmVu3xTQ==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/util-user-agent-browser@3.972.10': + resolution: {integrity: sha512-FAzqXvfEssGdSIz8ejatan0bOdx1qefBWKF/gWmVBXIP1HkS7v/wjjaqrAGGKvyihrXTXW00/2/1nTJtxpXz7g==} + + '@aws-sdk/util-user-agent-node@3.973.24': + resolution: {integrity: sha512-ZWwlkjcIp7cEL8ZfTpTAPNkwx25p7xol0xlKoWVVf22+nsjwmLcHYtTPjIV1cSpmB/b6DaK4cb1fSkvCXHgRdw==} + engines: {node: '>=20.0.0'} + peerDependencies: + aws-crt: '>=1.0.0' + peerDependenciesMeta: + aws-crt: + optional: true + + '@aws-sdk/xml-builder@3.972.22': + resolution: {integrity: sha512-PMYKKtJd70IsSG0yHrdAbxBr+ZWBKLvzFZfD3/urxgf6hXVMzuU5M+3MJ5G67RpOmLBu1fAUN65SbWuKUCOlAA==} + engines: {node: '>=20.0.0'} + + '@aws/lambda-invoke-store@0.2.4': + resolution: {integrity: sha512-iY8yvjE0y651BixKNPgmv1WrQc+GZ142sb0z4gYnChDDY2YqI4P/jsSopBWrKfAt7LOJAkOXt7rC/hms+WclQQ==} + engines: {node: '>=18.0.0'} + + '@babel/code-frame@7.29.0': + resolution: {integrity: sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==} + engines: {node: '>=6.9.0'} + + '@babel/compat-data@7.29.3': + resolution: {integrity: sha512-LIVqM46zQWZhj17qA8wb4nW/ixr2y1Nw+r1etiAWgRM6U1IqP+LNhL1yg440jYZR72jCWcWbLWzIosH+uP1fqg==} + engines: {node: '>=6.9.0'} + + '@babel/core@7.29.0': + resolution: {integrity: sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==} + engines: {node: '>=6.9.0'} + + '@babel/generator@7.29.1': + resolution: {integrity: sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-compilation-targets@7.28.6': + resolution: {integrity: sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==} + engines: {node: '>=6.9.0'} + + '@babel/helper-globals@7.28.0': + resolution: {integrity: sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-module-imports@7.28.6': + resolution: {integrity: sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-module-transforms@7.28.6': + resolution: {integrity: sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + + '@babel/helper-plugin-utils@7.28.6': + resolution: {integrity: sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug==} + engines: {node: '>=6.9.0'} + + '@babel/helper-string-parser@7.27.1': + resolution: {integrity: sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==} + engines: {node: '>=6.9.0'} + + '@babel/helper-validator-identifier@7.28.5': + resolution: {integrity: sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==} + engines: {node: '>=6.9.0'} + + '@babel/helper-validator-option@7.27.1': + resolution: {integrity: sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==} + engines: {node: '>=6.9.0'} + + '@babel/helpers@7.29.2': + resolution: {integrity: sha512-HoGuUs4sCZNezVEKdVcwqmZN8GoHirLUcLaYVNBK2J0DadGtdcqgr3BCbvH8+XUo4NGjNl3VOtSjEKNzqfFgKw==} + engines: {node: '>=6.9.0'} + + '@babel/parser@7.29.3': + resolution: {integrity: sha512-b3ctpQwp+PROvU/cttc4OYl4MzfJUWy6FZg+PMXfzmt/+39iHVF0sDfqay8TQM3JA2EUOyKcFZt75jWriQijsA==} + engines: {node: '>=6.0.0'} + hasBin: true + + '@babel/plugin-transform-react-jsx-self@7.27.1': + resolution: {integrity: sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-react-jsx-source@7.27.1': + resolution: {integrity: sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/template@7.28.6': + resolution: {integrity: sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==} + engines: {node: '>=6.9.0'} + + '@babel/traverse@7.29.0': + resolution: {integrity: sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==} + engines: {node: '>=6.9.0'} + + '@babel/types@7.29.0': + resolution: {integrity: sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==} + engines: {node: '>=6.9.0'} + + '@codemirror/autocomplete@6.20.2': + resolution: {integrity: sha512-G5FPkgIiLjOgZMjqVjvuKQ1rGPtHogLldJr33eFJdVLtmwY+giGrlv/ewljLz6b9BSQLkjxuwBc6g6omDM+YxQ==} + + '@codemirror/commands@6.10.3': + resolution: {integrity: sha512-JFRiqhKu+bvSkDLI+rUhJwSxQxYb759W5GBezE8Uc8mHLqC9aV/9aTC7yJSqCtB3F00pylrLCwnyS91Ap5ej4Q==} + + '@codemirror/lang-sql@6.10.0': + resolution: {integrity: sha512-6ayPkEd/yRw0XKBx5uAiToSgGECo/GY2NoJIHXIIQh1EVwLuKoU8BP/qK0qH5NLXAbtJRLuT73hx7P9X34iO4w==} + + '@codemirror/language@6.12.3': + resolution: {integrity: sha512-QwCZW6Tt1siP37Jet9Tb02Zs81TQt6qQrZR2H+eGMcFsL1zMrk2/b9CLC7/9ieP1fjIUMgviLWMmgiHoJrj+ZA==} + + '@codemirror/lint@6.9.6': + resolution: {integrity: sha512-6Kp7r6XfCi/D/5sdXieMfg9pJU1bUEx96WITuLU6ESaKizCz0QHFMjY/TaFSbigDdEAIgi93itLBIUETP4oK+A==} + + '@codemirror/search@6.7.0': + resolution: {integrity: sha512-ZvGm99wc/s2cITtMT15LFdn8aH/aS+V+DqyGq/N5ZlV5vWtH+nILvC2nw0zX7ByNoHHDZ2IxxdW38O0tc5nVHg==} + + '@codemirror/state@6.6.0': + resolution: {integrity: sha512-4nbvra5R5EtiCzr9BTHiTLc+MLXK2QGiAVYMyi8PkQd3SR+6ixar/Q/01Fa21TBIDOZXgeWV4WppsQolSreAPQ==} + + '@codemirror/view@6.42.1': + resolution: {integrity: sha512-ToN3oFc0nsxNUYVF5P0ztLgbC4UPPjPtA9aKYhkOKQaZASpOUo6ISXyQLP66ctVwlDc+j6Jv0uK5IFALkiXztg==} + + '@dprint/darwin-arm64@0.54.0': + resolution: {integrity: sha512-yqRI4enH+BDp+4+ZsPVdZM5h873JK1lN7li9l9A5u4C4cvh1oEsiBWAzEPccRkJ2ctF8LgaizBSxO38sqEVYbw==} + cpu: [arm64] + os: [darwin] + + '@dprint/darwin-x64@0.54.0': + resolution: {integrity: sha512-W9BARpgHypcQwatg5mnHaCpX6pLX5dBxxiv+tZKruhOmq8MKYOrAYDXlceMuHSowmWREfUF5yL4SRgXDGI6WQw==} + cpu: [x64] + os: [darwin] + + '@dprint/linux-arm64-glibc@0.54.0': + resolution: {integrity: sha512-VhM7p70VFuNqxZMdiv1e+nMboPj/hMFlTIBWrRaX7+6VThs9mJr9+94wrUeXgfnfsyaEKSbRFa/dru1PINoSNw==} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@dprint/linux-arm64-musl@0.54.0': + resolution: {integrity: sha512-QS1A74Lv60/L9oemHCzbHgOLbV2smSJG5IxS5fjf8ZWetyUt918WDzIHBilz/+uiB+OlW2UVTsm952UG0YOrLw==} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@dprint/linux-loong64-glibc@0.54.0': + resolution: {integrity: sha512-8Myka2/0KbhuZnEKL6jagPXTgDKVpd/tfXDRa0oibUBgaqOSku6iRMzHGa/PhqHL+s14Gcp+/cIHz0zU3Tkgug==} + cpu: [loong64] + os: [linux] + libc: [glibc] + + '@dprint/linux-loong64-musl@0.54.0': + resolution: {integrity: sha512-/AN3xCuMhC4PK7Pbj7/4zBuhFGr4m0OHV/5uGTfzpkKX/3+AXoyKl7PbT2VlNMGXAK0kuRThfjtx23gIwlWk7Q==} + cpu: [loong64] + os: [linux] + libc: [musl] + + '@dprint/linux-riscv64-glibc@0.54.0': + resolution: {integrity: sha512-Aw2vXzzwFDpPbXh6ajsSabVCkCc66C3hCyMKprR/IxYvFtjYX80nh1ox0c7iaw6c4HacHMRLGw7FUSXvomPaEQ==} + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@dprint/linux-x64-glibc@0.54.0': + resolution: {integrity: sha512-zZqj3wQELOX8n6QfT2uuWoMf64Wv0lMXNyam3btm+PKkg0P6a54TPL09Bs9XsViOdxgTcamsiQ7HlErt/LEjIA==} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@dprint/linux-x64-musl@0.54.0': + resolution: {integrity: sha512-it6Qdt06dyW2adbAXpOCb7/KQLxlm4i1UphUAWqWsZk4t3EYetyAza9J0g3Vu9itIWSEIo9MnccgANckQJ6+qw==} + cpu: [x64] + os: [linux] + libc: [musl] + + '@dprint/win32-arm64@0.54.0': + resolution: {integrity: sha512-F5kjV/6I9YtNOTDWHUpTqM2HHHS510BPL7z4NJuU0nDnaVeks7GwNEltGr56CcsG8XQYhkiAsqZytPu6AhA2hQ==} + cpu: [arm64] + os: [win32] + + '@dprint/win32-x64@0.54.0': + resolution: {integrity: sha512-AAr2ye/DtgYXDplRoPS+5U++x7T6W4a3I9FvTFWFxziFmUptvAg5G2c4FcXoAduSruhYZJvjDZrLseR2c3IwXg==} + cpu: [x64] + os: [win32] + + '@drizzle-team/brocli@0.10.2': + resolution: {integrity: sha512-z33Il7l5dKjUgGULTqBsQBQwckHh5AbIuxhdsIxDDiZAzBOrZO6q9ogcWC65kU382AfynTfgNumVcNIjuIua6w==} + + '@esbuild-kit/core-utils@3.3.2': + resolution: {integrity: sha512-sPRAnw9CdSsRmEtnsl2WXWdyquogVpB3yZ3dgwJfe8zrOzTsV7cJvmwrKVa+0ma5BoiGJ+BoqkMvawbayKUsqQ==} + deprecated: 'Merged into tsx: https://tsx.is' + + '@esbuild-kit/esm-loader@2.6.5': + resolution: {integrity: sha512-FxEMIkJKnodyA1OaCUoEvbYRkoZlLZ4d/eXFu9Fh8CbBBgP5EmZxrfTRyN0qpXZ4vOvqnE5YdRdcrmUUXuU+dA==} + deprecated: 'Merged into tsx: https://tsx.is' + + '@esbuild/aix-ppc64@0.25.12': + resolution: {integrity: sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + + '@esbuild/aix-ppc64@0.27.7': + resolution: {integrity: sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + + '@esbuild/android-arm64@0.18.20': + resolution: {integrity: sha512-Nz4rJcchGDtENV0eMKUNa6L12zz2zBDXuhj/Vjh18zGqB44Bi7MBMSXjgunJgjRhCmKOjnPuZp4Mb6OKqtMHLQ==} + engines: {node: '>=12'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm64@0.25.12': + resolution: {integrity: sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm64@0.27.7': + resolution: {integrity: sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm@0.18.20': + resolution: {integrity: sha512-fyi7TDI/ijKKNZTUJAQqiG5T7YjJXgnzkURqmGj13C6dCqckZBLdl4h7bkhHt/t0WP+zO9/zwroDvANaOqO5Sw==} + engines: {node: '>=12'} + cpu: [arm] + os: [android] + + '@esbuild/android-arm@0.25.12': + resolution: {integrity: sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + + '@esbuild/android-arm@0.27.7': + resolution: {integrity: sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + + '@esbuild/android-x64@0.18.20': + resolution: {integrity: sha512-8GDdlePJA8D6zlZYJV/jnrRAi6rOiNaCC/JclcXpB+KIuvfBN4owLtgzY2bsxnx666XjJx2kDPUmnTtR8qKQUg==} + engines: {node: '>=12'} + cpu: [x64] + os: [android] + + '@esbuild/android-x64@0.25.12': + resolution: {integrity: sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + + '@esbuild/android-x64@0.27.7': + resolution: {integrity: sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + + '@esbuild/darwin-arm64@0.18.20': + resolution: {integrity: sha512-bxRHW5kHU38zS2lPTPOyuyTm+S+eobPUnTNkdJEfAddYgEcll4xkT8DB9d2008DtTbl7uJag2HuE5NZAZgnNEA==} + engines: {node: '>=12'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-arm64@0.25.12': + resolution: {integrity: sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-arm64@0.27.7': + resolution: {integrity: sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-x64@0.18.20': + resolution: {integrity: sha512-pc5gxlMDxzm513qPGbCbDukOdsGtKhfxD1zJKXjCCcU7ju50O7MeAZ8c4krSJcOIJGFR+qx21yMMVYwiQvyTyQ==} + engines: {node: '>=12'} + cpu: [x64] + os: [darwin] + + '@esbuild/darwin-x64@0.25.12': + resolution: {integrity: sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + + '@esbuild/darwin-x64@0.27.7': + resolution: {integrity: sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + + '@esbuild/freebsd-arm64@0.18.20': + resolution: {integrity: sha512-yqDQHy4QHevpMAaxhhIwYPMv1NECwOvIpGCZkECn8w2WFHXjEwrBn3CeNIYsibZ/iZEUemj++M26W3cNR5h+Tw==} + engines: {node: '>=12'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-arm64@0.25.12': + resolution: {integrity: sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-arm64@0.27.7': + resolution: {integrity: sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.18.20': + resolution: {integrity: sha512-tgWRPPuQsd3RmBZwarGVHZQvtzfEBOreNuxEMKFcd5DaDn2PbBxfwLcj4+aenoh7ctXcbXmOQIn8HI6mCSw5MQ==} + engines: {node: '>=12'} + cpu: [x64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.25.12': + resolution: {integrity: sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.27.7': + resolution: {integrity: sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + + '@esbuild/linux-arm64@0.18.20': + resolution: {integrity: sha512-2YbscF+UL7SQAVIpnWvYwM+3LskyDmPhe31pE7/aoTMFKKzIc9lLbyGUpmmb8a8AixOL61sQ/mFh3jEjHYFvdA==} + engines: {node: '>=12'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm64@0.25.12': + resolution: {integrity: sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm64@0.27.7': + resolution: {integrity: sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm@0.18.20': + resolution: {integrity: sha512-/5bHkMWnq1EgKr1V+Ybz3s1hWXok7mDFUMQ4cG10AfW3wL02PSZi5kFpYKrptDsgb2WAJIvRcDm+qIvXf/apvg==} + engines: {node: '>=12'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-arm@0.25.12': + resolution: {integrity: sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-arm@0.27.7': + resolution: {integrity: sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-ia32@0.18.20': + resolution: {integrity: sha512-P4etWwq6IsReT0E1KHU40bOnzMHoH73aXp96Fs8TIT6z9Hu8G6+0SHSw9i2isWrD2nbx2qo5yUqACgdfVGx7TA==} + engines: {node: '>=12'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-ia32@0.25.12': + resolution: {integrity: sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-ia32@0.27.7': + resolution: {integrity: sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-loong64@0.18.20': + resolution: {integrity: sha512-nXW8nqBTrOpDLPgPY9uV+/1DjxoQ7DoB2N8eocyq8I9XuqJ7BiAMDMf9n1xZM9TgW0J8zrquIb/A7s3BJv7rjg==} + engines: {node: '>=12'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-loong64@0.25.12': + resolution: {integrity: sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-loong64@0.27.7': + resolution: {integrity: sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-mips64el@0.18.20': + resolution: {integrity: sha512-d5NeaXZcHp8PzYy5VnXV3VSd2D328Zb+9dEq5HE6bw6+N86JVPExrA6O68OPwobntbNJ0pzCpUFZTo3w0GyetQ==} + engines: {node: '>=12'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-mips64el@0.25.12': + resolution: {integrity: sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-mips64el@0.27.7': + resolution: {integrity: sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-ppc64@0.18.20': + resolution: {integrity: sha512-WHPyeScRNcmANnLQkq6AfyXRFr5D6N2sKgkFo2FqguP44Nw2eyDlbTdZwd9GYk98DZG9QItIiTlFLHJHjxP3FA==} + engines: {node: '>=12'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-ppc64@0.25.12': + resolution: {integrity: sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-ppc64@0.27.7': + resolution: {integrity: sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-riscv64@0.18.20': + resolution: {integrity: sha512-WSxo6h5ecI5XH34KC7w5veNnKkju3zBRLEQNY7mv5mtBmrP/MjNBCAlsM2u5hDBlS3NGcTQpoBvRzqBcRtpq1A==} + engines: {node: '>=12'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-riscv64@0.25.12': + resolution: {integrity: sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-riscv64@0.27.7': + resolution: {integrity: sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-s390x@0.18.20': + resolution: {integrity: sha512-+8231GMs3mAEth6Ja1iK0a1sQ3ohfcpzpRLH8uuc5/KVDFneH6jtAJLFGafpzpMRO6DzJ6AvXKze9LfFMrIHVQ==} + engines: {node: '>=12'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-s390x@0.25.12': + resolution: {integrity: sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-s390x@0.27.7': + resolution: {integrity: sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-x64@0.18.20': + resolution: {integrity: sha512-UYqiqemphJcNsFEskc73jQ7B9jgwjWrSayxawS6UVFZGWrAAtkzjxSqnoclCXxWtfwLdzU+vTpcNYhpn43uP1w==} + engines: {node: '>=12'} + cpu: [x64] + os: [linux] + + '@esbuild/linux-x64@0.25.12': + resolution: {integrity: sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + + '@esbuild/linux-x64@0.27.7': + resolution: {integrity: sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + + '@esbuild/netbsd-arm64@0.25.12': + resolution: {integrity: sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + + '@esbuild/netbsd-arm64@0.27.7': + resolution: {integrity: sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + + '@esbuild/netbsd-x64@0.18.20': + resolution: {integrity: sha512-iO1c++VP6xUBUmltHZoMtCUdPlnPGdBom6IrO4gyKPFFVBKioIImVooR5I83nTew5UOYrk3gIJhbZh8X44y06A==} + engines: {node: '>=12'} + cpu: [x64] + os: [netbsd] + + '@esbuild/netbsd-x64@0.25.12': + resolution: {integrity: sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + + '@esbuild/netbsd-x64@0.27.7': + resolution: {integrity: sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + + '@esbuild/openbsd-arm64@0.25.12': + resolution: {integrity: sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + + '@esbuild/openbsd-arm64@0.27.7': + resolution: {integrity: sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + + '@esbuild/openbsd-x64@0.18.20': + resolution: {integrity: sha512-e5e4YSsuQfX4cxcygw/UCPIEP6wbIL+se3sxPdCiMbFLBWu0eiZOJ7WoD+ptCLrmjZBK1Wk7I6D/I3NglUGOxg==} + engines: {node: '>=12'} + cpu: [x64] + os: [openbsd] + + '@esbuild/openbsd-x64@0.25.12': + resolution: {integrity: sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + + '@esbuild/openbsd-x64@0.27.7': + resolution: {integrity: sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + + '@esbuild/openharmony-arm64@0.25.12': + resolution: {integrity: sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openharmony] + + '@esbuild/openharmony-arm64@0.27.7': + resolution: {integrity: sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openharmony] + + '@esbuild/sunos-x64@0.18.20': + resolution: {integrity: sha512-kDbFRFp0YpTQVVrqUd5FTYmWo45zGaXe0X8E1G/LKFC0v8x0vWrhOWSLITcCn63lmZIxfOMXtCfti/RxN/0wnQ==} + engines: {node: '>=12'} + cpu: [x64] + os: [sunos] + + '@esbuild/sunos-x64@0.25.12': + resolution: {integrity: sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + + '@esbuild/sunos-x64@0.27.7': + resolution: {integrity: sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + + '@esbuild/win32-arm64@0.18.20': + resolution: {integrity: sha512-ddYFR6ItYgoaq4v4JmQQaAI5s7npztfV4Ag6NrhiaW0RrnOXqBkgwZLofVTlq1daVTQNhtI5oieTvkRPfZrePg==} + engines: {node: '>=12'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-arm64@0.25.12': + resolution: {integrity: sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-arm64@0.27.7': + resolution: {integrity: sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-ia32@0.18.20': + resolution: {integrity: sha512-Wv7QBi3ID/rROT08SABTS7eV4hX26sVduqDOTe1MvGMjNd3EjOz4b7zeexIR62GTIEKrfJXKL9LFxTYgkyeu7g==} + engines: {node: '>=12'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-ia32@0.25.12': + resolution: {integrity: sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-ia32@0.27.7': + resolution: {integrity: sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-x64@0.18.20': + resolution: {integrity: sha512-kTdfRcSiDfQca/y9QIkng02avJ+NCaQvrMejlsB3RRv5sE9rRoeBPISaZpKxHELzRxZyLvNts1P27W3wV+8geQ==} + engines: {node: '>=12'} + cpu: [x64] + os: [win32] + + '@esbuild/win32-x64@0.25.12': + resolution: {integrity: sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + + '@esbuild/win32-x64@0.27.7': + resolution: {integrity: sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + + '@hono/node-server@1.19.14': + resolution: {integrity: sha512-GwtvgtXxnWsucXvbQXkRgqksiH2Qed37H9xHZocE5sA3N8O8O8/8FA3uclQXxXVzc9XBZuEOMK7+r02FmSpHtw==} + engines: {node: '>=18.14.1'} + peerDependencies: + hono: ^4 + + '@jridgewell/gen-mapping@0.3.13': + resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} + + '@jridgewell/remapping@2.3.5': + resolution: {integrity: sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==} + + '@jridgewell/resolve-uri@3.1.2': + resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} + engines: {node: '>=6.0.0'} + + '@jridgewell/sourcemap-codec@1.5.5': + resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} + + '@jridgewell/trace-mapping@0.3.31': + resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + + '@lezer/common@1.5.2': + resolution: {integrity: sha512-sxQE460fPZyU3sdc8lafxiPwJHBzZRy/udNFynGQky1SePYBdhkBl1kOagA9uT3pxR8K09bOrmTUqA9wb/PjSQ==} + + '@lezer/highlight@1.2.3': + resolution: {integrity: sha512-qXdH7UqTvGfdVBINrgKhDsVTJTxactNNxLk7+UMwZhU13lMHaOBlJe9Vqp907ya56Y3+ed2tlqzys7jDkTmW0g==} + + '@lezer/lr@1.4.10': + resolution: {integrity: sha512-rnCpTIBafOx4mRp43xOxDJbFipJm/c0cia/V5TiGlhmMa+wsSdoGmUN3w5Bqrks/09Q/D4tNAmWaT8p6NRi77A==} + + '@marijn/find-cluster-break@1.0.2': + resolution: {integrity: sha512-l0h88YhZFyKdXIFNfSWpyjStDjGHwZ/U7iobcK1cQQD8sejsONdQtTVU+1wVN1PBw40PiiHB1vA5S7VTfQiP9g==} + + '@nodable/entities@2.1.0': + resolution: {integrity: sha512-nyT7T3nbMyBI/lvr6L5TyWbFJAI9FTgVRakNoBqCD+PmID8DzFrrNdLLtHMwMszOtqZa8PAOV24ZqDnQrhQINA==} + + '@oxlint/binding-android-arm-eabi@1.63.0': + resolution: {integrity: sha512-A9xLtQt7i0OA1PoB/meog6kikXI9CdwEp7ZwQqmgnpKn3G3b1orvTDy8CQ6T7w1HvDrgWGB78PkFKcWgibcTCg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [android] + + '@oxlint/binding-android-arm64@1.63.0': + resolution: {integrity: sha512-SQo+ZMvdR9l3CxZp5W5gFNxSiDxclY6lOzzNpKYLF8asESpm3Pwumx0gER5T7aHLF1/2BAAtLD3DiDkdgy4V1A==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [android] + + '@oxlint/binding-darwin-arm64@1.63.0': + resolution: {integrity: sha512-6W82XjJDTmMnjg30427l0dufpnyLoq7wEukKdM6/g2VIybRVuQiBVh43EA4b+UxZ3+tLcKm+Or/pXGNgLCEU8g==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [darwin] + + '@oxlint/binding-darwin-x64@1.63.0': + resolution: {integrity: sha512-CnWd/YCuVG5W1BYkjJEVbJG11o526O9qAwBEQM+nh8K19CRFUkFdROXCyYkGmroHEYQe4vgQ6+lh3550Lp35Xw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [darwin] + + '@oxlint/binding-freebsd-x64@1.63.0': + resolution: {integrity: sha512-a4eZAqrmtajqcxfdAzC+l7g3PaE3V8hpAYqqeD3fTxLXOMFdK3eNTZrU80n4dDEVm0JXy1aL5PqvqWldBl6zYA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [freebsd] + + '@oxlint/binding-linux-arm-gnueabihf@1.63.0': + resolution: {integrity: sha512-tYUtU9TdbU3uXF5D62g5zXJ13iniFGhXQx5vp9cyEjGdbSAY3VdFBSaldYvyoDmgMZ0ZYuwQP1Y4t2Fhejwa0w==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + + '@oxlint/binding-linux-arm-musleabihf@1.63.0': + resolution: {integrity: sha512-I5r3twFf776UZg9dmRo2xbrKt00tTkORXEVe0ctg4vdTkQvJAjiCHxnbAU2HL1AiJ9cqADA76MAliuilsAWnvg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + + '@oxlint/binding-linux-arm64-gnu@1.63.0': + resolution: {integrity: sha512-t7ltUkg6FFh4b564QyGir8xIj/QZbXu8FlcRkcyW9+ztr/mfRHlvUOFd95pJCXi9s/L5DrUeWWgpXRS+V+6igQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@oxlint/binding-linux-arm64-musl@1.63.0': + resolution: {integrity: sha512-Q5mmZy/XWjuYFUuQyYjOvZ5U/JkKEwnpir6hGxhh6HcdP0V/BKxLo8dqkfF/t7r7AguB17dfS/8+go5AQDRR6g==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@oxlint/binding-linux-ppc64-gnu@1.63.0': + resolution: {integrity: sha512-uBGtuZ0TzLB4x5wVa82HGNvYqY8buwDhyCnCP0R0gkk9szqVsP0MeTtD5HX7EsEuFIt+aYmYxuxeVxs3nTSwtQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@oxlint/binding-linux-riscv64-gnu@1.63.0': + resolution: {integrity: sha512-h4s6FwxE+9MeA181o0dnDwHP32Y/bG8EiB/vrD6Ib+AMt6haigDc/0bUtI/sLmQDBMJnUfaCmtSSrEAqjtEVrA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@oxlint/binding-linux-riscv64-musl@1.63.0': + resolution: {integrity: sha512-2EaNcCBR8Mcjl5ARtuN3BdEpVkX7KpjSjMGZ/mJMIeaXgTtdz5ytg2VwygMSStA/k0ixfvZFoZOfjDEcouV5vQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [riscv64] + os: [linux] + libc: [musl] + + '@oxlint/binding-linux-s390x-gnu@1.63.0': + resolution: {integrity: sha512-p4hlf/fd7TrYYl3QrWWD0GocqJefwMu3cHQhmi2FvEB/YOvFb5DZN3SMBaPi7B1TM5DeypkEtrVib674q1KKPg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@oxlint/binding-linux-x64-gnu@1.63.0': + resolution: {integrity: sha512-Vgq9rkRVcPcjbcH+ihYTfpeR7vCXfqpd+z5ItTGc0yYUV59L5ceHYN1iV4H9bKGV7Rn5hkVc7x3mSvHegduENA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@oxlint/binding-linux-x64-musl@1.63.0': + resolution: {integrity: sha512-3/Lkq/ncooA61rorrC+ZQed1Bc4VpGj+WnGsp58zmxKgvZ2vhreu+dcVyr3mX8NUpq7mfZ4gDDTou/yrF1Pd7A==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [musl] + + '@oxlint/binding-openharmony-arm64@1.63.0': + resolution: {integrity: sha512-0/EdD/6hDkx5Mfd769PTjvEM8mZ/6Dfukp1dBCL/2PjlIVGEtYdNZyok6ChqYPsT9JcFnlQnUeQzO0/1L/oC9w==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [openharmony] + + '@oxlint/binding-win32-arm64-msvc@1.63.0': + resolution: {integrity: sha512-wb0CUkN8ngwPiRQBjD1Cj0LsHeNvm+Xt6YBHDMtj2DVQVD6Oj8Ri7g6BD+KICf6LaBqZlmzOvy6nF9E/8yyGOg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [win32] + + '@oxlint/binding-win32-ia32-msvc@1.63.0': + resolution: {integrity: sha512-BX5iq+ovdNlVYhSn5qPMUIT0uwAwt2lmEnCnzK+Gkhw4DovIvhGb96OFhV8yzQNUnQxn/xGkOR+X+BLrLDNm8w==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ia32] + os: [win32] + + '@oxlint/binding-win32-x64-msvc@1.63.0': + resolution: {integrity: sha512-QeN/WELOfsXMeYwxvfgQrl6CbVftYUCZsGXHjXQd5Trccm8+i4gmtxaOui4xbJQaiDlviF8F3yLSBloQUeFsfA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [win32] + + '@rolldown/pluginutils@1.0.0-beta.27': + resolution: {integrity: sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==} + + '@rollup/rollup-android-arm-eabi@4.60.3': + resolution: {integrity: sha512-x35CNW/ANXG3hE/EZpRU8MXX1JDN86hBb2wMGAtltkz7pc6cxgjpy1OMMfDosOQ+2hWqIkag/fGok1Yady9nGw==} + cpu: [arm] + os: [android] + + '@rollup/rollup-android-arm64@4.60.3': + resolution: {integrity: sha512-xw3xtkDApIOGayehp2+Rz4zimfkaX65r4t47iy+ymQB2G4iJCBBfj0ogVg5jpvjpn8UWn/+q9tprxleYeNp3Hw==} + cpu: [arm64] + os: [android] + + '@rollup/rollup-darwin-arm64@4.60.3': + resolution: {integrity: sha512-vo6Y5Qfpx7/5EaamIwi0WqW2+zfiusVihKatLvtN1VFVy3D13uERk/6gZLU1UiHRL6fDXqj/ELIeVRGnvcTE1g==} + cpu: [arm64] + os: [darwin] + + '@rollup/rollup-darwin-x64@4.60.3': + resolution: {integrity: sha512-D+0QGcZhBzTN82weOnsSlY7V7+RMmPuF1CkbxyMAGE8+ZHeUjyb76ZiWmBlCu//AQQONvxcqRbwZTajZKqjuOw==} + cpu: [x64] + os: [darwin] + + '@rollup/rollup-freebsd-arm64@4.60.3': + resolution: {integrity: sha512-6HnvHCT7fDyj6R0Ph7A6x8dQS/S38MClRWeDLqc0MdfWkxjiu1HSDYrdPhqSILzjTIC/pnXbbJbo+ft+gy/9hQ==} + cpu: [arm64] + os: [freebsd] + + '@rollup/rollup-freebsd-x64@4.60.3': + resolution: {integrity: sha512-KHLgC3WKlUYW3ShFKnnosZDOJ0xjg9zp7au3sIm2bs/tGBeC2ipmvRh/N7JKi0t9Ue20C0dpEshi8WUubg+cnA==} + cpu: [x64] + os: [freebsd] + + '@rollup/rollup-linux-arm-gnueabihf@4.60.3': + resolution: {integrity: sha512-DV6fJoxEYWJOvaZIsok7KrYl0tPvga5OZ2yvKHNNYyk/2roMLqQAbGhr78EQ5YhHpnhLKJD3S1WFusAkmUuV5g==} + cpu: [arm] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-arm-musleabihf@4.60.3': + resolution: {integrity: sha512-mQKoJAzvuOs6F+TZybQO4GOTSMUu7v0WdxEk24krQ/uUxXoPTtHjuaUuPmFhtBcM4K0ons8nrE3JyhTuCFtT/w==} + cpu: [arm] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-arm64-gnu@4.60.3': + resolution: {integrity: sha512-Whjj2qoiJ6+OOJMGptTYazaJvjOJm+iKHpXQM1P3LzGjt7Ff++Tp7nH4N8J/BUA7R9IHfDyx4DJIflifwnbmIA==} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-arm64-musl@4.60.3': + resolution: {integrity: sha512-4YTNHKqGng5+yiZt3mg77nmyuCfmNfX4fPmyUapBcIk+BdwSwmCWGXOUxhXbBEkFHtoN5boLj/5NON+u5QC9tg==} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-loong64-gnu@4.60.3': + resolution: {integrity: sha512-SU3kNlhkpI4UqlUc2VXPGK9o886ZsSeGfMAX2ba2b8DKmMXq4AL7KUrkSWVbb7koVqx41Yczx6dx5PNargIrEA==} + cpu: [loong64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-loong64-musl@4.60.3': + resolution: {integrity: sha512-6lDLl5h4TXpB1mTf2rQWnAk/LcXrx9vBfu/DT5TIPhvMhRWaZ5MxkIc8u4lJAmBo6klTe1ywXIUHFjylW505sg==} + cpu: [loong64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-ppc64-gnu@4.60.3': + resolution: {integrity: sha512-BMo8bOw8evlup/8G+cj5xWtPyp93xPdyoSN16Zy90Q2QZ0ZYRhCt6ZJSwbrRzG9HApFabjwj2p25TUPDWrhzqQ==} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-ppc64-musl@4.60.3': + resolution: {integrity: sha512-E0L8X1dZN1/Rph+5VPF6Xj2G7JJvMACVXtamTJIDrVI44Y3K+G8gQaMEAavbqCGTa16InptiVrX6eM6pmJ+7qA==} + cpu: [ppc64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-riscv64-gnu@4.60.3': + resolution: {integrity: sha512-oZJ/WHaVfHUiRAtmTAeo3DcevNsVvH8mbvodjZy7D5QKvCefO371SiKRpxoDcCxB3PTRTLayWBkvmDQKTcX/sw==} + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-riscv64-musl@4.60.3': + resolution: {integrity: sha512-Dhbyh7j9FybM3YaTgaHmVALwA8AkUwTPccyCQ79TG9AJUsMQqgN1DDEZNr4+QUfwiWvLDumW5vdwzoeUF+TNxQ==} + cpu: [riscv64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-s390x-gnu@4.60.3': + resolution: {integrity: sha512-cJd1X5XhHHlltkaypz1UcWLA8AcoIi1aWhsvaWDskD1oz2eKCypnqvTQ8ykMNI0RSmm7NkTdSqSSD7zM0xa6Ig==} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-x64-gnu@4.60.3': + resolution: {integrity: sha512-DAZDBHQfG2oQuhY7mc6I3/qB4LU2fQCjRvxbDwd/Jdvb9fypP4IJ4qmtu6lNjes6B531AI8cg1aKC2di97bUxA==} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-x64-musl@4.60.3': + resolution: {integrity: sha512-cRxsE8c13mZOh3vP+wLDxpQBRrOHDIGOWyDL93Sy0Ga8y515fBcC2pjUfFwUe5T7tqvTvWbCpg1URM/AXdWIXA==} + cpu: [x64] + os: [linux] + libc: [musl] + + '@rollup/rollup-openbsd-x64@4.60.3': + resolution: {integrity: sha512-QaWcIgRxqEdQdhJqW4DJctsH6HCmo5vHxY0krHSX4jMtOqfzC+dqDGuHM87bu4H8JBeibWx7jFz+h6/4C8wA5Q==} + cpu: [x64] + os: [openbsd] + + '@rollup/rollup-openharmony-arm64@4.60.3': + resolution: {integrity: sha512-AaXwSvUi3QIPtroAUw1t5yHGIyqKEXwH54WUocFolZhpGDruJcs8c+xPNDRn4XiQsS7MEwnYsHW2l0MBLDMkWg==} + cpu: [arm64] + os: [openharmony] + + '@rollup/rollup-win32-arm64-msvc@4.60.3': + resolution: {integrity: sha512-65LAKM/bAWDqKNEelHlcHvm2V+Vfb8C6INFxQXRHCvaVN1rJfwr4NvdP4FyzUaLqWfaCGaadf6UbTm8xJeYfEg==} + cpu: [arm64] + os: [win32] + + '@rollup/rollup-win32-ia32-msvc@4.60.3': + resolution: {integrity: sha512-EEM2gyhBF5MFnI6vMKdX1LAosE627RGBzIoGMdLloPZkXrUN0Ckqgr2Qi8+J3zip/8NVVro3/FjB+tjhZUgUHA==} + cpu: [ia32] + os: [win32] + + '@rollup/rollup-win32-x64-gnu@4.60.3': + resolution: {integrity: sha512-E5Eb5H/DpxaoXH++Qkv28RcUJboMopmdDUALBczvHMf7hNIxaDZqwY5lK12UK1BHacSmvupoEWGu+n993Z0y1A==} + cpu: [x64] + os: [win32] + + '@rollup/rollup-win32-x64-msvc@4.60.3': + resolution: {integrity: sha512-hPt/bgL5cE+Qp+/TPHBqptcAgPzgj46mPcg/16zNUmbQk0j+mOEQV/+Lqu8QRtDV3Ek95Q6FeFITpuhl6OTsAA==} + cpu: [x64] + os: [win32] + + '@sinclair/typebox@0.34.49': + resolution: {integrity: sha512-brySQQs7Jtn0joV8Xh9ZV/hZb9Ozb0pmazDIASBkYKCjXrXU3mpcFahmK/z4YDhGkQvP9mWJbVyahdtU5wQA+A==} + + '@smithy/chunked-blob-reader-native@4.2.3': + resolution: {integrity: sha512-jA5k5Udn7Y5717L86h4EIv06wIr3xn8GM1qHRi/Nf31annXcXHJjBKvgztnbn2TxH3xWrPBfgwHsOwZf0UmQWw==} + engines: {node: '>=18.0.0'} + + '@smithy/chunked-blob-reader@5.2.2': + resolution: {integrity: sha512-St+kVicSyayWQca+I1rGitaOEH6uKgE8IUWoYnnEX26SWdWQcL6LvMSD19Lg+vYHKdT9B2Zuu7rd3i6Wnyb/iw==} + engines: {node: '>=18.0.0'} + + '@smithy/config-resolver@4.4.17': + resolution: {integrity: sha512-TzDZcAnhTyAHbXVxWZo7/tEcrIeFq20IBk8So3OLOetWpR8EwY/yEqBMBFaJMeyEiREDq4NfEl+qO3OAUD+vbQ==} + engines: {node: '>=18.0.0'} + + '@smithy/core@3.23.17': + resolution: {integrity: sha512-x7BlLbUFL8NWCGjMF9C+1N5cVCxcPa7g6Tv9B4A2luWx3be3oU8hQ96wIwxe/s7OhIzvoJH73HAUSg5JXVlEtQ==} + engines: {node: '>=18.0.0'} + + '@smithy/credential-provider-imds@4.2.14': + resolution: {integrity: sha512-Au28zBN48ZAoXdooGUHemuVBrkE+Ie6RPmGNIAJsFqj33Vhb6xAgRifUydZ2aY+M+KaMAETAlKk5NC5h1G7wpg==} + engines: {node: '>=18.0.0'} + + '@smithy/eventstream-codec@4.2.14': + resolution: {integrity: sha512-erZq0nOIpzfeZdCyzZjdJb4nVSKLUmSkaQUVkRGQTXs30gyUGeKnrYEg+Xe1W5gE3aReS7IgsvANwVPxSzY6Pw==} + engines: {node: '>=18.0.0'} + + '@smithy/eventstream-serde-browser@4.2.14': + resolution: {integrity: sha512-8IelTCtTctWRbb+0Dcy+C0aICh1qa0qWXqgjcXDmMuCvPJRnv26hiDZoAau2ILOniki65mCPKqOQs/BaWvO4CQ==} + engines: {node: '>=18.0.0'} + + '@smithy/eventstream-serde-config-resolver@4.3.14': + resolution: {integrity: sha512-sqHiHpYRYo3FJlaIxD1J8PhbcmJAm7IuM16mVnwSkCToD7g00IBZzKuiLNMGmftULmEUX6/UAz8/NN5uMP8bVA==} + engines: {node: '>=18.0.0'} + + '@smithy/eventstream-serde-node@4.2.14': + resolution: {integrity: sha512-Ht/8BuGlKfFTy0H3+8eEu0vdpwGztCnaLLXtpXNdQqiR7Hj4vFScU3T436vRAjATglOIPjJXronY+1WxxNLSiw==} + engines: {node: '>=18.0.0'} + + '@smithy/eventstream-serde-universal@4.2.14': + resolution: {integrity: sha512-lWyt4T2XQZUZgK3tQ3Wn0w3XBvZsK/vjTuJl6bXbnGZBHH0ZUSONTYiK9TgjTTzU54xQr3DRFwpjmhp0oLm3gg==} + engines: {node: '>=18.0.0'} + + '@smithy/fetch-http-handler@5.3.17': + resolution: {integrity: sha512-bXOvQzaSm6MnmLaWA1elgfQcAtN4UP3vXqV97bHuoOrHQOJiLT3ds6o9eo5bqd0TJfRFpzdGnDQdW3FACiAVdw==} + engines: {node: '>=18.0.0'} + + '@smithy/hash-blob-browser@4.2.15': + resolution: {integrity: sha512-0PJ4Al3fg2nM4qKrAIxyNcApgqHAXcBkN8FeizOz69z0rb26uZ6lMESYtxegaTlXB5Hj84JfwMPavMrwDMjucA==} + engines: {node: '>=18.0.0'} + + '@smithy/hash-node@4.2.14': + resolution: {integrity: sha512-8ZBDY2DD4wr+GGjTpPtiglEsqr0lUP+KHqgZcWczFf6qeZ/YRjMIOoQWVQlmwu7EtxKTd8YXD8lblmYcpBIA1g==} + engines: {node: '>=18.0.0'} + + '@smithy/hash-stream-node@4.2.14': + resolution: {integrity: sha512-tw4GANWkZPb6+BdD4Fgucqzey2+r73Z/GRo9zklsCdwrnxxumUV83ZIaBDdudV4Ylazw3EPTiJZhpX42105ruQ==} + engines: {node: '>=18.0.0'} + + '@smithy/invalid-dependency@4.2.14': + resolution: {integrity: sha512-c21qJiTSb25xvvOp+H2TNZzPCngrvl5vIPqPB8zQ/DmJF4QWXO19x1dWfMJZ6wZuuWUPPm0gV8C0cU3+ifcWuw==} + engines: {node: '>=18.0.0'} + + '@smithy/is-array-buffer@2.2.0': + resolution: {integrity: sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA==} + engines: {node: '>=14.0.0'} + + '@smithy/is-array-buffer@4.2.2': + resolution: {integrity: sha512-n6rQ4N8Jj4YTQO3YFrlgZuwKodf4zUFs7EJIWH86pSCWBaAtAGBFfCM7Wx6D2bBJ2xqFNxGBSrUWswT3M0VJow==} + engines: {node: '>=18.0.0'} + + '@smithy/md5-js@4.2.14': + resolution: {integrity: sha512-V2v0vx+h0iUSNG1Alt+GNBMSLGCrl9iVsdd+Ap67HPM9PN479x12V8LkuMoKImNZxn3MXeuyUjls+/7ZACZghA==} + engines: {node: '>=18.0.0'} + + '@smithy/middleware-content-length@4.2.14': + resolution: {integrity: sha512-xhHq7fX4/3lv5NHxLUk3OeEvl0xZ+Ek3qIbWaCL4f9JwgDZEclPBElljaZCAItdGPQl/kSM4LPMOpy1MYgprpw==} + engines: {node: '>=18.0.0'} + + '@smithy/middleware-endpoint@4.4.32': + resolution: {integrity: sha512-ZZkgyjnJppiZbIm6Qbx92pbXYi1uzenIvGhBSCDlc7NwuAkiqSgS75j1czAD25ZLs2FjMjYy1q7gyRVWG6JA0Q==} + engines: {node: '>=18.0.0'} + + '@smithy/middleware-retry@4.5.7': + resolution: {integrity: sha512-bRt6ZImqVSeTk39Nm81K20ObIiAZ3WefY7G6+iz/0tZjs4dgRRjvRX2sgsH+zi6iDCRR/aQvQofLKxxz4rPBZg==} + engines: {node: '>=18.0.0'} + + '@smithy/middleware-serde@4.2.20': + resolution: {integrity: sha512-Lx9JMO9vArPtiChE3wbEZ5akMIDQpWQtlu90lhACQmNOXcGXRbaDywMHDzuDZ2OkZzP+9wQfZi3YJT9F67zTQQ==} + engines: {node: '>=18.0.0'} + + '@smithy/middleware-stack@4.2.14': + resolution: {integrity: sha512-2dvkUKLuFdKsCRmOE4Mn63co0Djtsm+JMh0bYZQupN1pJwMeE8FmQmRLLzzEMN0dnNi7CDCYYH8F0EVwWiPBeA==} + engines: {node: '>=18.0.0'} + + '@smithy/node-config-provider@4.3.14': + resolution: {integrity: sha512-S+gFjyo/weSVL0P1b9Ts8C/CwIfNCgUPikk3sl6QVsfE/uUuO+QsF+NsE/JkpvWqqyz1wg7HFdiaZuj5CoBMRg==} + engines: {node: '>=18.0.0'} + + '@smithy/node-http-handler@4.6.1': + resolution: {integrity: sha512-iB+orM4x3xrr57X3YaXazfKnntl0LHlZB1kcXSGzMV1Tt0+YwEjGlbjk/44qEGtBzXAz6yFDzkYTKSV6Pj2HUg==} + engines: {node: '>=18.0.0'} + + '@smithy/property-provider@4.2.14': + resolution: {integrity: sha512-WuM31CgfsnQ/10i7NYr0PyxqknD72Y5uMfUMVSniPjbEPceiTErb4eIqJQ+pdxNEAUEWrewrGjIRjVbVHsxZiQ==} + engines: {node: '>=18.0.0'} + + '@smithy/protocol-http@5.3.14': + resolution: {integrity: sha512-dN5F8kHx8RNU0r+pCwNmFZyz6ChjMkzShy/zup6MtkRmmix4vZzJdW+di7x//b1LiynIev88FM18ie+wwPcQtQ==} + engines: {node: '>=18.0.0'} + + '@smithy/querystring-builder@4.2.14': + resolution: {integrity: sha512-XYA5Z0IqTeF+5XDdh4BBmSA0HvbgVZIyv4cmOoUheDNR57K1HgBp9ukUMx3Cr3XpDHHpLBnexPE3LAtDsZkj2A==} + engines: {node: '>=18.0.0'} + + '@smithy/querystring-parser@4.2.14': + resolution: {integrity: sha512-hr+YyqBD23GVvRxGGrcc/oOeNlK3PzT5Fu4dzrDXxzS1LpFiuL2PQQqKPs87M79aW7ziMs+nvB3qdw77SqE7Lw==} + engines: {node: '>=18.0.0'} + + '@smithy/service-error-classification@4.3.1': + resolution: {integrity: sha512-aUQuDGh760ts/8MU+APjIZhlLPKhIIfqyzZaJikLEIMrdxFvxuLYD0WxWzaYWpmLbQlXDe9p7EWM3HsBe0K6Gw==} + engines: {node: '>=18.0.0'} + + '@smithy/shared-ini-file-loader@4.4.9': + resolution: {integrity: sha512-495/V2I15SHgedSJoDPD23JuSfKAp726ZI1V0wtjB07Wh7q/0tri/0e0DLefZCHgxZonrGKt/OCTpAtP1wE1kQ==} + engines: {node: '>=18.0.0'} + + '@smithy/signature-v4@5.3.14': + resolution: {integrity: sha512-1D9Y/nmlVjCeSivCbhZ7hgEpmHyY1h0GvpSZt3l0xcD9JjmjVC1CHOozS6+Gh+/ldMH8JuJ6cujObQqfayAVFA==} + engines: {node: '>=18.0.0'} + + '@smithy/smithy-client@4.12.13': + resolution: {integrity: sha512-y/Pcj1V9+qG98gyu1gvftHB7rDpdh+7kIBIggs55yGm3JdtBV8GT8IFF3a1qxZ79QnaJHX9GXzvBG6tAd+czJA==} + engines: {node: '>=18.0.0'} + + '@smithy/types@4.14.1': + resolution: {integrity: sha512-59b5HtSVrVR/eYNei3BUj3DCPKD/G7EtDDe7OEJE7i7FtQFugYo6MxbotS8mVJkLNVf8gYaAlEBwwtJ9HzhWSg==} + engines: {node: '>=18.0.0'} + + '@smithy/url-parser@4.2.14': + resolution: {integrity: sha512-p06BiBigJ8bTA3MgnOfCtDUWnAMY0YfedO/GRpmc7p+wg3KW8vbXy1xwSu5ASy0wV7rRYtlfZOIKH4XqfhjSQQ==} + engines: {node: '>=18.0.0'} + + '@smithy/util-base64@4.3.2': + resolution: {integrity: sha512-XRH6b0H/5A3SgblmMa5ErXQ2XKhfbQB+Fm/oyLZ2O2kCUrwgg55bU0RekmzAhuwOjA9qdN5VU2BprOvGGUkOOQ==} + engines: {node: '>=18.0.0'} + + '@smithy/util-body-length-browser@4.2.2': + resolution: {integrity: sha512-JKCrLNOup3OOgmzeaKQwi4ZCTWlYR5H4Gm1r2uTMVBXoemo1UEghk5vtMi1xSu2ymgKVGW631e2fp9/R610ZjQ==} + engines: {node: '>=18.0.0'} + + '@smithy/util-body-length-node@4.2.3': + resolution: {integrity: sha512-ZkJGvqBzMHVHE7r/hcuCxlTY8pQr1kMtdsVPs7ex4mMU+EAbcXppfo5NmyxMYi2XU49eqaz56j2gsk4dHHPG/g==} + engines: {node: '>=18.0.0'} + + '@smithy/util-buffer-from@2.2.0': + resolution: {integrity: sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA==} + engines: {node: '>=14.0.0'} + + '@smithy/util-buffer-from@4.2.2': + resolution: {integrity: sha512-FDXD7cvUoFWwN6vtQfEta540Y/YBe5JneK3SoZg9bThSoOAC/eGeYEua6RkBgKjGa/sz6Y+DuBZj3+YEY21y4Q==} + engines: {node: '>=18.0.0'} + + '@smithy/util-config-provider@4.2.2': + resolution: {integrity: sha512-dWU03V3XUprJwaUIFVv4iOnS1FC9HnMHDfUrlNDSh4315v0cWyaIErP8KiqGVbf5z+JupoVpNM7ZB3jFiTejvQ==} + engines: {node: '>=18.0.0'} + + '@smithy/util-defaults-mode-browser@4.3.49': + resolution: {integrity: sha512-a5bNrdiONYB/qE2BuKegvUMd/+ZDwdg4vsNuuSzYE8qs2EYAdK9CynL+Rzn29PbPiUqoz/cbpRbcLzD5lEevHw==} + engines: {node: '>=18.0.0'} + + '@smithy/util-defaults-mode-node@4.2.54': + resolution: {integrity: sha512-g1cvrJvOnzeJgEdf7AE4luI7gp6L8weE0y9a9wQUSGtjb8QRHDbCJYuE4Sy0SD9N8RrnNPFsPltAz/OSoBR9Zw==} + engines: {node: '>=18.0.0'} + + '@smithy/util-endpoints@3.4.2': + resolution: {integrity: sha512-a55Tr+3OKld4TTtnT+RhKOQHyPxm3j/xL4OR83WBUhLJaKDS9dnJ7arRMOp3t31dcLhApwG9bgvrRXBHlLdIkg==} + engines: {node: '>=18.0.0'} + + '@smithy/util-hex-encoding@4.2.2': + resolution: {integrity: sha512-Qcz3W5vuHK4sLQdyT93k/rfrUwdJ8/HZ+nMUOyGdpeGA1Wxt65zYwi3oEl9kOM+RswvYq90fzkNDahPS8K0OIg==} + engines: {node: '>=18.0.0'} + + '@smithy/util-middleware@4.2.14': + resolution: {integrity: sha512-1Su2vj9RYNDEv/V+2E+jXkkwGsgR7dc4sfHn9Z7ruzQHJIEni9zzw5CauvRXlFJfmgcqYP8fWa0dkh2Q2YaQyw==} + engines: {node: '>=18.0.0'} + + '@smithy/util-retry@4.3.8': + resolution: {integrity: sha512-LUIxbTBi+OpvXpg91poGA6BdyoleMDLnfXjVDqyi2RvZmTveY5loE/FgYUBCR5LU2BThW2SoZRh8dTIIy38IPw==} + engines: {node: '>=18.0.0'} + + '@smithy/util-stream@4.5.25': + resolution: {integrity: sha512-/PFpG4k8Ze8Ei+mMKj3oiPICYekthuzePZMgZbCqMiXIHHf4n2aZ4Ps0aSRShycFTGuj/J6XldmC0x0DwednIA==} + engines: {node: '>=18.0.0'} + + '@smithy/util-uri-escape@4.2.2': + resolution: {integrity: sha512-2kAStBlvq+lTXHyAZYfJRb/DfS3rsinLiwb+69SstC9Vb0s9vNWkRwpnj918Pfi85mzi42sOqdV72OLxWAISnw==} + engines: {node: '>=18.0.0'} + + '@smithy/util-utf8@2.3.0': + resolution: {integrity: sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A==} + engines: {node: '>=14.0.0'} + + '@smithy/util-utf8@4.2.2': + resolution: {integrity: sha512-75MeYpjdWRe8M5E3AW0O4Cx3UadweS+cwdXjwYGBW5h/gxxnbeZ877sLPX/ZJA9GVTlL/qG0dXP29JWFCD1Ayw==} + engines: {node: '>=18.0.0'} + + '@smithy/util-waiter@4.3.0': + resolution: {integrity: sha512-JyjYmLAfS+pdxF92o4yLgEoy0zhayKTw73FU1aofLWwLcJw7iSqIY2exGmMTrl/lmZugP5p/zxdFSippJDfKWA==} + engines: {node: '>=18.0.0'} + + '@smithy/uuid@1.1.2': + resolution: {integrity: sha512-O/IEdcCUKkubz60tFbGA7ceITTAJsty+lBjNoorP4Z6XRqaFb/OjQjZODophEcuq68nKm6/0r+6/lLQ+XVpk8g==} + engines: {node: '>=18.0.0'} + + '@tailwindcss/node@4.3.0': + resolution: {integrity: sha512-aFb4gUhFOgdh9AXo4IzBEOzBkkAxm9VigwDJnMIYv3lcfXCJVesNfbEaBl4BNgVRyid92AmdviqwBUBRKSeY3g==} + + '@tailwindcss/oxide-android-arm64@4.3.0': + resolution: {integrity: sha512-TJPiq67tKlLuObP6RkwvVGDoxCMBVtDgKkLfa/uyj7/FyxvQwHS+UOnVrXXgbEsfUaMgiVvC4KbJnRr26ho4Ng==} + engines: {node: '>= 20'} + cpu: [arm64] + os: [android] + + '@tailwindcss/oxide-darwin-arm64@4.3.0': + resolution: {integrity: sha512-oMN/WZRb+SO37BmUElEgeEWuU8E/HXRkiODxJxLe1UTHVXLrdVSgfaJV7pSlhRGMSOiXLuxTIjfsF3wYvz8cgQ==} + engines: {node: '>= 20'} + cpu: [arm64] + os: [darwin] + + '@tailwindcss/oxide-darwin-x64@4.3.0': + resolution: {integrity: sha512-N6CUmu4a6bKVADfw77p+iw6Yd9Q3OBhe0veaDX+QazfuVYlQsHfDgxBrsjQ/IW+zywL8mTrNd0SdJT/zgtvMdA==} + engines: {node: '>= 20'} + cpu: [x64] + os: [darwin] + + '@tailwindcss/oxide-freebsd-x64@4.3.0': + resolution: {integrity: sha512-zDL5hBkQdH5C6MpqbK3gQAgP80tsMwSI26vjOzjJtNCMUo0lFgOItzHKBIupOZNQxt3ouPH7RPhvNhiTfCe5CQ==} + engines: {node: '>= 20'} + cpu: [x64] + os: [freebsd] + + '@tailwindcss/oxide-linux-arm-gnueabihf@4.3.0': + resolution: {integrity: sha512-R06HdNi7A7OEoMsf6d4tjZ71RCWnZQPHj2mnotSFURjNLdBC+cIgXQ7l81CqeoiQftjf6OOblxXMInMgN2VzMA==} + engines: {node: '>= 20'} + cpu: [arm] + os: [linux] + + '@tailwindcss/oxide-linux-arm64-gnu@4.3.0': + resolution: {integrity: sha512-qTJHELX8jetjhRQHCLilkVLmybpzNQAtaI/gaoVoidn/ufbNDbAo8KlK2J+yPoc8wQxvDxCmh/5lr8nC1+lTbg==} + engines: {node: '>= 20'} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@tailwindcss/oxide-linux-arm64-musl@4.3.0': + resolution: {integrity: sha512-Z6sukiQsngnWO+l39X4pPbiWT81IC+PLKF+PHxIlyZbGNb9MODfYlXEVlFvej5BOZInWX01kVyzeLvHsXhfczQ==} + engines: {node: '>= 20'} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@tailwindcss/oxide-linux-x64-gnu@4.3.0': + resolution: {integrity: sha512-DRNdQRpSGzRGfARVuVkxvM8Q12nh19l4BF/G7zGA1oe+9wcC6saFBHTISrpIcKzhiXtSrlSrluCfvMuledoCTQ==} + engines: {node: '>= 20'} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@tailwindcss/oxide-linux-x64-musl@4.3.0': + resolution: {integrity: sha512-Z0IADbDo8bh6I7h2IQMx601AdXBLfFpEdUotft86evd/8ZPflZe9COPO8Q1vw+pfLWIUo9zN/JGZvwuAJqduqg==} + engines: {node: '>= 20'} + cpu: [x64] + os: [linux] + libc: [musl] + + '@tailwindcss/oxide-wasm32-wasi@4.3.0': + resolution: {integrity: sha512-HNZGOUxEmElksYR7S6sC5jTeNGpobAsy9u7Gu0AskJ8/20FR9GqebUyB+HBcU/ax6BHuiuJi+Oda4B+YX6H1yA==} + engines: {node: '>=14.0.0'} + cpu: [wasm32] + bundledDependencies: + - '@napi-rs/wasm-runtime' + - '@emnapi/core' + - '@emnapi/runtime' + - '@tybys/wasm-util' + - '@emnapi/wasi-threads' + - tslib + + '@tailwindcss/oxide-win32-arm64-msvc@4.3.0': + resolution: {integrity: sha512-Pe+RPVTi1T+qymuuRpcdvwSVZjnll/f7n8gBxMMh3xLTctMDKqpdfGimbMyioqtLhUYZxdJ9wGNhV7MKHvgZsQ==} + engines: {node: '>= 20'} + cpu: [arm64] + os: [win32] + + '@tailwindcss/oxide-win32-x64-msvc@4.3.0': + resolution: {integrity: sha512-Mvrf2kXW/yeW/OTezZlCGOirXRcUuLIBx/5Y12BaPM7wJoryG6dfS/NJL8aBPqtTEx/Vm4T4vKzFUcKDT+TKUA==} + engines: {node: '>= 20'} + cpu: [x64] + os: [win32] + + '@tailwindcss/oxide@4.3.0': + resolution: {integrity: sha512-F7HZGBeN9I0/AuuJS5PwcD8xayx5ri5GhjYUDBEVYUkexyA/giwbDNjRVrxSezE3T250OU2K/wp/ltWx3UOefg==} + engines: {node: '>= 20'} + + '@tailwindcss/vite@4.3.0': + resolution: {integrity: sha512-t6J3OrB5Fc0ExuhohouH0fWUGMYL6PTLhW+E7zIk/pdbnJARZDCwjBznFnkh5ynRnIRSI4YjtTH0t6USjJISrw==} + peerDependencies: + vite: ^5.2.0 || ^6 || ^7 || ^8 + + '@types/babel__core@7.20.5': + resolution: {integrity: sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==} + + '@types/babel__generator@7.27.0': + resolution: {integrity: sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==} + + '@types/babel__template@7.4.4': + resolution: {integrity: sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==} + + '@types/babel__traverse@7.28.0': + resolution: {integrity: sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==} + + '@types/bcrypt@6.0.0': + resolution: {integrity: sha512-/oJGukuH3D2+D+3H4JWLaAsJ/ji86dhRidzZ/Od7H/i8g+aCmvkeCc6Ni/f9uxGLSQVCRZkX2/lqEFG2BvWtlQ==} + + '@types/better-sqlite3@7.6.13': + resolution: {integrity: sha512-NMv9ASNARoKksWtsq/SHakpYAYnhBrQgGD8zkLYk/jaK8jUGn08CfEdTRgYhMypUQAfzSP8W6gNLe0q19/t4VA==} + + '@types/estree@1.0.8': + resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} + + '@types/node@25.6.2': + resolution: {integrity: sha512-sokuT28dxf9JT5Kady1fsXOvI4HVpjZa95NKT5y9PNTIrs2AsobR4GFAA90ZG8M+nxVRLysCXsVj6eGC7Vbrlw==} + + '@types/nodemailer@8.0.0': + resolution: {integrity: sha512-fyf8jWULsCo0d0BuoQ75i6IeoHs47qcqxWc7yUdUcV0pOZGjUTTOvwdG1PRXUDqN/8A64yQdQdnA2pZgcdi+cA==} + + '@types/react-dom@19.2.3': + resolution: {integrity: sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==} + peerDependencies: + '@types/react': ^19.2.0 + + '@types/react@19.2.14': + resolution: {integrity: sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==} + + '@types/tar-stream@3.1.4': + resolution: {integrity: sha512-921gW0+g29mCJX0fRvqeHzBlE/XclDaAG0Ousy1LCghsOhvaKacDeRGEVzQP9IPfKn8Vysy7FEXAIxycpc/CMg==} + + '@vitejs/plugin-react@4.7.0': + resolution: {integrity: sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==} + engines: {node: ^14.18.0 || >=16.0.0} + peerDependencies: + vite: ^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 + + ajv-formats@3.0.1: + resolution: {integrity: sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==} + peerDependencies: + ajv: ^8.0.0 + peerDependenciesMeta: + ajv: + optional: true + + ajv@8.20.0: + resolution: {integrity: sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==} + + b4a@1.8.1: + resolution: {integrity: sha512-aiqre1Nr0B/6DgE2N5vwTc+2/oQZ4Wh1t4NznYY4E00y8LCt6NqdRv81so00oo27D8MVKTpUa/MwUUtBLXCoDw==} + peerDependencies: + react-native-b4a: '*' + peerDependenciesMeta: + react-native-b4a: + optional: true + + bare-events@2.8.2: + resolution: {integrity: sha512-riJjyv1/mHLIPX4RwiK+oW9/4c3TEUeORHKefKAKnZ5kyslbN+HXowtbaVEqt4IMUB7OXlfixcs6gsFeo/jhiQ==} + peerDependencies: + bare-abort-controller: '*' + peerDependenciesMeta: + bare-abort-controller: + optional: true + + bare-fs@4.7.1: + resolution: {integrity: sha512-WDRsyVN52eAx/lBamKD6uyw8H4228h/x0sGGGegOamM2cd7Pag88GfMQalobXI+HaEUxpCkbKQUDOQqt9wawRw==} + engines: {bare: '>=1.16.0'} + peerDependencies: + bare-buffer: '*' + peerDependenciesMeta: + bare-buffer: + optional: true + + bare-os@3.9.1: + resolution: {integrity: sha512-6M5XjcnsygQNPMCMPXSK379xrJFiZ/AEMNBmFEmQW8d/789VQATvriyi5r0HYTL9TkQ26rn3kgdTG3aisbrXkQ==} + engines: {bare: '>=1.14.0'} + + bare-path@3.0.0: + resolution: {integrity: sha512-tyfW2cQcB5NN8Saijrhqn0Zh7AnFNsnczRcuWODH0eYAXBsJ5gVxAUuNr7tsHSC6IZ77cA0SitzT+s47kot8Mw==} + + bare-stream@2.13.1: + resolution: {integrity: sha512-Vp0cnjYyrEC4whYTymQ+YZi6pBpfiICZO3cfRG8sy67ZNWe951urv1x4eW1BKNngw3U+3fPYb5JQvHbCtxH7Ow==} + peerDependencies: + bare-abort-controller: '*' + bare-buffer: '*' + bare-events: '*' + peerDependenciesMeta: + bare-abort-controller: + optional: true + bare-buffer: + optional: true + bare-events: + optional: true + + bare-url@2.4.3: + resolution: {integrity: sha512-Kccpc7ACfXaxfeInfqKcZtW4pT5YBn1mesc4sCsun6sRwtbJ4h+sNOaksUpYEJUKfN65YWC6Bw2OJEFiKxq8nQ==} + + base64-js@1.5.1: + resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==} + + baseline-browser-mapping@2.10.29: + resolution: {integrity: sha512-Asa2krT+XTPZINCS+2QcyS8WTkObE77RwkydwF7h6DmnKqbvlalz93m/dnphUyCa6SWSP51VgtEUf2FN+gelFQ==} + engines: {node: '>=6.0.0'} + hasBin: true + + bcrypt@6.0.0: + resolution: {integrity: sha512-cU8v/EGSrnH+HnxV2z0J7/blxH8gq7Xh2JFT6Aroax7UohdmiJJlxApMxtKfuI7z68NvvVcmR78k2LbT6efhRg==} + engines: {node: '>= 18'} + + better-sqlite3@12.9.0: + resolution: {integrity: sha512-wqUv4Gm3toFpHDQmaKD4QhZm3g1DjUBI0yzS4UBl6lElUmXFYdTQmmEDpAFa5o8FiFiymURypEnfVHzILKaxqQ==} + engines: {node: 20.x || 22.x || 23.x || 24.x || 25.x} + + bindings@1.5.0: + resolution: {integrity: sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==} + + bl@4.1.0: + resolution: {integrity: sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==} + + bowser@2.14.1: + resolution: {integrity: sha512-tzPjzCxygAKWFOJP011oxFHs57HzIhOEracIgAePE4pqB3LikALKnSzUyU4MGs9/iCEUuHlAJTjTc5M+u7YEGg==} + + browserslist@4.28.2: + resolution: {integrity: sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==} + engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} + hasBin: true + + buffer-from@1.1.2: + resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==} + + buffer@5.7.1: + resolution: {integrity: sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==} + + caniuse-lite@1.0.30001792: + resolution: {integrity: sha512-hVLMUZFgR4JJ6ACt1uEESvQN1/dBVqPAKY0hgrV70eN3391K6juAfTjKZLKvOMsx8PxA7gsY1/tLMMTcfFLLpw==} + + chownr@1.1.4: + resolution: {integrity: sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==} + + codemirror@6.0.2: + resolution: {integrity: sha512-VhydHotNW5w1UGK0Qj96BwSk/Zqbp9WbnyK2W/eVMv4QyF41INRGpjUhFJY7/uDNuudSc33a/PKr4iDqRduvHw==} + + convert-source-map@2.0.0: + resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} + + cookie@1.1.1: + resolution: {integrity: sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==} + engines: {node: '>=18'} + + crelt@1.0.6: + resolution: {integrity: sha512-VQ2MBenTq1fWZUH9DJNGti7kKv6EeAuYr3cLwxUWhIu1baTaXh4Ib5W2CqHVqib4/MqbYGJqiL3Zb8GJZr3l4g==} + + csstype@3.2.3: + resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} + + debug@4.4.3: + resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + decompress-response@6.0.0: + resolution: {integrity: sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==} + engines: {node: '>=10'} + + deep-extend@0.6.0: + resolution: {integrity: sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==} + engines: {node: '>=4.0.0'} + + detect-libc@2.1.2: + resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} + engines: {node: '>=8'} + + dprint@0.54.0: + resolution: {integrity: sha512-sIy25poR2gRP/tWPTgP0MPeJoJcpv0xzYDcsboapvthbEt1Qw3Al252CA0xFyIh2cYEGGKyBJtKokryv4ERlJw==} + hasBin: true + + drizzle-kit@0.31.10: + resolution: {integrity: sha512-7OZcmQUrdGI+DUNNsKBn1aW8qSoKuTH7d0mYgSP8bAzdFzKoovxEFnoGQp2dVs82EOJeYycqRtciopszwUf8bw==} + hasBin: true + + drizzle-orm@0.45.2: + resolution: {integrity: sha512-kY0BSaTNYWnoDMVoyY8uxmyHjpJW1geOmBMdSSicKo9CIIWkSxMIj2rkeSR51b8KAPB7m+qysjuHme5nKP+E5Q==} + peerDependencies: + '@aws-sdk/client-rds-data': '>=3' + '@cloudflare/workers-types': '>=4' + '@electric-sql/pglite': '>=0.2.0' + '@libsql/client': '>=0.10.0' + '@libsql/client-wasm': '>=0.10.0' + '@neondatabase/serverless': '>=0.10.0' + '@op-engineering/op-sqlite': '>=2' + '@opentelemetry/api': ^1.4.1 + '@planetscale/database': '>=1.13' + '@prisma/client': '*' + '@tidbcloud/serverless': '*' + '@types/better-sqlite3': '*' + '@types/pg': '*' + '@types/sql.js': '*' + '@upstash/redis': '>=1.34.7' + '@vercel/postgres': '>=0.8.0' + '@xata.io/client': '*' + better-sqlite3: '>=7' + bun-types: '*' + expo-sqlite: '>=14.0.0' + gel: '>=2' + knex: '*' + kysely: '*' + mysql2: '>=2' + pg: '>=8' + postgres: '>=3' + prisma: '*' + sql.js: '>=1' + sqlite3: '>=5' + peerDependenciesMeta: + '@aws-sdk/client-rds-data': + optional: true + '@cloudflare/workers-types': + optional: true + '@electric-sql/pglite': + optional: true + '@libsql/client': + optional: true + '@libsql/client-wasm': + optional: true + '@neondatabase/serverless': + optional: true + '@op-engineering/op-sqlite': + optional: true + '@opentelemetry/api': + optional: true + '@planetscale/database': + optional: true + '@prisma/client': + optional: true + '@tidbcloud/serverless': + optional: true + '@types/better-sqlite3': + optional: true + '@types/pg': + optional: true + '@types/sql.js': + optional: true + '@upstash/redis': + optional: true + '@vercel/postgres': + optional: true + '@xata.io/client': + optional: true + better-sqlite3: + optional: true + bun-types: + optional: true + expo-sqlite: + optional: true + gel: + optional: true + knex: + optional: true + kysely: + optional: true + mysql2: + optional: true + pg: + optional: true + postgres: + optional: true + prisma: + optional: true + sql.js: + optional: true + sqlite3: + optional: true + + electron-to-chromium@1.5.353: + resolution: {integrity: sha512-kOrWphBi8TOZyiJZqsgqIle0lw+tzmnQK83pV9dZUd01Nm2POECSyFQMAuarzZdYqQW7FH9RaYOuaRo3h+bQ3w==} + + end-of-stream@1.4.5: + resolution: {integrity: sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==} + + enhanced-resolve@5.21.2: + resolution: {integrity: sha512-xe9vQb5kReirPUxgQrXA3ihgbCqssmTiM7cOZ+Gzu+VeGWgpV98lLZvp0dl4yriyAePcewxGUs9UpKD8PET9KQ==} + engines: {node: '>=10.13.0'} + + esbuild@0.18.20: + resolution: {integrity: sha512-ceqxoedUrcayh7Y7ZX6NdbbDzGROiyVBgC4PriJThBKSVPWnnFHZAkfI1lJT8QFkOwH4qOS2SJkS4wvpGl8BpA==} + engines: {node: '>=12'} + hasBin: true + + esbuild@0.25.12: + resolution: {integrity: sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==} + engines: {node: '>=18'} + hasBin: true + + esbuild@0.27.7: + resolution: {integrity: sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w==} + engines: {node: '>=18'} + hasBin: true + + escalade@3.2.0: + resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} + engines: {node: '>=6'} + + events-universal@1.0.1: + resolution: {integrity: sha512-LUd5euvbMLpwOF8m6ivPCbhQeSiYVNb8Vs0fQ8QjXo0JTkEHpz8pxdQf0gStltaPpw0Cca8b39KxvK9cfKRiAw==} + + expand-template@2.0.3: + resolution: {integrity: sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==} + engines: {node: '>=6'} + + fast-deep-equal@3.1.3: + resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} + + fast-fifo@1.3.2: + resolution: {integrity: sha512-/d9sfos4yxzpwkDkuN7k2SqFKtYNmCTzgfEpz82x34IM9/zc8KGxQoXg1liNC/izpRM/MBdt44Nmx41ZWqk+FQ==} + + fast-uri@3.1.2: + resolution: {integrity: sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ==} + + fast-xml-builder@1.2.0: + resolution: {integrity: sha512-00aAWieqff+ZJhsXA4g1g7M8k+7AYoMUUHF+/zFb5U6Uv/P0Vl4QZo84/IcufzYalLuEj9928bXN9PbbFzMF0Q==} + + fast-xml-parser@5.7.2: + resolution: {integrity: sha512-P7oW7tLbYnhOLQk/Gv7cZgzgMPP/XN03K02/Jy6Y/NHzyIAIpxuZIM/YqAkfiXFPxA2CTm7NtCijK9EDu09u2w==} + hasBin: true + + fdir@6.5.0: + resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} + engines: {node: '>=12.0.0'} + peerDependencies: + picomatch: ^3 || ^4 + peerDependenciesMeta: + picomatch: + optional: true + + file-uri-to-path@1.0.0: + resolution: {integrity: sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==} + + fs-constants@1.0.0: + resolution: {integrity: sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==} + + fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + gensync@1.0.0-beta.2: + resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==} + engines: {node: '>=6.9.0'} + + get-tsconfig@4.14.0: + resolution: {integrity: sha512-yTb+8DXzDREzgvYmh6s9vHsSVCHeC0G3PI5bEXNBHtmshPnO+S5O7qgLEOn0I5QvMy6kpZN8K1NKGyilLb93wA==} + + github-from-package@0.0.0: + resolution: {integrity: sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==} + + graceful-fs@4.2.11: + resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} + + hono@4.12.18: + resolution: {integrity: sha512-RWzP96k/yv0PQfyXnWjs6zot20TqfpfsNXhOnev8d1InAxubW93L11/oNUc3tQqn2G0bSdAOBpX+2uDFHV7kdQ==} + engines: {node: '>=16.9.0'} + + ieee754@1.2.1: + resolution: {integrity: sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==} + + inherits@2.0.4: + resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} + + ini@1.3.8: + resolution: {integrity: sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==} + + jiti@2.7.0: + resolution: {integrity: sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==} + hasBin: true + + js-tokens@4.0.0: + resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} + + jsesc@3.1.0: + resolution: {integrity: sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==} + engines: {node: '>=6'} + hasBin: true + + json-schema-traverse@1.0.0: + resolution: {integrity: sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==} + + json5@2.2.3: + resolution: {integrity: sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==} + engines: {node: '>=6'} + hasBin: true + + lightningcss-android-arm64@1.32.0: + resolution: {integrity: sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [android] + + lightningcss-darwin-arm64@1.32.0: + resolution: {integrity: sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [darwin] + + lightningcss-darwin-x64@1.32.0: + resolution: {integrity: sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [darwin] + + lightningcss-freebsd-x64@1.32.0: + resolution: {integrity: sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [freebsd] + + lightningcss-linux-arm-gnueabihf@1.32.0: + resolution: {integrity: sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==} + engines: {node: '>= 12.0.0'} + cpu: [arm] + os: [linux] + + lightningcss-linux-arm64-gnu@1.32.0: + resolution: {integrity: sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + libc: [glibc] + + lightningcss-linux-arm64-musl@1.32.0: + resolution: {integrity: sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + libc: [musl] + + lightningcss-linux-x64-gnu@1.32.0: + resolution: {integrity: sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + libc: [glibc] + + lightningcss-linux-x64-musl@1.32.0: + resolution: {integrity: sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + libc: [musl] + + lightningcss-win32-arm64-msvc@1.32.0: + resolution: {integrity: sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [win32] + + lightningcss-win32-x64-msvc@1.32.0: + resolution: {integrity: sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [win32] + + lightningcss@1.32.0: + resolution: {integrity: sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==} + engines: {node: '>= 12.0.0'} + + lru-cache@5.1.1: + resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} + + lucide-react@1.14.0: + resolution: {integrity: sha512-+1mdWcfSJVUsaTIjN9zoezmUhfXo5l0vP7ekBMPo3jcS/aIkxHnXqAPsByszMZx/Y8oQBRJxJx5xg+RH3urzxA==} + peerDependencies: + react: ^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0 + + magic-string@0.30.21: + resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + + marked@18.0.3: + resolution: {integrity: sha512-7VT90JOkDeaRWpfjOReRGPEKn0ecdARBkDGL+tT1wZY0efPPqkUxLUSmzy/C7TIylQYJC9STISEsCHrqb/7VIA==} + engines: {node: '>= 20'} + hasBin: true + + mimic-response@3.1.0: + resolution: {integrity: sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==} + engines: {node: '>=10'} + + minimist@1.2.8: + resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==} + + mkdirp-classic@0.5.3: + resolution: {integrity: sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==} + + ms@2.1.3: + resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + + nanoid@3.3.12: + resolution: {integrity: sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + + napi-build-utils@2.0.0: + resolution: {integrity: sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==} + + node-abi@3.92.0: + resolution: {integrity: sha512-KdHvFWZjEKDf0cakgFjebl371GPsISX2oZHcuyKqM7DtogIsHrqKeLTo8wBHxaXRAQlY2PsPlZmfo+9ZCxEREQ==} + engines: {node: '>=10'} + + node-addon-api@8.7.0: + resolution: {integrity: sha512-9MdFxmkKaOYVTV+XVRG8ArDwwQ77XIgIPyKASB1k3JPq3M8fGQQQE3YpMOrKm6g//Ktx8ivZr8xo1Qmtqub+GA==} + engines: {node: ^18 || ^20 || >= 21} + + node-cron@4.2.1: + resolution: {integrity: sha512-lgimEHPE/QDgFlywTd8yTR61ptugX3Qer29efeyWw2rv259HtGBNn1vZVmp8lB9uo9wC0t/AT4iGqXxia+CJFg==} + engines: {node: '>=6.0.0'} + + node-gyp-build@4.8.4: + resolution: {integrity: sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ==} + hasBin: true + + node-releases@2.0.38: + resolution: {integrity: sha512-3qT/88Y3FbH/Kx4szpQQ4HzUbVrHPKTLVpVocKiLfoYvw9XSGOX2FmD2d6DrXbVYyAQTF2HeF6My8jmzx7/CRw==} + + nodemailer@8.0.7: + resolution: {integrity: sha512-pkjE4mkBzQjdJT4/UmlKl3pX0rC9fZmjh7c6C9o7lv66Ac6w9WCnzPzhbPNxwZAzlF4mdq4CSWB5+FbK6FWCow==} + engines: {node: '>=6.0.0'} + + once@1.4.0: + resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} + + oxlint@1.63.0: + resolution: {integrity: sha512-9TGXetdjgIHOJ9OiReomP7nnrMkV9HxC1xM2ramJSLQpzxjsAJtQwa4wqkJN2f/uCrqZuJseFuSlWDdvcruveg==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + peerDependencies: + oxlint-tsgolint: '>=0.22.1' + peerDependenciesMeta: + oxlint-tsgolint: + optional: true + + path-expression-matcher@1.5.0: + resolution: {integrity: sha512-cbrerZV+6rvdQrrD+iGMcZFEiiSrbv9Tfdkvnusy6y0x0GKBXREFg/Y65GhIfm0tnLntThhzCnfKwp1WRjeCyQ==} + engines: {node: '>=14.0.0'} + + picocolors@1.1.1: + resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + + picomatch@4.0.4: + resolution: {integrity: sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==} + engines: {node: '>=12'} + + postcss@8.5.14: + resolution: {integrity: sha512-SoSL4+OSEtR99LHFZQiJLkT59C5B1amGO1NzTwj7TT1qCUgUO6hxOvzkOYxD+vMrXBM3XJIKzokoERdqQq/Zmg==} + engines: {node: ^10 || ^12 || >=14} + + postgres@3.4.9: + resolution: {integrity: sha512-GD3qdB0x1z9xgFI6cdRD6xu2Sp2WCOEoe3mtnyB5Ee0XrrL5Pe+e4CCnJrRMnL1zYtRDZmQQVbvOttLnKDLnaw==} + engines: {node: '>=12'} + + prebuild-install@7.1.3: + resolution: {integrity: sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==} + engines: {node: '>=10'} + deprecated: No longer maintained. Please contact the author of the relevant native addon; alternatives are available. + hasBin: true + + pump@3.0.4: + resolution: {integrity: sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==} + + rc@1.2.8: + resolution: {integrity: sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==} + hasBin: true + + react-dom@19.2.6: + resolution: {integrity: sha512-0prMI+hvBbPjsWnxDLxlCGyM8PN6UuWjEUCYmZhO67xIV9Xasa/r/vDnq+Xyq4Lo27g8QSbO5YzARu0D1Sps3g==} + peerDependencies: + react: ^19.2.6 + + react-refresh@0.17.0: + resolution: {integrity: sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==} + engines: {node: '>=0.10.0'} + + react-router@7.15.0: + resolution: {integrity: sha512-HW9vYwuM8f4yx66Izy8xfrzCM+SBJluoZcCbww9A1TySax11S5Vgw6fi3ZjMONw9J4gQwngL7PzkyIpJJpJ7RQ==} + engines: {node: '>=20.0.0'} + peerDependencies: + react: '>=18' + react-dom: '>=18' + peerDependenciesMeta: + react-dom: + optional: true + + react@19.2.6: + resolution: {integrity: sha512-sfWGGfavi0xr8Pg0sVsyHMAOziVYKgPLNrS7ig+ivMNb3wbCBw3KxtflsGBAwD3gYQlE/AEZsTLgToRrSCjb0Q==} + engines: {node: '>=0.10.0'} + + readable-stream@3.6.2: + resolution: {integrity: sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==} + engines: {node: '>= 6'} + + require-from-string@2.0.2: + resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} + engines: {node: '>=0.10.0'} + + resolve-pkg-maps@1.0.0: + resolution: {integrity: sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==} + + rollup@4.60.3: + resolution: {integrity: sha512-pAQK9HalE84QSm4Po3EmWIZPd3FnjkShVkiMlz1iligWYkWQ7wHYd1PF/T7QZ5TVSD6uSTon5gBVMSM4JfBV+A==} + engines: {node: '>=18.0.0', npm: '>=8.0.0'} + hasBin: true + + safe-buffer@5.2.1: + resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==} + + scheduler@0.27.0: + resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==} + + semver@6.3.1: + resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==} + hasBin: true + + semver@7.8.0: + resolution: {integrity: sha512-AcM7dV/5ul4EekoQ29Agm5vri8JNqRyj39o0qpX6vDF2GZrtutZl5RwgD1XnZjiTAfncsJhMI48QQH3sN87YNA==} + engines: {node: '>=10'} + hasBin: true + + set-cookie-parser@2.7.2: + resolution: {integrity: sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==} + + simple-concat@1.0.1: + resolution: {integrity: sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==} + + simple-get@4.0.1: + resolution: {integrity: sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==} + + source-map-js@1.2.1: + resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} + engines: {node: '>=0.10.0'} + + source-map-support@0.5.21: + resolution: {integrity: sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==} + + source-map@0.6.1: + resolution: {integrity: sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==} + engines: {node: '>=0.10.0'} + + sql.js@1.14.1: + resolution: {integrity: sha512-gcj8zBWU5cFsi9WUP+4bFNXAyF1iRpA3LLyS/DP5xlrNzGmPIizUeBggKa8DbDwdqaKwUcTEnChtd2grWo/x/A==} + + streamx@2.25.0: + resolution: {integrity: sha512-0nQuG6jf1w+wddNEEXCF4nTg3LtufWINB5eFEN+5TNZW7KWJp6x87+JFL43vaAUPyCfH1wID+mNVyW6OHtFamg==} + + string_decoder@1.3.0: + resolution: {integrity: sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==} + + strip-json-comments@2.0.1: + resolution: {integrity: sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==} + engines: {node: '>=0.10.0'} + + strnum@2.3.0: + resolution: {integrity: sha512-ums3KNd42PGyx5xaoVTO1mjU1bH3NpY4vsrVlnv9PNGqQj8wd7rJ6nEypLrJ7z5vxK5RP0yMLo6J/Gsm62DI5Q==} + + style-mod@4.1.3: + resolution: {integrity: sha512-i/n8VsZydrugj3Iuzll8+x/00GH2vnYsk1eomD8QiRrSAeW6ItbCQDtfXCeJHd0iwiNagqjQkvpvREEPtW3IoQ==} + + tailwindcss@4.3.0: + resolution: {integrity: sha512-y6nxMGB1nMW9R6k96e5gdIFzcfL/gTJRNaqGes1YvkLnPVXzWgbqFF2yLC0T8G774n24cx3Pe8XrKoniCOAH+Q==} + + tapable@2.3.3: + resolution: {integrity: sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==} + engines: {node: '>=6'} + + tar-fs@2.1.4: + resolution: {integrity: sha512-mDAjwmZdh7LTT6pNleZ05Yt65HC3E+NiQzl672vQG38jIrehtJk/J3mNwIg+vShQPcLF/LV7CMnDW6vjj6sfYQ==} + + tar-stream@2.2.0: + resolution: {integrity: sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==} + engines: {node: '>=6'} + + tar-stream@3.2.0: + resolution: {integrity: sha512-ojzvCvVaNp6aOTFmG7jaRD0meowIAuPc3cMMhSgKiVWws1GyHbGd/xvnyuRKcKlMpt3qvxx6r0hreCNITP9hIg==} + + teex@1.0.1: + resolution: {integrity: sha512-eYE6iEI62Ni1H8oIa7KlDU6uQBtqr4Eajni3wX7rpfXD8ysFx8z0+dri+KWEPWpBsxXfxu58x/0jvTVT1ekOSg==} + + text-decoder@1.2.7: + resolution: {integrity: sha512-vlLytXkeP4xvEq2otHeJfSQIRyWxo/oZGEbXrtEEF9Hnmrdly59sUbzZ/QgyWuLYHctCHxFF4tRQZNQ9k60ExQ==} + + tinyglobby@0.2.16: + resolution: {integrity: sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==} + engines: {node: '>=12.0.0'} + + tslib@2.8.1: + resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} + + tsx@4.21.0: + resolution: {integrity: sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==} + engines: {node: '>=18.0.0'} + hasBin: true + + tunnel-agent@0.6.0: + resolution: {integrity: sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==} + + typescript@6.0.3: + resolution: {integrity: sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==} + engines: {node: '>=14.17'} + hasBin: true + + undici-types@7.19.2: + resolution: {integrity: sha512-qYVnV5OEm2AW8cJMCpdV20CDyaN3g0AjDlOGf1OW4iaDEx8MwdtChUp4zu4H0VP3nDRF/8RKWH+IPp9uW0YGZg==} + + update-browserslist-db@1.2.3: + resolution: {integrity: sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==} + hasBin: true + peerDependencies: + browserslist: '>= 4.21.0' + + util-deprecate@1.0.2: + resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} + + uuid@14.0.0: + resolution: {integrity: sha512-Qo+uWgilfSmAhXCMav1uYFynlQO7fMFiMVZsQqZRMIXp0O7rR7qjkj+cPvBHLgBqi960QCoo/PH2/6ZtVqKvrg==} + hasBin: true + + vite@6.4.2: + resolution: {integrity: sha512-2N/55r4JDJ4gdrCvGgINMy+HH3iRpNIz8K6SFwVsA+JbQScLiC+clmAxBgwiSPgcG9U15QmvqCGWzMbqda5zGQ==} + engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} + hasBin: true + peerDependencies: + '@types/node': ^18.0.0 || ^20.0.0 || >=22.0.0 + jiti: '>=1.21.0' + less: '*' + lightningcss: ^1.21.0 + sass: '*' + sass-embedded: '*' + stylus: '*' + sugarss: '*' + 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 + + w3c-keyname@2.2.8: + resolution: {integrity: sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==} + + wrappy@1.0.2: + resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} + + xml-naming@0.1.0: + resolution: {integrity: sha512-k8KO9hrMyNk6tUWqUfkTEZbezRRpONVOzUTnc97VnCvyj6Tf9lyUR9EDAIeiVLv56jsMcoXEwjW8Kv5yPY52lw==} + engines: {node: '>=16.0.0'} + + yallist@3.1.1: + resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==} + + zod@3.25.76: + resolution: {integrity: sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==} + +snapshots: + + '@aws-crypto/crc32@5.2.0': + dependencies: + '@aws-crypto/util': 5.2.0 + '@aws-sdk/types': 3.973.8 + tslib: 2.8.1 + + '@aws-crypto/crc32c@5.2.0': + dependencies: + '@aws-crypto/util': 5.2.0 + '@aws-sdk/types': 3.973.8 + tslib: 2.8.1 + + '@aws-crypto/sha1-browser@5.2.0': + dependencies: + '@aws-crypto/supports-web-crypto': 5.2.0 + '@aws-crypto/util': 5.2.0 + '@aws-sdk/types': 3.973.8 + '@aws-sdk/util-locate-window': 3.965.5 + '@smithy/util-utf8': 2.3.0 + tslib: 2.8.1 + + '@aws-crypto/sha256-browser@5.2.0': + dependencies: + '@aws-crypto/sha256-js': 5.2.0 + '@aws-crypto/supports-web-crypto': 5.2.0 + '@aws-crypto/util': 5.2.0 + '@aws-sdk/types': 3.973.8 + '@aws-sdk/util-locate-window': 3.965.5 + '@smithy/util-utf8': 2.3.0 + tslib: 2.8.1 + + '@aws-crypto/sha256-js@5.2.0': + dependencies: + '@aws-crypto/util': 5.2.0 + '@aws-sdk/types': 3.973.8 + tslib: 2.8.1 + + '@aws-crypto/supports-web-crypto@5.2.0': + dependencies: + tslib: 2.8.1 + + '@aws-crypto/util@5.2.0': + dependencies: + '@aws-sdk/types': 3.973.8 + '@smithy/util-utf8': 2.3.0 + tslib: 2.8.1 + + '@aws-sdk/client-s3@3.1045.0': + dependencies: + '@aws-crypto/sha1-browser': 5.2.0 + '@aws-crypto/sha256-browser': 5.2.0 + '@aws-crypto/sha256-js': 5.2.0 + '@aws-sdk/core': 3.974.8 + '@aws-sdk/credential-provider-node': 3.972.39 + '@aws-sdk/middleware-bucket-endpoint': 3.972.10 + '@aws-sdk/middleware-expect-continue': 3.972.10 + '@aws-sdk/middleware-flexible-checksums': 3.974.16 + '@aws-sdk/middleware-host-header': 3.972.10 + '@aws-sdk/middleware-location-constraint': 3.972.10 + '@aws-sdk/middleware-logger': 3.972.10 + '@aws-sdk/middleware-recursion-detection': 3.972.11 + '@aws-sdk/middleware-sdk-s3': 3.972.37 + '@aws-sdk/middleware-ssec': 3.972.10 + '@aws-sdk/middleware-user-agent': 3.972.38 + '@aws-sdk/region-config-resolver': 3.972.13 + '@aws-sdk/signature-v4-multi-region': 3.996.25 + '@aws-sdk/types': 3.973.8 + '@aws-sdk/util-endpoints': 3.996.8 + '@aws-sdk/util-user-agent-browser': 3.972.10 + '@aws-sdk/util-user-agent-node': 3.973.24 + '@smithy/config-resolver': 4.4.17 + '@smithy/core': 3.23.17 + '@smithy/eventstream-serde-browser': 4.2.14 + '@smithy/eventstream-serde-config-resolver': 4.3.14 + '@smithy/eventstream-serde-node': 4.2.14 + '@smithy/fetch-http-handler': 5.3.17 + '@smithy/hash-blob-browser': 4.2.15 + '@smithy/hash-node': 4.2.14 + '@smithy/hash-stream-node': 4.2.14 + '@smithy/invalid-dependency': 4.2.14 + '@smithy/md5-js': 4.2.14 + '@smithy/middleware-content-length': 4.2.14 + '@smithy/middleware-endpoint': 4.4.32 + '@smithy/middleware-retry': 4.5.7 + '@smithy/middleware-serde': 4.2.20 + '@smithy/middleware-stack': 4.2.14 + '@smithy/node-config-provider': 4.3.14 + '@smithy/node-http-handler': 4.6.1 + '@smithy/protocol-http': 5.3.14 + '@smithy/smithy-client': 4.12.13 + '@smithy/types': 4.14.1 + '@smithy/url-parser': 4.2.14 + '@smithy/util-base64': 4.3.2 + '@smithy/util-body-length-browser': 4.2.2 + '@smithy/util-body-length-node': 4.2.3 + '@smithy/util-defaults-mode-browser': 4.3.49 + '@smithy/util-defaults-mode-node': 4.2.54 + '@smithy/util-endpoints': 3.4.2 + '@smithy/util-middleware': 4.2.14 + '@smithy/util-retry': 4.3.8 + '@smithy/util-stream': 4.5.25 + '@smithy/util-utf8': 4.2.2 + '@smithy/util-waiter': 4.3.0 + tslib: 2.8.1 + transitivePeerDependencies: + - aws-crt + + '@aws-sdk/core@3.974.8': + dependencies: + '@aws-sdk/types': 3.973.8 + '@aws-sdk/xml-builder': 3.972.22 + '@smithy/core': 3.23.17 + '@smithy/node-config-provider': 4.3.14 + '@smithy/property-provider': 4.2.14 + '@smithy/protocol-http': 5.3.14 + '@smithy/signature-v4': 5.3.14 + '@smithy/smithy-client': 4.12.13 + '@smithy/types': 4.14.1 + '@smithy/util-base64': 4.3.2 + '@smithy/util-middleware': 4.2.14 + '@smithy/util-retry': 4.3.8 + '@smithy/util-utf8': 4.2.2 + tslib: 2.8.1 + + '@aws-sdk/crc64-nvme@3.972.7': + dependencies: + '@smithy/types': 4.14.1 + tslib: 2.8.1 + + '@aws-sdk/credential-provider-env@3.972.34': + dependencies: + '@aws-sdk/core': 3.974.8 + '@aws-sdk/types': 3.973.8 + '@smithy/property-provider': 4.2.14 + '@smithy/types': 4.14.1 + tslib: 2.8.1 + + '@aws-sdk/credential-provider-http@3.972.36': + dependencies: + '@aws-sdk/core': 3.974.8 + '@aws-sdk/types': 3.973.8 + '@smithy/fetch-http-handler': 5.3.17 + '@smithy/node-http-handler': 4.6.1 + '@smithy/property-provider': 4.2.14 + '@smithy/protocol-http': 5.3.14 + '@smithy/smithy-client': 4.12.13 + '@smithy/types': 4.14.1 + '@smithy/util-stream': 4.5.25 + tslib: 2.8.1 + + '@aws-sdk/credential-provider-ini@3.972.38': + dependencies: + '@aws-sdk/core': 3.974.8 + '@aws-sdk/credential-provider-env': 3.972.34 + '@aws-sdk/credential-provider-http': 3.972.36 + '@aws-sdk/credential-provider-login': 3.972.38 + '@aws-sdk/credential-provider-process': 3.972.34 + '@aws-sdk/credential-provider-sso': 3.972.38 + '@aws-sdk/credential-provider-web-identity': 3.972.38 + '@aws-sdk/nested-clients': 3.997.6 + '@aws-sdk/types': 3.973.8 + '@smithy/credential-provider-imds': 4.2.14 + '@smithy/property-provider': 4.2.14 + '@smithy/shared-ini-file-loader': 4.4.9 + '@smithy/types': 4.14.1 + tslib: 2.8.1 + transitivePeerDependencies: + - aws-crt + + '@aws-sdk/credential-provider-login@3.972.38': + dependencies: + '@aws-sdk/core': 3.974.8 + '@aws-sdk/nested-clients': 3.997.6 + '@aws-sdk/types': 3.973.8 + '@smithy/property-provider': 4.2.14 + '@smithy/protocol-http': 5.3.14 + '@smithy/shared-ini-file-loader': 4.4.9 + '@smithy/types': 4.14.1 + tslib: 2.8.1 + transitivePeerDependencies: + - aws-crt + + '@aws-sdk/credential-provider-node@3.972.39': + dependencies: + '@aws-sdk/credential-provider-env': 3.972.34 + '@aws-sdk/credential-provider-http': 3.972.36 + '@aws-sdk/credential-provider-ini': 3.972.38 + '@aws-sdk/credential-provider-process': 3.972.34 + '@aws-sdk/credential-provider-sso': 3.972.38 + '@aws-sdk/credential-provider-web-identity': 3.972.38 + '@aws-sdk/types': 3.973.8 + '@smithy/credential-provider-imds': 4.2.14 + '@smithy/property-provider': 4.2.14 + '@smithy/shared-ini-file-loader': 4.4.9 + '@smithy/types': 4.14.1 + tslib: 2.8.1 + transitivePeerDependencies: + - aws-crt + + '@aws-sdk/credential-provider-process@3.972.34': + dependencies: + '@aws-sdk/core': 3.974.8 + '@aws-sdk/types': 3.973.8 + '@smithy/property-provider': 4.2.14 + '@smithy/shared-ini-file-loader': 4.4.9 + '@smithy/types': 4.14.1 + tslib: 2.8.1 + + '@aws-sdk/credential-provider-sso@3.972.38': + dependencies: + '@aws-sdk/core': 3.974.8 + '@aws-sdk/nested-clients': 3.997.6 + '@aws-sdk/token-providers': 3.1041.0 + '@aws-sdk/types': 3.973.8 + '@smithy/property-provider': 4.2.14 + '@smithy/shared-ini-file-loader': 4.4.9 + '@smithy/types': 4.14.1 + tslib: 2.8.1 + transitivePeerDependencies: + - aws-crt + + '@aws-sdk/credential-provider-web-identity@3.972.38': + dependencies: + '@aws-sdk/core': 3.974.8 + '@aws-sdk/nested-clients': 3.997.6 + '@aws-sdk/types': 3.973.8 + '@smithy/property-provider': 4.2.14 + '@smithy/shared-ini-file-loader': 4.4.9 + '@smithy/types': 4.14.1 + tslib: 2.8.1 + transitivePeerDependencies: + - aws-crt + + '@aws-sdk/middleware-bucket-endpoint@3.972.10': + dependencies: + '@aws-sdk/types': 3.973.8 + '@aws-sdk/util-arn-parser': 3.972.3 + '@smithy/node-config-provider': 4.3.14 + '@smithy/protocol-http': 5.3.14 + '@smithy/types': 4.14.1 + '@smithy/util-config-provider': 4.2.2 + tslib: 2.8.1 + + '@aws-sdk/middleware-expect-continue@3.972.10': + dependencies: + '@aws-sdk/types': 3.973.8 + '@smithy/protocol-http': 5.3.14 + '@smithy/types': 4.14.1 + tslib: 2.8.1 + + '@aws-sdk/middleware-flexible-checksums@3.974.16': + dependencies: + '@aws-crypto/crc32': 5.2.0 + '@aws-crypto/crc32c': 5.2.0 + '@aws-crypto/util': 5.2.0 + '@aws-sdk/core': 3.974.8 + '@aws-sdk/crc64-nvme': 3.972.7 + '@aws-sdk/types': 3.973.8 + '@smithy/is-array-buffer': 4.2.2 + '@smithy/node-config-provider': 4.3.14 + '@smithy/protocol-http': 5.3.14 + '@smithy/types': 4.14.1 + '@smithy/util-middleware': 4.2.14 + '@smithy/util-stream': 4.5.25 + '@smithy/util-utf8': 4.2.2 + tslib: 2.8.1 + + '@aws-sdk/middleware-host-header@3.972.10': + dependencies: + '@aws-sdk/types': 3.973.8 + '@smithy/protocol-http': 5.3.14 + '@smithy/types': 4.14.1 + tslib: 2.8.1 + + '@aws-sdk/middleware-location-constraint@3.972.10': + dependencies: + '@aws-sdk/types': 3.973.8 + '@smithy/types': 4.14.1 + tslib: 2.8.1 + + '@aws-sdk/middleware-logger@3.972.10': + dependencies: + '@aws-sdk/types': 3.973.8 + '@smithy/types': 4.14.1 + tslib: 2.8.1 + + '@aws-sdk/middleware-recursion-detection@3.972.11': + dependencies: + '@aws-sdk/types': 3.973.8 + '@aws/lambda-invoke-store': 0.2.4 + '@smithy/protocol-http': 5.3.14 + '@smithy/types': 4.14.1 + tslib: 2.8.1 + + '@aws-sdk/middleware-sdk-s3@3.972.37': + dependencies: + '@aws-sdk/core': 3.974.8 + '@aws-sdk/types': 3.973.8 + '@aws-sdk/util-arn-parser': 3.972.3 + '@smithy/core': 3.23.17 + '@smithy/node-config-provider': 4.3.14 + '@smithy/protocol-http': 5.3.14 + '@smithy/signature-v4': 5.3.14 + '@smithy/smithy-client': 4.12.13 + '@smithy/types': 4.14.1 + '@smithy/util-config-provider': 4.2.2 + '@smithy/util-middleware': 4.2.14 + '@smithy/util-stream': 4.5.25 + '@smithy/util-utf8': 4.2.2 + tslib: 2.8.1 + + '@aws-sdk/middleware-ssec@3.972.10': + dependencies: + '@aws-sdk/types': 3.973.8 + '@smithy/types': 4.14.1 + tslib: 2.8.1 + + '@aws-sdk/middleware-user-agent@3.972.38': + dependencies: + '@aws-sdk/core': 3.974.8 + '@aws-sdk/types': 3.973.8 + '@aws-sdk/util-endpoints': 3.996.8 + '@smithy/core': 3.23.17 + '@smithy/protocol-http': 5.3.14 + '@smithy/types': 4.14.1 + '@smithy/util-retry': 4.3.8 + tslib: 2.8.1 + + '@aws-sdk/nested-clients@3.997.6': + dependencies: + '@aws-crypto/sha256-browser': 5.2.0 + '@aws-crypto/sha256-js': 5.2.0 + '@aws-sdk/core': 3.974.8 + '@aws-sdk/middleware-host-header': 3.972.10 + '@aws-sdk/middleware-logger': 3.972.10 + '@aws-sdk/middleware-recursion-detection': 3.972.11 + '@aws-sdk/middleware-user-agent': 3.972.38 + '@aws-sdk/region-config-resolver': 3.972.13 + '@aws-sdk/signature-v4-multi-region': 3.996.25 + '@aws-sdk/types': 3.973.8 + '@aws-sdk/util-endpoints': 3.996.8 + '@aws-sdk/util-user-agent-browser': 3.972.10 + '@aws-sdk/util-user-agent-node': 3.973.24 + '@smithy/config-resolver': 4.4.17 + '@smithy/core': 3.23.17 + '@smithy/fetch-http-handler': 5.3.17 + '@smithy/hash-node': 4.2.14 + '@smithy/invalid-dependency': 4.2.14 + '@smithy/middleware-content-length': 4.2.14 + '@smithy/middleware-endpoint': 4.4.32 + '@smithy/middleware-retry': 4.5.7 + '@smithy/middleware-serde': 4.2.20 + '@smithy/middleware-stack': 4.2.14 + '@smithy/node-config-provider': 4.3.14 + '@smithy/node-http-handler': 4.6.1 + '@smithy/protocol-http': 5.3.14 + '@smithy/smithy-client': 4.12.13 + '@smithy/types': 4.14.1 + '@smithy/url-parser': 4.2.14 + '@smithy/util-base64': 4.3.2 + '@smithy/util-body-length-browser': 4.2.2 + '@smithy/util-body-length-node': 4.2.3 + '@smithy/util-defaults-mode-browser': 4.3.49 + '@smithy/util-defaults-mode-node': 4.2.54 + '@smithy/util-endpoints': 3.4.2 + '@smithy/util-middleware': 4.2.14 + '@smithy/util-retry': 4.3.8 + '@smithy/util-utf8': 4.2.2 + tslib: 2.8.1 + transitivePeerDependencies: + - aws-crt + + '@aws-sdk/region-config-resolver@3.972.13': + dependencies: + '@aws-sdk/types': 3.973.8 + '@smithy/config-resolver': 4.4.17 + '@smithy/node-config-provider': 4.3.14 + '@smithy/types': 4.14.1 + tslib: 2.8.1 + + '@aws-sdk/signature-v4-multi-region@3.996.25': + dependencies: + '@aws-sdk/middleware-sdk-s3': 3.972.37 + '@aws-sdk/types': 3.973.8 + '@smithy/protocol-http': 5.3.14 + '@smithy/signature-v4': 5.3.14 + '@smithy/types': 4.14.1 + tslib: 2.8.1 + + '@aws-sdk/token-providers@3.1041.0': + dependencies: + '@aws-sdk/core': 3.974.8 + '@aws-sdk/nested-clients': 3.997.6 + '@aws-sdk/types': 3.973.8 + '@smithy/property-provider': 4.2.14 + '@smithy/shared-ini-file-loader': 4.4.9 + '@smithy/types': 4.14.1 + tslib: 2.8.1 + transitivePeerDependencies: + - aws-crt + + '@aws-sdk/types@3.973.8': + dependencies: + '@smithy/types': 4.14.1 + tslib: 2.8.1 + + '@aws-sdk/util-arn-parser@3.972.3': + dependencies: + tslib: 2.8.1 + + '@aws-sdk/util-endpoints@3.996.8': + dependencies: + '@aws-sdk/types': 3.973.8 + '@smithy/types': 4.14.1 + '@smithy/url-parser': 4.2.14 + '@smithy/util-endpoints': 3.4.2 + tslib: 2.8.1 + + '@aws-sdk/util-locate-window@3.965.5': + dependencies: + tslib: 2.8.1 + + '@aws-sdk/util-user-agent-browser@3.972.10': + dependencies: + '@aws-sdk/types': 3.973.8 + '@smithy/types': 4.14.1 + bowser: 2.14.1 + tslib: 2.8.1 + + '@aws-sdk/util-user-agent-node@3.973.24': + dependencies: + '@aws-sdk/middleware-user-agent': 3.972.38 + '@aws-sdk/types': 3.973.8 + '@smithy/node-config-provider': 4.3.14 + '@smithy/types': 4.14.1 + '@smithy/util-config-provider': 4.2.2 + tslib: 2.8.1 + + '@aws-sdk/xml-builder@3.972.22': + dependencies: + '@nodable/entities': 2.1.0 + '@smithy/types': 4.14.1 + fast-xml-parser: 5.7.2 + tslib: 2.8.1 + + '@aws/lambda-invoke-store@0.2.4': {} + + '@babel/code-frame@7.29.0': + dependencies: + '@babel/helper-validator-identifier': 7.28.5 + js-tokens: 4.0.0 + picocolors: 1.1.1 + + '@babel/compat-data@7.29.3': {} + + '@babel/core@7.29.0': + dependencies: + '@babel/code-frame': 7.29.0 + '@babel/generator': 7.29.1 + '@babel/helper-compilation-targets': 7.28.6 + '@babel/helper-module-transforms': 7.28.6(@babel/core@7.29.0) + '@babel/helpers': 7.29.2 + '@babel/parser': 7.29.3 + '@babel/template': 7.28.6 + '@babel/traverse': 7.29.0 + '@babel/types': 7.29.0 + '@jridgewell/remapping': 2.3.5 + convert-source-map: 2.0.0 + debug: 4.4.3 + gensync: 1.0.0-beta.2 + json5: 2.2.3 + semver: 6.3.1 + transitivePeerDependencies: + - supports-color + + '@babel/generator@7.29.1': + dependencies: + '@babel/parser': 7.29.3 + '@babel/types': 7.29.0 + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + jsesc: 3.1.0 + + '@babel/helper-compilation-targets@7.28.6': + dependencies: + '@babel/compat-data': 7.29.3 + '@babel/helper-validator-option': 7.27.1 + browserslist: 4.28.2 + lru-cache: 5.1.1 + semver: 6.3.1 + + '@babel/helper-globals@7.28.0': {} + + '@babel/helper-module-imports@7.28.6': + dependencies: + '@babel/traverse': 7.29.0 + '@babel/types': 7.29.0 + transitivePeerDependencies: + - supports-color + + '@babel/helper-module-transforms@7.28.6(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-module-imports': 7.28.6 + '@babel/helper-validator-identifier': 7.28.5 + '@babel/traverse': 7.29.0 + transitivePeerDependencies: + - supports-color + + '@babel/helper-plugin-utils@7.28.6': {} + + '@babel/helper-string-parser@7.27.1': {} + + '@babel/helper-validator-identifier@7.28.5': {} + + '@babel/helper-validator-option@7.27.1': {} + + '@babel/helpers@7.29.2': + dependencies: + '@babel/template': 7.28.6 + '@babel/types': 7.29.0 + + '@babel/parser@7.29.3': + dependencies: + '@babel/types': 7.29.0 + + '@babel/plugin-transform-react-jsx-self@7.27.1(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/plugin-transform-react-jsx-source@7.27.1(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/template@7.28.6': + dependencies: + '@babel/code-frame': 7.29.0 + '@babel/parser': 7.29.3 + '@babel/types': 7.29.0 + + '@babel/traverse@7.29.0': + dependencies: + '@babel/code-frame': 7.29.0 + '@babel/generator': 7.29.1 + '@babel/helper-globals': 7.28.0 + '@babel/parser': 7.29.3 + '@babel/template': 7.28.6 + '@babel/types': 7.29.0 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + + '@babel/types@7.29.0': + dependencies: + '@babel/helper-string-parser': 7.27.1 + '@babel/helper-validator-identifier': 7.28.5 + + '@codemirror/autocomplete@6.20.2': + dependencies: + '@codemirror/language': 6.12.3 + '@codemirror/state': 6.6.0 + '@codemirror/view': 6.42.1 + '@lezer/common': 1.5.2 + + '@codemirror/commands@6.10.3': + dependencies: + '@codemirror/language': 6.12.3 + '@codemirror/state': 6.6.0 + '@codemirror/view': 6.42.1 + '@lezer/common': 1.5.2 + + '@codemirror/lang-sql@6.10.0': + dependencies: + '@codemirror/autocomplete': 6.20.2 + '@codemirror/language': 6.12.3 + '@codemirror/state': 6.6.0 + '@lezer/common': 1.5.2 + '@lezer/highlight': 1.2.3 + '@lezer/lr': 1.4.10 + + '@codemirror/language@6.12.3': + dependencies: + '@codemirror/state': 6.6.0 + '@codemirror/view': 6.42.1 + '@lezer/common': 1.5.2 + '@lezer/highlight': 1.2.3 + '@lezer/lr': 1.4.10 + style-mod: 4.1.3 + + '@codemirror/lint@6.9.6': + dependencies: + '@codemirror/state': 6.6.0 + '@codemirror/view': 6.42.1 + crelt: 1.0.6 + + '@codemirror/search@6.7.0': + dependencies: + '@codemirror/state': 6.6.0 + '@codemirror/view': 6.42.1 + crelt: 1.0.6 + + '@codemirror/state@6.6.0': + dependencies: + '@marijn/find-cluster-break': 1.0.2 + + '@codemirror/view@6.42.1': + dependencies: + '@codemirror/state': 6.6.0 + crelt: 1.0.6 + style-mod: 4.1.3 + w3c-keyname: 2.2.8 + + '@dprint/darwin-arm64@0.54.0': + optional: true + + '@dprint/darwin-x64@0.54.0': + optional: true + + '@dprint/linux-arm64-glibc@0.54.0': + optional: true + + '@dprint/linux-arm64-musl@0.54.0': + optional: true + + '@dprint/linux-loong64-glibc@0.54.0': + optional: true + + '@dprint/linux-loong64-musl@0.54.0': + optional: true + + '@dprint/linux-riscv64-glibc@0.54.0': + optional: true + + '@dprint/linux-x64-glibc@0.54.0': + optional: true + + '@dprint/linux-x64-musl@0.54.0': + optional: true + + '@dprint/win32-arm64@0.54.0': + optional: true + + '@dprint/win32-x64@0.54.0': + optional: true + + '@drizzle-team/brocli@0.10.2': {} + + '@esbuild-kit/core-utils@3.3.2': + dependencies: + esbuild: 0.18.20 + source-map-support: 0.5.21 + + '@esbuild-kit/esm-loader@2.6.5': + dependencies: + '@esbuild-kit/core-utils': 3.3.2 + get-tsconfig: 4.14.0 + + '@esbuild/aix-ppc64@0.25.12': + optional: true + + '@esbuild/aix-ppc64@0.27.7': + optional: true + + '@esbuild/android-arm64@0.18.20': + optional: true + + '@esbuild/android-arm64@0.25.12': + optional: true + + '@esbuild/android-arm64@0.27.7': + optional: true + + '@esbuild/android-arm@0.18.20': + optional: true + + '@esbuild/android-arm@0.25.12': + optional: true + + '@esbuild/android-arm@0.27.7': + optional: true + + '@esbuild/android-x64@0.18.20': + optional: true + + '@esbuild/android-x64@0.25.12': + optional: true + + '@esbuild/android-x64@0.27.7': + optional: true + + '@esbuild/darwin-arm64@0.18.20': + optional: true + + '@esbuild/darwin-arm64@0.25.12': + optional: true + + '@esbuild/darwin-arm64@0.27.7': + optional: true + + '@esbuild/darwin-x64@0.18.20': + optional: true + + '@esbuild/darwin-x64@0.25.12': + optional: true + + '@esbuild/darwin-x64@0.27.7': + optional: true + + '@esbuild/freebsd-arm64@0.18.20': + optional: true + + '@esbuild/freebsd-arm64@0.25.12': + optional: true + + '@esbuild/freebsd-arm64@0.27.7': + optional: true + + '@esbuild/freebsd-x64@0.18.20': + optional: true + + '@esbuild/freebsd-x64@0.25.12': + optional: true + + '@esbuild/freebsd-x64@0.27.7': + optional: true + + '@esbuild/linux-arm64@0.18.20': + optional: true + + '@esbuild/linux-arm64@0.25.12': + optional: true + + '@esbuild/linux-arm64@0.27.7': + optional: true + + '@esbuild/linux-arm@0.18.20': + optional: true + + '@esbuild/linux-arm@0.25.12': + optional: true + + '@esbuild/linux-arm@0.27.7': + optional: true + + '@esbuild/linux-ia32@0.18.20': + optional: true + + '@esbuild/linux-ia32@0.25.12': + optional: true + + '@esbuild/linux-ia32@0.27.7': + optional: true + + '@esbuild/linux-loong64@0.18.20': + optional: true + + '@esbuild/linux-loong64@0.25.12': + optional: true + + '@esbuild/linux-loong64@0.27.7': + optional: true + + '@esbuild/linux-mips64el@0.18.20': + optional: true + + '@esbuild/linux-mips64el@0.25.12': + optional: true + + '@esbuild/linux-mips64el@0.27.7': + optional: true + + '@esbuild/linux-ppc64@0.18.20': + optional: true + + '@esbuild/linux-ppc64@0.25.12': + optional: true + + '@esbuild/linux-ppc64@0.27.7': + optional: true + + '@esbuild/linux-riscv64@0.18.20': + optional: true + + '@esbuild/linux-riscv64@0.25.12': + optional: true + + '@esbuild/linux-riscv64@0.27.7': + optional: true + + '@esbuild/linux-s390x@0.18.20': + optional: true + + '@esbuild/linux-s390x@0.25.12': + optional: true + + '@esbuild/linux-s390x@0.27.7': + optional: true + + '@esbuild/linux-x64@0.18.20': + optional: true + + '@esbuild/linux-x64@0.25.12': + optional: true + + '@esbuild/linux-x64@0.27.7': + optional: true + + '@esbuild/netbsd-arm64@0.25.12': + optional: true + + '@esbuild/netbsd-arm64@0.27.7': + optional: true + + '@esbuild/netbsd-x64@0.18.20': + optional: true + + '@esbuild/netbsd-x64@0.25.12': + optional: true + + '@esbuild/netbsd-x64@0.27.7': + optional: true + + '@esbuild/openbsd-arm64@0.25.12': + optional: true + + '@esbuild/openbsd-arm64@0.27.7': + optional: true + + '@esbuild/openbsd-x64@0.18.20': + optional: true + + '@esbuild/openbsd-x64@0.25.12': + optional: true + + '@esbuild/openbsd-x64@0.27.7': + optional: true + + '@esbuild/openharmony-arm64@0.25.12': + optional: true + + '@esbuild/openharmony-arm64@0.27.7': + optional: true + + '@esbuild/sunos-x64@0.18.20': + optional: true + + '@esbuild/sunos-x64@0.25.12': + optional: true + + '@esbuild/sunos-x64@0.27.7': + optional: true + + '@esbuild/win32-arm64@0.18.20': + optional: true + + '@esbuild/win32-arm64@0.25.12': + optional: true + + '@esbuild/win32-arm64@0.27.7': + optional: true + + '@esbuild/win32-ia32@0.18.20': + optional: true + + '@esbuild/win32-ia32@0.25.12': + optional: true + + '@esbuild/win32-ia32@0.27.7': + optional: true + + '@esbuild/win32-x64@0.18.20': + optional: true + + '@esbuild/win32-x64@0.25.12': + optional: true + + '@esbuild/win32-x64@0.27.7': + optional: true + + '@hono/node-server@1.19.14(hono@4.12.18)': + dependencies: + hono: 4.12.18 + + '@jridgewell/gen-mapping@0.3.13': + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + '@jridgewell/trace-mapping': 0.3.31 + + '@jridgewell/remapping@2.3.5': + dependencies: + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + + '@jridgewell/resolve-uri@3.1.2': {} + + '@jridgewell/sourcemap-codec@1.5.5': {} + + '@jridgewell/trace-mapping@0.3.31': + dependencies: + '@jridgewell/resolve-uri': 3.1.2 + '@jridgewell/sourcemap-codec': 1.5.5 + + '@lezer/common@1.5.2': {} + + '@lezer/highlight@1.2.3': + dependencies: + '@lezer/common': 1.5.2 + + '@lezer/lr@1.4.10': + dependencies: + '@lezer/common': 1.5.2 + + '@marijn/find-cluster-break@1.0.2': {} + + '@nodable/entities@2.1.0': {} + + '@oxlint/binding-android-arm-eabi@1.63.0': + optional: true + + '@oxlint/binding-android-arm64@1.63.0': + optional: true + + '@oxlint/binding-darwin-arm64@1.63.0': + optional: true + + '@oxlint/binding-darwin-x64@1.63.0': + optional: true + + '@oxlint/binding-freebsd-x64@1.63.0': + optional: true + + '@oxlint/binding-linux-arm-gnueabihf@1.63.0': + optional: true + + '@oxlint/binding-linux-arm-musleabihf@1.63.0': + optional: true + + '@oxlint/binding-linux-arm64-gnu@1.63.0': + optional: true + + '@oxlint/binding-linux-arm64-musl@1.63.0': + optional: true + + '@oxlint/binding-linux-ppc64-gnu@1.63.0': + optional: true + + '@oxlint/binding-linux-riscv64-gnu@1.63.0': + optional: true + + '@oxlint/binding-linux-riscv64-musl@1.63.0': + optional: true + + '@oxlint/binding-linux-s390x-gnu@1.63.0': + optional: true + + '@oxlint/binding-linux-x64-gnu@1.63.0': + optional: true + + '@oxlint/binding-linux-x64-musl@1.63.0': + optional: true + + '@oxlint/binding-openharmony-arm64@1.63.0': + optional: true + + '@oxlint/binding-win32-arm64-msvc@1.63.0': + optional: true + + '@oxlint/binding-win32-ia32-msvc@1.63.0': + optional: true + + '@oxlint/binding-win32-x64-msvc@1.63.0': + optional: true + + '@rolldown/pluginutils@1.0.0-beta.27': {} + + '@rollup/rollup-android-arm-eabi@4.60.3': + optional: true + + '@rollup/rollup-android-arm64@4.60.3': + optional: true + + '@rollup/rollup-darwin-arm64@4.60.3': + optional: true + + '@rollup/rollup-darwin-x64@4.60.3': + optional: true + + '@rollup/rollup-freebsd-arm64@4.60.3': + optional: true + + '@rollup/rollup-freebsd-x64@4.60.3': + optional: true + + '@rollup/rollup-linux-arm-gnueabihf@4.60.3': + optional: true + + '@rollup/rollup-linux-arm-musleabihf@4.60.3': + optional: true + + '@rollup/rollup-linux-arm64-gnu@4.60.3': + optional: true + + '@rollup/rollup-linux-arm64-musl@4.60.3': + optional: true + + '@rollup/rollup-linux-loong64-gnu@4.60.3': + optional: true + + '@rollup/rollup-linux-loong64-musl@4.60.3': + optional: true + + '@rollup/rollup-linux-ppc64-gnu@4.60.3': + optional: true + + '@rollup/rollup-linux-ppc64-musl@4.60.3': + optional: true + + '@rollup/rollup-linux-riscv64-gnu@4.60.3': + optional: true + + '@rollup/rollup-linux-riscv64-musl@4.60.3': + optional: true + + '@rollup/rollup-linux-s390x-gnu@4.60.3': + optional: true + + '@rollup/rollup-linux-x64-gnu@4.60.3': + optional: true + + '@rollup/rollup-linux-x64-musl@4.60.3': + optional: true + + '@rollup/rollup-openbsd-x64@4.60.3': + optional: true + + '@rollup/rollup-openharmony-arm64@4.60.3': + optional: true + + '@rollup/rollup-win32-arm64-msvc@4.60.3': + optional: true + + '@rollup/rollup-win32-ia32-msvc@4.60.3': + optional: true + + '@rollup/rollup-win32-x64-gnu@4.60.3': + optional: true + + '@rollup/rollup-win32-x64-msvc@4.60.3': + optional: true + + '@sinclair/typebox@0.34.49': {} + + '@smithy/chunked-blob-reader-native@4.2.3': + dependencies: + '@smithy/util-base64': 4.3.2 + tslib: 2.8.1 + + '@smithy/chunked-blob-reader@5.2.2': + dependencies: + tslib: 2.8.1 + + '@smithy/config-resolver@4.4.17': + dependencies: + '@smithy/node-config-provider': 4.3.14 + '@smithy/types': 4.14.1 + '@smithy/util-config-provider': 4.2.2 + '@smithy/util-endpoints': 3.4.2 + '@smithy/util-middleware': 4.2.14 + tslib: 2.8.1 + + '@smithy/core@3.23.17': + dependencies: + '@smithy/protocol-http': 5.3.14 + '@smithy/types': 4.14.1 + '@smithy/url-parser': 4.2.14 + '@smithy/util-base64': 4.3.2 + '@smithy/util-body-length-browser': 4.2.2 + '@smithy/util-middleware': 4.2.14 + '@smithy/util-stream': 4.5.25 + '@smithy/util-utf8': 4.2.2 + '@smithy/uuid': 1.1.2 + tslib: 2.8.1 + + '@smithy/credential-provider-imds@4.2.14': + dependencies: + '@smithy/node-config-provider': 4.3.14 + '@smithy/property-provider': 4.2.14 + '@smithy/types': 4.14.1 + '@smithy/url-parser': 4.2.14 + tslib: 2.8.1 + + '@smithy/eventstream-codec@4.2.14': + dependencies: + '@aws-crypto/crc32': 5.2.0 + '@smithy/types': 4.14.1 + '@smithy/util-hex-encoding': 4.2.2 + tslib: 2.8.1 + + '@smithy/eventstream-serde-browser@4.2.14': + dependencies: + '@smithy/eventstream-serde-universal': 4.2.14 + '@smithy/types': 4.14.1 + tslib: 2.8.1 + + '@smithy/eventstream-serde-config-resolver@4.3.14': + dependencies: + '@smithy/types': 4.14.1 + tslib: 2.8.1 + + '@smithy/eventstream-serde-node@4.2.14': + dependencies: + '@smithy/eventstream-serde-universal': 4.2.14 + '@smithy/types': 4.14.1 + tslib: 2.8.1 + + '@smithy/eventstream-serde-universal@4.2.14': + dependencies: + '@smithy/eventstream-codec': 4.2.14 + '@smithy/types': 4.14.1 + tslib: 2.8.1 + + '@smithy/fetch-http-handler@5.3.17': + dependencies: + '@smithy/protocol-http': 5.3.14 + '@smithy/querystring-builder': 4.2.14 + '@smithy/types': 4.14.1 + '@smithy/util-base64': 4.3.2 + tslib: 2.8.1 + + '@smithy/hash-blob-browser@4.2.15': + dependencies: + '@smithy/chunked-blob-reader': 5.2.2 + '@smithy/chunked-blob-reader-native': 4.2.3 + '@smithy/types': 4.14.1 + tslib: 2.8.1 + + '@smithy/hash-node@4.2.14': + dependencies: + '@smithy/types': 4.14.1 + '@smithy/util-buffer-from': 4.2.2 + '@smithy/util-utf8': 4.2.2 + tslib: 2.8.1 + + '@smithy/hash-stream-node@4.2.14': + dependencies: + '@smithy/types': 4.14.1 + '@smithy/util-utf8': 4.2.2 + tslib: 2.8.1 + + '@smithy/invalid-dependency@4.2.14': + dependencies: + '@smithy/types': 4.14.1 + tslib: 2.8.1 + + '@smithy/is-array-buffer@2.2.0': + dependencies: + tslib: 2.8.1 + + '@smithy/is-array-buffer@4.2.2': + dependencies: + tslib: 2.8.1 + + '@smithy/md5-js@4.2.14': + dependencies: + '@smithy/types': 4.14.1 + '@smithy/util-utf8': 4.2.2 + tslib: 2.8.1 + + '@smithy/middleware-content-length@4.2.14': + dependencies: + '@smithy/protocol-http': 5.3.14 + '@smithy/types': 4.14.1 + tslib: 2.8.1 + + '@smithy/middleware-endpoint@4.4.32': + dependencies: + '@smithy/core': 3.23.17 + '@smithy/middleware-serde': 4.2.20 + '@smithy/node-config-provider': 4.3.14 + '@smithy/shared-ini-file-loader': 4.4.9 + '@smithy/types': 4.14.1 + '@smithy/url-parser': 4.2.14 + '@smithy/util-middleware': 4.2.14 + tslib: 2.8.1 + + '@smithy/middleware-retry@4.5.7': + dependencies: + '@smithy/core': 3.23.17 + '@smithy/node-config-provider': 4.3.14 + '@smithy/protocol-http': 5.3.14 + '@smithy/service-error-classification': 4.3.1 + '@smithy/smithy-client': 4.12.13 + '@smithy/types': 4.14.1 + '@smithy/util-middleware': 4.2.14 + '@smithy/util-retry': 4.3.8 + '@smithy/uuid': 1.1.2 + tslib: 2.8.1 + + '@smithy/middleware-serde@4.2.20': + dependencies: + '@smithy/core': 3.23.17 + '@smithy/protocol-http': 5.3.14 + '@smithy/types': 4.14.1 + tslib: 2.8.1 + + '@smithy/middleware-stack@4.2.14': + dependencies: + '@smithy/types': 4.14.1 + tslib: 2.8.1 + + '@smithy/node-config-provider@4.3.14': + dependencies: + '@smithy/property-provider': 4.2.14 + '@smithy/shared-ini-file-loader': 4.4.9 + '@smithy/types': 4.14.1 + tslib: 2.8.1 + + '@smithy/node-http-handler@4.6.1': + dependencies: + '@smithy/protocol-http': 5.3.14 + '@smithy/querystring-builder': 4.2.14 + '@smithy/types': 4.14.1 + tslib: 2.8.1 + + '@smithy/property-provider@4.2.14': + dependencies: + '@smithy/types': 4.14.1 + tslib: 2.8.1 + + '@smithy/protocol-http@5.3.14': + dependencies: + '@smithy/types': 4.14.1 + tslib: 2.8.1 + + '@smithy/querystring-builder@4.2.14': + dependencies: + '@smithy/types': 4.14.1 + '@smithy/util-uri-escape': 4.2.2 + tslib: 2.8.1 + + '@smithy/querystring-parser@4.2.14': + dependencies: + '@smithy/types': 4.14.1 + tslib: 2.8.1 + + '@smithy/service-error-classification@4.3.1': + dependencies: + '@smithy/types': 4.14.1 + + '@smithy/shared-ini-file-loader@4.4.9': + dependencies: + '@smithy/types': 4.14.1 + tslib: 2.8.1 + + '@smithy/signature-v4@5.3.14': + dependencies: + '@smithy/is-array-buffer': 4.2.2 + '@smithy/protocol-http': 5.3.14 + '@smithy/types': 4.14.1 + '@smithy/util-hex-encoding': 4.2.2 + '@smithy/util-middleware': 4.2.14 + '@smithy/util-uri-escape': 4.2.2 + '@smithy/util-utf8': 4.2.2 + tslib: 2.8.1 + + '@smithy/smithy-client@4.12.13': + dependencies: + '@smithy/core': 3.23.17 + '@smithy/middleware-endpoint': 4.4.32 + '@smithy/middleware-stack': 4.2.14 + '@smithy/protocol-http': 5.3.14 + '@smithy/types': 4.14.1 + '@smithy/util-stream': 4.5.25 + tslib: 2.8.1 + + '@smithy/types@4.14.1': + dependencies: + tslib: 2.8.1 + + '@smithy/url-parser@4.2.14': + dependencies: + '@smithy/querystring-parser': 4.2.14 + '@smithy/types': 4.14.1 + tslib: 2.8.1 + + '@smithy/util-base64@4.3.2': + dependencies: + '@smithy/util-buffer-from': 4.2.2 + '@smithy/util-utf8': 4.2.2 + tslib: 2.8.1 + + '@smithy/util-body-length-browser@4.2.2': + dependencies: + tslib: 2.8.1 + + '@smithy/util-body-length-node@4.2.3': + dependencies: + tslib: 2.8.1 + + '@smithy/util-buffer-from@2.2.0': + dependencies: + '@smithy/is-array-buffer': 2.2.0 + tslib: 2.8.1 + + '@smithy/util-buffer-from@4.2.2': + dependencies: + '@smithy/is-array-buffer': 4.2.2 + tslib: 2.8.1 + + '@smithy/util-config-provider@4.2.2': + dependencies: + tslib: 2.8.1 + + '@smithy/util-defaults-mode-browser@4.3.49': + dependencies: + '@smithy/property-provider': 4.2.14 + '@smithy/smithy-client': 4.12.13 + '@smithy/types': 4.14.1 + tslib: 2.8.1 + + '@smithy/util-defaults-mode-node@4.2.54': + dependencies: + '@smithy/config-resolver': 4.4.17 + '@smithy/credential-provider-imds': 4.2.14 + '@smithy/node-config-provider': 4.3.14 + '@smithy/property-provider': 4.2.14 + '@smithy/smithy-client': 4.12.13 + '@smithy/types': 4.14.1 + tslib: 2.8.1 + + '@smithy/util-endpoints@3.4.2': + dependencies: + '@smithy/node-config-provider': 4.3.14 + '@smithy/types': 4.14.1 + tslib: 2.8.1 + + '@smithy/util-hex-encoding@4.2.2': + dependencies: + tslib: 2.8.1 + + '@smithy/util-middleware@4.2.14': + dependencies: + '@smithy/types': 4.14.1 + tslib: 2.8.1 + + '@smithy/util-retry@4.3.8': + dependencies: + '@smithy/service-error-classification': 4.3.1 + '@smithy/types': 4.14.1 + tslib: 2.8.1 + + '@smithy/util-stream@4.5.25': + dependencies: + '@smithy/fetch-http-handler': 5.3.17 + '@smithy/node-http-handler': 4.6.1 + '@smithy/types': 4.14.1 + '@smithy/util-base64': 4.3.2 + '@smithy/util-buffer-from': 4.2.2 + '@smithy/util-hex-encoding': 4.2.2 + '@smithy/util-utf8': 4.2.2 + tslib: 2.8.1 + + '@smithy/util-uri-escape@4.2.2': + dependencies: + tslib: 2.8.1 + + '@smithy/util-utf8@2.3.0': + dependencies: + '@smithy/util-buffer-from': 2.2.0 + tslib: 2.8.1 + + '@smithy/util-utf8@4.2.2': + dependencies: + '@smithy/util-buffer-from': 4.2.2 + tslib: 2.8.1 + + '@smithy/util-waiter@4.3.0': + dependencies: + '@smithy/types': 4.14.1 + tslib: 2.8.1 + + '@smithy/uuid@1.1.2': + dependencies: + tslib: 2.8.1 + + '@tailwindcss/node@4.3.0': + dependencies: + '@jridgewell/remapping': 2.3.5 + enhanced-resolve: 5.21.2 + jiti: 2.7.0 + lightningcss: 1.32.0 + magic-string: 0.30.21 + source-map-js: 1.2.1 + tailwindcss: 4.3.0 + + '@tailwindcss/oxide-android-arm64@4.3.0': + optional: true + + '@tailwindcss/oxide-darwin-arm64@4.3.0': + optional: true + + '@tailwindcss/oxide-darwin-x64@4.3.0': + optional: true + + '@tailwindcss/oxide-freebsd-x64@4.3.0': + optional: true + + '@tailwindcss/oxide-linux-arm-gnueabihf@4.3.0': + optional: true + + '@tailwindcss/oxide-linux-arm64-gnu@4.3.0': + optional: true + + '@tailwindcss/oxide-linux-arm64-musl@4.3.0': + optional: true + + '@tailwindcss/oxide-linux-x64-gnu@4.3.0': + optional: true + + '@tailwindcss/oxide-linux-x64-musl@4.3.0': + optional: true + + '@tailwindcss/oxide-wasm32-wasi@4.3.0': + optional: true + + '@tailwindcss/oxide-win32-arm64-msvc@4.3.0': + optional: true + + '@tailwindcss/oxide-win32-x64-msvc@4.3.0': + optional: true + + '@tailwindcss/oxide@4.3.0': + 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 + + '@tailwindcss/vite@4.3.0(vite@6.4.2(@types/node@25.6.2)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.21.0))': + dependencies: + '@tailwindcss/node': 4.3.0 + '@tailwindcss/oxide': 4.3.0 + tailwindcss: 4.3.0 + vite: 6.4.2(@types/node@25.6.2)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.21.0) + + '@types/babel__core@7.20.5': + dependencies: + '@babel/parser': 7.29.3 + '@babel/types': 7.29.0 + '@types/babel__generator': 7.27.0 + '@types/babel__template': 7.4.4 + '@types/babel__traverse': 7.28.0 + + '@types/babel__generator@7.27.0': + dependencies: + '@babel/types': 7.29.0 + + '@types/babel__template@7.4.4': + dependencies: + '@babel/parser': 7.29.3 + '@babel/types': 7.29.0 + + '@types/babel__traverse@7.28.0': + dependencies: + '@babel/types': 7.29.0 + + '@types/bcrypt@6.0.0': + dependencies: + '@types/node': 25.6.2 + + '@types/better-sqlite3@7.6.13': + dependencies: + '@types/node': 25.6.2 + + '@types/estree@1.0.8': {} + + '@types/node@25.6.2': + dependencies: + undici-types: 7.19.2 + + '@types/nodemailer@8.0.0': + dependencies: + '@types/node': 25.6.2 + + '@types/react-dom@19.2.3(@types/react@19.2.14)': + dependencies: + '@types/react': 19.2.14 + + '@types/react@19.2.14': + dependencies: + csstype: 3.2.3 + + '@types/tar-stream@3.1.4': + dependencies: + '@types/node': 25.6.2 + + '@vitejs/plugin-react@4.7.0(vite@6.4.2(@types/node@25.6.2)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.21.0))': + dependencies: + '@babel/core': 7.29.0 + '@babel/plugin-transform-react-jsx-self': 7.27.1(@babel/core@7.29.0) + '@babel/plugin-transform-react-jsx-source': 7.27.1(@babel/core@7.29.0) + '@rolldown/pluginutils': 1.0.0-beta.27 + '@types/babel__core': 7.20.5 + react-refresh: 0.17.0 + vite: 6.4.2(@types/node@25.6.2)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.21.0) + transitivePeerDependencies: + - supports-color + + ajv-formats@3.0.1(ajv@8.20.0): + optionalDependencies: + ajv: 8.20.0 + + ajv@8.20.0: + dependencies: + fast-deep-equal: 3.1.3 + fast-uri: 3.1.2 + json-schema-traverse: 1.0.0 + require-from-string: 2.0.2 + + b4a@1.8.1: {} + + bare-events@2.8.2: {} + + bare-fs@4.7.1: + dependencies: + bare-events: 2.8.2 + bare-path: 3.0.0 + bare-stream: 2.13.1(bare-events@2.8.2) + bare-url: 2.4.3 + fast-fifo: 1.3.2 + transitivePeerDependencies: + - bare-abort-controller + - react-native-b4a + + bare-os@3.9.1: {} + + bare-path@3.0.0: + dependencies: + bare-os: 3.9.1 + + bare-stream@2.13.1(bare-events@2.8.2): + dependencies: + streamx: 2.25.0 + teex: 1.0.1 + optionalDependencies: + bare-events: 2.8.2 + transitivePeerDependencies: + - react-native-b4a + + bare-url@2.4.3: + dependencies: + bare-path: 3.0.0 + + base64-js@1.5.1: {} + + baseline-browser-mapping@2.10.29: {} + + bcrypt@6.0.0: + dependencies: + node-addon-api: 8.7.0 + node-gyp-build: 4.8.4 + + better-sqlite3@12.9.0: + dependencies: + bindings: 1.5.0 + prebuild-install: 7.1.3 + + bindings@1.5.0: + dependencies: + file-uri-to-path: 1.0.0 + + bl@4.1.0: + dependencies: + buffer: 5.7.1 + inherits: 2.0.4 + readable-stream: 3.6.2 + + bowser@2.14.1: {} + + browserslist@4.28.2: + dependencies: + baseline-browser-mapping: 2.10.29 + caniuse-lite: 1.0.30001792 + electron-to-chromium: 1.5.353 + node-releases: 2.0.38 + update-browserslist-db: 1.2.3(browserslist@4.28.2) + + buffer-from@1.1.2: {} + + buffer@5.7.1: + dependencies: + base64-js: 1.5.1 + ieee754: 1.2.1 + + caniuse-lite@1.0.30001792: {} + + chownr@1.1.4: {} + + codemirror@6.0.2: + dependencies: + '@codemirror/autocomplete': 6.20.2 + '@codemirror/commands': 6.10.3 + '@codemirror/language': 6.12.3 + '@codemirror/lint': 6.9.6 + '@codemirror/search': 6.7.0 + '@codemirror/state': 6.6.0 + '@codemirror/view': 6.42.1 + + convert-source-map@2.0.0: {} + + cookie@1.1.1: {} + + crelt@1.0.6: {} + + csstype@3.2.3: {} + + debug@4.4.3: + dependencies: + ms: 2.1.3 + + decompress-response@6.0.0: + dependencies: + mimic-response: 3.1.0 + + deep-extend@0.6.0: {} + + detect-libc@2.1.2: {} + + dprint@0.54.0: + optionalDependencies: + '@dprint/darwin-arm64': 0.54.0 + '@dprint/darwin-x64': 0.54.0 + '@dprint/linux-arm64-glibc': 0.54.0 + '@dprint/linux-arm64-musl': 0.54.0 + '@dprint/linux-loong64-glibc': 0.54.0 + '@dprint/linux-loong64-musl': 0.54.0 + '@dprint/linux-riscv64-glibc': 0.54.0 + '@dprint/linux-x64-glibc': 0.54.0 + '@dprint/linux-x64-musl': 0.54.0 + '@dprint/win32-arm64': 0.54.0 + '@dprint/win32-x64': 0.54.0 + + drizzle-kit@0.31.10: + dependencies: + '@drizzle-team/brocli': 0.10.2 + '@esbuild-kit/esm-loader': 2.6.5 + esbuild: 0.25.12 + tsx: 4.21.0 + + drizzle-orm@0.45.2(@types/better-sqlite3@7.6.13)(better-sqlite3@12.9.0)(postgres@3.4.9)(sql.js@1.14.1): + optionalDependencies: + '@types/better-sqlite3': 7.6.13 + better-sqlite3: 12.9.0 + postgres: 3.4.9 + sql.js: 1.14.1 + + electron-to-chromium@1.5.353: {} + + end-of-stream@1.4.5: + dependencies: + once: 1.4.0 + + enhanced-resolve@5.21.2: + dependencies: + graceful-fs: 4.2.11 + tapable: 2.3.3 + + esbuild@0.18.20: + optionalDependencies: + '@esbuild/android-arm': 0.18.20 + '@esbuild/android-arm64': 0.18.20 + '@esbuild/android-x64': 0.18.20 + '@esbuild/darwin-arm64': 0.18.20 + '@esbuild/darwin-x64': 0.18.20 + '@esbuild/freebsd-arm64': 0.18.20 + '@esbuild/freebsd-x64': 0.18.20 + '@esbuild/linux-arm': 0.18.20 + '@esbuild/linux-arm64': 0.18.20 + '@esbuild/linux-ia32': 0.18.20 + '@esbuild/linux-loong64': 0.18.20 + '@esbuild/linux-mips64el': 0.18.20 + '@esbuild/linux-ppc64': 0.18.20 + '@esbuild/linux-riscv64': 0.18.20 + '@esbuild/linux-s390x': 0.18.20 + '@esbuild/linux-x64': 0.18.20 + '@esbuild/netbsd-x64': 0.18.20 + '@esbuild/openbsd-x64': 0.18.20 + '@esbuild/sunos-x64': 0.18.20 + '@esbuild/win32-arm64': 0.18.20 + '@esbuild/win32-ia32': 0.18.20 + '@esbuild/win32-x64': 0.18.20 + + esbuild@0.25.12: + optionalDependencies: + '@esbuild/aix-ppc64': 0.25.12 + '@esbuild/android-arm': 0.25.12 + '@esbuild/android-arm64': 0.25.12 + '@esbuild/android-x64': 0.25.12 + '@esbuild/darwin-arm64': 0.25.12 + '@esbuild/darwin-x64': 0.25.12 + '@esbuild/freebsd-arm64': 0.25.12 + '@esbuild/freebsd-x64': 0.25.12 + '@esbuild/linux-arm': 0.25.12 + '@esbuild/linux-arm64': 0.25.12 + '@esbuild/linux-ia32': 0.25.12 + '@esbuild/linux-loong64': 0.25.12 + '@esbuild/linux-mips64el': 0.25.12 + '@esbuild/linux-ppc64': 0.25.12 + '@esbuild/linux-riscv64': 0.25.12 + '@esbuild/linux-s390x': 0.25.12 + '@esbuild/linux-x64': 0.25.12 + '@esbuild/netbsd-arm64': 0.25.12 + '@esbuild/netbsd-x64': 0.25.12 + '@esbuild/openbsd-arm64': 0.25.12 + '@esbuild/openbsd-x64': 0.25.12 + '@esbuild/openharmony-arm64': 0.25.12 + '@esbuild/sunos-x64': 0.25.12 + '@esbuild/win32-arm64': 0.25.12 + '@esbuild/win32-ia32': 0.25.12 + '@esbuild/win32-x64': 0.25.12 + + esbuild@0.27.7: + 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 + + escalade@3.2.0: {} + + events-universal@1.0.1: + dependencies: + bare-events: 2.8.2 + transitivePeerDependencies: + - bare-abort-controller + + expand-template@2.0.3: {} + + fast-deep-equal@3.1.3: {} + + fast-fifo@1.3.2: {} + + fast-uri@3.1.2: {} + + fast-xml-builder@1.2.0: + dependencies: + path-expression-matcher: 1.5.0 + xml-naming: 0.1.0 + + fast-xml-parser@5.7.2: + dependencies: + '@nodable/entities': 2.1.0 + fast-xml-builder: 1.2.0 + path-expression-matcher: 1.5.0 + strnum: 2.3.0 + + fdir@6.5.0(picomatch@4.0.4): + optionalDependencies: + picomatch: 4.0.4 + + file-uri-to-path@1.0.0: {} + + fs-constants@1.0.0: {} + + fsevents@2.3.3: + optional: true + + gensync@1.0.0-beta.2: {} + + get-tsconfig@4.14.0: + dependencies: + resolve-pkg-maps: 1.0.0 + + github-from-package@0.0.0: {} + + graceful-fs@4.2.11: {} + + hono@4.12.18: {} + + ieee754@1.2.1: {} + + inherits@2.0.4: {} + + ini@1.3.8: {} + + jiti@2.7.0: {} + + js-tokens@4.0.0: {} + + jsesc@3.1.0: {} + + json-schema-traverse@1.0.0: {} + + json5@2.2.3: {} + + lightningcss-android-arm64@1.32.0: + optional: true + + lightningcss-darwin-arm64@1.32.0: + optional: true + + lightningcss-darwin-x64@1.32.0: + optional: true + + lightningcss-freebsd-x64@1.32.0: + optional: true + + lightningcss-linux-arm-gnueabihf@1.32.0: + optional: true + + lightningcss-linux-arm64-gnu@1.32.0: + optional: true + + lightningcss-linux-arm64-musl@1.32.0: + optional: true + + lightningcss-linux-x64-gnu@1.32.0: + optional: true + + lightningcss-linux-x64-musl@1.32.0: + optional: true + + lightningcss-win32-arm64-msvc@1.32.0: + optional: true + + lightningcss-win32-x64-msvc@1.32.0: + optional: true + + lightningcss@1.32.0: + dependencies: + detect-libc: 2.1.2 + 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 + + lru-cache@5.1.1: + dependencies: + yallist: 3.1.1 + + lucide-react@1.14.0(react@19.2.6): + dependencies: + react: 19.2.6 + + magic-string@0.30.21: + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + + marked@18.0.3: {} + + mimic-response@3.1.0: {} + + minimist@1.2.8: {} + + mkdirp-classic@0.5.3: {} + + ms@2.1.3: {} + + nanoid@3.3.12: {} + + napi-build-utils@2.0.0: {} + + node-abi@3.92.0: + dependencies: + semver: 7.8.0 + + node-addon-api@8.7.0: {} + + node-cron@4.2.1: {} + + node-gyp-build@4.8.4: {} + + node-releases@2.0.38: {} + + nodemailer@8.0.7: {} + + once@1.4.0: + dependencies: + wrappy: 1.0.2 + + oxlint@1.63.0: + optionalDependencies: + '@oxlint/binding-android-arm-eabi': 1.63.0 + '@oxlint/binding-android-arm64': 1.63.0 + '@oxlint/binding-darwin-arm64': 1.63.0 + '@oxlint/binding-darwin-x64': 1.63.0 + '@oxlint/binding-freebsd-x64': 1.63.0 + '@oxlint/binding-linux-arm-gnueabihf': 1.63.0 + '@oxlint/binding-linux-arm-musleabihf': 1.63.0 + '@oxlint/binding-linux-arm64-gnu': 1.63.0 + '@oxlint/binding-linux-arm64-musl': 1.63.0 + '@oxlint/binding-linux-ppc64-gnu': 1.63.0 + '@oxlint/binding-linux-riscv64-gnu': 1.63.0 + '@oxlint/binding-linux-riscv64-musl': 1.63.0 + '@oxlint/binding-linux-s390x-gnu': 1.63.0 + '@oxlint/binding-linux-x64-gnu': 1.63.0 + '@oxlint/binding-linux-x64-musl': 1.63.0 + '@oxlint/binding-openharmony-arm64': 1.63.0 + '@oxlint/binding-win32-arm64-msvc': 1.63.0 + '@oxlint/binding-win32-ia32-msvc': 1.63.0 + '@oxlint/binding-win32-x64-msvc': 1.63.0 + + path-expression-matcher@1.5.0: {} + + picocolors@1.1.1: {} + + picomatch@4.0.4: {} + + postcss@8.5.14: + dependencies: + nanoid: 3.3.12 + picocolors: 1.1.1 + source-map-js: 1.2.1 + + postgres@3.4.9: {} + + prebuild-install@7.1.3: + dependencies: + detect-libc: 2.1.2 + expand-template: 2.0.3 + github-from-package: 0.0.0 + minimist: 1.2.8 + mkdirp-classic: 0.5.3 + napi-build-utils: 2.0.0 + node-abi: 3.92.0 + pump: 3.0.4 + rc: 1.2.8 + simple-get: 4.0.1 + tar-fs: 2.1.4 + tunnel-agent: 0.6.0 + + pump@3.0.4: + dependencies: + end-of-stream: 1.4.5 + once: 1.4.0 + + rc@1.2.8: + dependencies: + deep-extend: 0.6.0 + ini: 1.3.8 + minimist: 1.2.8 + strip-json-comments: 2.0.1 + + react-dom@19.2.6(react@19.2.6): + dependencies: + react: 19.2.6 + scheduler: 0.27.0 + + react-refresh@0.17.0: {} + + react-router@7.15.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6): + dependencies: + cookie: 1.1.1 + react: 19.2.6 + set-cookie-parser: 2.7.2 + optionalDependencies: + react-dom: 19.2.6(react@19.2.6) + + react@19.2.6: {} + + readable-stream@3.6.2: + dependencies: + inherits: 2.0.4 + string_decoder: 1.3.0 + util-deprecate: 1.0.2 + + require-from-string@2.0.2: {} + + resolve-pkg-maps@1.0.0: {} + + rollup@4.60.3: + dependencies: + '@types/estree': 1.0.8 + optionalDependencies: + '@rollup/rollup-android-arm-eabi': 4.60.3 + '@rollup/rollup-android-arm64': 4.60.3 + '@rollup/rollup-darwin-arm64': 4.60.3 + '@rollup/rollup-darwin-x64': 4.60.3 + '@rollup/rollup-freebsd-arm64': 4.60.3 + '@rollup/rollup-freebsd-x64': 4.60.3 + '@rollup/rollup-linux-arm-gnueabihf': 4.60.3 + '@rollup/rollup-linux-arm-musleabihf': 4.60.3 + '@rollup/rollup-linux-arm64-gnu': 4.60.3 + '@rollup/rollup-linux-arm64-musl': 4.60.3 + '@rollup/rollup-linux-loong64-gnu': 4.60.3 + '@rollup/rollup-linux-loong64-musl': 4.60.3 + '@rollup/rollup-linux-ppc64-gnu': 4.60.3 + '@rollup/rollup-linux-ppc64-musl': 4.60.3 + '@rollup/rollup-linux-riscv64-gnu': 4.60.3 + '@rollup/rollup-linux-riscv64-musl': 4.60.3 + '@rollup/rollup-linux-s390x-gnu': 4.60.3 + '@rollup/rollup-linux-x64-gnu': 4.60.3 + '@rollup/rollup-linux-x64-musl': 4.60.3 + '@rollup/rollup-openbsd-x64': 4.60.3 + '@rollup/rollup-openharmony-arm64': 4.60.3 + '@rollup/rollup-win32-arm64-msvc': 4.60.3 + '@rollup/rollup-win32-ia32-msvc': 4.60.3 + '@rollup/rollup-win32-x64-gnu': 4.60.3 + '@rollup/rollup-win32-x64-msvc': 4.60.3 + fsevents: 2.3.3 + + safe-buffer@5.2.1: {} + + scheduler@0.27.0: {} + + semver@6.3.1: {} + + semver@7.8.0: {} + + set-cookie-parser@2.7.2: {} + + simple-concat@1.0.1: {} + + simple-get@4.0.1: + dependencies: + decompress-response: 6.0.0 + once: 1.4.0 + simple-concat: 1.0.1 + + source-map-js@1.2.1: {} + + source-map-support@0.5.21: + dependencies: + buffer-from: 1.1.2 + source-map: 0.6.1 + + source-map@0.6.1: {} + + sql.js@1.14.1: {} + + streamx@2.25.0: + dependencies: + events-universal: 1.0.1 + fast-fifo: 1.3.2 + text-decoder: 1.2.7 + transitivePeerDependencies: + - bare-abort-controller + - react-native-b4a + + string_decoder@1.3.0: + dependencies: + safe-buffer: 5.2.1 + + strip-json-comments@2.0.1: {} + + strnum@2.3.0: {} + + style-mod@4.1.3: {} + + tailwindcss@4.3.0: {} + + tapable@2.3.3: {} + + tar-fs@2.1.4: + dependencies: + chownr: 1.1.4 + mkdirp-classic: 0.5.3 + pump: 3.0.4 + tar-stream: 2.2.0 + + tar-stream@2.2.0: + dependencies: + bl: 4.1.0 + end-of-stream: 1.4.5 + fs-constants: 1.0.0 + inherits: 2.0.4 + readable-stream: 3.6.2 + + tar-stream@3.2.0: + dependencies: + b4a: 1.8.1 + bare-fs: 4.7.1 + fast-fifo: 1.3.2 + streamx: 2.25.0 + transitivePeerDependencies: + - bare-abort-controller + - bare-buffer + - react-native-b4a + + teex@1.0.1: + dependencies: + streamx: 2.25.0 + transitivePeerDependencies: + - bare-abort-controller + - react-native-b4a + + text-decoder@1.2.7: + dependencies: + b4a: 1.8.1 + transitivePeerDependencies: + - react-native-b4a + + tinyglobby@0.2.16: + dependencies: + fdir: 6.5.0(picomatch@4.0.4) + picomatch: 4.0.4 + + tslib@2.8.1: {} + + tsx@4.21.0: + dependencies: + esbuild: 0.27.7 + get-tsconfig: 4.14.0 + optionalDependencies: + fsevents: 2.3.3 + + tunnel-agent@0.6.0: + dependencies: + safe-buffer: 5.2.1 + + typescript@6.0.3: {} + + undici-types@7.19.2: {} + + update-browserslist-db@1.2.3(browserslist@4.28.2): + dependencies: + browserslist: 4.28.2 + escalade: 3.2.0 + picocolors: 1.1.1 + + util-deprecate@1.0.2: {} + + uuid@14.0.0: {} + + vite@6.4.2(@types/node@25.6.2)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.21.0): + dependencies: + esbuild: 0.25.12 + fdir: 6.5.0(picomatch@4.0.4) + picomatch: 4.0.4 + postcss: 8.5.14 + rollup: 4.60.3 + tinyglobby: 0.2.16 + optionalDependencies: + '@types/node': 25.6.2 + fsevents: 2.3.3 + jiti: 2.7.0 + lightningcss: 1.32.0 + tsx: 4.21.0 + + w3c-keyname@2.2.8: {} + + wrappy@1.0.2: {} + + xml-naming@0.1.0: {} + + yallist@3.1.1: {} + + zod@3.25.76: {} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml new file mode 100644 index 0000000..e939096 --- /dev/null +++ b/pnpm-workspace.yaml @@ -0,0 +1,5 @@ +allowBuilds: + bcrypt: true + better-sqlite3: true + dprint: true + esbuild: true diff --git a/server.ts b/server.ts new file mode 100644 index 0000000..58e232a --- /dev/null +++ b/server.ts @@ -0,0 +1,157 @@ +import { serve } from '@hono/node-server' +import { Hono } from 'hono' +import { cors } from 'hono/cors' +import { serveStatic } from '@hono/node-server/serve-static' +import { readFileSync, existsSync } from 'node:fs' +import { resolve } from 'node:path' +import { marked } from 'marked' + +import type { AuthEnv } from '~/api/auth.server' +import { authMiddleware } from '~/api/auth.server' +import { healthRoutes } from '~/api/health' +import { accountRoutes } from '~/api/accounts' +import { collectionsRoutes } from '~/api/collections' +import { versionRoutes } from '~/api/versions' +import { uploadRoutes } from '~/api/uploads' +import { fileRoutes } from '~/api/files' +import { schemaRoutes } from '~/api/schemas' +import { adminRoutes } from '~/api/admin' +import { queryRoutes } from '~/api/query' +import { arkRoutes } from '~/api/ark' +import { arkMiddleware } from '~/api/ark-middleware.server' + +const isProd = process.env.NODE_ENV === 'production' +const app = new Hono() + +// --- CORS --- +app.use('/api/*', cors({ origin: '*', credentials: true })) + +// --- Auth middleware for API routes --- +app.use('/api/*', authMiddleware) + +// --- ARK resolution middleware --- +app.use('/ark\\:*', arkMiddleware) + +// --- API routes --- +app.route('/api', healthRoutes) +app.route('/api', accountRoutes) +app.route('/api', collectionsRoutes) +app.route('/api', versionRoutes) +app.route('/api', uploadRoutes) +app.route('/api', fileRoutes) +app.route('/api', schemaRoutes) +app.route('/api', adminRoutes) +app.route('/api', queryRoutes) +app.route('/api', arkRoutes) + +// API 404 catch-all +app.all('/api/*', (c) => { + return c.json({ error: 'API route not found', statusCode: 404 }, 404) +}) + +// --- Blog content API (serves rendered markdown) --- +app.get('/api/blog/:slug', (c) => { + const slug = c.req.param('slug') + const mdPath = resolve('content/blog', `${slug}.md`) + if (!existsSync(mdPath)) { + return c.json({ error: 'Not found' }, 404) + } + const raw = readFileSync(mdPath, 'utf-8') + // Strip frontmatter + const fmEnd = raw.indexOf('---', 4) + const body = fmEnd > 0 ? raw.slice(fmEnd + 3).trim() : raw + const html = marked(body) + return c.html(typeof html === 'string' ? html : '') +}) + +// --- SSR --- +if (isProd) { + // Serve static assets from Vite build output + app.use('/assets/*', serveStatic({ root: './dist/client' })) + app.use('/favicon.svg', serveStatic({ root: './dist/client' })) + + // Run migrations on startup + const { runMigrations } = await import('~/db/migrate') + await runMigrations() + + app.get('*', async (c) => { + const { render } = await import('./dist/server/entry-server.js' as string) + const template = readFileSync(resolve('dist/client/index.html'), 'utf-8') + const { html, ssrData, redirect, statusCode, title, description } = await render(c.req.raw) + + if (redirect) { + return c.redirect(redirect, 302) + } + + let page = template + .replace('', html) + .replace( + '', + ``, + ) + + if (title) { + page = page.replace('Underlay', `${title}`) + } + if (description) { + page = page.replace( + '', + `\n`, + ) + } + + return c.html(page, statusCode ?? 200) + }) +} else { + const { createServer: createViteServer } = await import('vite') + const vite = await createViteServer({ + server: { middlewareMode: true }, + appType: 'custom', + }) + + // Vite's Connect middleware for HMR and asset transforms + app.use('*', async (c, next) => { + const nodeReq = (c.env as any).incoming + const nodeRes = (c.env as any).outgoing + if (!nodeReq || !nodeRes) return next() + return new Promise((resolve) => { + vite.middlewares(nodeReq, nodeRes, () => resolve(next())) + }) + }) + + app.get('*', async (c) => { + const url = c.req.url + let template = readFileSync(resolve('index.html'), 'utf-8') + template = await vite.transformIndexHtml(url, template) + + const { render } = await vite.ssrLoadModule('/src/entry-server.tsx') + const { html, ssrData, redirect, statusCode, title, description } = await render(c.req.raw) + + if (redirect) { + return c.redirect(redirect, 302) + } + + let page = template + .replace('', html) + .replace( + '', + ``, + ) + + if (title) { + page = page.replace('Underlay', `${title}`) + } + if (description) { + page = page.replace( + '', + `\n`, + ) + } + + return c.html(page, statusCode ?? 200) + }) +} + +const port = Number(process.env.PORT) || 3000 +console.log(`Server running at http://localhost:${port}`) +serve({ fetch: app.fetch, port }) diff --git a/src/App.tsx b/src/App.tsx new file mode 100644 index 0000000..62003e4 --- /dev/null +++ b/src/App.tsx @@ -0,0 +1,76 @@ +import { lazy, Suspense } from 'react' +import { Routes, Route } from 'react-router' +import { routes } from '~/routes' + +const componentMap: Record< + string, + React.LazyExoticComponent +> = { + // Public pages + 'home': lazy(() => import('~/routes/home')), + 'explore': lazy(() => import('~/routes/explore')), + 'query': lazy(() => import('~/routes/query')), + 'login': lazy(() => import('~/routes/login')), + 'signup': lazy(() => import('~/routes/signup')), + 'logout': lazy(() => import('~/routes/logout')), + 'forgot-password': lazy(() => import('~/routes/forgot-password')), + 'reset-password': lazy(() => import('~/routes/reset-password')), + + // Schemas + 'schemas': lazy(() => import('~/routes/schemas')), + 'schema-detail': lazy(() => import('~/routes/schemas/detail')), + + // Blog + 'blog': lazy(() => import('~/routes/blog')), + 'blog-post': lazy(() => import('~/routes/blog/post')), + + // Docs + 'docs': lazy(() => import('~/routes/docs')), + 'docs-concepts': lazy(() => import('~/routes/docs/concepts')), + 'docs-quickstart': lazy(() => import('~/routes/docs/quickstart')), + 'docs-integration': lazy(() => import('~/routes/docs/integration')), + 'docs-self-host': lazy(() => import('~/routes/docs/self-host')), + 'docs-api': lazy(() => import('~/routes/docs/api')), + 'docs-api-accounts': lazy(() => import('~/routes/docs/api/accounts')), + 'docs-api-collections': lazy(() => import('~/routes/docs/api/collections')), + 'docs-api-versions': lazy(() => import('~/routes/docs/api/versions')), + 'docs-api-files': lazy(() => import('~/routes/docs/api/files')), + + // Auth-required + 'dashboard': lazy(() => import('~/routes/dashboard')), + 'settings': lazy(() => import('~/routes/settings')), + 'settings-keys': lazy(() => import('~/routes/settings/keys')), + 'settings-sessions': lazy(() => import('~/routes/settings/sessions')), + 'settings-avatar': lazy(() => import('~/routes/settings/avatar')), + 'invitations-accept': lazy(() => import('~/routes/invitations/accept')), + + // Admin + 'admin-mirror': lazy(() => import('~/routes/admin/mirror')), + + // Dynamic owner/collection + 'owner': lazy(() => import('~/routes/owner')), + 'owner-settings': lazy(() => import('~/routes/owner/settings')), + 'owner-settings-keys': lazy(() => import('~/routes/owner/settings-keys')), + 'owner-settings-members': lazy(() => import('~/routes/owner/settings-members')), + 'collection': lazy(() => import('~/routes/collection')), + 'collection-versions': lazy(() => import('~/routes/collection/versions')), + 'collection-version': lazy(() => import('~/routes/collection/version')), + 'collection-schemas': lazy(() => import('~/routes/collection/schemas')), + 'collection-diff': lazy(() => import('~/routes/collection/diff')), + 'collection-settings': lazy(() => import('~/routes/collection/settings')), +} + +export default function App() { + return ( + + + {routes.map((r) => { + const Page = componentMap[r.id] + return Page ? ( + } /> + ) : null + })} + + + ) +} diff --git a/src/api/accounts.ts b/src/api/accounts.ts new file mode 100644 index 0000000..81d6412 --- /dev/null +++ b/src/api/accounts.ts @@ -0,0 +1,1180 @@ +import { Hono } from 'hono'; +import { getCookie } from 'hono/cookie'; +import { eq, and, count } from 'drizzle-orm'; +import { db, schema } from '../db/client.server.js'; +import bcrypt from 'bcrypt'; +import { v4 as uuidv4 } from 'uuid'; +import { requireAuth, setSessionCookie, clearSessionCookie, type AuthEnv } from './auth.server.js'; +import { uploadToS3, deleteS3Objects, listS3Objects } from '../lib/s3.js'; +import { sendEmail } from '../lib/email.js'; + +/** Base URL for public assets (avatars, etc.) */ +const ASSETS_BASE_URL = process.env.ASSETS_BASE_URL ?? "https://assets.underlay.org"; + +const RESERVED_SLUGS = new Set([ + "explore", "docs", "connect", "blog", "dashboard", "settings", + "api", "login", "signup", "admin", "about", "help", "support", + "search", "new", "create", "edit", "delete", "404", "500", +]); + +const app = new Hono(); + +// Signup +app.post('/accounts/signup', async (c) => { + const { email, password, username, displayName } = await c.req.json(); + + if (RESERVED_SLUGS.has(username.toLowerCase())) { + return c.json({ error: "That username is reserved", statusCode: 422 }, 422); + } + + const existing = await db + .select() + .from(schema.accounts) + .where(eq(schema.accounts.slug, username)) + .limit(1); + + if (existing.length > 0) { + return c.json({ error: "Username already taken", statusCode: 409 }, 409); + } + + const passwordHash = await bcrypt.hash(password, 10); + const id = uuidv4(); + + await db.insert(schema.accounts).values({ + id, + slug: username, + type: "user", + displayName, + email, + passwordHash, + }); + + const sessionId = uuidv4(); + const expiresAt = new Date(Date.now() + 30 * 24 * 60 * 60 * 1000); // 30 days + await db.insert(schema.sessions).values({ + id: sessionId, + userId: id, + expiresAt, + userAgent: c.req.header('user-agent') ?? null, + ipAddress: c.req.header('x-forwarded-for') || 'unknown', + }); + + setSessionCookie(c, sessionId); + + return c.json({ id, slug: username, displayName }, 201); +}); + +// Login +app.post('/accounts/login', async (c) => { + const { email, password } = await c.req.json(); + + const [account] = await db + .select() + .from(schema.accounts) + .where(eq(schema.accounts.email, email)) + .limit(1); + + if (!account?.passwordHash) { + return c.json({ error: "Invalid credentials", statusCode: 401 }, 401); + } + + const valid = await bcrypt.compare(password, account.passwordHash); + if (!valid) { + return c.json({ error: "Invalid credentials", statusCode: 401 }, 401); + } + + const sessionId = uuidv4(); + const expiresAt = new Date(Date.now() + 30 * 24 * 60 * 60 * 1000); + await db.insert(schema.sessions).values({ + id: sessionId, + userId: account.id, + expiresAt, + userAgent: c.req.header('user-agent') ?? null, + ipAddress: c.req.header('x-forwarded-for') || 'unknown', + }); + + setSessionCookie(c, sessionId); + + return c.json({ id: account.id, slug: account.slug, displayName: account.displayName }); +}); + +// Logout +app.post('/accounts/logout', async (c) => { + const sessionId = getCookie(c, 'session'); + if (sessionId) { + await db.delete(schema.sessions).where(eq(schema.sessions.id, sessionId)); + } + clearSessionCookie(c); + return c.json({ ok: true }); +}); + +// Get current user +app.get('/accounts/me', requireAuth(), async (c) => { + const [account] = await db + .select({ + id: schema.accounts.id, + slug: schema.accounts.slug, + type: schema.accounts.type, + displayName: schema.accounts.displayName, + email: schema.accounts.email, + bio: schema.accounts.bio, + website: schema.accounts.website, + location: schema.accounts.location, + avatarUrl: schema.accounts.avatarUrl, + emailVerified: schema.accounts.emailVerified, + notificationPrefs: schema.accounts.notificationPrefs, + createdAt: schema.accounts.createdAt, + }) + .from(schema.accounts) + .where(eq(schema.accounts.id, c.get('accountId')!)) + .limit(1); + + if (!account) { + return c.json({ error: "Account not found", statusCode: 404 }, 404); + } + + // Fetch org memberships + const memberships = await db + .select({ + orgId: schema.orgMemberships.orgId, + role: schema.orgMemberships.role, + slug: schema.accounts.slug, + displayName: schema.accounts.displayName, + }) + .from(schema.orgMemberships) + .innerJoin(schema.accounts, eq(schema.orgMemberships.orgId, schema.accounts.id)) + .where(eq(schema.orgMemberships.userId, account.id)); + + return c.json({ ...account, orgs: memberships }); +}); + +// Get account by slug (public) +app.get('/accounts/:slug', async (c) => { + const slug = c.req.param('slug'); + const [account] = await db + .select({ + id: schema.accounts.id, + slug: schema.accounts.slug, + type: schema.accounts.type, + displayName: schema.accounts.displayName, + bio: schema.accounts.bio, + website: schema.accounts.website, + location: schema.accounts.location, + avatarUrl: schema.accounts.avatarUrl, + arkNaan: schema.accounts.arkNaan, + createdAt: schema.accounts.createdAt, + }) + .from(schema.accounts) + .where(eq(schema.accounts.slug, slug)) + .limit(1); + + if (!account) { + return c.json({ error: "Account not found", statusCode: 404 }, 404); + } + + // Include ARK shoulder if minted + const [shoulderRow] = await db + .select({ shoulder: schema.arkShoulders.shoulder }) + .from(schema.arkShoulders) + .where(eq(schema.arkShoulders.accountId, account.id)) + .limit(1); + + return c.json({ ...account, arkShoulder: shoulderRow?.shoulder ?? null }); +}); + +// Update own profile +app.patch('/accounts/me', requireAuth(), async (c) => { + const { displayName, bio, website, location, notificationPrefs } = await c.req.json(); + + const updates: Record = {}; + if (displayName !== undefined) updates.displayName = displayName; + if (bio !== undefined) updates.bio = bio; + if (website !== undefined) updates.website = website; + if (location !== undefined) updates.location = location; + if (notificationPrefs !== undefined) updates.notificationPrefs = notificationPrefs; + + if (Object.keys(updates).length > 0) { + await db.update(schema.accounts).set(updates).where(eq(schema.accounts.id, c.get('accountId')!)); + } + + return c.json({ ok: true }); +}); + +// Change email (requires current password) +app.post('/accounts/me/email', requireAuth(), async (c) => { + const { newEmail, password } = await c.req.json(); + + const [account] = await db + .select() + .from(schema.accounts) + .where(eq(schema.accounts.id, c.get('accountId')!)) + .limit(1); + + if (!account?.passwordHash) { + return c.json({ error: "Cannot change email for this account type", statusCode: 400 }, 400); + } + + const valid = await bcrypt.compare(password, account.passwordHash); + if (!valid) { + return c.json({ error: "Invalid password", statusCode: 401 }, 401); + } + + // Check email not taken + const [existing] = await db + .select() + .from(schema.accounts) + .where(eq(schema.accounts.email, newEmail)) + .limit(1); + + if (existing && existing.id !== account.id) { + return c.json({ error: "Email already in use", statusCode: 409 }, 409); + } + + await db + .update(schema.accounts) + .set({ email: newEmail, emailVerified: false }) + .where(eq(schema.accounts.id, c.get('accountId')!)); + + return c.json({ ok: true }); +}); + +// Change password +app.post('/accounts/me/password', requireAuth(), async (c) => { + const { currentPassword, newPassword } = await c.req.json(); + + if (newPassword.length < 8) { + return c.json({ error: "Password must be at least 8 characters", statusCode: 422 }, 422); + } + + const [account] = await db + .select() + .from(schema.accounts) + .where(eq(schema.accounts.id, c.get('accountId')!)) + .limit(1); + + if (!account?.passwordHash) { + return c.json({ error: "Cannot change password for this account type", statusCode: 400 }, 400); + } + + const valid = await bcrypt.compare(currentPassword, account.passwordHash); + if (!valid) { + return c.json({ error: "Current password is incorrect", statusCode: 401 }, 401); + } + + const newHash = await bcrypt.hash(newPassword, 10); + await db + .update(schema.accounts) + .set({ passwordHash: newHash }) + .where(eq(schema.accounts.id, c.get('accountId')!)); + + return c.json({ ok: true }); +}); + +// Upload avatar +app.post('/accounts/me/avatar', requireAuth(), async (c) => { + const body = await c.req.parseBody(); + const file = Object.values(body).find((v): v is File => v instanceof File); + if (!file) { + return c.json({ error: "No file uploaded", statusCode: 400 }, 400); + } + + const allowedTypes = ["image/jpeg", "image/png", "image/gif", "image/webp"]; + if (!allowedTypes.includes(file.type)) { + return c.json({ error: "Only JPEG, PNG, GIF, and WebP images are allowed", statusCode: 422 }, 422); + } + + const buffer = Buffer.from(await file.arrayBuffer()); + if (buffer.length > 5 * 1024 * 1024) { + return c.json({ error: "Image must be less than 5MB", statusCode: 422 }, 422); + } + + const ext = file.type.split("/")[1] === "jpeg" ? "jpg" : file.type.split("/")[1]; + const accountId = c.get('accountId')!; + const key = `avatars/${accountId}/${Date.now()}.${ext}`; + + await uploadToS3(key, buffer, file.type); + + await db + .update(schema.accounts) + .set({ avatarUrl: `${ASSETS_BASE_URL}/${key}` }) + .where(eq(schema.accounts.id, accountId)); + + return c.json({ ok: true, avatarUrl: `${ASSETS_BASE_URL}/${key}` }); +}); + +// List sessions +app.get('/accounts/me/sessions', requireAuth(), async (c) => { + const sessions = await db + .select({ + id: schema.sessions.id, + userAgent: schema.sessions.userAgent, + ipAddress: schema.sessions.ipAddress, + createdAt: schema.sessions.createdAt, + expiresAt: schema.sessions.expiresAt, + }) + .from(schema.sessions) + .where(eq(schema.sessions.userId, c.get('accountId')!)); + + // Get current session ID to mark it + const currentSessionId = getCookie(c, 'session'); + return c.json(sessions.map((s) => ({ + ...s, + current: s.id === currentSessionId, + }))); +}); + +// Revoke a session +app.delete('/accounts/me/sessions/:sessionId', requireAuth(), async (c) => { + const sessionId = c.req.param('sessionId'); + + const [session] = await db + .select() + .from(schema.sessions) + .where(and(eq(schema.sessions.id, sessionId), eq(schema.sessions.userId, c.get('accountId')!))) + .limit(1); + + if (!session) { + return c.json({ error: "Session not found", statusCode: 404 }, 404); + } + + await db.delete(schema.sessions).where(eq(schema.sessions.id, sessionId)); + return c.json({ ok: true }); +}); + +// Delete own account +app.delete('/accounts/me', requireAuth(), async (c) => { + const { password, confirmSlug } = await c.req.json(); + + const [account] = await db + .select() + .from(schema.accounts) + .where(eq(schema.accounts.id, c.get('accountId')!)) + .limit(1); + + if (!account?.passwordHash) { + return c.json({ error: "Cannot delete this account type", statusCode: 400 }, 400); + } + + if (confirmSlug !== account.slug) { + return c.json({ error: "Username confirmation does not match", statusCode: 422 }, 422); + } + + const valid = await bcrypt.compare(password, account.passwordHash); + if (!valid) { + return c.json({ error: "Invalid password", statusCode: 401 }, 401); + } + + // Check for owned collections + const [collCount] = await db + .select({ count: count() }) + .from(schema.collections) + .where(eq(schema.collections.accountId, account.id)); + + if (collCount && collCount.count > 0) { + return c.json({ + error: `You still own ${collCount.count} collection(s). Transfer or delete them before deleting your account.`, + statusCode: 422, + }, 422); + } + + // Clean up S3 avatars + try { + const avatarKeys = await listS3Objects(`avatars/${account.id}/`); + if (avatarKeys.length > 0) { + await deleteS3Objects(avatarKeys); + } + } catch { + // Non-fatal: avatar cleanup failed + } + + // Cascade will handle sessions, memberships, api keys + await db.delete(schema.accounts).where(eq(schema.accounts.id, account.id)); + clearSessionCookie(c); + return c.json({ ok: true }); +}); + +// --- Forgot Password --- +app.post('/accounts/forgot-password', async (c) => { + const { email } = await c.req.json(); + + const [account] = await db + .select() + .from(schema.accounts) + .where(eq(schema.accounts.email, email)) + .limit(1); + + // Always return success to prevent email enumeration + if (!account) { + return c.json({ ok: true }); + } + + const rawToken = uuidv4(); + const tokenHash = await bcrypt.hash(rawToken, 10); + const expiresAt = new Date(Date.now() + 60 * 60 * 1000); // 1 hour + + await db.insert(schema.passwordResetTokens).values({ + userId: account.id, + tokenHash, + expiresAt, + }); + + // Send email (no-op if SMTP not configured) + const origin = new URL(c.req.url).origin; + const resetUrl = `${origin}/reset-password?token=${rawToken}&email=${encodeURIComponent(email)}`; + await sendEmail({ + to: email, + subject: "Reset your Underlay password", + text: `Click here to reset your password: ${resetUrl}\n\nThis link expires in 1 hour. If you didn't request this, ignore this email.`, + html: `

Click here to reset your password.

This link expires in 1 hour. If you didn't request this, ignore this email.

`, + }); + + return c.json({ ok: true }); +}); + +// --- Reset Password --- +app.post('/accounts/reset-password', async (c) => { + const { email, token, newPassword } = await c.req.json(); + + if (newPassword.length < 8) { + return c.json({ error: "Password must be at least 8 characters", statusCode: 422 }, 422); + } + + const [account] = await db + .select() + .from(schema.accounts) + .where(eq(schema.accounts.email, email)) + .limit(1); + + if (!account) { + return c.json({ error: "Invalid or expired reset link", statusCode: 400 }, 400); + } + + // Find valid unused tokens for this user + const tokens = await db + .select() + .from(schema.passwordResetTokens) + .where(and( + eq(schema.passwordResetTokens.userId, account.id), + )); + + let validToken = null; + for (const t of tokens) { + if (t.usedAt) continue; + if (new Date(t.expiresAt) < new Date()) continue; + const match = await bcrypt.compare(token, t.tokenHash); + if (match) { + validToken = t; + break; + } + } + + if (!validToken) { + return c.json({ error: "Invalid or expired reset link", statusCode: 400 }, 400); + } + + const newHash = await bcrypt.hash(newPassword, 10); + await db.update(schema.accounts).set({ passwordHash: newHash }).where(eq(schema.accounts.id, account.id)); + await db + .update(schema.passwordResetTokens) + .set({ usedAt: new Date() }) + .where(eq(schema.passwordResetTokens.id, validToken.id)); + + return c.json({ ok: true }); +}); + +// Create API key +app.post('/accounts/keys', requireAuth(), async (c) => { + const { label, scope, collectionId, expiresIn } = await c.req.json(); + + const rawKey = `ul_${uuidv4().replace(/-/g, "")}`; + const keyHash = await bcrypt.hash(rawKey, 10); + const keyPrefix = rawKey.slice(0, 12); + + const expiresAt = expiresIn + ? new Date(Date.now() + expiresIn * 24 * 60 * 60 * 1000) + : null; + + const [key] = await db + .insert(schema.apiKeys) + .values({ + accountId: c.get('accountId')!, + scope, + keyHash, + keyPrefix, + label, + collectionId: collectionId ?? null, + expiresAt, + }) + .returning(); + + return c.json({ + id: key!.id, + key: rawKey, // shown once + label, + scope, + keyPrefix, + collectionId: collectionId ?? null, + expiresAt, + }, 201); +}); + +// List API keys +app.get('/accounts/keys', requireAuth(), async (c) => { + const keys = await db + .select({ + id: schema.apiKeys.id, + label: schema.apiKeys.label, + scope: schema.apiKeys.scope, + keyPrefix: schema.apiKeys.keyPrefix, + collectionId: schema.apiKeys.collectionId, + expiresAt: schema.apiKeys.expiresAt, + createdAt: schema.apiKeys.createdAt, + lastUsedAt: schema.apiKeys.lastUsedAt, + }) + .from(schema.apiKeys) + .where(eq(schema.apiKeys.accountId, c.get('accountId')!)); + return c.json(keys); +}); + +// Delete API key +app.delete('/accounts/keys/:id', requireAuth(), async (c) => { + const id = c.req.param('id'); + const [key] = await db + .select() + .from(schema.apiKeys) + .where(eq(schema.apiKeys.id, id)) + .limit(1); + + if (!key || key.accountId !== c.get('accountId')) { + return c.json({ error: "Key not found", statusCode: 404 }, 404); + } + + await db.delete(schema.apiKeys).where(eq(schema.apiKeys.id, id)); + return c.json({ ok: true }); +}); + +// --- Org-scoped API Keys --- + +// Create API key for an org +app.post('/accounts/:slug/keys', requireAuth(), async (c) => { + const slug = c.req.param('slug'); + const { label, scope, collectionId, expiresIn } = await c.req.json(); + + const [org] = await db + .select() + .from(schema.accounts) + .where(and(eq(schema.accounts.slug, slug), eq(schema.accounts.type, "org"))) + .limit(1); + + if (!org) return c.json({ error: "Organization not found", statusCode: 404 }, 404); + + // Must be owner or admin + const [membership] = await db + .select() + .from(schema.orgMemberships) + .where(and(eq(schema.orgMemberships.orgId, org.id), eq(schema.orgMemberships.userId, c.get('accountId')!))) + .limit(1); + + if (!membership || membership.role === "member") { + return c.json({ error: "Must be an owner or admin to manage org API keys", statusCode: 403 }, 403); + } + + const rawKey = `ul_${uuidv4().replace(/-/g, "")}`; + const keyHash = await bcrypt.hash(rawKey, 10); + const keyPrefix = rawKey.slice(0, 12); + + const expiresAt = expiresIn + ? new Date(Date.now() + expiresIn * 24 * 60 * 60 * 1000) + : null; + + const [key] = await db + .insert(schema.apiKeys) + .values({ + accountId: org.id, + scope, + keyHash, + keyPrefix, + label, + collectionId: collectionId ?? null, + expiresAt, + }) + .returning(); + + return c.json({ + id: key!.id, + key: rawKey, + label, + scope, + keyPrefix, + collectionId: collectionId ?? null, + expiresAt, + }, 201); +}); + +// List org API keys +app.get('/accounts/:slug/keys', requireAuth(), async (c) => { + const slug = c.req.param('slug'); + + const [org] = await db + .select() + .from(schema.accounts) + .where(and(eq(schema.accounts.slug, slug), eq(schema.accounts.type, "org"))) + .limit(1); + + if (!org) return c.json({ error: "Organization not found", statusCode: 404 }, 404); + + // Must be a member + const [membership] = await db + .select() + .from(schema.orgMemberships) + .where(and(eq(schema.orgMemberships.orgId, org.id), eq(schema.orgMemberships.userId, c.get('accountId')!))) + .limit(1); + + if (!membership) return c.json({ error: "Forbidden", statusCode: 403 }, 403); + + const keys = await db + .select({ + id: schema.apiKeys.id, + label: schema.apiKeys.label, + scope: schema.apiKeys.scope, + keyPrefix: schema.apiKeys.keyPrefix, + collectionId: schema.apiKeys.collectionId, + expiresAt: schema.apiKeys.expiresAt, + createdAt: schema.apiKeys.createdAt, + lastUsedAt: schema.apiKeys.lastUsedAt, + }) + .from(schema.apiKeys) + .where(eq(schema.apiKeys.accountId, org.id)); + + return c.json(keys); +}); + +// Delete org API key +app.delete('/accounts/:slug/keys/:id', requireAuth(), async (c) => { + const slug = c.req.param('slug'); + const id = c.req.param('id'); + + const [org] = await db + .select() + .from(schema.accounts) + .where(and(eq(schema.accounts.slug, slug), eq(schema.accounts.type, "org"))) + .limit(1); + + if (!org) return c.json({ error: "Organization not found", statusCode: 404 }, 404); + + const [membership] = await db + .select() + .from(schema.orgMemberships) + .where(and(eq(schema.orgMemberships.orgId, org.id), eq(schema.orgMemberships.userId, c.get('accountId')!))) + .limit(1); + + if (!membership || membership.role === "member") { + return c.json({ error: "Must be an owner or admin to manage org API keys", statusCode: 403 }, 403); + } + + const [key] = await db + .select() + .from(schema.apiKeys) + .where(and(eq(schema.apiKeys.id, id), eq(schema.apiKeys.accountId, org.id))) + .limit(1); + + if (!key) return c.json({ error: "Key not found", statusCode: 404 }, 404); + + await db.delete(schema.apiKeys).where(eq(schema.apiKeys.id, id)); + return c.json({ ok: true }); +}); + +// --- Org Management --- + +// Create organization +app.post('/accounts/orgs', requireAuth(), async (c) => { + const { slug, displayName } = await c.req.json(); + + if (RESERVED_SLUGS.has(slug.toLowerCase())) { + return c.json({ error: "That name is reserved", statusCode: 422 }, 422); + } + + if (!/^[a-z0-9][a-z0-9\-]*[a-z0-9]$/.test(slug) || slug.length < 2) { + return c.json({ error: "Slug must be lowercase alphanumeric with hyphens, at least 2 characters", statusCode: 422 }, 422); + } + + const existing = await db + .select() + .from(schema.accounts) + .where(eq(schema.accounts.slug, slug)) + .limit(1); + + if (existing.length > 0) { + return c.json({ error: "Name already taken", statusCode: 409 }, 409); + } + + const id = uuidv4(); + await db.insert(schema.accounts).values({ + id, + slug, + type: "org", + displayName, + }); + + // Add the creating user as owner + await db.insert(schema.orgMemberships).values({ + orgId: id, + userId: c.get('accountId')!, + role: "owner", + }); + + return c.json({ id, slug, displayName, type: "org" }, 201); +}); + +// List org members +app.get('/accounts/:slug/members', requireAuth(), async (c) => { + const slug = c.req.param('slug'); + + const [org] = await db + .select() + .from(schema.accounts) + .where(and(eq(schema.accounts.slug, slug), eq(schema.accounts.type, "org"))) + .limit(1); + + if (!org) return c.json({ error: "Organization not found", statusCode: 404 }, 404); + + // Must be a member to view + const [membership] = await db + .select() + .from(schema.orgMemberships) + .where(and(eq(schema.orgMemberships.orgId, org.id), eq(schema.orgMemberships.userId, c.get('accountId')!))) + .limit(1); + + if (!membership) return c.json({ error: "Forbidden", statusCode: 403 }, 403); + + const members = await db + .select({ + userId: schema.orgMemberships.userId, + role: schema.orgMemberships.role, + slug: schema.accounts.slug, + displayName: schema.accounts.displayName, + }) + .from(schema.orgMemberships) + .innerJoin(schema.accounts, eq(schema.orgMemberships.userId, schema.accounts.id)) + .where(eq(schema.orgMemberships.orgId, org.id)); + + return c.json(members); +}); + +// Add org member +app.post('/accounts/:slug/members', requireAuth(), async (c) => { + const slug = c.req.param('slug'); + const { username, role } = await c.req.json(); + + const [org] = await db + .select() + .from(schema.accounts) + .where(and(eq(schema.accounts.slug, slug), eq(schema.accounts.type, "org"))) + .limit(1); + + if (!org) return c.json({ error: "Organization not found", statusCode: 404 }, 404); + + // Must be owner or admin + const [callerMembership] = await db + .select() + .from(schema.orgMemberships) + .where(and(eq(schema.orgMemberships.orgId, org.id), eq(schema.orgMemberships.userId, c.get('accountId')!))) + .limit(1); + + if (!callerMembership || callerMembership.role === "member") { + return c.json({ error: "Must be an owner or admin to add members", statusCode: 403 }, 403); + } + + // Find user to add + const [user] = await db + .select() + .from(schema.accounts) + .where(and(eq(schema.accounts.slug, username), eq(schema.accounts.type, "user"))) + .limit(1); + + if (!user) return c.json({ error: "User not found", statusCode: 404 }, 404); + + // Check not already a member + const [existing] = await db + .select() + .from(schema.orgMemberships) + .where(and(eq(schema.orgMemberships.orgId, org.id), eq(schema.orgMemberships.userId, user.id))) + .limit(1); + + if (existing) return c.json({ error: "Already a member", statusCode: 409 }, 409); + + await db.insert(schema.orgMemberships).values({ + orgId: org.id, + userId: user.id, + role: role ?? "member", + }); + + return c.json({ ok: true, username, role }, 201); +}); + +// Update member role +app.patch('/accounts/:slug/members/:userId', requireAuth(), async (c) => { + const slug = c.req.param('slug'); + const userId = c.req.param('userId'); + const { role } = await c.req.json(); + + const [org] = await db + .select() + .from(schema.accounts) + .where(and(eq(schema.accounts.slug, slug), eq(schema.accounts.type, "org"))) + .limit(1); + + if (!org) return c.json({ error: "Organization not found", statusCode: 404 }, 404); + + // Must be owner + const [callerMembership] = await db + .select() + .from(schema.orgMemberships) + .where(and(eq(schema.orgMemberships.orgId, org.id), eq(schema.orgMemberships.userId, c.get('accountId')!))) + .limit(1); + + if (!callerMembership || callerMembership.role !== "owner") { + return c.json({ error: "Must be an owner to change roles", statusCode: 403 }, 403); + } + + await db + .update(schema.orgMemberships) + .set({ role }) + .where(and(eq(schema.orgMemberships.orgId, org.id), eq(schema.orgMemberships.userId, userId))); + + return c.json({ ok: true }); +}); + +// Remove member +app.delete('/accounts/:slug/members/:userId', requireAuth(), async (c) => { + const slug = c.req.param('slug'); + const userId = c.req.param('userId'); + + const [org] = await db + .select() + .from(schema.accounts) + .where(and(eq(schema.accounts.slug, slug), eq(schema.accounts.type, "org"))) + .limit(1); + + if (!org) return c.json({ error: "Organization not found", statusCode: 404 }, 404); + + // Must be owner or admin (or removing yourself) + const [callerMembership] = await db + .select() + .from(schema.orgMemberships) + .where(and(eq(schema.orgMemberships.orgId, org.id), eq(schema.orgMemberships.userId, c.get('accountId')!))) + .limit(1); + + const isSelf = c.get('accountId') === userId; + if (!callerMembership || (callerMembership.role === "member" && !isSelf)) { + return c.json({ error: "Forbidden", statusCode: 403 }, 403); + } + + await db + .delete(schema.orgMemberships) + .where(and(eq(schema.orgMemberships.orgId, org.id), eq(schema.orgMemberships.userId, userId))); + + return c.json({ ok: true }); +}); + +// Update org profile +app.patch('/accounts/:slug', requireAuth(), async (c) => { + const slug = c.req.param('slug'); + const { displayName, bio, website, location } = await c.req.json(); + + const [org] = await db + .select() + .from(schema.accounts) + .where(and(eq(schema.accounts.slug, slug), eq(schema.accounts.type, "org"))) + .limit(1); + + if (!org) return c.json({ error: "Organization not found", statusCode: 404 }, 404); + + // Must be owner + const [callerMembership] = await db + .select() + .from(schema.orgMemberships) + .where(and(eq(schema.orgMemberships.orgId, org.id), eq(schema.orgMemberships.userId, c.get('accountId')!))) + .limit(1); + + if (!callerMembership || callerMembership.role !== "owner") { + return c.json({ error: "Must be an owner to update the organization", statusCode: 403 }, 403); + } + + const updates: Record = {}; + if (displayName !== undefined) updates.displayName = displayName; + if (bio !== undefined) updates.bio = bio; + if (website !== undefined) updates.website = website; + if (location !== undefined) updates.location = location; + + if (Object.keys(updates).length > 0) { + await db.update(schema.accounts).set(updates).where(eq(schema.accounts.id, org.id)); + } + + return c.json({ ok: true }); +}); + +// Upload org avatar +app.post('/accounts/:slug/avatar', requireAuth(), async (c) => { + const slug = c.req.param('slug'); + + const [org] = await db + .select() + .from(schema.accounts) + .where(and(eq(schema.accounts.slug, slug), eq(schema.accounts.type, "org"))) + .limit(1); + + if (!org) return c.json({ error: "Organization not found", statusCode: 404 }, 404); + + const [membership] = await db + .select() + .from(schema.orgMemberships) + .where(and(eq(schema.orgMemberships.orgId, org.id), eq(schema.orgMemberships.userId, c.get('accountId')!))) + .limit(1); + + if (!membership || membership.role !== "owner") { + return c.json({ error: "Must be an owner to update the organization avatar", statusCode: 403 }, 403); + } + + const body = await c.req.parseBody(); + const file = Object.values(body).find((v): v is File => v instanceof File); + if (!file) { + return c.json({ error: "No file uploaded", statusCode: 400 }, 400); + } + + const allowedTypes = ["image/jpeg", "image/png", "image/gif", "image/webp"]; + if (!allowedTypes.includes(file.type)) { + return c.json({ error: "Only JPEG, PNG, GIF, and WebP images are allowed", statusCode: 422 }, 422); + } + + const buffer = Buffer.from(await file.arrayBuffer()); + if (buffer.length > 5 * 1024 * 1024) { + return c.json({ error: "Image must be less than 5MB", statusCode: 422 }, 422); + } + + const ext = file.type.split("/")[1] === "jpeg" ? "jpg" : file.type.split("/")[1]; + const key = `avatars/${org.id}/${Date.now()}.${ext}`; + + await uploadToS3(key, buffer, file.type); + + await db.update(schema.accounts).set({ avatarUrl: `${ASSETS_BASE_URL}/${key}` }).where(eq(schema.accounts.id, org.id)); + + return c.json({ ok: true, avatarUrl: `${ASSETS_BASE_URL}/${key}` }); +}); + +// --- Org Invitations --- + +// Invite user to org +app.post('/accounts/:slug/invitations', requireAuth(), async (c) => { + const slug = c.req.param('slug'); + const { email, role } = await c.req.json(); + + const [org] = await db + .select() + .from(schema.accounts) + .where(and(eq(schema.accounts.slug, slug), eq(schema.accounts.type, "org"))) + .limit(1); + + if (!org) return c.json({ error: "Organization not found", statusCode: 404 }, 404); + + const [callerMembership] = await db + .select() + .from(schema.orgMemberships) + .where(and(eq(schema.orgMemberships.orgId, org.id), eq(schema.orgMemberships.userId, c.get('accountId')!))) + .limit(1); + + if (!callerMembership || callerMembership.role === "member") { + return c.json({ error: "Must be an owner or admin to invite members", statusCode: 403 }, 403); + } + + // Check if already a member (by email) + const [existingUser] = await db + .select() + .from(schema.accounts) + .where(eq(schema.accounts.email, email)) + .limit(1); + + if (existingUser) { + const [existingMembership] = await db + .select() + .from(schema.orgMemberships) + .where(and(eq(schema.orgMemberships.orgId, org.id), eq(schema.orgMemberships.userId, existingUser.id))) + .limit(1); + + if (existingMembership) { + return c.json({ error: "User is already a member", statusCode: 409 }, 409); + } + } + + const token = uuidv4(); + const expiresAt = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000); // 7 days + + await db.insert(schema.orgInvitations).values({ + orgId: org.id, + email, + role, + invitedBy: c.get('accountId')!, + token, + expiresAt, + }); + + // Send invitation email + const origin = new URL(c.req.url).origin; + const inviteUrl = `${origin}/invitations/accept?token=${token}`; + await sendEmail({ + to: email, + subject: `You've been invited to join ${org.displayName} on Underlay`, + text: `You've been invited to join ${org.displayName} as a ${role}.\n\nAccept: ${inviteUrl}\n\nThis invitation expires in 7 days.`, + html: `

You've been invited to join ${org.displayName} as a ${role}.

Accept invitation

This invitation expires in 7 days.

`, + }); + + return c.json({ ok: true }, 201); +}); + +// List pending invitations for an org +app.get('/accounts/:slug/invitations', requireAuth(), async (c) => { + const slug = c.req.param('slug'); + + const [org] = await db + .select() + .from(schema.accounts) + .where(and(eq(schema.accounts.slug, slug), eq(schema.accounts.type, "org"))) + .limit(1); + + if (!org) return c.json({ error: "Organization not found", statusCode: 404 }, 404); + + const [membership] = await db + .select() + .from(schema.orgMemberships) + .where(and(eq(schema.orgMemberships.orgId, org.id), eq(schema.orgMemberships.userId, c.get('accountId')!))) + .limit(1); + + if (!membership) return c.json({ error: "Forbidden", statusCode: 403 }, 403); + + const invitations = await db + .select({ + id: schema.orgInvitations.id, + email: schema.orgInvitations.email, + role: schema.orgInvitations.role, + expiresAt: schema.orgInvitations.expiresAt, + acceptedAt: schema.orgInvitations.acceptedAt, + createdAt: schema.orgInvitations.createdAt, + }) + .from(schema.orgInvitations) + .where(eq(schema.orgInvitations.orgId, org.id)); + + return c.json(invitations); +}); + +// Cancel an invitation +app.delete('/accounts/:slug/invitations/:id', requireAuth(), async (c) => { + const slug = c.req.param('slug'); + const id = c.req.param('id'); + + const [org] = await db + .select() + .from(schema.accounts) + .where(and(eq(schema.accounts.slug, slug), eq(schema.accounts.type, "org"))) + .limit(1); + + if (!org) return c.json({ error: "Organization not found", statusCode: 404 }, 404); + + const [membership] = await db + .select() + .from(schema.orgMemberships) + .where(and(eq(schema.orgMemberships.orgId, org.id), eq(schema.orgMemberships.userId, c.get('accountId')!))) + .limit(1); + + if (!membership || membership.role === "member") { + return c.json({ error: "Must be an owner or admin to cancel invitations", statusCode: 403 }, 403); + } + + await db.delete(schema.orgInvitations).where(eq(schema.orgInvitations.id, id)); + return c.json({ ok: true }); +}); + +// Accept an invitation (public, token-based) +app.post('/accounts/invitations/accept', requireAuth(), async (c) => { + const { token } = await c.req.json(); + + const [invitation] = await db + .select() + .from(schema.orgInvitations) + .where(eq(schema.orgInvitations.token, token)) + .limit(1); + + if (!invitation) { + return c.json({ error: "Invitation not found", statusCode: 404 }, 404); + } + + if (invitation.acceptedAt) { + return c.json({ error: "Invitation already accepted", statusCode: 409 }, 409); + } + + if (new Date(invitation.expiresAt) < new Date()) { + return c.json({ error: "Invitation has expired", statusCode: 410 }, 410); + } + + // Verify the logged-in user's email matches the invitation + const [account] = await db + .select() + .from(schema.accounts) + .where(eq(schema.accounts.id, c.get('accountId')!)) + .limit(1); + + if (!account || account.email !== invitation.email) { + return c.json({ error: "This invitation was sent to a different email address", statusCode: 403 }, 403); + } + + // Add to org + await db.insert(schema.orgMemberships).values({ + orgId: invitation.orgId, + userId: c.get('accountId')!, + role: invitation.role as "owner" | "admin" | "member", + }); + + // Mark invitation as accepted + await db + .update(schema.orgInvitations) + .set({ acceptedAt: new Date() }) + .where(eq(schema.orgInvitations.id, invitation.id)); + + // Get org slug for redirect + const [org] = await db + .select({ slug: schema.accounts.slug }) + .from(schema.accounts) + .where(eq(schema.accounts.id, invitation.orgId)) + .limit(1); + + return c.json({ ok: true, orgSlug: org?.slug ?? "" }); +}); + +// Delete org +app.delete('/accounts/:slug', requireAuth(), async (c) => { + const slug = c.req.param('slug'); + + const [org] = await db + .select() + .from(schema.accounts) + .where(and(eq(schema.accounts.slug, slug), eq(schema.accounts.type, "org"))) + .limit(1); + + if (!org) return c.json({ error: "Organization not found", statusCode: 404 }, 404); + + // Must be owner + const [callerMembership] = await db + .select() + .from(schema.orgMemberships) + .where(and(eq(schema.orgMemberships.orgId, org.id), eq(schema.orgMemberships.userId, c.get('accountId')!))) + .limit(1); + + if (!callerMembership || callerMembership.role !== "owner") { + return c.json({ error: "Must be an owner to delete the organization", statusCode: 403 }, 403); + } + + // Cascade will handle memberships, collections, etc. + await db.delete(schema.accounts).where(eq(schema.accounts.id, org.id)); + return c.json({ ok: true }); +}); + +export { app as accountRoutes }; diff --git a/src/api/admin.ts b/src/api/admin.ts new file mode 100644 index 0000000..c790549 --- /dev/null +++ b/src/api/admin.ts @@ -0,0 +1,132 @@ +import { Hono } from "hono"; +import { streamSSE } from "hono/streaming"; +import { type AuthEnv } from "./auth.server.js"; +import { getMirrorConfig } from "../lib/mirror-config.js"; +import { + runMirrorSync, + testUpstreamConnection, + getMirrorStatus, + getSyncHistory, + syncEvents, + stopSync, + cleanupStaleRuns, + isSyncRunning, + getActiveRunId, + getActiveRunLogs, + type SyncProgressEvent, +} from "../lib/mirror-sync.js"; + +const app = new Hono(); + +// All admin routes require mirror mode to be enabled +app.use("*", async (c, next) => { + const config = getMirrorConfig(); + if (!config.enabled) { + return c.json({ error: "Not found", statusCode: 404 }, 404); + } + await next(); +}); + +// Get mirror status +app.get("/admin/mirror/status", async (c) => { + const status = await getMirrorStatus(); + return c.json(status); +}); + +// Test upstream connection +app.post("/admin/mirror/test", async (c) => { + const config = getMirrorConfig(); + const result = await testUpstreamConnection(config.upstream); + return c.json(result); +}); + +// Trigger a sync manually (fire-and-forget, client uses SSE for progress) +app.post("/admin/mirror/sync", async (c) => { + if (isSyncRunning()) { + return c.json({ started: false, error: "A sync is already running" }); + } + // Start sync in background — don't await + runMirrorSync("manual").catch((err) => { + console.error("[mirror-sync] Unhandled sync error:", err); + }); + return c.json({ started: true }); +}); + +// Stop a running sync (also cleans up stale DB rows from crashed processes) +app.post("/admin/mirror/sync/stop", async (c) => { + const stopped = stopSync(); + if (!stopped) { + // No active sync in this process — clean up stale DB rows + const cleaned = await cleanupStaleRuns(); + return c.json({ stopped: false, cleaned }); + } + return c.json({ stopped: true }); +}); + +// SSE endpoint for live sync progress (replays buffered logs on connect) +app.get("/admin/mirror/sync/progress", (c) => { + return streamSSE(c, async (stream) => { + // Replay buffered logs so reconnects/refreshes don't lose history + const buffered = getActiveRunLogs(); + if (buffered.length > 0) { + for (const msg of buffered) { + const replayEvent: SyncProgressEvent = { + type: "collection", + message: msg, + progress: { + collectionsTotal: 0, + collectionsProcessed: 0, + versionsPulled: 0, + filesDownloaded: 0, + filesSkipped: 0, + errors: 0, + }, + }; + await stream.writeSSE({ data: JSON.stringify(replayEvent) }); + } + } + + // If no sync is running, close immediately + if (!isSyncRunning()) { + return; + } + + const onProgress = async (event: SyncProgressEvent) => { + await stream.writeSSE({ data: JSON.stringify(event) }); + if (event.type === "done") { + setTimeout(() => stream.close(), 100); + } + }; + + syncEvents.on("progress", onProgress); + + stream.onAbort(() => { + syncEvents.off("progress", onProgress); + }); + + // Keep the stream open until aborted or done + await new Promise((resolve) => { + stream.onAbort(() => resolve()); + }); + }); +}); + +// Get current sync running state (for page refresh reconnection) +app.get("/admin/mirror/sync/active", async (c) => { + return c.json({ + running: isSyncRunning(), + runId: getActiveRunId(), + logs: getActiveRunLogs(), + }); +}); + +// Sync history +app.get("/admin/mirror/history", async (c) => { + const limit = Math.min( + Number(c.req.query("limit")) || 20, + 100, + ); + return c.json(await getSyncHistory(limit)); +}); + +export { app as adminRoutes }; diff --git a/src/api/ark-middleware.server.ts b/src/api/ark-middleware.server.ts new file mode 100644 index 0000000..60819e8 --- /dev/null +++ b/src/api/ark-middleware.server.ts @@ -0,0 +1,101 @@ +import type { MiddlewareHandler } from 'hono' +import { DEFAULT_NAAN, buildErc } from '../lib/ark.js' + +/** + * Hono middleware that intercepts /ark:NAAN/... URLs and resolves them. + * In the new single-server architecture, we call the API route handler internally + * via a local fetch to localhost (same process). + */ +export const arkMiddleware: MiddlewareHandler = async (c, _next) => { + const url = new URL(c.req.url) + const pathname = url.pathname + const search = url.search + + if (!pathname.startsWith('/ark:')) { + return _next() + } + + const fullPath = pathname.slice(1) // strip leading / + + // Check if this is a root NAAN path + const afterLabel = fullPath.slice(4) // strip "ark:" + const slashIdx = afterLabel.indexOf('/') + const naan = slashIdx === -1 ? afterLabel : afterLabel.slice(0, slashIdx) + const afterNaan = slashIdx === -1 ? '' : afterLabel.slice(slashIdx + 1) + + if (!afterNaan.trim()) { + return new Response( + [ + `The Underlay assigns identifiers within the ARK domain ${naan} with the following principles:`, + '', + '1. Persistence: ARKs are never reassigned. Once minted, an ARK will always resolve to the same collection or record, or return a tombstone response if the object has been deleted.', + '', + '2. Transparency: Appending ?info or ?? to any ARK returns an Electronic Resource Citation (ERC) describing the identified object.', + '', + '3. Openness: ARKs are free, open identifiers requiring no licensing fees. The Underlay uses the ARK scheme as specified by the ARK Alliance.', + '', + '4. Scope: Underlay ARKs primarily identify versioned data collections and the records within them. Collection ARKs redirect to the collection overview; version-qualified ARKs redirect to specific version pages; record ARKs redirect to the canonical URL of the identified record.', + '', + `For more information, see: https://underlay.org/ark:${naan}/`, + ].join('\n'), + { headers: { 'Content-Type': 'text/plain; charset=utf-8' } }, + ) + } + + // Resolve the ARK via internal API + const port = Number(process.env.PORT) || 3000 + const apiBase = `http://localhost:${port}` + const params = new URLSearchParams({ path: fullPath }) + let resolveRes: Response + try { + resolveRes = await fetch(`${apiBase}/api/ark/resolve?${params}`) + } catch { + return new Response('ARK resolver unavailable', { status: 503 }) + } + + if (!resolveRes.ok) { + const body = await resolveRes.json().catch(() => ({})) + if (body?.type === 'not_found') { + return new Response('ARK not found', { status: 404 }) + } + return new Response('ARK resolution error', { status: 502 }) + } + + const data = await resolveRes.json() + + if (data.type === 'not_found') { + return new Response('ARK not found', { status: 404 }) + } + + const { metadata } = data + const resolvedNaan = metadata?.naan ?? DEFAULT_NAAN + + // Handle inflections + if (search === '?info' || search === '??' || search === '%3F%3F') { + const erc = buildErc({ + type: metadata.type, + who: metadata.who ?? metadata.ownerName ?? '(:unkn)', + what: metadata.what ?? metadata.collectionName ?? '(:unkn)', + when: metadata.when ?? '(:unkn)', + where: metadata.where ?? metadata.arkUrl ?? '(:unkn)', + naan: resolvedNaan, + }) + return new Response(erc, { + headers: { 'Content-Type': 'text/plain; charset=utf-8' }, + }) + } + + if (search === '?json') { + return new Response(JSON.stringify(metadata, null, 2), { + headers: { 'Content-Type': 'application/json' }, + }) + } + + // Regular resolution — redirect + const targetUrl = data.url + const redirectTarget = targetUrl.startsWith('/') + ? `${url.origin}${targetUrl}` + : targetUrl + + return c.redirect(redirectTarget, 302) +} diff --git a/src/api/ark.ts b/src/api/ark.ts new file mode 100644 index 0000000..ef2a4c6 --- /dev/null +++ b/src/api/ark.ts @@ -0,0 +1,514 @@ +import { Hono } from 'hono'; +import { eq, and, desc } from 'drizzle-orm'; +import { db, schema } from '../db/client.server.js'; +import { requireAuth, type AuthEnv } from './auth.server.js'; +import { + DEFAULT_NAAN, + parseArkPath, + buildArkUrl, + buildErc, + formatErcDate, + collectionToArkId, + getOrMintShoulder, +} from '../lib/ark.js'; + +const app = new Hono(); + +// --- Resolution --- + +app.get('/ark/resolve', async (c) => { + const path = c.req.query('path'); + if (!path) return c.json({ error: 'Missing path' }, 400); + + // path = "ark:NAAN/shoulder+collection..." + const arkLabelIdx = path.indexOf('ark:'); + if (arkLabelIdx === -1) return c.json({ error: 'Invalid ARK path' }, 400); + + const afterLabel = path.slice(arkLabelIdx + 4); // strip "ark:" + const slashIdx = afterLabel.indexOf('/'); + if (slashIdx === -1) return c.json({ type: 'not_found' }, 404); + + const naan = afterLabel.slice(0, slashIdx); + const pathAfterNaan = afterLabel.slice(slashIdx + 1); + + // Root NAAN path (no name part) — handled in middleware; shouldn't reach here + if (!pathAfterNaan) return c.json({ type: 'not_found' }, 404); + + const components = parseArkPath(pathAfterNaan); + if (!components) return c.json({ type: 'not_found' }, 404); + + const { shoulder, collectionArkId, version, recordType, recordId } = components; + + // Lookup shoulder → account + const [shoulderRow] = await db + .select({ accountId: schema.arkShoulders.accountId }) + .from(schema.arkShoulders) + .where(eq(schema.arkShoulders.shoulder, shoulder)) + .limit(1); + if (!shoulderRow) return c.json({ type: 'not_found' }, 404); + + // Lookup collectionArkId → collection + owner + const [collRow] = await db + .select({ + collectionId: schema.arkCollections.collectionId, + enabled: schema.arkCollections.enabled, + customUrl: schema.arkCollections.customUrl, + collectionSlug: schema.collections.slug, + collectionName: schema.collections.name, + ownerSlug: schema.accounts.slug, + ownerName: schema.accounts.displayName, + ownerNaan: schema.accounts.arkNaan, + collectionAccountId: schema.collections.accountId, + }) + .from(schema.arkCollections) + .innerJoin(schema.collections, eq(schema.arkCollections.collectionId, schema.collections.id)) + .innerJoin(schema.accounts, eq(schema.collections.accountId, schema.accounts.id)) + .where(eq(schema.arkCollections.arkId, collectionArkId)) + .limit(1); + + if (!collRow || !collRow.enabled) return c.json({ type: 'not_found' }, 404); + + // Verify the shoulder belongs to the collection's owner + if (shoulderRow.accountId !== collRow.collectionAccountId) { + return c.json({ type: 'not_found' }, 404); + } + + const resolvedNaan = collRow.ownerNaan ?? naan; + const { collectionId, collectionSlug, collectionName, ownerSlug, ownerName } = collRow; + + // --- Resolve version --- + let versionRow: { + id: number; + number: number; + semver: string; + message: string | null; + readme: string | null; + pushedBy: string | null; + appId: string | null; + actorId: string | null; + createdAt: Date; + } | null = null; + + if (version !== undefined) { + const [row] = await db + .select({ + id: schema.versions.id, + number: schema.versions.number, + semver: schema.versions.semver, + message: schema.versions.message, + readme: schema.versions.readme, + pushedBy: schema.versions.pushedBy, + appId: schema.versions.appId, + actorId: schema.versions.actorId, + createdAt: schema.versions.createdAt, + }) + .from(schema.versions) + .where(and(eq(schema.versions.collectionId, collectionId), eq(schema.versions.number, version))) + .limit(1); + if (!row) return c.json({ type: 'not_found' }, 404); + versionRow = row; + } else { + const [row] = await db + .select({ + id: schema.versions.id, + number: schema.versions.number, + semver: schema.versions.semver, + message: schema.versions.message, + readme: schema.versions.readme, + pushedBy: schema.versions.pushedBy, + appId: schema.versions.appId, + actorId: schema.versions.actorId, + createdAt: schema.versions.createdAt, + }) + .from(schema.versions) + .where(eq(schema.versions.collectionId, collectionId)) + .orderBy(desc(schema.versions.number)) + .limit(1); + versionRow = row ?? null; + } + + const arkUrl = buildArkUrl(resolvedNaan, shoulder, collectionArkId, version, recordType, recordId); + + // --- Record resolution --- + if (recordType && recordId) { + const [artRow] = await db + .select({ redirectUrlField: schema.arkRecordTypes.redirectUrlField }) + .from(schema.arkRecordTypes) + .where( + and( + eq(schema.arkRecordTypes.collectionId, collectionId), + eq(schema.arkRecordTypes.recordType, recordType), + ), + ) + .limit(1); + + if (!artRow) return c.json({ type: 'not_found' }, 404); + + if (!versionRow) return c.json({ type: 'not_found' }, 404); + + const [recordRow] = await db + .select({ data: schema.records.data }) + .from(schema.records) + .where( + and( + eq(schema.records.versionId, versionRow.id), + eq(schema.records.recordId, recordId), + eq(schema.records.type, recordType), + ), + ) + .limit(1); + + if (!recordRow) return c.json({ type: 'not_found' }, 404); + + const data = recordRow.data as Record; + const redirectUrl = data[artRow.redirectUrlField]; + if (typeof redirectUrl !== 'string') { + return c.json({ type: 'not_found', error: 'No URL found for this record' }, 404); + } + + // Fetch the type schema for metadata + const [vs] = await db + .select({ schema: schema.schemas.schema }) + .from(schema.versionSchemas) + .innerJoin(schema.schemas, eq(schema.versionSchemas.schemaId, schema.schemas.id)) + .where( + and( + eq(schema.versionSchemas.versionId, versionRow.id), + eq(schema.versionSchemas.slug, recordType), + ), + ) + .limit(1); + + return c.json({ + type: 'redirect' as const, + url: redirectUrl, + metadata: { + type: 'record', + who: ownerName, + what: `${recordType} ${recordId} in ${collectionName}`, + when: formatErcDate(versionRow.createdAt), + where: arkUrl, + naan: resolvedNaan, + collectionName, + ownerName, + versionNumber: versionRow.number, + semver: versionRow.semver, + recordType, + recordId, + schema: vs?.schema ?? null, + data, + createdAt: versionRow.createdAt, + arkUrl, + }, + }); + } + + // --- Collection / version resolution --- + if (collRow.customUrl) { + const what = versionRow + ? `${collectionName} ${versionRow.semver}` + : collectionName; + const when = versionRow + ? formatErcDate(versionRow.createdAt) + : '(:unkn)'; + return c.json({ + type: 'redirect' as const, + url: collRow.customUrl, + metadata: { + type: version !== undefined ? 'version' : 'collection', + who: ownerName, + what, + when, + where: arkUrl, + naan: resolvedNaan, + collectionName, + ownerName, + versionNumber: versionRow?.number, + semver: versionRow?.semver, + message: versionRow?.message, + pushedBy: versionRow?.pushedBy, + appId: versionRow?.appId, + actorId: versionRow?.actorId, + createdAt: versionRow?.createdAt, + arkUrl, + }, + }); + } + + if (version !== undefined && versionRow) { + const url = `/${ownerSlug}/${collectionSlug}/v/${versionRow.number}`; + return c.json({ + type: 'redirect' as const, + url, + metadata: { + type: 'version', + who: ownerName, + what: `${collectionName} ${versionRow.semver}`, + when: formatErcDate(versionRow.createdAt), + where: arkUrl, + naan: resolvedNaan, + collectionName, + ownerName, + versionNumber: versionRow.number, + semver: versionRow.semver, + message: versionRow.message, + pushedBy: versionRow.pushedBy, + appId: versionRow.appId, + actorId: versionRow.actorId, + createdAt: versionRow.createdAt, + arkUrl, + }, + }); + } + + // Default: redirect to collection overview + const url = `/${ownerSlug}/${collectionSlug}`; + return c.json({ + type: 'redirect' as const, + url, + metadata: { + type: 'collection', + who: ownerName, + what: collectionName, + when: versionRow ? formatErcDate(versionRow.createdAt) : '(:unkn)', + where: arkUrl, + naan: resolvedNaan, + collectionName, + ownerName, + versionNumber: versionRow?.number, + semver: versionRow?.semver, + createdAt: versionRow?.createdAt, + arkUrl, + }, + }); +}); + +// --- Collection ARK settings --- + +app.get('/collections/:owner/:slug/ark', requireAuth('read'), async (c) => { + const owner = c.req.param('owner'); + const slug = c.req.param('slug'); + + const [coll] = await db + .select({ + id: schema.collections.id, + accountId: schema.collections.accountId, + ownerNaan: schema.accounts.arkNaan, + }) + .from(schema.collections) + .innerJoin(schema.accounts, eq(schema.collections.accountId, schema.accounts.id)) + .where(and(eq(schema.accounts.slug, owner), eq(schema.collections.slug, slug))) + .limit(1); + if (!coll) return c.json({ error: 'Collection not found' }, 404); + + // Must be owner/member + const hasAccess = await checkCollectionAccess(coll.accountId, c.get('accountId')!); + if (!hasAccess) return c.json({ error: 'Forbidden' }, 403); + + const naan = coll.ownerNaan ?? DEFAULT_NAAN; + + const [arkRow] = await db + .select({ + arkId: schema.arkCollections.arkId, + enabled: schema.arkCollections.enabled, + customUrl: schema.arkCollections.customUrl, + shoulder: schema.arkShoulders.shoulder, + }) + .from(schema.arkCollections) + .innerJoin( + schema.arkShoulders, + eq(schema.arkShoulders.accountId, coll.accountId), + ) + .where(eq(schema.arkCollections.collectionId, coll.id)) + .limit(1); + + if (!arkRow) { + return c.json({ enabled: false, customUrl: null, arkUrl: null, shoulder: null, arkId: null }); + } + + const arkUrl = buildArkUrl(naan, arkRow.shoulder, arkRow.arkId); + return c.json({ enabled: arkRow.enabled, customUrl: arkRow.customUrl, arkUrl, shoulder: arkRow.shoulder, arkId: arkRow.arkId }); +}); + +app.patch('/collections/:owner/:slug/ark', requireAuth('write'), async (c) => { + const owner = c.req.param('owner'); + const slug = c.req.param('slug'); + const { enabled, customUrl } = await c.req.json(); + + const [coll] = await db + .select({ id: schema.collections.id, accountId: schema.collections.accountId }) + .from(schema.collections) + .innerJoin(schema.accounts, eq(schema.collections.accountId, schema.accounts.id)) + .where(and(eq(schema.accounts.slug, owner), eq(schema.collections.slug, slug))) + .limit(1); + if (!coll) return c.json({ error: 'Collection not found' }, 404); + + const hasAccess = await checkCollectionAccess(coll.accountId, c.get('accountId')!); + if (!hasAccess) return c.json({ error: 'Forbidden' }, 403); + + const [existing] = await db + .select({ collectionId: schema.arkCollections.collectionId }) + .from(schema.arkCollections) + .where(eq(schema.arkCollections.collectionId, coll.id)) + .limit(1); + + if (!existing) { + // Collection predates ARK tables — mint now + await getOrMintShoulder(coll.accountId); + const arkId = collectionToArkId(coll.id); + await db.insert(schema.arkCollections).values({ + collectionId: coll.id, + arkId, + enabled: enabled ?? true, + customUrl: customUrl ?? null, + }); + } else { + const updates: Record = {}; + if (enabled !== undefined) updates.enabled = enabled; + if (customUrl !== undefined) updates.customUrl = customUrl ?? null; + if (Object.keys(updates).length > 0) { + await db + .update(schema.arkCollections) + .set(updates) + .where(eq(schema.arkCollections.collectionId, coll.id)); + } + } + + return c.json({ ok: true }); +}); + +// --- Record type ARK settings --- + +app.get('/collections/:owner/:slug/ark/record-types', requireAuth('read'), async (c) => { + const owner = c.req.param('owner'); + const slug = c.req.param('slug'); + + const [coll] = await db + .select({ id: schema.collections.id, accountId: schema.collections.accountId }) + .from(schema.collections) + .innerJoin(schema.accounts, eq(schema.collections.accountId, schema.accounts.id)) + .where(and(eq(schema.accounts.slug, owner), eq(schema.collections.slug, slug))) + .limit(1); + if (!coll) return c.json({ error: 'Collection not found' }, 404); + + const hasAccess = await checkCollectionAccess(coll.accountId, c.get('accountId')!); + if (!hasAccess) return c.json({ error: 'Forbidden' }, 403); + + const rows = await db + .select({ + recordType: schema.arkRecordTypes.recordType, + redirectUrlField: schema.arkRecordTypes.redirectUrlField, + }) + .from(schema.arkRecordTypes) + .where(eq(schema.arkRecordTypes.collectionId, coll.id)); + + return c.json(rows); +}); + +app.patch('/collections/:owner/:slug/ark/record-types', requireAuth('write'), async (c) => { + const owner = c.req.param('owner'); + const slug = c.req.param('slug'); + const { recordType, redirectUrlField } = await c.req.json(); + + if (!recordType) return c.json({ error: 'recordType required' }, 400); + + const [coll] = await db + .select({ id: schema.collections.id, accountId: schema.collections.accountId }) + .from(schema.collections) + .innerJoin(schema.accounts, eq(schema.collections.accountId, schema.accounts.id)) + .where(and(eq(schema.accounts.slug, owner), eq(schema.collections.slug, slug))) + .limit(1); + if (!coll) return c.json({ error: 'Collection not found' }, 404); + + const hasAccess = await checkCollectionAccess(coll.accountId, c.get('accountId')!); + if (!hasAccess) return c.json({ error: 'Forbidden' }, 403); + + if (redirectUrlField === null) { + await db + .delete(schema.arkRecordTypes) + .where( + and( + eq(schema.arkRecordTypes.collectionId, coll.id), + eq(schema.arkRecordTypes.recordType, recordType), + ), + ); + } else { + await db + .insert(schema.arkRecordTypes) + .values({ collectionId: coll.id, recordType, redirectUrlField }) + .onConflictDoUpdate({ + target: [schema.arkRecordTypes.collectionId, schema.arkRecordTypes.recordType], + set: { redirectUrlField }, + }); + } + + return c.json({ ok: true }); +}); + +// --- Org ARK NAAN --- + +app.patch('/accounts/:slug/ark', requireAuth('admin'), async (c) => { + const slug = c.req.param('slug'); + const { naan } = await c.req.json(); + + if (naan !== null && !/^\d{1,16}$/.test(naan)) { + return c.json({ error: 'NAAN must be numeric (up to 16 digits)' }, 400); + } + + const [account] = await db + .select({ id: schema.accounts.id, type: schema.accounts.type }) + .from(schema.accounts) + .where(eq(schema.accounts.slug, slug)) + .limit(1); + if (!account) return c.json({ error: 'Account not found' }, 404); + + // Must be owner/admin of the org (or the user themselves) + if (account.type === 'org') { + const [membership] = await db + .select({ role: schema.orgMemberships.role }) + .from(schema.orgMemberships) + .where( + and( + eq(schema.orgMemberships.orgId, account.id), + eq(schema.orgMemberships.userId, c.get('accountId')!), + ), + ) + .limit(1); + if (!membership || (membership.role !== 'owner' && membership.role !== 'admin')) { + return c.json({ error: 'Forbidden' }, 403); + } + } else if (account.id !== c.get('accountId')) { + return c.json({ error: 'Forbidden' }, 403); + } + + await db.update(schema.accounts).set({ arkNaan: naan }).where(eq(schema.accounts.id, account.id)); + return c.json({ ok: true }); +}); + +// --- Helpers --- + +async function checkCollectionAccess(ownerAccountId: string, requestAccountId: string): Promise { + const [account] = await db + .select({ id: schema.accounts.id, type: schema.accounts.type }) + .from(schema.accounts) + .where(eq(schema.accounts.id, ownerAccountId)) + .limit(1); + if (!account) return false; + if (account.id === requestAccountId) return true; + if (account.type === 'org') { + const [membership] = await db + .select({ role: schema.orgMemberships.role }) + .from(schema.orgMemberships) + .where( + and( + eq(schema.orgMemberships.orgId, account.id), + eq(schema.orgMemberships.userId, requestAccountId), + ), + ) + .limit(1); + return !!membership; + } + return false; +} + +export const arkRoutes = app; diff --git a/src/api/auth.server.ts b/src/api/auth.server.ts new file mode 100644 index 0000000..7746385 --- /dev/null +++ b/src/api/auth.server.ts @@ -0,0 +1,131 @@ +import type { Context, MiddlewareHandler } from 'hono' +import { createMiddleware } from 'hono/factory' +import { eq } from 'drizzle-orm' +import { db, schema } from '../db/client.server.js' +import bcrypt from 'bcrypt' +import { getCookie, setCookie, deleteCookie } from 'hono/cookie' + +export type AuthEnv = { + Variables: { + accountId?: string + apiKeyScope?: 'read' | 'write' | 'admin' + apiKeyCollectionId?: string | null + sessionUserId?: string + } +} + +const publicPaths = new Set([ + '/api/health', + '/api/accounts/signup', + '/api/accounts/login', + '/api/accounts/forgot-password', + '/api/accounts/reset-password', + '/api/query/generate-sql', +]) + +const internalToken = process.env.INTERNAL_API_TOKEN ?? 'internal-dev-token' +const sessionSecret = process.env.SESSION_SECRET ?? 'dev-secret-change-me' + +export const authMiddleware = createMiddleware(async (c, next) => { + // Internal service calls + const internalHeader = c.req.header('x-internal-token') + if (internalHeader === internalToken) { + c.set('apiKeyScope', 'read') + return next() + } + + // API key auth via Bearer token + const auth = c.req.header('authorization') + if (auth?.startsWith('Bearer ')) { + const token = auth.slice(7) + const keys = await db.select().from(schema.apiKeys) + let matched = false + for (const key of keys) { + const match = await bcrypt.compare(token, key.keyHash) + if (match) { + c.set('accountId', key.accountId) + c.set('apiKeyScope', key.scope as 'read' | 'write' | 'admin') + c.set('apiKeyCollectionId', key.collectionId) + await db + .update(schema.apiKeys) + .set({ lastUsedAt: new Date() }) + .where(eq(schema.apiKeys.id, key.id)) + matched = true + break + } + } + if (!matched) { + return c.json({ error: 'Invalid API key', statusCode: 401 }, 401) + } + return next() + } + + // Session cookie auth + const sessionCookie = getCookie(c, 'session') + if (sessionCookie) { + try { + // Try to parse as signed cookie (value.signature format) + let sessionId = sessionCookie + const dotIdx = sessionCookie.lastIndexOf('.') + if (dotIdx > 0) { + sessionId = sessionCookie.slice(0, dotIdx) + } + if (sessionId) { + const [session] = await db + .select() + .from(schema.sessions) + .where(eq(schema.sessions.id, sessionId)) + .limit(1) + if (session && new Date(session.expiresAt) > new Date()) { + c.set('sessionUserId', session.userId) + c.set('accountId', session.userId) + c.set('apiKeyScope', 'admin') + } + } + } catch { + // Invalid or expired cookie — ignore silently + } + } + + // Public GETs are allowed without auth + if (c.req.method === 'GET') return next() + + // All writes require auth, except public paths + if (!c.get('accountId')) { + const path = new URL(c.req.url).pathname + if (publicPaths.has(path)) return next() + return c.json({ error: 'Authentication required', statusCode: 401 }, 401) + } + + return next() +}) + +export function requireAuth(scope?: 'read' | 'write' | 'admin'): MiddlewareHandler { + return async (c, next) => { + if (!c.get('accountId')) { + return c.json({ error: 'Authentication required', statusCode: 401 }, 401) + } + if (scope === 'admin' && c.get('apiKeyScope') !== 'admin') { + return c.json({ error: 'Admin access required', statusCode: 403 }, 403) + } + if (scope === 'write' && c.get('apiKeyScope') === 'read') { + return c.json({ error: 'Write access required', statusCode: 403 }, 403) + } + return next() + } +} + +// Helper to set signed session cookie +export function setSessionCookie(c: Context, sessionId: string) { + setCookie(c, 'session', sessionId, { + httpOnly: true, + secure: process.env.NODE_ENV === 'production', + sameSite: 'Lax', + path: '/', + maxAge: 30 * 24 * 60 * 60, // 30 days + }) +} + +export function clearSessionCookie(c: Context) { + deleteCookie(c, 'session', { path: '/' }) +} diff --git a/src/api/collections.ts b/src/api/collections.ts new file mode 100644 index 0000000..c0a0b98 --- /dev/null +++ b/src/api/collections.ts @@ -0,0 +1,526 @@ +import { Hono } from "hono"; +import { stream } from "hono/streaming"; +import { eq, and, ilike, or, sql } from "drizzle-orm"; +import { db, schema } from "../db/client.server.js"; +import { requireAuth, type AuthEnv } from "./auth.server.js"; +import { v4 as uuidv4 } from "uuid"; +import { pack as tarPack } from "tar-stream"; +import { createGzip } from "node:zlib"; +import { downloadFromS3 } from "../lib/s3.js"; +import { DEFAULT_NAAN, collectionToArkId, getOrMintShoulder, buildArkUrl } from "../lib/ark.js"; + +const app = new Hono(); + +// Browse public collections +app.get("/collections", async (c) => { + const q = c.req.query("q"); + const limit = c.req.query("limit"); + const offset = c.req.query("offset"); + const take = Math.min(parseInt(limit ?? "50", 10), 100); + const skip = parseInt(offset ?? "0", 10); + + const conditions = [eq(schema.collections.public, true)]; + if (q) { + conditions.push( + or( + ilike(schema.collections.name, `%${q}%`), + ilike(schema.collections.description, `%${q}%`), + )!, + ); + } + + const results = await db + .select({ + id: schema.collections.id, + slug: schema.collections.slug, + name: schema.collections.name, + description: schema.collections.description, + ownerSlug: schema.accounts.slug, + ownerName: schema.accounts.displayName, + createdAt: schema.collections.createdAt, + updatedAt: schema.collections.updatedAt, + }) + .from(schema.collections) + .innerJoin(schema.accounts, eq(schema.collections.accountId, schema.accounts.id)) + .where(and(...conditions)) + .limit(take) + .offset(skip) + .orderBy(schema.collections.updatedAt); + + return c.json(results); +}); + +// Create collection +app.post("/accounts/:owner/collections", requireAuth("write"), async (c) => { + const owner = c.req.param("owner"); + const { slug, name, description, public: isPublic } = await c.req.json<{ + slug: string; + name: string; + description?: string; + public?: boolean; + }>(); + + // Resolve owner account + const [account] = await db + .select() + .from(schema.accounts) + .where(eq(schema.accounts.slug, owner)) + .limit(1); + + if (!account) { + return c.json({ error: "Account not found", statusCode: 404 }, 404); + } + + // Check permission: user must own the account or be a member of the org + if (account.type === "user" && account.id !== c.get("accountId")) { + return c.json({ error: "Forbidden", statusCode: 403 }, 403); + } + if (account.type === "org") { + const [membership] = await db + .select() + .from(schema.orgMemberships) + .where( + and( + eq(schema.orgMemberships.orgId, account.id), + eq(schema.orgMemberships.userId, c.get("accountId")!), + ), + ) + .limit(1); + if (!membership) { + return c.json({ error: "Forbidden", statusCode: 403 }, 403); + } + } + + // Check for existing collection with same slug under this owner + const [existing] = await db + .select({ id: schema.collections.id }) + .from(schema.collections) + .where( + and( + eq(schema.collections.accountId, account.id), + eq(schema.collections.slug, slug), + ), + ) + .limit(1); + + if (existing) { + return c.json({ error: "Collection already exists", statusCode: 409 }, 409); + } + + const id = uuidv4(); + await db.insert(schema.collections).values({ + id, + accountId: account.id, + slug, + name, + description: description ?? null, + public: isPublic ?? false, + }); + + // Auto-mint ARK for the new collection + try { + const shoulder = await getOrMintShoulder(account.id); + const arkId = collectionToArkId(id); + await db.insert(schema.arkCollections).values({ collectionId: id, arkId, enabled: true }); + const naan = account.arkNaan ?? DEFAULT_NAAN; + const arkUrl = buildArkUrl(naan, shoulder, arkId); + return c.json({ id, owner, slug, name, ark: arkUrl }, 201); + } catch { + // ARK minting failure is non-fatal + return c.json({ id, owner, slug, name }, 201); + } +}); + +// Get collection +app.get("/collections/:owner/:slug", async (c) => { + const owner = c.req.param("owner"); + const slug = c.req.param("slug"); + + const [result] = await db + .select({ + id: schema.collections.id, + slug: schema.collections.slug, + name: schema.collections.name, + description: schema.collections.description, + public: schema.collections.public, + ownerSlug: schema.accounts.slug, + ownerName: schema.accounts.displayName, + ownerType: schema.accounts.type, + createdAt: schema.collections.createdAt, + updatedAt: schema.collections.updatedAt, + }) + .from(schema.collections) + .innerJoin(schema.accounts, eq(schema.collections.accountId, schema.accounts.id)) + .where(and(eq(schema.accounts.slug, owner), eq(schema.collections.slug, slug))) + .limit(1); + + if (!result) { + return c.json({ error: "Collection not found", statusCode: 404 }, 404); + } + + if (!result.public && c.get("accountId") !== result.id) { + // Check if user owns or is member of the owning account + const [account] = await db + .select() + .from(schema.accounts) + .where(eq(schema.accounts.slug, owner)) + .limit(1); + + if (!account) { + return c.json({ error: "Collection not found", statusCode: 404 }, 404); + } + + let hasAccess = account.id === c.get("accountId"); + if (!hasAccess && account.type === "org") { + const [membership] = await db + .select() + .from(schema.orgMemberships) + .where( + and( + eq(schema.orgMemberships.orgId, account.id), + eq(schema.orgMemberships.userId, c.get("accountId")!), + ), + ) + .limit(1); + hasAccess = !!membership; + } + + if (!hasAccess) { + return c.json({ error: "Collection not found", statusCode: 404 }, 404); + } + } + + // Get latest version info + const [latestVersion] = await db + .select({ + id: schema.versions.id, + number: schema.versions.number, + semver: schema.versions.semver, + recordCount: schema.versions.recordCount, + fileCount: schema.versions.fileCount, + totalBytes: schema.versions.totalBytes, + createdAt: schema.versions.createdAt, + message: schema.versions.message, + readme: schema.versions.readme, + }) + .from(schema.versions) + .where(eq(schema.versions.collectionId, result.id)) + .orderBy(sql`${schema.versions.number} desc`) + .limit(1); + + // Get per-type record counts for latest version + let typeCounts: { type: string; count: number }[] = []; + if (latestVersion) { + const rows = await db + .select({ + type: schema.records.type, + count: sql`count(*)::int`, + }) + .from(schema.records) + .where(eq(schema.records.versionId, latestVersion.id)) + .groupBy(schema.records.type); + typeCounts = rows.map((r) => ({ type: r.type, count: r.count })); + } + + // Fetch ARK URL if enabled + let ark: string | null = null; + try { + const [arkRow] = await db + .select({ + arkId: schema.arkCollections.arkId, + enabled: schema.arkCollections.enabled, + shoulder: schema.arkShoulders.shoulder, + ownerNaan: schema.accounts.arkNaan, + }) + .from(schema.arkCollections) + .innerJoin(schema.collections, eq(schema.arkCollections.collectionId, schema.collections.id)) + .innerJoin(schema.accounts, eq(schema.collections.accountId, schema.accounts.id)) + .innerJoin(schema.arkShoulders, eq(schema.arkShoulders.accountId, schema.accounts.id)) + .where(eq(schema.arkCollections.collectionId, result.id)) + .limit(1); + if (arkRow?.enabled) { + ark = buildArkUrl(arkRow.ownerNaan ?? DEFAULT_NAAN, arkRow.shoulder, arkRow.arkId); + } + } catch { + // Non-fatal + } + + const { id: _vid, ...latestVersionData } = latestVersion ?? { id: undefined }; + return c.json({ ...result, ark, latestVersion: latestVersion ? { ...latestVersionData, typeCounts } : null }); +}); + +// Update collection +app.patch("/collections/:owner/:slug", requireAuth("write"), async (c) => { + const owner = c.req.param("owner"); + const slug = c.req.param("slug"); + const updates = await c.req.json<{ + name?: string; + description?: string; + public?: boolean; + }>(); + + const [account] = await db + .select() + .from(schema.accounts) + .where(eq(schema.accounts.slug, owner)) + .limit(1); + + if (!account) { + return c.json({ error: "Not found", statusCode: 404 }, 404); + } + + const [collection] = await db + .select() + .from(schema.collections) + .where(and(eq(schema.collections.accountId, account.id), eq(schema.collections.slug, slug))) + .limit(1); + + if (!collection) { + return c.json({ error: "Not found", statusCode: 404 }, 404); + } + + await db + .update(schema.collections) + .set({ ...updates, updatedAt: new Date() }) + .where(eq(schema.collections.id, collection.id)); + + return c.json({ ok: true }); +}); + +// Delete collection +app.delete("/collections/:owner/:slug", requireAuth("admin"), async (c) => { + const owner = c.req.param("owner"); + const slug = c.req.param("slug"); + + const [account] = await db + .select() + .from(schema.accounts) + .where(eq(schema.accounts.slug, owner)) + .limit(1); + + if (!account) { + return c.json({ error: "Not found", statusCode: 404 }, 404); + } + + const [collection] = await db + .select() + .from(schema.collections) + .where(and(eq(schema.collections.accountId, account.id), eq(schema.collections.slug, slug))) + .limit(1); + + if (!collection) { + return c.json({ error: "Not found", statusCode: 404 }, 404); + } + + await db.delete(schema.collections).where(eq(schema.collections.id, collection.id)); + return c.json({ ok: true }); +}); + +// List collections for an account +app.get("/accounts/:owner/collections", async (c) => { + const owner = c.req.param("owner"); + + const [account] = await db + .select() + .from(schema.accounts) + .where(eq(schema.accounts.slug, owner)) + .limit(1); + + if (!account) return c.json([]); + + // Check if the requester owns this account or is an org member + let hasFullAccess = c.get("accountId") === account.id; + if (!hasFullAccess && account.type === "org" && c.get("accountId")) { + const [membership] = await db + .select() + .from(schema.orgMemberships) + .where( + and( + eq(schema.orgMemberships.orgId, account.id), + eq(schema.orgMemberships.userId, c.get("accountId")!), + ), + ) + .limit(1); + hasFullAccess = !!membership; + } + + const conditions = [eq(schema.collections.accountId, account.id)]; + if (!hasFullAccess) { + conditions.push(eq(schema.collections.public, true)); + } + + const results = await db + .select({ + id: schema.collections.id, + slug: schema.collections.slug, + name: schema.collections.name, + description: schema.collections.description, + public: schema.collections.public, + createdAt: schema.collections.createdAt, + updatedAt: schema.collections.updatedAt, + }) + .from(schema.collections) + .where(and(...conditions)) + .orderBy(schema.collections.updatedAt); + + return c.json(results); +}); + +// Export collection as .tar.gz archive +app.get("/collections/:owner/:slug/export", async (c) => { + const owner = c.req.param("owner"); + const slug = c.req.param("slug"); + const versionParam = c.req.query("version"); + + // Resolve collection + const [collection] = await db + .select({ + id: schema.collections.id, + slug: schema.collections.slug, + name: schema.collections.name, + description: schema.collections.description, + public: schema.collections.public, + accountId: schema.collections.accountId, + }) + .from(schema.collections) + .innerJoin(schema.accounts, eq(schema.collections.accountId, schema.accounts.id)) + .where(and(eq(schema.accounts.slug, owner), eq(schema.collections.slug, slug))) + .limit(1); + + if (!collection) { + return c.json({ error: "Collection not found", statusCode: 404 }, 404); + } + + if (!collection.public && c.get("accountId") !== collection.accountId) { + return c.json({ error: "Collection not found", statusCode: 404 }, 404); + } + + // Resolve version (latest if not specified) + const versionConditions = [eq(schema.versions.collectionId, collection.id)]; + if (versionParam) { + versionConditions.push(eq(schema.versions.number, parseInt(versionParam, 10))); + } + + const [version] = await db + .select() + .from(schema.versions) + .where(and(...versionConditions)) + .orderBy(sql`${schema.versions.number} desc`) + .limit(1); + + if (!version) { + return c.json({ error: "No versions found", statusCode: 404 }, 404); + } + + // Fetch records and files for this version + const records = await db + .select({ + recordId: schema.records.recordId, + type: schema.records.type, + data: schema.records.data, + }) + .from(schema.records) + .where(eq(schema.records.versionId, version.id)); + + const versionFiles = await db + .select({ + hash: schema.versionFiles.fileHash, + size: schema.files.size, + mimeType: schema.files.mimeType, + storageKey: schema.files.storageKey, + }) + .from(schema.versionFiles) + .innerJoin(schema.files, eq(schema.versionFiles.fileHash, schema.files.hash)) + .where(eq(schema.versionFiles.versionId, version.id)); + + // Load schemas for this version + const versionSchemaEntries = await db + .select({ + slug: schema.versionSchemas.slug, + schemaBody: schema.schemas.schema, + }) + .from(schema.versionSchemas) + .innerJoin(schema.schemas, eq(schema.versionSchemas.schemaId, schema.schemas.id)) + .where(eq(schema.versionSchemas.versionId, version.id)); + + const schemasMap = Object.fromEntries( + versionSchemaEntries.map((e) => [e.slug, e.schemaBody]), + ); + + // Add manifest.json + const manifest = { + collection: { owner, slug, name: collection.name, description: collection.description }, + version: { + number: version.number, + semver: version.semver, + hash: version.hash, + message: version.message, + recordCount: version.recordCount, + fileCount: version.fileCount, + totalBytes: version.totalBytes, + createdAt: version.createdAt, + }, + schemas: schemasMap, + }; + + // Build tar.gz stream + const pack = tarPack(); + const gzip = createGzip(); + + const filename = `${owner}-${slug}-v${version.number}.tar.gz`; + + // Add manifest + const manifestBuf = Buffer.from(JSON.stringify(manifest, null, 2)); + pack.entry({ name: "manifest.json", size: manifestBuf.length }, manifestBuf); + + // Add records as NDJSON grouped by type + const recordsByType = new Map(); + for (const rec of records) { + const existing = recordsByType.get(rec.type) ?? []; + existing.push(rec); + recordsByType.set(rec.type, existing); + } + + for (const [type, typeRecords] of recordsByType) { + const lines = typeRecords.map((r) => + JSON.stringify({ id: r.recordId, type: r.type, data: r.data }), + ); + const buf = Buffer.from(lines.join("\n") + "\n"); + pack.entry({ name: `records/${type}.ndjson`, size: buf.length }, buf); + } + + // Add files + for (const file of versionFiles) { + try { + const fileBuffer = await downloadFromS3(file.storageKey); + pack.entry({ name: `files/${file.hash}`, size: fileBuffer.length }, fileBuffer); + } catch { + // Skip files that can't be downloaded (shouldn't happen in normal operation) + } + } + + pack.finalize(); + + // Pipe tar → gzip and collect into a ReadableStream + const outputStream = pack.pipe(gzip); + const readableStream = new ReadableStream({ + start(controller) { + outputStream.on("data", (chunk: Buffer) => { + controller.enqueue(new Uint8Array(chunk)); + }); + outputStream.on("end", () => { + controller.close(); + }); + outputStream.on("error", (err) => { + controller.error(err); + }); + }, + }); + + return c.body(readableStream, 200, { + "Content-Type": "application/gzip", + "Content-Disposition": `attachment; filename="${filename}"`, + }); +}); + +export { app as collectionsRoutes }; diff --git a/src/api/files.ts b/src/api/files.ts new file mode 100644 index 0000000..3496191 --- /dev/null +++ b/src/api/files.ts @@ -0,0 +1,262 @@ +import { Hono } from "hono"; +import { eq, and, sql } from "drizzle-orm"; +import { db, schema } from "../db/client.server.js"; +import { requireAuth, type AuthEnv } from "./auth.server.js"; +import { uploadToS3, headS3Object, getS3ObjectMeta } from "../lib/s3.js"; +import { createHash } from "node:crypto"; + +/** + * Check if a file hash is referenced by any public (non-private) record + * in a non-private field of the latest version of this collection. + */ +async function isFilePubliclyAccessible( + owner: string, + slug: string, + fileHash: string, + accountId: string | undefined, +): Promise { + // Resolve collection + const [collection] = await db + .select({ + id: schema.collections.id, + accountId: schema.collections.accountId, + }) + .from(schema.collections) + .innerJoin(schema.accounts, eq(schema.collections.accountId, schema.accounts.id)) + .where(and(eq(schema.accounts.slug, owner), eq(schema.collections.slug, slug))) + .limit(1); + + if (!collection) return false; + + // Owner always has access + if (accountId != null && accountId === collection.accountId) { + return true; + } + + // Get the latest version + const [latest] = await db + .select({ id: schema.versions.id }) + .from(schema.versions) + .where(eq(schema.versions.collectionId, collection.id)) + .orderBy(sql`${schema.versions.number} desc`) + .limit(1); + + if (!latest) return false; + + // Check if file is associated with this version at all + const [vf] = await db + .select({ fileHash: schema.versionFiles.fileHash }) + .from(schema.versionFiles) + .where( + and(eq(schema.versionFiles.versionId, latest.id), eq(schema.versionFiles.fileHash, fileHash)), + ) + .limit(1); + + if (!vf) return false; + + // Load version schemas to determine private types and fields + const schemaEntries = await db + .select({ + slug: schema.versionSchemas.slug, + schemaBody: schema.schemas.schema, + }) + .from(schema.versionSchemas) + .innerJoin(schema.schemas, eq(schema.versionSchemas.schemaId, schema.schemas.id)) + .where(eq(schema.versionSchemas.versionId, latest.id)); + + const privateTypes = new Set(); + const typeSchemaMap = new Map>(); + for (const entry of schemaEntries) { + const body = entry.schemaBody as Record; + typeSchemaMap.set(entry.slug, body); + if (body?.private === true) privateTypes.add(entry.slug); + } + + // Find public records that reference this file hash + // A record references a file if its data JSON contains the hash string + const records = await db + .select({ type: schema.records.type, data: schema.records.data }) + .from(schema.records) + .where( + and( + eq(schema.records.versionId, latest.id), + eq(schema.records.private, false), + sql`${schema.records.data}::text LIKE ${"%" + fileHash + "%"}`, + ), + ) + .limit(10); + + // Check if any matching record is a public type with the file in a public field + for (const rec of records) { + if (privateTypes.has(rec.type)) continue; + + // Get private fields for this type + const typeSchema = typeSchemaMap.get(rec.type); + const typeProps = typeSchema?.properties as Record | undefined; + if (!typeProps) return true; // no schema constraints, allow + + const privateFields = new Set(); + for (const [fieldName, fieldDef] of Object.entries(typeProps)) { + if ((fieldDef as any)?.private === true) privateFields.add(fieldName); + } + + // Check if the file reference is in a public field + const data = rec.data as Record; + for (const [key, val] of Object.entries(data)) { + if (privateFields.has(key)) continue; + if ( + val && + typeof val === "object" && + "$file" in val && + (val as { $file: string }).$file === `sha256:${fileHash}` + ) { + return true; // found in a public field of a public record + } + } + } + + return false; +} + +const app = new Hono(); + +// Check if file exists +app.on("HEAD", "/collections/:owner/:slug/files/:hash", async (c) => { + const owner = c.req.param("owner"); + const slug = c.req.param("slug"); + const hash = c.req.param("hash"); + const cleanHash = hash.replace("sha256:", ""); + + const [file] = await db + .select() + .from(schema.files) + .where(eq(schema.files.hash, cleanHash)) + .limit(1); + + if (!file) { + return c.body(null, 404); + } + + // Check visibility + const accessible = await isFilePubliclyAccessible(owner, slug, cleanHash, c.get("accountId")); + if (!accessible) { + return c.body(null, 404); + } + + c.header("Content-Length", String(file.size)); + c.header("Content-Type", file.mimeType); + return c.body(null, 200); +}); + +// Download file +app.get("/collections/:owner/:slug/files/:hash", async (c) => { + const owner = c.req.param("owner"); + const slug = c.req.param("slug"); + const hash = c.req.param("hash"); + const cleanHash = hash.replace("sha256:", ""); + + const [file] = await db + .select() + .from(schema.files) + .where(eq(schema.files.hash, cleanHash)) + .limit(1); + + if (!file) { + return c.json({ error: "File not found", statusCode: 404 }, 404); + } + + // Check visibility + const accessible = await isFilePubliclyAccessible(owner, slug, cleanHash, c.get("accountId")); + if (!accessible) { + return c.json({ error: "File not found", statusCode: 404 }, 404); + } + + // Redirect to CDN + const cdnUrl = `https://assets.underlay.org/files/${cleanHash.slice(0, 2)}/${cleanHash.slice(2, 4)}/${cleanHash}`; + return c.redirect(cdnUrl); +}); + +// Upload file +app.put( + "/collections/:owner/:slug/files/:hash", + requireAuth("write"), + async (c) => { + const owner = c.req.param("owner"); + const slug = c.req.param("slug"); + const hash = c.req.param("hash"); + const cleanHash = hash.replace("sha256:", ""); + + // Check if file already exists in DB + const [existing] = await db + .select() + .from(schema.files) + .where(eq(schema.files.hash, cleanHash)) + .limit(1); + + if (existing) { + return c.json({ hash: cleanHash, status: "exists" }, 200); + } + + // Check if file exists in S3 but not in local DB (shared bucket scenario) + const s3Key = `files/${cleanHash.slice(0, 2)}/${cleanHash.slice(2, 4)}/${cleanHash}`; + const s3Meta = await getS3ObjectMeta(s3Key); + if (s3Meta !== null) { + await db.insert(schema.files).values({ + hash: cleanHash, + size: s3Meta.size, + mimeType: s3Meta.contentType, + storageKey: s3Key, + }).onConflictDoNothing(); + return c.json({ hash: cleanHash, status: "exists" }, 200); + } + + // Try multipart first + const contentType = c.req.header("content-type") ?? "application/octet-stream"; + + let buffer: Buffer; + let mimeType: string; + + if (contentType.startsWith("multipart/")) { + const body = await c.req.parseBody(); + const file = body["file"]; + if (file instanceof File) { + const ab = await file.arrayBuffer(); + buffer = Buffer.from(ab); + mimeType = file.type || "application/octet-stream"; + } else { + return c.json({ error: "No file in multipart body", statusCode: 400 }, 400); + } + } else { + // Raw binary body + const ab = await c.req.arrayBuffer(); + buffer = Buffer.from(ab); + mimeType = contentType; + } + + // Verify hash + const computedHash = createHash("sha256").update(buffer).digest("hex"); + if (computedHash !== cleanHash) { + return c.json({ + error: "Hash mismatch", + expected: cleanHash, + computed: computedHash, + statusCode: 400, + }, 400); + } + + const storageKey = `files/${cleanHash.slice(0, 2)}/${cleanHash.slice(2, 4)}/${cleanHash}`; + + await uploadToS3(storageKey, buffer, mimeType); + + await db.insert(schema.files).values({ + hash: cleanHash, + size: buffer.length, + mimeType, + storageKey, + }); + + return c.json({ hash: cleanHash, size: buffer.length }, 201); + }, +); + +export const fileRoutes = app; diff --git a/src/api/health.ts b/src/api/health.ts new file mode 100644 index 0000000..13d062e --- /dev/null +++ b/src/api/health.ts @@ -0,0 +1,9 @@ +import { Hono } from 'hono' + +const app = new Hono() + +app.get('/health', (c) => { + return c.json({ status: 'ok', timestamp: new Date().toISOString() }) +}) + +export { app as healthRoutes } diff --git a/src/api/plugins/auth.ts b/src/api/plugins/auth.ts deleted file mode 100644 index 87749f3..0000000 --- a/src/api/plugins/auth.ts +++ /dev/null @@ -1,117 +0,0 @@ -import type { FastifyInstance, FastifyRequest, FastifyReply } from "fastify"; -import fp from "fastify-plugin"; -import { eq } from "drizzle-orm"; -import { db, schema } from "../../db/index.js"; -import bcrypt from "bcrypt"; - -declare module "fastify" { - interface FastifyRequest { - accountId?: string; - apiKeyScope?: "read" | "write" | "admin"; - apiKeyCollectionId?: string | null; - sessionUserId?: string; - } -} - -async function authPlugin(app: FastifyInstance) { - app.decorateRequest("accountId", undefined); - app.decorateRequest("apiKeyScope", undefined); - app.decorateRequest("apiKeyCollectionId", undefined); - app.decorateRequest("sessionUserId", undefined); - - // Paths that never require auth (even for POST) - const publicPaths = new Set([ - "/api/health", - "/api/accounts/signup", - "/api/accounts/login", - "/api/accounts/forgot-password", - "/api/accounts/reset-password", - "/api/query/generate-sql", - ]); - - const internalToken = process.env.INTERNAL_API_TOKEN ?? "internal-dev-token"; - - app.addHook("onRequest", async (request: FastifyRequest, reply: FastifyReply) => { - // Internal service calls from Astro SSR - const internalHeader = request.headers["x-internal-token"]; - if (internalHeader === internalToken) { - request.apiKeyScope = "read"; - return; - } - - // API key auth via Bearer token - const auth = request.headers.authorization; - if (auth?.startsWith("Bearer ")) { - const token = auth.slice(7); - const keys = await db.select().from(schema.apiKeys); - let matched = false; - for (const key of keys) { - const match = await bcrypt.compare(token, key.keyHash); - if (match) { - request.accountId = key.accountId; - request.apiKeyScope = key.scope as "read" | "write" | "admin"; - request.apiKeyCollectionId = key.collectionId; - await db - .update(schema.apiKeys) - .set({ lastUsedAt: new Date() }) - .where(eq(schema.apiKeys.id, key.id)); - matched = true; - break; - } - } - if (!matched) { - return reply.status(401).send({ error: "Invalid API key", statusCode: 401 }); - } - return; - } - - // Session cookie auth - const sessionCookie = request.cookies?.session; - if (sessionCookie) { - try { - const unsigned = request.unsignCookie(sessionCookie); - const sessionId = unsigned.valid ? unsigned.value : sessionCookie; - if (sessionId) { - const [session] = await db - .select() - .from(schema.sessions) - .where(eq(schema.sessions.id, sessionId)) - .limit(1); - if (session && new Date(session.expiresAt) > new Date()) { - request.sessionUserId = session.userId; - request.accountId = session.userId; - request.apiKeyScope = "admin"; - } - } - } catch { - // Invalid or expired cookie — ignore silently - } - } - - // Public GETs are allowed without auth (rate-limited by IP) - if (request.method === "GET") return; - - // All writes (POST/PATCH/PUT/DELETE) require auth, except public paths - if (!request.accountId) { - const path = request.url.split("?")[0] ?? ""; - if (publicPaths.has(path)) return; - return reply.status(401).send({ error: "Authentication required", statusCode: 401 }); - } - }); -} - -export function requireAuth(scope?: "read" | "write" | "admin") { - return async (request: FastifyRequest, reply: FastifyReply) => { - if (!request.accountId) { - return reply.status(401).send({ error: "Authentication required", statusCode: 401 }); - } - if (scope === "admin" && request.apiKeyScope !== "admin") { - return reply.status(403).send({ error: "Admin access required", statusCode: 403 }); - } - if (scope === "write" && request.apiKeyScope === "read") { - return reply.status(403).send({ error: "Write access required", statusCode: 403 }); - } - }; -} - -export default fp(authPlugin); diff --git a/src/api/query.ts b/src/api/query.ts new file mode 100644 index 0000000..4c64266 --- /dev/null +++ b/src/api/query.ts @@ -0,0 +1,394 @@ +import { Hono } from 'hono'; +import { eq, and, desc, ilike, or, inArray } from 'drizzle-orm'; +import { db, schema } from '../db/client.server.js'; +import { requireAuth, type AuthEnv } from './auth.server.js'; +import { buildSqliteBuffer, generateAllDDL, generateDDL } from '../lib/sqlite-gen.js'; + +// In-memory LRU cache: key = `${collectionId}:${versionNumber}`, value = { buffer, expiresAt } +const sqliteCache = new Map>; expiresAt: number }>(); +const CACHE_TTL_MS = 30 * 60 * 1000; // 30 minutes +const CACHE_MAX_ENTRIES = 10; + +function cleanExpired() { + const now = Date.now(); + for (const [key, entry] of sqliteCache) { + if (entry.expiresAt < now) sqliteCache.delete(key); + } +} + +function evictIfNeeded() { + while (sqliteCache.size >= CACHE_MAX_ENTRIES) { + // Evict oldest entry (first key in Map insertion order) + const firstKey = sqliteCache.keys().next().value; + if (firstKey) sqliteCache.delete(firstKey); + else break; + } +} + +// Run cleanup every 5 minutes +setInterval(cleanExpired, 5 * 60 * 1000); + +async function getOrBuildSqlite(owner: string, slug: string, versionNumber: number) { + // Resolve collection + const [collection] = await db + .select({ id: schema.collections.id, accountId: schema.collections.accountId, public: schema.collections.public }) + .from(schema.collections) + .innerJoin(schema.accounts, eq(schema.accounts.id, schema.collections.accountId)) + .where(and(eq(schema.accounts.slug, owner), eq(schema.collections.slug, slug))) + .limit(1); + + if (!collection) return null; + + // Resolve version + const [version] = await db + .select({ id: schema.versions.id, number: schema.versions.number }) + .from(schema.versions) + .where(and(eq(schema.versions.collectionId, collection.id), eq(schema.versions.number, versionNumber))) + .limit(1); + + if (!version) return null; + + const cacheKey = `${collection.id}:${version.number}`; + + // Check cache (re-insert to move to end for LRU ordering) + const cached = sqliteCache.get(cacheKey); + if (cached && cached.expiresAt > Date.now()) { + sqliteCache.delete(cacheKey); + cached.expiresAt = Date.now() + CACHE_TTL_MS; + sqliteCache.set(cacheKey, cached); + return cached; + } + + // Load schemas for this version + const versionSchemas = await db + .select({ slug: schema.versionSchemas.slug, schema: schema.schemas.schema }) + .from(schema.versionSchemas) + .innerJoin(schema.schemas, eq(schema.versionSchemas.schemaId, schema.schemas.id)) + .where(eq(schema.versionSchemas.versionId, version.id)); + + const schemasMap: Record = {}; + for (const vs of versionSchemas) { + schemasMap[vs.slug] = vs.schema; + } + + // Load records + const records = await db + .select({ recordId: schema.records.recordId, type: schema.records.type, data: schema.records.data }) + .from(schema.records) + .where(eq(schema.records.versionId, version.id)); + + // Build SQLite + const buffer = buildSqliteBuffer(schemasMap, records as any); + const ddl = generateAllDDL(schemasMap); + + // Generate sample data (first row per table) for LLM context + const sampleRows: Record> = {}; + for (const [typeName] of Object.entries(schemasMap)) { + const firstRecord = records.find((r) => r.type === typeName); + if (firstRecord && firstRecord.data && typeof firstRecord.data === 'object') { + sampleRows[typeName] = firstRecord.data as Record; + } + } + + // Build DDL with inline sample rows (each sample right after its CREATE TABLE) + const ddlWithSamples = Object.entries(schemasMap) + .map(([name, s]) => { + const tableDdl = generateDDL(name, s); + const sample = sampleRows[name]; + if (sample) { + return tableDdl + `\n-- Example row: ${JSON.stringify(sample)}`; + } + return tableDdl; + }) + .join('\n\n'); + + const entry = { buffer, ddl, ddlWithSamples, sampleRows, expiresAt: Date.now() + CACHE_TTL_MS }; + evictIfNeeded(); + sqliteCache.set(cacheKey, entry); + return entry; +} + +const app = new Hono(); + +// GET /query/sqlite/:owner/:slug/:version — Download SQLite file for a version +app.get('/query/sqlite/:owner/:slug/:version', async (c) => { + const owner = c.req.param('owner'); + const slug = c.req.param('slug'); + const version = c.req.param('version'); + const versionNum = parseInt(version, 10); + if (isNaN(versionNum)) return c.json({ error: 'Invalid version number' }, 400); + + const result = await getOrBuildSqlite(owner, slug, versionNum); + if (!result) return c.json({ error: 'Collection or version not found' }, 404); + + return new Response(new Uint8Array(result.buffer), { + status: 200, + headers: { + 'Content-Type': 'application/x-sqlite3', + 'Content-Disposition': `attachment; filename="${slug}-v${versionNum}.sqlite"`, + 'Cache-Control': 'public, max-age=86400', + }, + }); +}); + +// GET /query/ddl/:owner/:slug/:version — Get DDL (schema only) for a version +app.get('/query/ddl/:owner/:slug/:version', async (c) => { + const owner = c.req.param('owner'); + const slug = c.req.param('slug'); + const version = c.req.param('version'); + const versionNum = parseInt(version, 10); + if (isNaN(versionNum)) return c.json({ error: 'Invalid version number' }, 400); + + const result = await getOrBuildSqlite(owner, slug, versionNum); + if (!result) return c.json({ error: 'Collection or version not found' }, 404); + + return c.json({ ddl: result.ddl }); +}); + +// POST /query/generate-sql — LLM-powered SQL generation from natural language +app.post('/query/generate-sql', async (c) => { + const { collections: collectionRefs, question } = await c.req.json(); + + if (!collectionRefs?.length || !question) { + return c.json({ error: 'collections and question are required' }, 400); + } + + const cfAccountId = process.env.CF_ACCOUNT_ID; + const cfApiToken = process.env.CF_API_TOKEN; + + if (!cfAccountId || !cfApiToken) { + return c.json({ + error: 'LLM not configured', + message: 'Set CF_ACCOUNT_ID and CF_API_TOKEN environment variables to enable natural language queries. You can still write SQL directly.', + }, 503); + } + + // Build DDL with sample rows server-side + let combinedDdl: string; + let totalRecords = 0; + + if (collectionRefs.length === 1) { + const ref = collectionRefs[0]; + const result = await getOrBuildSqlite(ref.owner, ref.slug, ref.version); + if (!result) return c.json({ error: `Collection ${ref.owner}/${ref.slug} v${ref.version} not found` }, 404); + combinedDdl = result.ddlWithSamples; + // Count records from cache (approximation from the version table already captured) + } else { + const parts: string[] = []; + for (const ref of collectionRefs) { + const result = await getOrBuildSqlite(ref.owner, ref.slug, ref.version); + if (!result) return c.json({ error: `Collection ${ref.owner}/${ref.slug} v${ref.version} not found` }, 404); + const prefix = ref.slug.replace(/-/g, '_'); + // Prefix table names and add _source column to DDL + const ddlPrefixed = result.ddlWithSamples + .replace(/CREATE TABLE "([^"]+)"/g, `CREATE TABLE "${prefix}__$1"`) + .replace(/\);/g, `,\n "_source" TEXT\n);`); + parts.push(`-- Collection: ${ref.owner}/${ref.slug} v${ref.version}\n` + ddlPrefixed); + } + combinedDdl = parts.join('\n\n'); + } + + const isMultiCollection = collectionRefs.length > 1; + + const systemPrompt = `You are a SQL assistant for SQLite databases. Given a schema and a user's question, produce a single SELECT query that answers it. + +Respond in EXACTLY this format (two sections separated by the marker): + +SQL: + + +REASONING: + + +Important rules: +- Examine the "Example row" comments in the schema — they show the ACTUAL data format stored in each column.${isMultiCollection ? ` +- When multiple collections are loaded, consider ALL of them in your answer unless the question specifies otherwise. +- Every table has a "_source" column containing the collection identifier (e.g. "account/collection"). For row-level results, include _source as a column. For aggregations, include GROUP_CONCAT(DISTINCT _source) as _source so the user can see which collections contributed to the result. +- When counting across multiple tables, use UNION ALL to combine rows, not JOIN.` : ''} +- Only use JOIN when the question asks about relationships between tables. +- COUNT(*) counts rows.${isMultiCollection ? ' Use UNION ALL to combine rows from separate tables before counting.' : ''} +- When tables have a prefix like "collection__TableName", always use that full prefixed name. +- Do NOT include columns that don't exist in the schema.`; + + const userPrompt = `Schema:\n${combinedDdl}\n\nQuestion: ${question}`; + + // Log the full prompt for debugging + console.info(`[generate-sql] User prompt:\n${userPrompt}`); + + try { + const response = await fetch( + `https://api.cloudflare.com/client/v4/accounts/${cfAccountId}/ai/run/@cf/meta/llama-3.3-70b-instruct-fp8-fast`, + { + method: 'POST', + headers: { + Authorization: `Bearer ${cfApiToken}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + messages: [ + { role: 'system', content: systemPrompt }, + { role: 'user', content: userPrompt }, + ], + max_tokens: 800, + temperature: 0, + }), + }, + ); + + if (!response.ok) { + const text = await response.text(); + console.error(`Cloudflare AI error: ${response.status} ${text}`); + return c.json({ error: 'LLM request failed', rawResponse: text }, 502); + } + + const data = (await response.json()) as any; + let raw = data?.result?.response?.trim(); + + if (!raw) { + return c.json({ error: 'LLM returned empty response', rawResponse: JSON.stringify(data) }, 500); + } + + // Parse structured response + let sql: string; + let reasoning: string | undefined; + + const sqlMarker = raw.indexOf('SQL:'); + const reasoningMarker = raw.indexOf('REASONING:'); + + if (sqlMarker !== -1 && reasoningMarker !== -1) { + sql = raw.substring(sqlMarker + 4, reasoningMarker).replace(/```sql\n?/g, '').replace(/```/g, '').trim(); + reasoning = raw.substring(reasoningMarker + 10).trim(); + } else { + // Fallback: treat entire response as SQL + sql = raw.replace(/```sql\n?/g, '').replace(/```/g, '').trim(); + } + + // Basic safety: only allow SELECT statements + const normalized = sql.replace(/--.*$/gm, '').trim().toUpperCase(); + if (!normalized.startsWith('SELECT') && !normalized.startsWith('WITH')) { + return c.json({ + error: 'Generated query is not a SELECT statement', + sql, + reasoning, + rawResponse: raw, + }, 400); + } + + return c.json({ sql, reasoning }); + } catch (err: any) { + console.error(`LLM generation error: ${err.message}`); + return c.json({ error: 'Failed to generate SQL' }, 500); + } +}); + +// GET /query/collections/search?q=term — Search collections (public + user's private) +app.get('/query/collections/search', async (c) => { + const q = c.req.query('q'); + if (!q || q.trim().length < 2) return c.json([]); + + const term = `%${q.trim()}%`; + const userId = c.get('accountId'); + + // Build accessible account IDs (user's own + orgs they belong to) + let accessibleAccountIds: string[] = []; + if (userId) { + const memberships = await db + .select({ orgId: schema.orgMemberships.orgId }) + .from(schema.orgMemberships) + .where(eq(schema.orgMemberships.userId, userId)); + accessibleAccountIds = [userId, ...memberships.map((m) => m.orgId)]; + } + + // Query: public collections OR private collections owned by accessible accounts + const searchCondition = or( + ilike(schema.accounts.slug, term), + ilike(schema.collections.slug, term), + ilike(schema.collections.name, term), + ); + + let whereCondition; + if (accessibleAccountIds.length > 0) { + whereCondition = and( + searchCondition, + or( + eq(schema.collections.public, true), + inArray(schema.collections.accountId, accessibleAccountIds), + ), + ); + } else { + whereCondition = and(searchCondition, eq(schema.collections.public, true)); + } + + const collections = await db + .select({ + ownerSlug: schema.accounts.slug, + slug: schema.collections.slug, + name: schema.collections.name, + description: schema.collections.description, + public: schema.collections.public, + }) + .from(schema.collections) + .innerJoin(schema.accounts, eq(schema.accounts.id, schema.collections.accountId)) + .where(whereCondition) + .limit(20); + + // Get latest version + record count for each match + const result = []; + for (const c2 of collections) { + const [latestVersion] = await db + .select({ number: schema.versions.number, semver: schema.versions.semver, recordCount: schema.versions.recordCount }) + .from(schema.versions) + .innerJoin(schema.collections, eq(schema.collections.id, schema.versions.collectionId)) + .innerJoin(schema.accounts, eq(schema.accounts.id, schema.collections.accountId)) + .where(and(eq(schema.accounts.slug, c2.ownerSlug), eq(schema.collections.slug, c2.slug))) + .orderBy(desc(schema.versions.number)) + .limit(1); + + result.push({ + ownerSlug: c2.ownerSlug, + slug: c2.slug, + name: c2.name, + description: c2.description, + public: c2.public, + latestVersion: latestVersion?.number ?? null, + latestSemver: latestVersion?.semver ?? null, + recordCount: latestVersion?.recordCount ?? 0, + }); + } + + return c.json(result); +}); + +// GET /query/collections/:owner/:slug/versions — List versions for a collection +app.get('/query/collections/:owner/:slug/versions', async (c) => { + const owner = c.req.param('owner'); + const slug = c.req.param('slug'); + + const versions = await db + .select({ + number: schema.versions.number, + semver: schema.versions.semver, + recordCount: schema.versions.recordCount, + createdAt: schema.versions.createdAt, + message: schema.versions.message, + }) + .from(schema.versions) + .innerJoin(schema.collections, eq(schema.collections.id, schema.versions.collectionId)) + .innerJoin(schema.accounts, eq(schema.accounts.id, schema.collections.accountId)) + .where( + and( + eq(schema.accounts.slug, owner), + eq(schema.collections.slug, slug), + eq(schema.collections.public, true), + ), + ) + .orderBy(desc(schema.versions.number)); + + if (versions.length === 0) { + return c.json({ error: 'Collection not found or not public' }, 404); + } + + return c.json(versions); +}); + +export const queryRoutes = app; diff --git a/src/api/routes/accounts.ts b/src/api/routes/accounts.ts deleted file mode 100644 index 3e40204..0000000 --- a/src/api/routes/accounts.ts +++ /dev/null @@ -1,1318 +0,0 @@ -import type { FastifyInstance } from "fastify"; -import { eq, and, count } from "drizzle-orm"; -import { db, schema } from "../../db/index.js"; -import bcrypt from "bcrypt"; -import { v4 as uuidv4 } from "uuid"; -import { requireAuth } from "../plugins/auth.js"; -import { uploadToS3, deleteS3Objects, listS3Objects } from "../../lib/s3.js"; -import { sendEmail } from "../../lib/email.js"; - -/** Base URL for public assets (avatars, etc.) */ -const ASSETS_BASE_URL = process.env.ASSETS_BASE_URL ?? "https://assets.underlay.org"; - -const RESERVED_SLUGS = new Set([ - "explore", "docs", "connect", "blog", "dashboard", "settings", - "api", "login", "signup", "admin", "about", "help", "support", - "search", "new", "create", "edit", "delete", "404", "500", -]); - -export async function accountRoutes(app: FastifyInstance) { - // Signup - app.post("/accounts/signup", async (request, reply) => { - const { email, password, username, displayName } = request.body as { - email: string; - password: string; - username: string; - displayName: string; - }; - - if (RESERVED_SLUGS.has(username.toLowerCase())) { - return reply.status(422).send({ error: "That username is reserved", statusCode: 422 }); - } - - const existing = await db - .select() - .from(schema.accounts) - .where(eq(schema.accounts.slug, username)) - .limit(1); - - if (existing.length > 0) { - return reply.status(409).send({ error: "Username already taken", statusCode: 409 }); - } - - const passwordHash = await bcrypt.hash(password, 10); - const id = uuidv4(); - - await db.insert(schema.accounts).values({ - id, - slug: username, - type: "user", - displayName, - email, - passwordHash, - }); - - const sessionId = uuidv4(); - const expiresAt = new Date(Date.now() + 30 * 24 * 60 * 60 * 1000); // 30 days - await db.insert(schema.sessions).values({ - id: sessionId, - userId: id, - expiresAt, - userAgent: request.headers["user-agent"] ?? null, - ipAddress: request.ip, - }); - - reply.setCookie("session", sessionId, { - path: "/", - httpOnly: true, - secure: process.env.NODE_ENV === "production", - sameSite: "lax", - expires: expiresAt, - }); - - return reply.status(201).send({ id, slug: username, displayName }); - }); - - // Login - app.post("/accounts/login", async (request, reply) => { - const { email, password } = request.body as { email: string; password: string }; - - const [account] = await db - .select() - .from(schema.accounts) - .where(eq(schema.accounts.email, email)) - .limit(1); - - if (!account?.passwordHash) { - return reply.status(401).send({ error: "Invalid credentials", statusCode: 401 }); - } - - const valid = await bcrypt.compare(password, account.passwordHash); - if (!valid) { - return reply.status(401).send({ error: "Invalid credentials", statusCode: 401 }); - } - - const sessionId = uuidv4(); - const expiresAt = new Date(Date.now() + 30 * 24 * 60 * 60 * 1000); - await db.insert(schema.sessions).values({ - id: sessionId, - userId: account.id, - expiresAt, - userAgent: request.headers["user-agent"] ?? null, - ipAddress: request.ip, - }); - - reply.setCookie("session", sessionId, { - path: "/", - httpOnly: true, - secure: process.env.NODE_ENV === "production", - sameSite: "lax", - expires: expiresAt, - }); - - return { id: account.id, slug: account.slug, displayName: account.displayName }; - }); - - // Logout - app.post("/accounts/logout", async (request, reply) => { - const sessionId = request.cookies?.session; - if (sessionId) { - await db.delete(schema.sessions).where(eq(schema.sessions.id, sessionId)); - } - reply.clearCookie("session", { path: "/" }); - return { ok: true }; - }); - - // Get current user - app.get( - "/accounts/me", - { preHandler: [requireAuth()] }, - async (request, reply) => { - const [account] = await db - .select({ - id: schema.accounts.id, - slug: schema.accounts.slug, - type: schema.accounts.type, - displayName: schema.accounts.displayName, - email: schema.accounts.email, - bio: schema.accounts.bio, - website: schema.accounts.website, - location: schema.accounts.location, - avatarUrl: schema.accounts.avatarUrl, - emailVerified: schema.accounts.emailVerified, - notificationPrefs: schema.accounts.notificationPrefs, - createdAt: schema.accounts.createdAt, - }) - .from(schema.accounts) - .where(eq(schema.accounts.id, request.accountId!)) - .limit(1); - - if (!account) { - return reply.status(404).send({ error: "Account not found", statusCode: 404 }); - } - - // Fetch org memberships - const memberships = await db - .select({ - orgId: schema.orgMemberships.orgId, - role: schema.orgMemberships.role, - slug: schema.accounts.slug, - displayName: schema.accounts.displayName, - }) - .from(schema.orgMemberships) - .innerJoin(schema.accounts, eq(schema.orgMemberships.orgId, schema.accounts.id)) - .where(eq(schema.orgMemberships.userId, account.id)); - - return { ...account, orgs: memberships }; - }, - ); - - // Get account by slug (public) - app.get("/accounts/:slug", async (request, reply) => { - const { slug } = request.params as { slug: string }; - const [account] = await db - .select({ - id: schema.accounts.id, - slug: schema.accounts.slug, - type: schema.accounts.type, - displayName: schema.accounts.displayName, - bio: schema.accounts.bio, - website: schema.accounts.website, - location: schema.accounts.location, - avatarUrl: schema.accounts.avatarUrl, - arkNaan: schema.accounts.arkNaan, - createdAt: schema.accounts.createdAt, - }) - .from(schema.accounts) - .where(eq(schema.accounts.slug, slug)) - .limit(1); - - if (!account) { - return reply.status(404).send({ error: "Account not found", statusCode: 404 }); - } - - // Include ARK shoulder if minted - const [shoulderRow] = await db - .select({ shoulder: schema.arkShoulders.shoulder }) - .from(schema.arkShoulders) - .where(eq(schema.arkShoulders.accountId, account.id)) - .limit(1); - - return { ...account, arkShoulder: shoulderRow?.shoulder ?? null }; - }); - - // Update own profile - app.patch( - "/accounts/me", - { preHandler: [requireAuth()] }, - async (request, reply) => { - const { displayName, bio, website, location, notificationPrefs } = request.body as { - displayName?: string; - bio?: string; - website?: string; - location?: string; - notificationPrefs?: Record; - }; - - const updates: Record = {}; - if (displayName !== undefined) updates.displayName = displayName; - if (bio !== undefined) updates.bio = bio; - if (website !== undefined) updates.website = website; - if (location !== undefined) updates.location = location; - if (notificationPrefs !== undefined) updates.notificationPrefs = notificationPrefs; - - if (Object.keys(updates).length > 0) { - await db.update(schema.accounts).set(updates).where(eq(schema.accounts.id, request.accountId!)); - } - - return { ok: true }; - }, - ); - - // Change email (requires current password) - app.post( - "/accounts/me/email", - { preHandler: [requireAuth()] }, - async (request, reply) => { - const { newEmail, password } = request.body as { newEmail: string; password: string }; - - const [account] = await db - .select() - .from(schema.accounts) - .where(eq(schema.accounts.id, request.accountId!)) - .limit(1); - - if (!account?.passwordHash) { - return reply.status(400).send({ error: "Cannot change email for this account type", statusCode: 400 }); - } - - const valid = await bcrypt.compare(password, account.passwordHash); - if (!valid) { - return reply.status(401).send({ error: "Invalid password", statusCode: 401 }); - } - - // Check email not taken - const [existing] = await db - .select() - .from(schema.accounts) - .where(eq(schema.accounts.email, newEmail)) - .limit(1); - - if (existing && existing.id !== account.id) { - return reply.status(409).send({ error: "Email already in use", statusCode: 409 }); - } - - await db - .update(schema.accounts) - .set({ email: newEmail, emailVerified: false }) - .where(eq(schema.accounts.id, request.accountId!)); - - return { ok: true }; - }, - ); - - // Change password - app.post( - "/accounts/me/password", - { preHandler: [requireAuth()] }, - async (request, reply) => { - const { currentPassword, newPassword } = request.body as { - currentPassword: string; - newPassword: string; - }; - - if (newPassword.length < 8) { - return reply.status(422).send({ error: "Password must be at least 8 characters", statusCode: 422 }); - } - - const [account] = await db - .select() - .from(schema.accounts) - .where(eq(schema.accounts.id, request.accountId!)) - .limit(1); - - if (!account?.passwordHash) { - return reply.status(400).send({ error: "Cannot change password for this account type", statusCode: 400 }); - } - - const valid = await bcrypt.compare(currentPassword, account.passwordHash); - if (!valid) { - return reply.status(401).send({ error: "Current password is incorrect", statusCode: 401 }); - } - - const newHash = await bcrypt.hash(newPassword, 10); - await db - .update(schema.accounts) - .set({ passwordHash: newHash }) - .where(eq(schema.accounts.id, request.accountId!)); - - return { ok: true }; - }, - ); - - // Upload avatar - app.post( - "/accounts/me/avatar", - { preHandler: [requireAuth()] }, - async (request, reply) => { - const data = await request.file(); - if (!data) { - return reply.status(400).send({ error: "No file uploaded", statusCode: 400 }); - } - - const allowedTypes = ["image/jpeg", "image/png", "image/gif", "image/webp"]; - if (!allowedTypes.includes(data.mimetype)) { - return reply.status(422).send({ error: "Only JPEG, PNG, GIF, and WebP images are allowed", statusCode: 422 }); - } - - const buffer = await data.toBuffer(); - if (buffer.length > 5 * 1024 * 1024) { - return reply.status(422).send({ error: "Image must be less than 5MB", statusCode: 422 }); - } - - const ext = data.mimetype.split("/")[1] === "jpeg" ? "jpg" : data.mimetype.split("/")[1]; - const key = `avatars/${request.accountId}/${Date.now()}.${ext}`; - - await uploadToS3(key, buffer, data.mimetype); - - await db - .update(schema.accounts) - .set({ avatarUrl: `${ASSETS_BASE_URL}/${key}` }) - .where(eq(schema.accounts.id, request.accountId!)); - - return { ok: true, avatarUrl: `${ASSETS_BASE_URL}/${key}` }; - }, - ); - - // List sessions - app.get( - "/accounts/me/sessions", - { preHandler: [requireAuth()] }, - async (request) => { - const sessions = await db - .select({ - id: schema.sessions.id, - userAgent: schema.sessions.userAgent, - ipAddress: schema.sessions.ipAddress, - createdAt: schema.sessions.createdAt, - expiresAt: schema.sessions.expiresAt, - }) - .from(schema.sessions) - .where(eq(schema.sessions.userId, request.accountId!)); - - // Get current session ID to mark it - const currentSessionId = request.cookies?.session; - return sessions.map((s) => ({ - ...s, - current: s.id === currentSessionId, - })); - }, - ); - - // Revoke a session - app.delete( - "/accounts/me/sessions/:sessionId", - { preHandler: [requireAuth()] }, - async (request, reply) => { - const { sessionId } = request.params as { sessionId: string }; - - const [session] = await db - .select() - .from(schema.sessions) - .where(and(eq(schema.sessions.id, sessionId), eq(schema.sessions.userId, request.accountId!))) - .limit(1); - - if (!session) { - return reply.status(404).send({ error: "Session not found", statusCode: 404 }); - } - - await db.delete(schema.sessions).where(eq(schema.sessions.id, sessionId)); - return { ok: true }; - }, - ); - - // Delete own account - app.delete( - "/accounts/me", - { preHandler: [requireAuth()] }, - async (request, reply) => { - const { password, confirmSlug } = request.body as { password: string; confirmSlug: string }; - - const [account] = await db - .select() - .from(schema.accounts) - .where(eq(schema.accounts.id, request.accountId!)) - .limit(1); - - if (!account?.passwordHash) { - return reply.status(400).send({ error: "Cannot delete this account type", statusCode: 400 }); - } - - if (confirmSlug !== account.slug) { - return reply.status(422).send({ error: "Username confirmation does not match", statusCode: 422 }); - } - - const valid = await bcrypt.compare(password, account.passwordHash); - if (!valid) { - return reply.status(401).send({ error: "Invalid password", statusCode: 401 }); - } - - // Check for owned collections - const [collCount] = await db - .select({ count: count() }) - .from(schema.collections) - .where(eq(schema.collections.accountId, account.id)); - - if (collCount && collCount.count > 0) { - return reply.status(422).send({ - error: `You still own ${collCount.count} collection(s). Transfer or delete them before deleting your account.`, - statusCode: 422, - }); - } - - // Clean up S3 avatars - try { - const avatarKeys = await listS3Objects(`avatars/${account.id}/`); - if (avatarKeys.length > 0) { - await deleteS3Objects(avatarKeys); - } - } catch { - // Non-fatal: avatar cleanup failed - } - - // Cascade will handle sessions, memberships, api keys - await db.delete(schema.accounts).where(eq(schema.accounts.id, account.id)); - reply.clearCookie("session", { path: "/" }); - return { ok: true }; - }, - ); - - // --- Forgot Password --- - app.post("/accounts/forgot-password", async (request, reply) => { - const { email } = request.body as { email: string }; - - const [account] = await db - .select() - .from(schema.accounts) - .where(eq(schema.accounts.email, email)) - .limit(1); - - // Always return success to prevent email enumeration - if (!account) { - return { ok: true }; - } - - const rawToken = uuidv4(); - const tokenHash = await bcrypt.hash(rawToken, 10); - const expiresAt = new Date(Date.now() + 60 * 60 * 1000); // 1 hour - - await db.insert(schema.passwordResetTokens).values({ - userId: account.id, - tokenHash, - expiresAt, - }); - - // Send email (no-op if SMTP not configured) - const resetUrl = `${request.protocol}://${request.hostname}/reset-password?token=${rawToken}&email=${encodeURIComponent(email)}`; - await sendEmail({ - to: email, - subject: "Reset your Underlay password", - text: `Click here to reset your password: ${resetUrl}\n\nThis link expires in 1 hour. If you didn't request this, ignore this email.`, - html: `

Click here to reset your password.

This link expires in 1 hour. If you didn't request this, ignore this email.

`, - }); - - return { ok: true }; - }); - - // --- Reset Password --- - app.post("/accounts/reset-password", async (request, reply) => { - const { email, token, newPassword } = request.body as { - email: string; - token: string; - newPassword: string; - }; - - if (newPassword.length < 8) { - return reply.status(422).send({ error: "Password must be at least 8 characters", statusCode: 422 }); - } - - const [account] = await db - .select() - .from(schema.accounts) - .where(eq(schema.accounts.email, email)) - .limit(1); - - if (!account) { - return reply.status(400).send({ error: "Invalid or expired reset link", statusCode: 400 }); - } - - // Find valid unused tokens for this user - const tokens = await db - .select() - .from(schema.passwordResetTokens) - .where(and( - eq(schema.passwordResetTokens.userId, account.id), - )); - - let validToken = null; - for (const t of tokens) { - if (t.usedAt) continue; - if (new Date(t.expiresAt) < new Date()) continue; - const match = await bcrypt.compare(token, t.tokenHash); - if (match) { - validToken = t; - break; - } - } - - if (!validToken) { - return reply.status(400).send({ error: "Invalid or expired reset link", statusCode: 400 }); - } - - const newHash = await bcrypt.hash(newPassword, 10); - await db.update(schema.accounts).set({ passwordHash: newHash }).where(eq(schema.accounts.id, account.id)); - await db - .update(schema.passwordResetTokens) - .set({ usedAt: new Date() }) - .where(eq(schema.passwordResetTokens.id, validToken.id)); - - return { ok: true }; - }); - - // Create API key - app.post( - "/accounts/keys", - { preHandler: [requireAuth()] }, - async (request, reply) => { - const { label, scope, collectionId, expiresIn } = request.body as { - label: string; - scope: "read" | "write" | "admin"; - collectionId?: string; - expiresIn?: number; // days, optional - }; - - const rawKey = `ul_${uuidv4().replace(/-/g, "")}`; - const keyHash = await bcrypt.hash(rawKey, 10); - const keyPrefix = rawKey.slice(0, 12); - - const expiresAt = expiresIn - ? new Date(Date.now() + expiresIn * 24 * 60 * 60 * 1000) - : null; - - const [key] = await db - .insert(schema.apiKeys) - .values({ - accountId: request.accountId!, - scope, - keyHash, - keyPrefix, - label, - collectionId: collectionId ?? null, - expiresAt, - }) - .returning(); - - return reply.status(201).send({ - id: key!.id, - key: rawKey, // shown once - label, - scope, - keyPrefix, - collectionId: collectionId ?? null, - expiresAt, - }); - }, - ); - - // List API keys - app.get( - "/accounts/keys", - { preHandler: [requireAuth()] }, - async (request) => { - const keys = await db - .select({ - id: schema.apiKeys.id, - label: schema.apiKeys.label, - scope: schema.apiKeys.scope, - keyPrefix: schema.apiKeys.keyPrefix, - collectionId: schema.apiKeys.collectionId, - expiresAt: schema.apiKeys.expiresAt, - createdAt: schema.apiKeys.createdAt, - lastUsedAt: schema.apiKeys.lastUsedAt, - }) - .from(schema.apiKeys) - .where(eq(schema.apiKeys.accountId, request.accountId!)); - return keys; - }, - ); - - // Delete API key - app.delete( - "/accounts/keys/:id", - { preHandler: [requireAuth()] }, - async (request, reply) => { - const { id } = request.params as { id: string }; - const [key] = await db - .select() - .from(schema.apiKeys) - .where(eq(schema.apiKeys.id, id)) - .limit(1); - - if (!key || key.accountId !== request.accountId) { - return reply.status(404).send({ error: "Key not found", statusCode: 404 }); - } - - await db.delete(schema.apiKeys).where(eq(schema.apiKeys.id, id)); - return { ok: true }; - }, - ); - - // --- Org-scoped API Keys --- - - // Create API key for an org - app.post( - "/accounts/:slug/keys", - { preHandler: [requireAuth()] }, - async (request, reply) => { - const { slug } = request.params as { slug: string }; - const { label, scope, collectionId, expiresIn } = request.body as { - label: string; - scope: "read" | "write" | "admin"; - collectionId?: string; - expiresIn?: number; - }; - - const [org] = await db - .select() - .from(schema.accounts) - .where(and(eq(schema.accounts.slug, slug), eq(schema.accounts.type, "org"))) - .limit(1); - - if (!org) return reply.status(404).send({ error: "Organization not found", statusCode: 404 }); - - // Must be owner or admin - const [membership] = await db - .select() - .from(schema.orgMemberships) - .where(and(eq(schema.orgMemberships.orgId, org.id), eq(schema.orgMemberships.userId, request.accountId!))) - .limit(1); - - if (!membership || membership.role === "member") { - return reply.status(403).send({ error: "Must be an owner or admin to manage org API keys", statusCode: 403 }); - } - - const rawKey = `ul_${uuidv4().replace(/-/g, "")}`; - const keyHash = await bcrypt.hash(rawKey, 10); - const keyPrefix = rawKey.slice(0, 12); - - const expiresAt = expiresIn - ? new Date(Date.now() + expiresIn * 24 * 60 * 60 * 1000) - : null; - - const [key] = await db - .insert(schema.apiKeys) - .values({ - accountId: org.id, - scope, - keyHash, - keyPrefix, - label, - collectionId: collectionId ?? null, - expiresAt, - }) - .returning(); - - return reply.status(201).send({ - id: key!.id, - key: rawKey, - label, - scope, - keyPrefix, - collectionId: collectionId ?? null, - expiresAt, - }); - }, - ); - - // List org API keys - app.get( - "/accounts/:slug/keys", - { preHandler: [requireAuth()] }, - async (request, reply) => { - const { slug } = request.params as { slug: string }; - - const [org] = await db - .select() - .from(schema.accounts) - .where(and(eq(schema.accounts.slug, slug), eq(schema.accounts.type, "org"))) - .limit(1); - - if (!org) return reply.status(404).send({ error: "Organization not found", statusCode: 404 }); - - // Must be a member - const [membership] = await db - .select() - .from(schema.orgMemberships) - .where(and(eq(schema.orgMemberships.orgId, org.id), eq(schema.orgMemberships.userId, request.accountId!))) - .limit(1); - - if (!membership) return reply.status(403).send({ error: "Forbidden", statusCode: 403 }); - - const keys = await db - .select({ - id: schema.apiKeys.id, - label: schema.apiKeys.label, - scope: schema.apiKeys.scope, - keyPrefix: schema.apiKeys.keyPrefix, - collectionId: schema.apiKeys.collectionId, - expiresAt: schema.apiKeys.expiresAt, - createdAt: schema.apiKeys.createdAt, - lastUsedAt: schema.apiKeys.lastUsedAt, - }) - .from(schema.apiKeys) - .where(eq(schema.apiKeys.accountId, org.id)); - - return keys; - }, - ); - - // Delete org API key - app.delete( - "/accounts/:slug/keys/:id", - { preHandler: [requireAuth()] }, - async (request, reply) => { - const { slug, id } = request.params as { slug: string; id: string }; - - const [org] = await db - .select() - .from(schema.accounts) - .where(and(eq(schema.accounts.slug, slug), eq(schema.accounts.type, "org"))) - .limit(1); - - if (!org) return reply.status(404).send({ error: "Organization not found", statusCode: 404 }); - - const [membership] = await db - .select() - .from(schema.orgMemberships) - .where(and(eq(schema.orgMemberships.orgId, org.id), eq(schema.orgMemberships.userId, request.accountId!))) - .limit(1); - - if (!membership || membership.role === "member") { - return reply.status(403).send({ error: "Must be an owner or admin to manage org API keys", statusCode: 403 }); - } - - const [key] = await db - .select() - .from(schema.apiKeys) - .where(and(eq(schema.apiKeys.id, id), eq(schema.apiKeys.accountId, org.id))) - .limit(1); - - if (!key) return reply.status(404).send({ error: "Key not found", statusCode: 404 }); - - await db.delete(schema.apiKeys).where(eq(schema.apiKeys.id, id)); - return { ok: true }; - }, - ); - - // --- Org Management --- - - // Create organization - app.post( - "/accounts/orgs", - { preHandler: [requireAuth()] }, - async (request, reply) => { - const { slug, displayName } = request.body as { - slug: string; - displayName: string; - }; - - if (RESERVED_SLUGS.has(slug.toLowerCase())) { - return reply.status(422).send({ error: "That name is reserved", statusCode: 422 }); - } - - if (!/^[a-z0-9][a-z0-9\-]*[a-z0-9]$/.test(slug) || slug.length < 2) { - return reply - .status(422) - .send({ error: "Slug must be lowercase alphanumeric with hyphens, at least 2 characters", statusCode: 422 }); - } - - const existing = await db - .select() - .from(schema.accounts) - .where(eq(schema.accounts.slug, slug)) - .limit(1); - - if (existing.length > 0) { - return reply.status(409).send({ error: "Name already taken", statusCode: 409 }); - } - - const id = uuidv4(); - await db.insert(schema.accounts).values({ - id, - slug, - type: "org", - displayName, - }); - - // Add the creating user as owner - await db.insert(schema.orgMemberships).values({ - orgId: id, - userId: request.accountId!, - role: "owner", - }); - - return reply.status(201).send({ id, slug, displayName, type: "org" }); - }, - ); - - // List org members - app.get( - "/accounts/:slug/members", - { preHandler: [requireAuth()] }, - async (request, reply) => { - const { slug } = request.params as { slug: string }; - - const [org] = await db - .select() - .from(schema.accounts) - .where(and(eq(schema.accounts.slug, slug), eq(schema.accounts.type, "org"))) - .limit(1); - - if (!org) return reply.status(404).send({ error: "Organization not found", statusCode: 404 }); - - // Must be a member to view - const [membership] = await db - .select() - .from(schema.orgMemberships) - .where(and(eq(schema.orgMemberships.orgId, org.id), eq(schema.orgMemberships.userId, request.accountId!))) - .limit(1); - - if (!membership) return reply.status(403).send({ error: "Forbidden", statusCode: 403 }); - - const members = await db - .select({ - userId: schema.orgMemberships.userId, - role: schema.orgMemberships.role, - slug: schema.accounts.slug, - displayName: schema.accounts.displayName, - }) - .from(schema.orgMemberships) - .innerJoin(schema.accounts, eq(schema.orgMemberships.userId, schema.accounts.id)) - .where(eq(schema.orgMemberships.orgId, org.id)); - - return members; - }, - ); - - // Add org member - app.post( - "/accounts/:slug/members", - { preHandler: [requireAuth()] }, - async (request, reply) => { - const { slug } = request.params as { slug: string }; - const { username, role } = request.body as { username: string; role: "owner" | "admin" | "member" }; - - const [org] = await db - .select() - .from(schema.accounts) - .where(and(eq(schema.accounts.slug, slug), eq(schema.accounts.type, "org"))) - .limit(1); - - if (!org) return reply.status(404).send({ error: "Organization not found", statusCode: 404 }); - - // Must be owner or admin - const [callerMembership] = await db - .select() - .from(schema.orgMemberships) - .where(and(eq(schema.orgMemberships.orgId, org.id), eq(schema.orgMemberships.userId, request.accountId!))) - .limit(1); - - if (!callerMembership || callerMembership.role === "member") { - return reply.status(403).send({ error: "Must be an owner or admin to add members", statusCode: 403 }); - } - - // Find user to add - const [user] = await db - .select() - .from(schema.accounts) - .where(and(eq(schema.accounts.slug, username), eq(schema.accounts.type, "user"))) - .limit(1); - - if (!user) return reply.status(404).send({ error: "User not found", statusCode: 404 }); - - // Check not already a member - const [existing] = await db - .select() - .from(schema.orgMemberships) - .where(and(eq(schema.orgMemberships.orgId, org.id), eq(schema.orgMemberships.userId, user.id))) - .limit(1); - - if (existing) return reply.status(409).send({ error: "Already a member", statusCode: 409 }); - - await db.insert(schema.orgMemberships).values({ - orgId: org.id, - userId: user.id, - role: role ?? "member", - }); - - return reply.status(201).send({ ok: true, username, role }); - }, - ); - - // Update member role - app.patch( - "/accounts/:slug/members/:userId", - { preHandler: [requireAuth()] }, - async (request, reply) => { - const { slug, userId } = request.params as { slug: string; userId: string }; - const { role } = request.body as { role: "owner" | "admin" | "member" }; - - const [org] = await db - .select() - .from(schema.accounts) - .where(and(eq(schema.accounts.slug, slug), eq(schema.accounts.type, "org"))) - .limit(1); - - if (!org) return reply.status(404).send({ error: "Organization not found", statusCode: 404 }); - - // Must be owner - const [callerMembership] = await db - .select() - .from(schema.orgMemberships) - .where(and(eq(schema.orgMemberships.orgId, org.id), eq(schema.orgMemberships.userId, request.accountId!))) - .limit(1); - - if (!callerMembership || callerMembership.role !== "owner") { - return reply.status(403).send({ error: "Must be an owner to change roles", statusCode: 403 }); - } - - await db - .update(schema.orgMemberships) - .set({ role }) - .where(and(eq(schema.orgMemberships.orgId, org.id), eq(schema.orgMemberships.userId, userId))); - - return { ok: true }; - }, - ); - - // Remove member - app.delete( - "/accounts/:slug/members/:userId", - { preHandler: [requireAuth()] }, - async (request, reply) => { - const { slug, userId } = request.params as { slug: string; userId: string }; - - const [org] = await db - .select() - .from(schema.accounts) - .where(and(eq(schema.accounts.slug, slug), eq(schema.accounts.type, "org"))) - .limit(1); - - if (!org) return reply.status(404).send({ error: "Organization not found", statusCode: 404 }); - - // Must be owner or admin (or removing yourself) - const [callerMembership] = await db - .select() - .from(schema.orgMemberships) - .where(and(eq(schema.orgMemberships.orgId, org.id), eq(schema.orgMemberships.userId, request.accountId!))) - .limit(1); - - const isSelf = request.accountId === userId; - if (!callerMembership || (callerMembership.role === "member" && !isSelf)) { - return reply.status(403).send({ error: "Forbidden", statusCode: 403 }); - } - - await db - .delete(schema.orgMemberships) - .where(and(eq(schema.orgMemberships.orgId, org.id), eq(schema.orgMemberships.userId, userId))); - - return { ok: true }; - }, - ); - - // Update org profile - app.patch( - "/accounts/:slug", - { preHandler: [requireAuth()] }, - async (request, reply) => { - const { slug } = request.params as { slug: string }; - const { displayName, bio, website, location } = request.body as { - displayName?: string; - bio?: string; - website?: string; - location?: string; - }; - - const [org] = await db - .select() - .from(schema.accounts) - .where(and(eq(schema.accounts.slug, slug), eq(schema.accounts.type, "org"))) - .limit(1); - - if (!org) return reply.status(404).send({ error: "Organization not found", statusCode: 404 }); - - // Must be owner - const [callerMembership] = await db - .select() - .from(schema.orgMemberships) - .where(and(eq(schema.orgMemberships.orgId, org.id), eq(schema.orgMemberships.userId, request.accountId!))) - .limit(1); - - if (!callerMembership || callerMembership.role !== "owner") { - return reply.status(403).send({ error: "Must be an owner to update the organization", statusCode: 403 }); - } - - const updates: Record = {}; - if (displayName !== undefined) updates.displayName = displayName; - if (bio !== undefined) updates.bio = bio; - if (website !== undefined) updates.website = website; - if (location !== undefined) updates.location = location; - - if (Object.keys(updates).length > 0) { - await db.update(schema.accounts).set(updates).where(eq(schema.accounts.id, org.id)); - } - - return { ok: true }; - }, - ); - - // Upload org avatar - app.post( - "/accounts/:slug/avatar", - { preHandler: [requireAuth()] }, - async (request, reply) => { - const { slug } = request.params as { slug: string }; - - const [org] = await db - .select() - .from(schema.accounts) - .where(and(eq(schema.accounts.slug, slug), eq(schema.accounts.type, "org"))) - .limit(1); - - if (!org) return reply.status(404).send({ error: "Organization not found", statusCode: 404 }); - - const [membership] = await db - .select() - .from(schema.orgMemberships) - .where(and(eq(schema.orgMemberships.orgId, org.id), eq(schema.orgMemberships.userId, request.accountId!))) - .limit(1); - - if (!membership || membership.role !== "owner") { - return reply.status(403).send({ error: "Must be an owner to update the organization avatar", statusCode: 403 }); - } - - const data = await request.file(); - if (!data) { - return reply.status(400).send({ error: "No file uploaded", statusCode: 400 }); - } - - const allowedTypes = ["image/jpeg", "image/png", "image/gif", "image/webp"]; - if (!allowedTypes.includes(data.mimetype)) { - return reply.status(422).send({ error: "Only JPEG, PNG, GIF, and WebP images are allowed", statusCode: 422 }); - } - - const buffer = await data.toBuffer(); - if (buffer.length > 5 * 1024 * 1024) { - return reply.status(422).send({ error: "Image must be less than 5MB", statusCode: 422 }); - } - - const ext = data.mimetype.split("/")[1] === "jpeg" ? "jpg" : data.mimetype.split("/")[1]; - const key = `avatars/${org.id}/${Date.now()}.${ext}`; - - await uploadToS3(key, buffer, data.mimetype); - - await db.update(schema.accounts).set({ avatarUrl: `${ASSETS_BASE_URL}/${key}` }).where(eq(schema.accounts.id, org.id)); - - return { ok: true, avatarUrl: `${ASSETS_BASE_URL}/${key}` }; - }, - ); - - // --- Org Invitations --- - - // Invite user to org - app.post( - "/accounts/:slug/invitations", - { preHandler: [requireAuth()] }, - async (request, reply) => { - const { slug } = request.params as { slug: string }; - const { email, role } = request.body as { email: string; role: "owner" | "admin" | "member" }; - - const [org] = await db - .select() - .from(schema.accounts) - .where(and(eq(schema.accounts.slug, slug), eq(schema.accounts.type, "org"))) - .limit(1); - - if (!org) return reply.status(404).send({ error: "Organization not found", statusCode: 404 }); - - const [callerMembership] = await db - .select() - .from(schema.orgMemberships) - .where(and(eq(schema.orgMemberships.orgId, org.id), eq(schema.orgMemberships.userId, request.accountId!))) - .limit(1); - - if (!callerMembership || callerMembership.role === "member") { - return reply.status(403).send({ error: "Must be an owner or admin to invite members", statusCode: 403 }); - } - - // Check if already a member (by email) - const [existingUser] = await db - .select() - .from(schema.accounts) - .where(eq(schema.accounts.email, email)) - .limit(1); - - if (existingUser) { - const [existingMembership] = await db - .select() - .from(schema.orgMemberships) - .where(and(eq(schema.orgMemberships.orgId, org.id), eq(schema.orgMemberships.userId, existingUser.id))) - .limit(1); - - if (existingMembership) { - return reply.status(409).send({ error: "User is already a member", statusCode: 409 }); - } - } - - const token = uuidv4(); - const expiresAt = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000); // 7 days - - await db.insert(schema.orgInvitations).values({ - orgId: org.id, - email, - role, - invitedBy: request.accountId!, - token, - expiresAt, - }); - - // Send invitation email - const inviteUrl = `${request.protocol}://${request.hostname}/invitations/accept?token=${token}`; - await sendEmail({ - to: email, - subject: `You've been invited to join ${org.displayName} on Underlay`, - text: `You've been invited to join ${org.displayName} as a ${role}.\n\nAccept: ${inviteUrl}\n\nThis invitation expires in 7 days.`, - html: `

You've been invited to join ${org.displayName} as a ${role}.

Accept invitation

This invitation expires in 7 days.

`, - }); - - return reply.status(201).send({ ok: true }); - }, - ); - - // List pending invitations for an org - app.get( - "/accounts/:slug/invitations", - { preHandler: [requireAuth()] }, - async (request, reply) => { - const { slug } = request.params as { slug: string }; - - const [org] = await db - .select() - .from(schema.accounts) - .where(and(eq(schema.accounts.slug, slug), eq(schema.accounts.type, "org"))) - .limit(1); - - if (!org) return reply.status(404).send({ error: "Organization not found", statusCode: 404 }); - - const [membership] = await db - .select() - .from(schema.orgMemberships) - .where(and(eq(schema.orgMemberships.orgId, org.id), eq(schema.orgMemberships.userId, request.accountId!))) - .limit(1); - - if (!membership) return reply.status(403).send({ error: "Forbidden", statusCode: 403 }); - - const invitations = await db - .select({ - id: schema.orgInvitations.id, - email: schema.orgInvitations.email, - role: schema.orgInvitations.role, - expiresAt: schema.orgInvitations.expiresAt, - acceptedAt: schema.orgInvitations.acceptedAt, - createdAt: schema.orgInvitations.createdAt, - }) - .from(schema.orgInvitations) - .where(eq(schema.orgInvitations.orgId, org.id)); - - return invitations; - }, - ); - - // Cancel an invitation - app.delete( - "/accounts/:slug/invitations/:id", - { preHandler: [requireAuth()] }, - async (request, reply) => { - const { slug, id } = request.params as { slug: string; id: string }; - - const [org] = await db - .select() - .from(schema.accounts) - .where(and(eq(schema.accounts.slug, slug), eq(schema.accounts.type, "org"))) - .limit(1); - - if (!org) return reply.status(404).send({ error: "Organization not found", statusCode: 404 }); - - const [membership] = await db - .select() - .from(schema.orgMemberships) - .where(and(eq(schema.orgMemberships.orgId, org.id), eq(schema.orgMemberships.userId, request.accountId!))) - .limit(1); - - if (!membership || membership.role === "member") { - return reply.status(403).send({ error: "Must be an owner or admin to cancel invitations", statusCode: 403 }); - } - - await db.delete(schema.orgInvitations).where(eq(schema.orgInvitations.id, id)); - return { ok: true }; - }, - ); - - // Accept an invitation (public, token-based) - app.post("/accounts/invitations/accept", { preHandler: [requireAuth()] }, async (request, reply) => { - const { token } = request.body as { token: string }; - - const [invitation] = await db - .select() - .from(schema.orgInvitations) - .where(eq(schema.orgInvitations.token, token)) - .limit(1); - - if (!invitation) { - return reply.status(404).send({ error: "Invitation not found", statusCode: 404 }); - } - - if (invitation.acceptedAt) { - return reply.status(409).send({ error: "Invitation already accepted", statusCode: 409 }); - } - - if (new Date(invitation.expiresAt) < new Date()) { - return reply.status(410).send({ error: "Invitation has expired", statusCode: 410 }); - } - - // Verify the logged-in user's email matches the invitation - const [account] = await db - .select() - .from(schema.accounts) - .where(eq(schema.accounts.id, request.accountId!)) - .limit(1); - - if (!account || account.email !== invitation.email) { - return reply.status(403).send({ error: "This invitation was sent to a different email address", statusCode: 403 }); - } - - // Add to org - await db.insert(schema.orgMemberships).values({ - orgId: invitation.orgId, - userId: request.accountId!, - role: invitation.role as "owner" | "admin" | "member", - }); - - // Mark invitation as accepted - await db - .update(schema.orgInvitations) - .set({ acceptedAt: new Date() }) - .where(eq(schema.orgInvitations.id, invitation.id)); - - // Get org slug for redirect - const [org] = await db - .select({ slug: schema.accounts.slug }) - .from(schema.accounts) - .where(eq(schema.accounts.id, invitation.orgId)) - .limit(1); - - return { ok: true, orgSlug: org?.slug ?? "" }; - }); - - // Delete org - app.delete( - "/accounts/:slug", - { preHandler: [requireAuth()] }, - async (request, reply) => { - const { slug } = request.params as { slug: string }; - - const [org] = await db - .select() - .from(schema.accounts) - .where(and(eq(schema.accounts.slug, slug), eq(schema.accounts.type, "org"))) - .limit(1); - - if (!org) return reply.status(404).send({ error: "Organization not found", statusCode: 404 }); - - // Must be owner - const [callerMembership] = await db - .select() - .from(schema.orgMemberships) - .where(and(eq(schema.orgMemberships.orgId, org.id), eq(schema.orgMemberships.userId, request.accountId!))) - .limit(1); - - if (!callerMembership || callerMembership.role !== "owner") { - return reply.status(403).send({ error: "Must be an owner to delete the organization", statusCode: 403 }); - } - - // Cascade will handle memberships, collections, etc. - await db.delete(schema.accounts).where(eq(schema.accounts.id, org.id)); - return { ok: true }; - }, - ); -} diff --git a/src/api/routes/admin.ts b/src/api/routes/admin.ts deleted file mode 100644 index 9508b4f..0000000 --- a/src/api/routes/admin.ts +++ /dev/null @@ -1,116 +0,0 @@ -import type { FastifyInstance } from "fastify"; -import { getMirrorConfig } from "../../lib/mirror-config.js"; -import { - runMirrorSync, - testUpstreamConnection, - getMirrorStatus, - getSyncHistory, - syncEvents, - stopSync, - cleanupStaleRuns, - isSyncRunning, - getActiveRunId, - getActiveRunLogs, - type SyncProgressEvent, -} from "../../lib/mirror-sync.js"; - -export async function adminRoutes(app: FastifyInstance) { - // All admin routes require mirror mode to be enabled - app.addHook("onRequest", async (_request, reply) => { - const config = getMirrorConfig(); - if (!config.enabled) { - return reply.status(404).send({ error: "Not found", statusCode: 404 }); - } - }); - - // Get mirror status - app.get("/admin/mirror/status", async () => { - const status = await getMirrorStatus(); - return status; - }); - - // Test upstream connection - app.post("/admin/mirror/test", async () => { - const config = getMirrorConfig(); - const result = await testUpstreamConnection(config.upstream); - return result; - }); - - // Trigger a sync manually (fire-and-forget, client uses SSE for progress) - app.post("/admin/mirror/sync", async () => { - if (isSyncRunning()) { - return { started: false, error: "A sync is already running" }; - } - // Start sync in background — don't await - runMirrorSync("manual").catch((err) => { - console.error("[mirror-sync] Unhandled sync error:", err); - }); - return { started: true }; - }); - - // Stop a running sync (also cleans up stale DB rows from crashed processes) - app.post("/admin/mirror/sync/stop", async () => { - const stopped = stopSync(); - if (!stopped) { - // No active sync in this process — clean up stale DB rows - const cleaned = await cleanupStaleRuns(); - return { stopped: false, cleaned }; - } - return { stopped: true }; - }); - - // SSE endpoint for live sync progress (replays buffered logs on connect) - app.get("/admin/mirror/sync/progress", async (request, reply) => { - reply.raw.writeHead(200, { - "Content-Type": "text/event-stream", - "Cache-Control": "no-cache", - Connection: "keep-alive", - }); - - // Replay buffered logs so reconnects/refreshes don't lose history - const buffered = getActiveRunLogs(); - if (buffered.length > 0) { - for (const msg of buffered) { - const replayEvent: SyncProgressEvent = { type: "collection", message: msg, progress: { collectionsTotal: 0, collectionsProcessed: 0, versionsPulled: 0, filesDownloaded: 0, filesSkipped: 0, errors: 0 } }; - reply.raw.write(`data: ${JSON.stringify(replayEvent)}\n\n`); - } - } - - // If no sync is running, close immediately - if (!isSyncRunning()) { - reply.raw.end(); - return; - } - - function onProgress(event: SyncProgressEvent) { - reply.raw.write(`data: ${JSON.stringify(event)}\n\n`); - if (event.type === "done") { - setTimeout(() => reply.raw.end(), 100); - } - } - - syncEvents.on("progress", onProgress); - - request.raw.on("close", () => { - syncEvents.off("progress", onProgress); - }); - }); - - // Get current sync running state (for page refresh reconnection) - app.get("/admin/mirror/sync/active", async () => { - return { - running: isSyncRunning(), - runId: getActiveRunId(), - logs: getActiveRunLogs(), - }; - }); - - // Sync history - app.get("/admin/mirror/history", async (request) => { - const limit = Math.min( - Number((request.query as any)?.limit) || 20, - 100, - ); - return getSyncHistory(limit); - }); -} diff --git a/src/api/routes/ark.ts b/src/api/routes/ark.ts deleted file mode 100644 index 98556d3..0000000 --- a/src/api/routes/ark.ts +++ /dev/null @@ -1,529 +0,0 @@ -import type { FastifyInstance } from "fastify"; -import { eq, and, desc } from "drizzle-orm"; -import { db, schema } from "../../db/index.js"; -import { requireAuth } from "../plugins/auth.js"; -import { - DEFAULT_NAAN, - parseArkPath, - buildArkUrl, - buildErc, - formatErcDate, - collectionToArkId, - getOrMintShoulder, -} from "../../lib/ark.js"; - -export async function arkRoutes(app: FastifyInstance) { - // --- Resolution --- - - app.get("/ark/resolve", async (request, reply) => { - const { path } = request.query as { path?: string }; - if (!path) return reply.status(400).send({ error: "Missing path" }); - - // path = "ark:NAAN/shoulder+collection..." - const arkLabelIdx = path.indexOf("ark:"); - if (arkLabelIdx === -1) return reply.status(400).send({ error: "Invalid ARK path" }); - - const afterLabel = path.slice(arkLabelIdx + 4); // strip "ark:" - const slashIdx = afterLabel.indexOf("/"); - if (slashIdx === -1) return reply.status(404).send({ type: "not_found" }); - - const naan = afterLabel.slice(0, slashIdx); - const pathAfterNaan = afterLabel.slice(slashIdx + 1); - - // Root NAAN path (no name part) — handled in middleware; shouldn't reach here - if (!pathAfterNaan) return reply.status(404).send({ type: "not_found" }); - - const components = parseArkPath(pathAfterNaan); - if (!components) return reply.status(404).send({ type: "not_found" }); - - const { shoulder, collectionArkId, version, recordType, recordId } = components; - - // Lookup shoulder → account - const [shoulderRow] = await db - .select({ accountId: schema.arkShoulders.accountId }) - .from(schema.arkShoulders) - .where(eq(schema.arkShoulders.shoulder, shoulder)) - .limit(1); - if (!shoulderRow) return reply.status(404).send({ type: "not_found" }); - - // Lookup collectionArkId → collection + owner - const [collRow] = await db - .select({ - collectionId: schema.arkCollections.collectionId, - enabled: schema.arkCollections.enabled, - customUrl: schema.arkCollections.customUrl, - collectionSlug: schema.collections.slug, - collectionName: schema.collections.name, - ownerSlug: schema.accounts.slug, - ownerName: schema.accounts.displayName, - ownerNaan: schema.accounts.arkNaan, - collectionAccountId: schema.collections.accountId, - }) - .from(schema.arkCollections) - .innerJoin(schema.collections, eq(schema.arkCollections.collectionId, schema.collections.id)) - .innerJoin(schema.accounts, eq(schema.collections.accountId, schema.accounts.id)) - .where(eq(schema.arkCollections.arkId, collectionArkId)) - .limit(1); - - if (!collRow || !collRow.enabled) return reply.status(404).send({ type: "not_found" }); - - // Verify the shoulder belongs to the collection's owner - if (shoulderRow.accountId !== collRow.collectionAccountId) { - return reply.status(404).send({ type: "not_found" }); - } - - const resolvedNaan = collRow.ownerNaan ?? naan; - const { collectionId, collectionSlug, collectionName, ownerSlug, ownerName } = collRow; - - // --- Resolve version --- - let versionRow: { - id: number; - number: number; - semver: string; - message: string | null; - readme: string | null; - pushedBy: string | null; - appId: string | null; - actorId: string | null; - createdAt: Date; - } | null = null; - - if (version !== undefined) { - const [row] = await db - .select({ - id: schema.versions.id, - number: schema.versions.number, - semver: schema.versions.semver, - message: schema.versions.message, - readme: schema.versions.readme, - pushedBy: schema.versions.pushedBy, - appId: schema.versions.appId, - actorId: schema.versions.actorId, - createdAt: schema.versions.createdAt, - }) - .from(schema.versions) - .where(and(eq(schema.versions.collectionId, collectionId), eq(schema.versions.number, version))) - .limit(1); - if (!row) return reply.status(404).send({ type: "not_found" }); - versionRow = row; - } else { - const [row] = await db - .select({ - id: schema.versions.id, - number: schema.versions.number, - semver: schema.versions.semver, - message: schema.versions.message, - readme: schema.versions.readme, - pushedBy: schema.versions.pushedBy, - appId: schema.versions.appId, - actorId: schema.versions.actorId, - createdAt: schema.versions.createdAt, - }) - .from(schema.versions) - .where(eq(schema.versions.collectionId, collectionId)) - .orderBy(desc(schema.versions.number)) - .limit(1); - versionRow = row ?? null; - } - - const arkUrl = buildArkUrl(resolvedNaan, shoulder, collectionArkId, version, recordType, recordId); - - // --- Record resolution --- - if (recordType && recordId) { - const [artRow] = await db - .select({ redirectUrlField: schema.arkRecordTypes.redirectUrlField }) - .from(schema.arkRecordTypes) - .where( - and( - eq(schema.arkRecordTypes.collectionId, collectionId), - eq(schema.arkRecordTypes.recordType, recordType), - ), - ) - .limit(1); - - if (!artRow) return reply.status(404).send({ type: "not_found" }); - - if (!versionRow) return reply.status(404).send({ type: "not_found" }); - - const [recordRow] = await db - .select({ data: schema.records.data }) - .from(schema.records) - .where( - and( - eq(schema.records.versionId, versionRow.id), - eq(schema.records.recordId, recordId), - eq(schema.records.type, recordType), - ), - ) - .limit(1); - - if (!recordRow) return reply.status(404).send({ type: "not_found" }); - - const data = recordRow.data as Record; - const redirectUrl = data[artRow.redirectUrlField]; - if (typeof redirectUrl !== "string") { - return reply.status(404).send({ type: "not_found", error: "No URL found for this record" }); - } - - // Fetch the type schema for metadata - const [vs] = await db - .select({ schema: schema.schemas.schema }) - .from(schema.versionSchemas) - .innerJoin(schema.schemas, eq(schema.versionSchemas.schemaId, schema.schemas.id)) - .where( - and( - eq(schema.versionSchemas.versionId, versionRow.id), - eq(schema.versionSchemas.slug, recordType), - ), - ) - .limit(1); - - return { - type: "redirect" as const, - url: redirectUrl, - metadata: { - type: "record", - who: ownerName, - what: `${recordType} ${recordId} in ${collectionName}`, - when: formatErcDate(versionRow.createdAt), - where: arkUrl, - naan: resolvedNaan, - collectionName, - ownerName, - versionNumber: versionRow.number, - semver: versionRow.semver, - recordType, - recordId, - schema: vs?.schema ?? null, - data, - createdAt: versionRow.createdAt, - arkUrl, - }, - }; - } - - // --- Collection / version resolution --- - if (collRow.customUrl) { - const what = versionRow - ? `${collectionName} ${versionRow.semver}` - : collectionName; - const when = versionRow - ? formatErcDate(versionRow.createdAt) - : "(:unkn)"; - return { - type: "redirect" as const, - url: collRow.customUrl, - metadata: { - type: version !== undefined ? "version" : "collection", - who: ownerName, - what, - when, - where: arkUrl, - naan: resolvedNaan, - collectionName, - ownerName, - versionNumber: versionRow?.number, - semver: versionRow?.semver, - message: versionRow?.message, - pushedBy: versionRow?.pushedBy, - appId: versionRow?.appId, - actorId: versionRow?.actorId, - createdAt: versionRow?.createdAt, - arkUrl, - }, - }; - } - - if (version !== undefined && versionRow) { - const url = `/${ownerSlug}/${collectionSlug}/v/${versionRow.number}`; - return { - type: "redirect" as const, - url, - metadata: { - type: "version", - who: ownerName, - what: `${collectionName} ${versionRow.semver}`, - when: formatErcDate(versionRow.createdAt), - where: arkUrl, - naan: resolvedNaan, - collectionName, - ownerName, - versionNumber: versionRow.number, - semver: versionRow.semver, - message: versionRow.message, - pushedBy: versionRow.pushedBy, - appId: versionRow.appId, - actorId: versionRow.actorId, - createdAt: versionRow.createdAt, - arkUrl, - }, - }; - } - - // Default: redirect to collection overview - const url = `/${ownerSlug}/${collectionSlug}`; - return { - type: "redirect" as const, - url, - metadata: { - type: "collection", - who: ownerName, - what: collectionName, - when: versionRow ? formatErcDate(versionRow.createdAt) : "(:unkn)", - where: arkUrl, - naan: resolvedNaan, - collectionName, - ownerName, - versionNumber: versionRow?.number, - semver: versionRow?.semver, - createdAt: versionRow?.createdAt, - arkUrl, - }, - }; - }); - - // --- Collection ARK settings --- - - app.get( - "/collections/:owner/:slug/ark", - { preHandler: [requireAuth("read")] }, - async (request, reply) => { - const { owner, slug } = request.params as { owner: string; slug: string }; - - const [coll] = await db - .select({ - id: schema.collections.id, - accountId: schema.collections.accountId, - ownerNaan: schema.accounts.arkNaan, - }) - .from(schema.collections) - .innerJoin(schema.accounts, eq(schema.collections.accountId, schema.accounts.id)) - .where(and(eq(schema.accounts.slug, owner), eq(schema.collections.slug, slug))) - .limit(1); - if (!coll) return reply.status(404).send({ error: "Collection not found" }); - - // Must be owner/member - const hasAccess = await checkCollectionAccess(coll.accountId, request.accountId!); - if (!hasAccess) return reply.status(403).send({ error: "Forbidden" }); - - const naan = coll.ownerNaan ?? DEFAULT_NAAN; - - const [arkRow] = await db - .select({ - arkId: schema.arkCollections.arkId, - enabled: schema.arkCollections.enabled, - customUrl: schema.arkCollections.customUrl, - shoulder: schema.arkShoulders.shoulder, - }) - .from(schema.arkCollections) - .innerJoin( - schema.arkShoulders, - eq(schema.arkShoulders.accountId, coll.accountId), - ) - .where(eq(schema.arkCollections.collectionId, coll.id)) - .limit(1); - - if (!arkRow) { - return { enabled: false, customUrl: null, arkUrl: null, shoulder: null, arkId: null }; - } - - const arkUrl = buildArkUrl(naan, arkRow.shoulder, arkRow.arkId); - return { enabled: arkRow.enabled, customUrl: arkRow.customUrl, arkUrl, shoulder: arkRow.shoulder, arkId: arkRow.arkId }; - }, - ); - - app.patch( - "/collections/:owner/:slug/ark", - { preHandler: [requireAuth("write")] }, - async (request, reply) => { - const { owner, slug } = request.params as { owner: string; slug: string }; - const { enabled, customUrl } = request.body as { enabled?: boolean; customUrl?: string | null }; - - const [coll] = await db - .select({ id: schema.collections.id, accountId: schema.collections.accountId }) - .from(schema.collections) - .innerJoin(schema.accounts, eq(schema.collections.accountId, schema.accounts.id)) - .where(and(eq(schema.accounts.slug, owner), eq(schema.collections.slug, slug))) - .limit(1); - if (!coll) return reply.status(404).send({ error: "Collection not found" }); - - const hasAccess = await checkCollectionAccess(coll.accountId, request.accountId!); - if (!hasAccess) return reply.status(403).send({ error: "Forbidden" }); - - const [existing] = await db - .select({ collectionId: schema.arkCollections.collectionId }) - .from(schema.arkCollections) - .where(eq(schema.arkCollections.collectionId, coll.id)) - .limit(1); - - if (!existing) { - // Collection predates ARK tables — mint now - await getOrMintShoulder(coll.accountId); - const arkId = collectionToArkId(coll.id); - await db.insert(schema.arkCollections).values({ - collectionId: coll.id, - arkId, - enabled: enabled ?? true, - customUrl: customUrl ?? null, - }); - } else { - const updates: Record = {}; - if (enabled !== undefined) updates.enabled = enabled; - if (customUrl !== undefined) updates.customUrl = customUrl ?? null; - if (Object.keys(updates).length > 0) { - await db - .update(schema.arkCollections) - .set(updates) - .where(eq(schema.arkCollections.collectionId, coll.id)); - } - } - - return reply.status(200).send({ ok: true }); - }, - ); - - // --- Record type ARK settings --- - - app.get( - "/collections/:owner/:slug/ark/record-types", - { preHandler: [requireAuth("read")] }, - async (request, reply) => { - const { owner, slug } = request.params as { owner: string; slug: string }; - - const [coll] = await db - .select({ id: schema.collections.id, accountId: schema.collections.accountId }) - .from(schema.collections) - .innerJoin(schema.accounts, eq(schema.collections.accountId, schema.accounts.id)) - .where(and(eq(schema.accounts.slug, owner), eq(schema.collections.slug, slug))) - .limit(1); - if (!coll) return reply.status(404).send({ error: "Collection not found" }); - - const hasAccess = await checkCollectionAccess(coll.accountId, request.accountId!); - if (!hasAccess) return reply.status(403).send({ error: "Forbidden" }); - - const rows = await db - .select({ - recordType: schema.arkRecordTypes.recordType, - redirectUrlField: schema.arkRecordTypes.redirectUrlField, - }) - .from(schema.arkRecordTypes) - .where(eq(schema.arkRecordTypes.collectionId, coll.id)); - - return rows; - }, - ); - - app.patch( - "/collections/:owner/:slug/ark/record-types", - { preHandler: [requireAuth("write")] }, - async (request, reply) => { - const { owner, slug } = request.params as { owner: string; slug: string }; - const { recordType, redirectUrlField } = request.body as { - recordType: string; - redirectUrlField: string | null; - }; - - if (!recordType) return reply.status(400).send({ error: "recordType required" }); - - const [coll] = await db - .select({ id: schema.collections.id, accountId: schema.collections.accountId }) - .from(schema.collections) - .innerJoin(schema.accounts, eq(schema.collections.accountId, schema.accounts.id)) - .where(and(eq(schema.accounts.slug, owner), eq(schema.collections.slug, slug))) - .limit(1); - if (!coll) return reply.status(404).send({ error: "Collection not found" }); - - const hasAccess = await checkCollectionAccess(coll.accountId, request.accountId!); - if (!hasAccess) return reply.status(403).send({ error: "Forbidden" }); - - if (redirectUrlField === null) { - await db - .delete(schema.arkRecordTypes) - .where( - and( - eq(schema.arkRecordTypes.collectionId, coll.id), - eq(schema.arkRecordTypes.recordType, recordType), - ), - ); - } else { - await db - .insert(schema.arkRecordTypes) - .values({ collectionId: coll.id, recordType, redirectUrlField }) - .onConflictDoUpdate({ - target: [schema.arkRecordTypes.collectionId, schema.arkRecordTypes.recordType], - set: { redirectUrlField }, - }); - } - - return reply.status(200).send({ ok: true }); - }, - ); - - // --- Org ARK NAAN --- - - app.patch( - "/accounts/:slug/ark", - { preHandler: [requireAuth("admin")] }, - async (request, reply) => { - const { slug } = request.params as { slug: string }; - const { naan } = request.body as { naan: string | null }; - - if (naan !== null && !/^\d{1,16}$/.test(naan)) { - return reply.status(400).send({ error: "NAAN must be numeric (up to 16 digits)" }); - } - - const [account] = await db - .select({ id: schema.accounts.id, type: schema.accounts.type }) - .from(schema.accounts) - .where(eq(schema.accounts.slug, slug)) - .limit(1); - if (!account) return reply.status(404).send({ error: "Account not found" }); - - // Must be owner/admin of the org (or the user themselves) - if (account.type === "org") { - const [membership] = await db - .select({ role: schema.orgMemberships.role }) - .from(schema.orgMemberships) - .where( - and( - eq(schema.orgMemberships.orgId, account.id), - eq(schema.orgMemberships.userId, request.accountId!), - ), - ) - .limit(1); - if (!membership || (membership.role !== "owner" && membership.role !== "admin")) { - return reply.status(403).send({ error: "Forbidden" }); - } - } else if (account.id !== request.accountId) { - return reply.status(403).send({ error: "Forbidden" }); - } - - await db.update(schema.accounts).set({ arkNaan: naan }).where(eq(schema.accounts.id, account.id)); - return reply.status(200).send({ ok: true }); - }, - ); -} - -async function checkCollectionAccess(ownerAccountId: string, requestAccountId: string): Promise { - const [account] = await db - .select({ id: schema.accounts.id, type: schema.accounts.type }) - .from(schema.accounts) - .where(eq(schema.accounts.id, ownerAccountId)) - .limit(1); - if (!account) return false; - if (account.id === requestAccountId) return true; - if (account.type === "org") { - const [membership] = await db - .select({ role: schema.orgMemberships.role }) - .from(schema.orgMemberships) - .where( - and( - eq(schema.orgMemberships.orgId, account.id), - eq(schema.orgMemberships.userId, requestAccountId), - ), - ) - .limit(1); - return !!membership; - } - return false; -} diff --git a/src/api/routes/collections.ts b/src/api/routes/collections.ts deleted file mode 100644 index 3b54feb..0000000 --- a/src/api/routes/collections.ts +++ /dev/null @@ -1,515 +0,0 @@ -import type { FastifyInstance } from "fastify"; -import { eq, and, ilike, or, sql } from "drizzle-orm"; -import { db, schema } from "../../db/index.js"; -import { requireAuth } from "../plugins/auth.js"; -import { v4 as uuidv4 } from "uuid"; -import { pack as tarPack } from "tar-stream"; -import { createGzip } from "node:zlib"; -import { pipeline } from "node:stream/promises"; -import { downloadFromS3 } from "../../lib/s3.js"; -import { DEFAULT_NAAN, collectionToArkId, getOrMintShoulder, buildArkUrl } from "../../lib/ark.js"; - -export async function collectionsRoutes(app: FastifyInstance) { - // Browse public collections - app.get("/collections", async (request) => { - const { q, limit, offset } = request.query as { - q?: string; - limit?: string; - offset?: string; - }; - const take = Math.min(parseInt(limit ?? "50", 10), 100); - const skip = parseInt(offset ?? "0", 10); - - const conditions = [eq(schema.collections.public, true)]; - if (q) { - conditions.push( - or( - ilike(schema.collections.name, `%${q}%`), - ilike(schema.collections.description, `%${q}%`), - )!, - ); - } - - const results = await db - .select({ - id: schema.collections.id, - slug: schema.collections.slug, - name: schema.collections.name, - description: schema.collections.description, - ownerSlug: schema.accounts.slug, - ownerName: schema.accounts.displayName, - createdAt: schema.collections.createdAt, - updatedAt: schema.collections.updatedAt, - }) - .from(schema.collections) - .innerJoin(schema.accounts, eq(schema.collections.accountId, schema.accounts.id)) - .where(and(...conditions)) - .limit(take) - .offset(skip) - .orderBy(schema.collections.updatedAt); - - return results; - }); - - // Create collection - app.post( - "/accounts/:owner/collections", - { preHandler: [requireAuth("write")] }, - async (request, reply) => { - const { owner } = request.params as { owner: string }; - const { slug, name, description, public: isPublic } = request.body as { - slug: string; - name: string; - description?: string; - public?: boolean; - }; - - // Resolve owner account - const [account] = await db - .select() - .from(schema.accounts) - .where(eq(schema.accounts.slug, owner)) - .limit(1); - - if (!account) { - return reply.status(404).send({ error: "Account not found", statusCode: 404 }); - } - - // Check permission: user must own the account or be a member of the org - if (account.type === "user" && account.id !== request.accountId) { - return reply.status(403).send({ error: "Forbidden", statusCode: 403 }); - } - if (account.type === "org") { - const [membership] = await db - .select() - .from(schema.orgMemberships) - .where( - and( - eq(schema.orgMemberships.orgId, account.id), - eq(schema.orgMemberships.userId, request.accountId!), - ), - ) - .limit(1); - if (!membership) { - return reply.status(403).send({ error: "Forbidden", statusCode: 403 }); - } - } - - // Check for existing collection with same slug under this owner - const [existing] = await db - .select({ id: schema.collections.id }) - .from(schema.collections) - .where( - and( - eq(schema.collections.accountId, account.id), - eq(schema.collections.slug, slug), - ), - ) - .limit(1); - - if (existing) { - return reply.status(409).send({ error: "Collection already exists", statusCode: 409 }); - } - - const id = uuidv4(); - await db.insert(schema.collections).values({ - id, - accountId: account.id, - slug, - name, - description: description ?? null, - public: isPublic ?? false, - }); - - // Auto-mint ARK for the new collection - try { - const shoulder = await getOrMintShoulder(account.id); - const arkId = collectionToArkId(id); - await db.insert(schema.arkCollections).values({ collectionId: id, arkId, enabled: true }); - const naan = account.arkNaan ?? DEFAULT_NAAN; - const arkUrl = buildArkUrl(naan, shoulder, arkId); - return reply.status(201).send({ id, owner, slug, name, ark: arkUrl }); - } catch { - // ARK minting failure is non-fatal - return reply.status(201).send({ id, owner, slug, name }); - } - }, - ); - - // Get collection - app.get("/collections/:owner/:slug", async (request, reply) => { - const { owner, slug } = request.params as { owner: string; slug: string }; - - const [result] = await db - .select({ - id: schema.collections.id, - slug: schema.collections.slug, - name: schema.collections.name, - description: schema.collections.description, - public: schema.collections.public, - ownerSlug: schema.accounts.slug, - ownerName: schema.accounts.displayName, - ownerType: schema.accounts.type, - createdAt: schema.collections.createdAt, - updatedAt: schema.collections.updatedAt, - }) - .from(schema.collections) - .innerJoin(schema.accounts, eq(schema.collections.accountId, schema.accounts.id)) - .where(and(eq(schema.accounts.slug, owner), eq(schema.collections.slug, slug))) - .limit(1); - - if (!result) { - return reply.status(404).send({ error: "Collection not found", statusCode: 404 }); - } - - if (!result.public && request.accountId !== result.id) { - // Check if user owns or is member of the owning account - const [account] = await db - .select() - .from(schema.accounts) - .where(eq(schema.accounts.slug, owner)) - .limit(1); - - if (!account) { - return reply.status(404).send({ error: "Collection not found", statusCode: 404 }); - } - - let hasAccess = account.id === request.accountId; - if (!hasAccess && account.type === "org") { - const [membership] = await db - .select() - .from(schema.orgMemberships) - .where( - and( - eq(schema.orgMemberships.orgId, account.id), - eq(schema.orgMemberships.userId, request.accountId!), - ), - ) - .limit(1); - hasAccess = !!membership; - } - - if (!hasAccess) { - return reply.status(404).send({ error: "Collection not found", statusCode: 404 }); - } - } - - // Get latest version info - const [latestVersion] = await db - .select({ - id: schema.versions.id, - number: schema.versions.number, - semver: schema.versions.semver, - recordCount: schema.versions.recordCount, - fileCount: schema.versions.fileCount, - totalBytes: schema.versions.totalBytes, - createdAt: schema.versions.createdAt, - message: schema.versions.message, - readme: schema.versions.readme, - }) - .from(schema.versions) - .where(eq(schema.versions.collectionId, result.id)) - .orderBy(sql`${schema.versions.number} desc`) - .limit(1); - - // Get per-type record counts for latest version - let typeCounts: { type: string; count: number }[] = []; - if (latestVersion) { - const rows = await db - .select({ - type: schema.records.type, - count: sql`count(*)::int`, - }) - .from(schema.records) - .where(eq(schema.records.versionId, latestVersion.id)) - .groupBy(schema.records.type); - typeCounts = rows.map((r) => ({ type: r.type, count: r.count })); - } - - // Fetch ARK URL if enabled - let ark: string | null = null; - try { - const [arkRow] = await db - .select({ - arkId: schema.arkCollections.arkId, - enabled: schema.arkCollections.enabled, - shoulder: schema.arkShoulders.shoulder, - ownerNaan: schema.accounts.arkNaan, - }) - .from(schema.arkCollections) - .innerJoin(schema.collections, eq(schema.arkCollections.collectionId, schema.collections.id)) - .innerJoin(schema.accounts, eq(schema.collections.accountId, schema.accounts.id)) - .innerJoin(schema.arkShoulders, eq(schema.arkShoulders.accountId, schema.accounts.id)) - .where(eq(schema.arkCollections.collectionId, result.id)) - .limit(1); - if (arkRow?.enabled) { - ark = buildArkUrl(arkRow.ownerNaan ?? DEFAULT_NAAN, arkRow.shoulder, arkRow.arkId); - } - } catch { - // Non-fatal - } - - const { id: _vid, ...latestVersionData } = latestVersion ?? { id: undefined }; - return { ...result, ark, latestVersion: latestVersion ? { ...latestVersionData, typeCounts } : null }; - }); - - // Update collection - app.patch( - "/collections/:owner/:slug", - { preHandler: [requireAuth("write")] }, - async (request, reply) => { - const { owner, slug } = request.params as { owner: string; slug: string }; - const updates = request.body as { - name?: string; - description?: string; - public?: boolean; - }; - - const [account] = await db - .select() - .from(schema.accounts) - .where(eq(schema.accounts.slug, owner)) - .limit(1); - - if (!account) { - return reply.status(404).send({ error: "Not found", statusCode: 404 }); - } - - const [collection] = await db - .select() - .from(schema.collections) - .where(and(eq(schema.collections.accountId, account.id), eq(schema.collections.slug, slug))) - .limit(1); - - if (!collection) { - return reply.status(404).send({ error: "Not found", statusCode: 404 }); - } - - await db - .update(schema.collections) - .set({ ...updates, updatedAt: new Date() }) - .where(eq(schema.collections.id, collection.id)); - - return { ok: true }; - }, - ); - - // Delete collection - app.delete( - "/collections/:owner/:slug", - { preHandler: [requireAuth("admin")] }, - async (request, reply) => { - const { owner, slug } = request.params as { owner: string; slug: string }; - - const [account] = await db - .select() - .from(schema.accounts) - .where(eq(schema.accounts.slug, owner)) - .limit(1); - - if (!account) { - return reply.status(404).send({ error: "Not found", statusCode: 404 }); - } - - const [collection] = await db - .select() - .from(schema.collections) - .where(and(eq(schema.collections.accountId, account.id), eq(schema.collections.slug, slug))) - .limit(1); - - if (!collection) { - return reply.status(404).send({ error: "Not found", statusCode: 404 }); - } - - await db.delete(schema.collections).where(eq(schema.collections.id, collection.id)); - return { ok: true }; - }, - ); - - // List collections for an account - app.get("/accounts/:owner/collections", async (request) => { - const { owner } = request.params as { owner: string }; - - const [account] = await db - .select() - .from(schema.accounts) - .where(eq(schema.accounts.slug, owner)) - .limit(1); - - if (!account) return []; - - // Check if the requester owns this account or is an org member - let hasFullAccess = request.accountId === account.id; - if (!hasFullAccess && account.type === "org" && request.accountId) { - const [membership] = await db - .select() - .from(schema.orgMemberships) - .where( - and( - eq(schema.orgMemberships.orgId, account.id), - eq(schema.orgMemberships.userId, request.accountId), - ), - ) - .limit(1); - hasFullAccess = !!membership; - } - - const conditions = [eq(schema.collections.accountId, account.id)]; - if (!hasFullAccess) { - conditions.push(eq(schema.collections.public, true)); - } - - return db - .select({ - id: schema.collections.id, - slug: schema.collections.slug, - name: schema.collections.name, - description: schema.collections.description, - public: schema.collections.public, - createdAt: schema.collections.createdAt, - updatedAt: schema.collections.updatedAt, - }) - .from(schema.collections) - .where(and(...conditions)) - .orderBy(schema.collections.updatedAt); - }); - - // Export collection as .tar.gz archive - app.get("/collections/:owner/:slug/export", async (request, reply) => { - const { owner, slug } = request.params as { owner: string; slug: string }; - const { version: versionParam } = request.query as { version?: string }; - - // Resolve collection - const [collection] = await db - .select({ - id: schema.collections.id, - slug: schema.collections.slug, - name: schema.collections.name, - description: schema.collections.description, - public: schema.collections.public, - accountId: schema.collections.accountId, - }) - .from(schema.collections) - .innerJoin(schema.accounts, eq(schema.collections.accountId, schema.accounts.id)) - .where(and(eq(schema.accounts.slug, owner), eq(schema.collections.slug, slug))) - .limit(1); - - if (!collection) { - return reply.status(404).send({ error: "Collection not found", statusCode: 404 }); - } - - if (!collection.public && request.accountId !== collection.accountId) { - return reply.status(404).send({ error: "Collection not found", statusCode: 404 }); - } - - // Resolve version (latest if not specified) - const versionConditions = [eq(schema.versions.collectionId, collection.id)]; - if (versionParam) { - versionConditions.push(eq(schema.versions.number, parseInt(versionParam, 10))); - } - - const [version] = await db - .select() - .from(schema.versions) - .where(and(...versionConditions)) - .orderBy(sql`${schema.versions.number} desc`) - .limit(1); - - if (!version) { - return reply.status(404).send({ error: "No versions found", statusCode: 404 }); - } - - // Fetch records and files for this version - const records = await db - .select({ - recordId: schema.records.recordId, - type: schema.records.type, - data: schema.records.data, - }) - .from(schema.records) - .where(eq(schema.records.versionId, version.id)); - - const versionFiles = await db - .select({ - hash: schema.versionFiles.fileHash, - size: schema.files.size, - mimeType: schema.files.mimeType, - storageKey: schema.files.storageKey, - }) - .from(schema.versionFiles) - .innerJoin(schema.files, eq(schema.versionFiles.fileHash, schema.files.hash)) - .where(eq(schema.versionFiles.versionId, version.id)); - - // Build tar.gz stream - const pack = tarPack(); - const gzip = createGzip(); - - const filename = `${owner}-${slug}-v${version.number}.tar.gz`; - reply.header("Content-Type", "application/gzip"); - reply.header("Content-Disposition", `attachment; filename="${filename}"`); - - // Pipe tar → gzip → response - const outputStream = pack.pipe(gzip); - - // Load schemas for this version - const versionSchemaEntries = await db - .select({ - slug: schema.versionSchemas.slug, - schemaBody: schema.schemas.schema, - }) - .from(schema.versionSchemas) - .innerJoin(schema.schemas, eq(schema.versionSchemas.schemaId, schema.schemas.id)) - .where(eq(schema.versionSchemas.versionId, version.id)); - - const schemasMap = Object.fromEntries( - versionSchemaEntries.map((e) => [e.slug, e.schemaBody]), - ); - - // Add manifest.json - const manifest = { - collection: { owner, slug, name: collection.name, description: collection.description }, - version: { - number: version.number, - semver: version.semver, - hash: version.hash, - message: version.message, - recordCount: version.recordCount, - fileCount: version.fileCount, - totalBytes: version.totalBytes, - createdAt: version.createdAt, - }, - schemas: schemasMap, - }; - const manifestBuf = Buffer.from(JSON.stringify(manifest, null, 2)); - pack.entry({ name: "manifest.json", size: manifestBuf.length }, manifestBuf); - - // Add records as NDJSON grouped by type - const recordsByType = new Map(); - for (const rec of records) { - const existing = recordsByType.get(rec.type) ?? []; - existing.push(rec); - recordsByType.set(rec.type, existing); - } - - for (const [type, typeRecords] of recordsByType) { - const lines = typeRecords.map((r) => - JSON.stringify({ id: r.recordId, type: r.type, data: r.data }), - ); - const buf = Buffer.from(lines.join("\n") + "\n"); - pack.entry({ name: `records/${type}.ndjson`, size: buf.length }, buf); - } - - // Add files - for (const file of versionFiles) { - try { - const fileBuffer = await downloadFromS3(file.storageKey); - pack.entry({ name: `files/${file.hash}`, size: fileBuffer.length }, fileBuffer); - } catch { - // Skip files that can't be downloaded (shouldn't happen in normal operation) - } - } - - pack.finalize(); - return reply.send(outputStream); - }); -} diff --git a/src/api/routes/files.ts b/src/api/routes/files.ts deleted file mode 100644 index a595f55..0000000 --- a/src/api/routes/files.ts +++ /dev/null @@ -1,279 +0,0 @@ -import type { FastifyInstance } from "fastify"; -import { eq, and, sql } from "drizzle-orm"; -import { db, schema } from "../../db/index.js"; -import { requireAuth } from "../plugins/auth.js"; -import { uploadToS3, headS3Object, getS3ObjectMeta } from "../../lib/s3.js"; -import { createHash } from "node:crypto"; - -/** - * Check if a file hash is referenced by any public (non-private) record - * in a non-private field of the latest version of this collection. - */ -async function isFilePubliclyAccessible( - owner: string, - slug: string, - fileHash: string, - request: any, -): Promise { - // Resolve collection - const [collection] = await db - .select({ - id: schema.collections.id, - accountId: schema.collections.accountId, - }) - .from(schema.collections) - .innerJoin(schema.accounts, eq(schema.collections.accountId, schema.accounts.id)) - .where(and(eq(schema.accounts.slug, owner), eq(schema.collections.slug, slug))) - .limit(1); - - if (!collection) return false; - - // Owner always has access - if (request.accountId != null && request.accountId === collection.accountId) { - return true; - } - - // Get the latest version - const [latest] = await db - .select({ id: schema.versions.id }) - .from(schema.versions) - .where(eq(schema.versions.collectionId, collection.id)) - .orderBy(sql`${schema.versions.number} desc`) - .limit(1); - - if (!latest) return false; - - // Check if file is associated with this version at all - const [vf] = await db - .select({ fileHash: schema.versionFiles.fileHash }) - .from(schema.versionFiles) - .where( - and(eq(schema.versionFiles.versionId, latest.id), eq(schema.versionFiles.fileHash, fileHash)), - ) - .limit(1); - - if (!vf) return false; - - // Load version schemas to determine private types and fields - const schemaEntries = await db - .select({ - slug: schema.versionSchemas.slug, - schemaBody: schema.schemas.schema, - }) - .from(schema.versionSchemas) - .innerJoin(schema.schemas, eq(schema.versionSchemas.schemaId, schema.schemas.id)) - .where(eq(schema.versionSchemas.versionId, latest.id)); - - const privateTypes = new Set(); - const typeSchemaMap = new Map>(); - for (const entry of schemaEntries) { - const body = entry.schemaBody as Record; - typeSchemaMap.set(entry.slug, body); - if (body?.private === true) privateTypes.add(entry.slug); - } - - // Find public records that reference this file hash - // A record references a file if its data JSON contains the hash string - const records = await db - .select({ type: schema.records.type, data: schema.records.data }) - .from(schema.records) - .where( - and( - eq(schema.records.versionId, latest.id), - eq(schema.records.private, false), - sql`${schema.records.data}::text LIKE ${"%" + fileHash + "%"}`, - ), - ) - .limit(10); - - // Check if any matching record is a public type with the file in a public field - for (const rec of records) { - if (privateTypes.has(rec.type)) continue; - - // Get private fields for this type - const typeSchema = typeSchemaMap.get(rec.type); - const typeProps = typeSchema?.properties as Record | undefined; - if (!typeProps) return true; // no schema constraints, allow - - const privateFields = new Set(); - for (const [fieldName, fieldDef] of Object.entries(typeProps)) { - if ((fieldDef as any)?.private === true) privateFields.add(fieldName); - } - - // Check if the file reference is in a public field - const data = rec.data as Record; - for (const [key, val] of Object.entries(data)) { - if (privateFields.has(key)) continue; - if ( - val && - typeof val === "object" && - "$file" in val && - (val as { $file: string }).$file === `sha256:${fileHash}` - ) { - return true; // found in a public field of a public record - } - } - } - - return false; -} - -export async function fileRoutes(app: FastifyInstance) { - // Check if file exists - app.head("/collections/:owner/:slug/files/:hash", async (request, reply) => { - const { owner, slug, hash } = request.params as { owner: string; slug: string; hash: string }; - const cleanHash = hash.replace("sha256:", ""); - - const [file] = await db - .select() - .from(schema.files) - .where(eq(schema.files.hash, cleanHash)) - .limit(1); - - if (!file) { - return reply.status(404).send(); - } - - // Check visibility - const accessible = await isFilePubliclyAccessible(owner, slug, cleanHash, request); - if (!accessible) { - return reply.status(404).send(); - } - - reply.header("Content-Length", file.size); - reply.header("Content-Type", file.mimeType); - return reply.status(200).send(); - }); - - // Download file - app.get("/collections/:owner/:slug/files/:hash", async (request, reply) => { - const { owner, slug, hash } = request.params as { owner: string; slug: string; hash: string }; - const cleanHash = hash.replace("sha256:", ""); - - const [file] = await db - .select() - .from(schema.files) - .where(eq(schema.files.hash, cleanHash)) - .limit(1); - - if (!file) { - return reply.status(404).send({ error: "File not found", statusCode: 404 }); - } - - // Check visibility - const accessible = await isFilePubliclyAccessible(owner, slug, cleanHash, request); - if (!accessible) { - return reply.status(404).send({ error: "File not found", statusCode: 404 }); - } - - // Redirect to CDN - const cdnUrl = `https://assets.underlay.org/files/${cleanHash.slice(0, 2)}/${cleanHash.slice(2, 4)}/${cleanHash}`; - return reply.redirect(cdnUrl); - }); - - // Upload file - app.put( - "/collections/:owner/:slug/files/:hash", - { preHandler: [requireAuth("write")] }, - async (request, reply) => { - const { owner, slug, hash } = request.params as { - owner: string; - slug: string; - hash: string; - }; - const cleanHash = hash.replace("sha256:", ""); - - // Check if file already exists in DB - const [existing] = await db - .select() - .from(schema.files) - .where(eq(schema.files.hash, cleanHash)) - .limit(1); - - if (existing) { - return reply.status(200).send({ hash: cleanHash, status: "exists" }); - } - - // Check if file exists in S3 but not in local DB (shared bucket scenario) - const s3Key = `files/${cleanHash.slice(0, 2)}/${cleanHash.slice(2, 4)}/${cleanHash}`; - const s3Meta = await getS3ObjectMeta(s3Key); - if (s3Meta !== null) { - await db.insert(schema.files).values({ - hash: cleanHash, - size: s3Meta.size, - mimeType: s3Meta.contentType, - storageKey: s3Key, - }).onConflictDoNothing(); - return reply.status(200).send({ hash: cleanHash, status: "exists" }); - } - - // Read the request body as a buffer - const data = await request.file().catch(() => null); - if (!data) { - // Raw binary body — may already be parsed by content type parser - let buffer: Buffer; - if (Buffer.isBuffer(request.body)) { - buffer = request.body; - } else { - const chunks: Buffer[] = []; - for await (const chunk of request.raw) { - chunks.push(Buffer.from(chunk)); - } - buffer = Buffer.concat(chunks); - } - - // Verify hash - const computedHash = createHash("sha256").update(buffer).digest("hex"); - if (computedHash !== cleanHash) { - return reply.status(400).send({ - error: "Hash mismatch", - expected: cleanHash, - computed: computedHash, - statusCode: 400, - }); - } - - const contentType = - (request.headers["content-type"] as string) ?? "application/octet-stream"; - const storageKey = `files/${cleanHash.slice(0, 2)}/${cleanHash.slice(2, 4)}/${cleanHash}`; - - await uploadToS3(storageKey, buffer, contentType); - - await db.insert(schema.files).values({ - hash: cleanHash, - size: buffer.length, - mimeType: contentType, - storageKey, - }); - - return reply.status(201).send({ hash: cleanHash, size: buffer.length }); - } - - // Multipart upload - const buffer = await data.toBuffer(); - const computedHash = createHash("sha256").update(buffer).digest("hex"); - if (computedHash !== cleanHash) { - return reply.status(400).send({ - error: "Hash mismatch", - expected: cleanHash, - computed: computedHash, - statusCode: 400, - }); - } - - const mimeType = data.mimetype ?? "application/octet-stream"; - const storageKey = `files/${cleanHash.slice(0, 2)}/${cleanHash.slice(2, 4)}/${cleanHash}`; - - await uploadToS3(storageKey, buffer, mimeType); - - await db.insert(schema.files).values({ - hash: cleanHash, - size: buffer.length, - mimeType, - storageKey, - }); - - return reply.status(201).send({ hash: cleanHash, size: buffer.length }); - }, - ); -} diff --git a/src/api/routes/health.ts b/src/api/routes/health.ts deleted file mode 100644 index e010736..0000000 --- a/src/api/routes/health.ts +++ /dev/null @@ -1,7 +0,0 @@ -import type { FastifyInstance } from "fastify"; - -export async function healthRoutes(app: FastifyInstance) { - app.get("/health", async () => { - return { status: "ok", timestamp: new Date().toISOString() }; - }); -} diff --git a/src/api/routes/query.ts b/src/api/routes/query.ts deleted file mode 100644 index 484ba83..0000000 --- a/src/api/routes/query.ts +++ /dev/null @@ -1,394 +0,0 @@ -import type { FastifyInstance } from "fastify"; -import { eq, and, desc, ilike, or, inArray } from "drizzle-orm"; -import { db, schema } from "../../db/index.js"; -import { buildSqliteBuffer, generateAllDDL, generateDDL } from "../../lib/sqlite-gen.js"; - -// In-memory LRU cache: key = `${collectionId}:${versionNumber}`, value = { buffer, expiresAt } -const sqliteCache = new Map>; expiresAt: number }>(); -const CACHE_TTL_MS = 30 * 60 * 1000; // 30 minutes -const CACHE_MAX_ENTRIES = 10; - -function cleanExpired() { - const now = Date.now(); - for (const [key, entry] of sqliteCache) { - if (entry.expiresAt < now) sqliteCache.delete(key); - } -} - -function evictIfNeeded() { - while (sqliteCache.size >= CACHE_MAX_ENTRIES) { - // Evict oldest entry (first key in Map insertion order) - const firstKey = sqliteCache.keys().next().value; - if (firstKey) sqliteCache.delete(firstKey); - else break; - } -} - -// Run cleanup every 5 minutes -setInterval(cleanExpired, 5 * 60 * 1000); - -async function getOrBuildSqlite(owner: string, slug: string, versionNumber: number) { - // Resolve collection - const [collection] = await db - .select({ id: schema.collections.id, accountId: schema.collections.accountId, public: schema.collections.public }) - .from(schema.collections) - .innerJoin(schema.accounts, eq(schema.accounts.id, schema.collections.accountId)) - .where(and(eq(schema.accounts.slug, owner), eq(schema.collections.slug, slug))) - .limit(1); - - if (!collection) return null; - - // Resolve version - const [version] = await db - .select({ id: schema.versions.id, number: schema.versions.number }) - .from(schema.versions) - .where(and(eq(schema.versions.collectionId, collection.id), eq(schema.versions.number, versionNumber))) - .limit(1); - - if (!version) return null; - - const cacheKey = `${collection.id}:${version.number}`; - - // Check cache (re-insert to move to end for LRU ordering) - const cached = sqliteCache.get(cacheKey); - if (cached && cached.expiresAt > Date.now()) { - sqliteCache.delete(cacheKey); - cached.expiresAt = Date.now() + CACHE_TTL_MS; - sqliteCache.set(cacheKey, cached); - return cached; - } - - // Load schemas for this version - const versionSchemas = await db - .select({ slug: schema.versionSchemas.slug, schema: schema.schemas.schema }) - .from(schema.versionSchemas) - .innerJoin(schema.schemas, eq(schema.versionSchemas.schemaId, schema.schemas.id)) - .where(eq(schema.versionSchemas.versionId, version.id)); - - const schemasMap: Record = {}; - for (const vs of versionSchemas) { - schemasMap[vs.slug] = vs.schema; - } - - // Load records - const records = await db - .select({ recordId: schema.records.recordId, type: schema.records.type, data: schema.records.data }) - .from(schema.records) - .where(eq(schema.records.versionId, version.id)); - - // Build SQLite - const buffer = buildSqliteBuffer(schemasMap, records as any); - const ddl = generateAllDDL(schemasMap); - - // Generate sample data (first row per table) for LLM context - const sampleRows: Record> = {}; - for (const [typeName] of Object.entries(schemasMap)) { - const firstRecord = records.find((r) => r.type === typeName); - if (firstRecord && firstRecord.data && typeof firstRecord.data === "object") { - sampleRows[typeName] = firstRecord.data as Record; - } - } - - // Build DDL with inline sample rows (each sample right after its CREATE TABLE) - const ddlWithSamples = Object.entries(schemasMap) - .map(([name, s]) => { - const tableDdl = generateDDL(name, s); - const sample = sampleRows[name]; - if (sample) { - return tableDdl + `\n-- Example row: ${JSON.stringify(sample)}`; - } - return tableDdl; - }) - .join("\n\n"); - - const entry = { buffer, ddl, ddlWithSamples, sampleRows, expiresAt: Date.now() + CACHE_TTL_MS }; - evictIfNeeded(); - sqliteCache.set(cacheKey, entry); - return entry; -} - -export async function queryRoutes(app: FastifyInstance) { - // GET /query/sqlite/:owner/:slug/:version — Download SQLite file for a version - app.get<{ Params: { owner: string; slug: string; version: string } }>( - "/query/sqlite/:owner/:slug/:version", - async (request, reply) => { - const { owner, slug, version } = request.params; - const versionNum = parseInt(version, 10); - if (isNaN(versionNum)) return reply.status(400).send({ error: "Invalid version number" }); - - const result = await getOrBuildSqlite(owner, slug, versionNum); - if (!result) return reply.status(404).send({ error: "Collection or version not found" }); - - reply.header("Content-Type", "application/x-sqlite3"); - reply.header("Content-Disposition", `attachment; filename="${slug}-v${versionNum}.sqlite"`); - reply.header("Cache-Control", "public, max-age=86400"); - return reply.send(result.buffer); - }, - ); - - // GET /query/ddl/:owner/:slug/:version — Get DDL (schema only) for a version - app.get<{ Params: { owner: string; slug: string; version: string } }>( - "/query/ddl/:owner/:slug/:version", - async (request, reply) => { - const { owner, slug, version } = request.params; - const versionNum = parseInt(version, 10); - if (isNaN(versionNum)) return reply.status(400).send({ error: "Invalid version number" }); - - const result = await getOrBuildSqlite(owner, slug, versionNum); - if (!result) return reply.status(404).send({ error: "Collection or version not found" }); - - return { ddl: result.ddl }; - }, - ); - - // POST /query/generate-sql — LLM-powered SQL generation from natural language - app.post<{ Body: { collections: { owner: string; slug: string; version: number }[]; question: string } }>( - "/query/generate-sql", - async (request, reply) => { - const { collections: collectionRefs, question } = request.body as any; - - if (!collectionRefs?.length || !question) { - return reply.status(400).send({ error: "collections and question are required" }); - } - - const cfAccountId = process.env.CF_ACCOUNT_ID; - const cfApiToken = process.env.CF_API_TOKEN; - - if (!cfAccountId || !cfApiToken) { - return reply.status(503).send({ - error: "LLM not configured", - message: "Set CF_ACCOUNT_ID and CF_API_TOKEN environment variables to enable natural language queries. You can still write SQL directly.", - }); - } - - // Build DDL with sample rows server-side - let combinedDdl: string; - let totalRecords = 0; - - if (collectionRefs.length === 1) { - const ref = collectionRefs[0]; - const result = await getOrBuildSqlite(ref.owner, ref.slug, ref.version); - if (!result) return reply.status(404).send({ error: `Collection ${ref.owner}/${ref.slug} v${ref.version} not found` }); - combinedDdl = result.ddlWithSamples; - // Count records from cache (approximation from the version table already captured) - } else { - const parts: string[] = []; - for (const ref of collectionRefs) { - const result = await getOrBuildSqlite(ref.owner, ref.slug, ref.version); - if (!result) return reply.status(404).send({ error: `Collection ${ref.owner}/${ref.slug} v${ref.version} not found` }); - const prefix = ref.slug.replace(/-/g, "_"); - // Prefix table names and add _source column to DDL - const ddlPrefixed = result.ddlWithSamples - .replace(/CREATE TABLE "([^"]+)"/g, `CREATE TABLE "${prefix}__$1"`) - .replace(/\);/g, `,\n "_source" TEXT\n);`); - parts.push(`-- Collection: ${ref.owner}/${ref.slug} v${ref.version}\n` + ddlPrefixed); - } - combinedDdl = parts.join("\n\n"); - } - - const isMultiCollection = collectionRefs.length > 1; - - const systemPrompt = `You are a SQL assistant for SQLite databases. Given a schema and a user's question, produce a single SELECT query that answers it. - -Respond in EXACTLY this format (two sections separated by the marker): - -SQL: - - -REASONING: - - -Important rules: -- Examine the "Example row" comments in the schema — they show the ACTUAL data format stored in each column.${isMultiCollection ? ` -- When multiple collections are loaded, consider ALL of them in your answer unless the question specifies otherwise. -- Every table has a "_source" column containing the collection identifier (e.g. "account/collection"). For row-level results, include _source as a column. For aggregations, include GROUP_CONCAT(DISTINCT _source) as _source so the user can see which collections contributed to the result. -- When counting across multiple tables, use UNION ALL to combine rows, not JOIN.` : ""} -- Only use JOIN when the question asks about relationships between tables. -- COUNT(*) counts rows.${isMultiCollection ? " Use UNION ALL to combine rows from separate tables before counting." : ""} -- When tables have a prefix like "collection__TableName", always use that full prefixed name. -- Do NOT include columns that don't exist in the schema.`; - - const userPrompt = `Schema:\n${combinedDdl}\n\nQuestion: ${question}`; - - // Log the full prompt for debugging - app.log.info(`[generate-sql] User prompt:\n${userPrompt}`); - - try { - const response = await fetch( - `https://api.cloudflare.com/client/v4/accounts/${cfAccountId}/ai/run/@cf/meta/llama-3.3-70b-instruct-fp8-fast`, - { - method: "POST", - headers: { - Authorization: `Bearer ${cfApiToken}`, - "Content-Type": "application/json", - }, - body: JSON.stringify({ - messages: [ - { role: "system", content: systemPrompt }, - { role: "user", content: userPrompt }, - ], - max_tokens: 800, - temperature: 0, - }), - }, - ); - - if (!response.ok) { - const text = await response.text(); - app.log.error(`Cloudflare AI error: ${response.status} ${text}`); - return reply.status(502).send({ error: "LLM request failed", rawResponse: text }); - } - - const data = (await response.json()) as any; - let raw = data?.result?.response?.trim(); - - if (!raw) { - return reply.status(500).send({ error: "LLM returned empty response", rawResponse: JSON.stringify(data) }); - } - - // Parse structured response - let sql: string; - let reasoning: string | undefined; - - const sqlMarker = raw.indexOf("SQL:"); - const reasoningMarker = raw.indexOf("REASONING:"); - - if (sqlMarker !== -1 && reasoningMarker !== -1) { - sql = raw.substring(sqlMarker + 4, reasoningMarker).replace(/```sql\n?/g, "").replace(/```/g, "").trim(); - reasoning = raw.substring(reasoningMarker + 10).trim(); - } else { - // Fallback: treat entire response as SQL - sql = raw.replace(/```sql\n?/g, "").replace(/```/g, "").trim(); - } - - // Basic safety: only allow SELECT statements - const normalized = sql.replace(/--.*$/gm, "").trim().toUpperCase(); - if (!normalized.startsWith("SELECT") && !normalized.startsWith("WITH")) { - return reply.status(400).send({ - error: "Generated query is not a SELECT statement", - sql, - reasoning, - rawResponse: raw, - }); - } - - return { sql, reasoning }; - } catch (err: any) { - app.log.error(`LLM generation error: ${err.message}`); - return reply.status(500).send({ error: "Failed to generate SQL" }); - } - }, - ); - - // GET /query/collections/search?q=term — Search collections (public + user's private) - app.get<{ Querystring: { q?: string } }>("/query/collections/search", async (request) => { - const { q } = request.query as { q?: string }; - if (!q || q.trim().length < 2) return []; - - const term = `%${q.trim()}%`; - const userId = request.accountId; - - // Build accessible account IDs (user's own + orgs they belong to) - let accessibleAccountIds: string[] = []; - if (userId) { - const memberships = await db - .select({ orgId: schema.orgMemberships.orgId }) - .from(schema.orgMemberships) - .where(eq(schema.orgMemberships.userId, userId)); - accessibleAccountIds = [userId, ...memberships.map((m) => m.orgId)]; - } - - // Query: public collections OR private collections owned by accessible accounts - const searchCondition = or( - ilike(schema.accounts.slug, term), - ilike(schema.collections.slug, term), - ilike(schema.collections.name, term), - ); - - let whereCondition; - if (accessibleAccountIds.length > 0) { - whereCondition = and( - searchCondition, - or( - eq(schema.collections.public, true), - inArray(schema.collections.accountId, accessibleAccountIds), - ), - ); - } else { - whereCondition = and(searchCondition, eq(schema.collections.public, true)); - } - - const collections = await db - .select({ - ownerSlug: schema.accounts.slug, - slug: schema.collections.slug, - name: schema.collections.name, - description: schema.collections.description, - public: schema.collections.public, - }) - .from(schema.collections) - .innerJoin(schema.accounts, eq(schema.accounts.id, schema.collections.accountId)) - .where(whereCondition) - .limit(20); - - // Get latest version + record count for each match - const result = []; - for (const c of collections) { - const [latestVersion] = await db - .select({ number: schema.versions.number, semver: schema.versions.semver, recordCount: schema.versions.recordCount }) - .from(schema.versions) - .innerJoin(schema.collections, eq(schema.collections.id, schema.versions.collectionId)) - .innerJoin(schema.accounts, eq(schema.accounts.id, schema.collections.accountId)) - .where(and(eq(schema.accounts.slug, c.ownerSlug), eq(schema.collections.slug, c.slug))) - .orderBy(desc(schema.versions.number)) - .limit(1); - - result.push({ - ownerSlug: c.ownerSlug, - slug: c.slug, - name: c.name, - description: c.description, - public: c.public, - latestVersion: latestVersion?.number ?? null, - latestSemver: latestVersion?.semver ?? null, - recordCount: latestVersion?.recordCount ?? 0, - }); - } - - return result; - }); - - // GET /query/collections/:owner/:slug/versions — List versions for a collection - app.get<{ Params: { owner: string; slug: string } }>( - "/query/collections/:owner/:slug/versions", - async (request, reply) => { - const { owner, slug } = request.params; - - const versions = await db - .select({ - number: schema.versions.number, - semver: schema.versions.semver, - recordCount: schema.versions.recordCount, - createdAt: schema.versions.createdAt, - message: schema.versions.message, - }) - .from(schema.versions) - .innerJoin(schema.collections, eq(schema.collections.id, schema.versions.collectionId)) - .innerJoin(schema.accounts, eq(schema.accounts.id, schema.collections.accountId)) - .where( - and( - eq(schema.accounts.slug, owner), - eq(schema.collections.slug, slug), - eq(schema.collections.public, true), - ), - ) - .orderBy(desc(schema.versions.number)); - - if (versions.length === 0) { - return reply.status(404).send({ error: "Collection not found or not public" }); - } - - return versions; - }, - ); -} diff --git a/src/api/routes/schemas.ts b/src/api/routes/schemas.ts deleted file mode 100644 index 10272a6..0000000 --- a/src/api/routes/schemas.ts +++ /dev/null @@ -1,387 +0,0 @@ -import type { FastifyInstance } from "fastify"; -import { eq, and, sql, inArray, ilike } from "drizzle-orm"; -import { db, schema } from "../../db/index.js"; -import { requireAuth } from "../plugins/auth.js"; - -export async function schemaRoutes(app: FastifyInstance) { - // --- Global schema search --- - // GET /schemas?q=...&slug=...&label=...&schema_hash=...&limit=...&offset=... - app.get("/schemas", async (request, reply) => { - const { q, slug: slugFilter, label, schema_hash, limit, offset } = request.query as { - q?: string; - slug?: string; - label?: string; - schema_hash?: string; - limit?: string; - offset?: string; - }; - - const pageLimit = Math.min(parseInt(limit ?? "50", 10), 100); - const pageOffset = parseInt(offset ?? "0", 10); - - // Search by exact hash - if (schema_hash) { - const [row] = await db - .select() - .from(schema.schemas) - .where(eq(schema.schemas.schemaHash, schema_hash)) - .limit(1); - - if (!row) return reply.status(404).send({ error: "Schema not found", statusCode: 404 }); - - const labels = await db - .select({ label: schema.schemaLabels.label }) - .from(schema.schemaLabels) - .where(eq(schema.schemaLabels.schemaId, row.id)); - - const usageCount = await getUsageCount(row.id); - - return { - ...row, - labels: labels.map((l) => l.label), - usageCount, - }; - } - - // Search by slug (find schemas used as a particular type name) - if (slugFilter) { - const vsRows = await db - .select({ schemaId: schema.versionSchemas.schemaId }) - .from(schema.versionSchemas) - .where(eq(schema.versionSchemas.slug, slugFilter)) - .groupBy(schema.versionSchemas.schemaId) - .limit(pageLimit) - .offset(pageOffset); - - if (vsRows.length === 0) return []; - - const schemaIds = vsRows.map((r) => r.schemaId); - const schemaRows = await db - .select() - .from(schema.schemas) - .where(inArray(schema.schemas.id, schemaIds)); - - const allLabels = await db - .select({ schemaId: schema.schemaLabels.schemaId, label: schema.schemaLabels.label }) - .from(schema.schemaLabels) - .where(inArray(schema.schemaLabels.schemaId, schemaIds)); - - const labelsMap = new Map(); - for (const l of allLabels) { - if (!labelsMap.has(l.schemaId)) labelsMap.set(l.schemaId, []); - labelsMap.get(l.schemaId)!.push(l.label); - } - - return schemaRows.map((s) => ({ - ...s, - labels: labelsMap.get(s.id) ?? [], - })); - } - - // Search by label - if (label) { - const labelRows = await db - .select({ - schemaId: schema.schemaLabels.schemaId, - label: schema.schemaLabels.label, - }) - .from(schema.schemaLabels) - .where(ilike(schema.schemaLabels.label, `%${label}%`)) - .limit(pageLimit) - .offset(pageOffset); - - if (labelRows.length === 0) return []; - - const schemaIds = [...new Set(labelRows.map((r) => r.schemaId))]; - const schemaRows = await db - .select() - .from(schema.schemas) - .where(inArray(schema.schemas.id, schemaIds)); - - // Gather all labels for these schemas - const allLabels = await db - .select({ schemaId: schema.schemaLabels.schemaId, label: schema.schemaLabels.label }) - .from(schema.schemaLabels) - .where(inArray(schema.schemaLabels.schemaId, schemaIds)); - - const labelsMap = new Map(); - for (const l of allLabels) { - if (!labelsMap.has(l.schemaId)) labelsMap.set(l.schemaId, []); - labelsMap.get(l.schemaId)!.push(l.label); - } - - return schemaRows.map((s) => ({ - ...s, - labels: labelsMap.get(s.id) ?? [], - })); - } - - // Full-text search across schema JSON (search for field names, types, etc.) - if (q) { - const rows = await db - .select() - .from(schema.schemas) - .where(sql`${schema.schemas.schema}::text ILIKE ${"%" + q + "%"}`) - .limit(pageLimit) - .offset(pageOffset); - - const schemaIds = rows.map((r) => r.id); - const allLabels = schemaIds.length > 0 - ? await db - .select({ schemaId: schema.schemaLabels.schemaId, label: schema.schemaLabels.label }) - .from(schema.schemaLabels) - .where(inArray(schema.schemaLabels.schemaId, schemaIds)) - : []; - - const labelsMap = new Map(); - for (const l of allLabels) { - if (!labelsMap.has(l.schemaId)) labelsMap.set(l.schemaId, []); - labelsMap.get(l.schemaId)!.push(l.label); - } - - return rows.map((s) => ({ - ...s, - labels: labelsMap.get(s.id) ?? [], - })); - } - - // No filter: list all schemas - const rows = await db - .select() - .from(schema.schemas) - .orderBy(sql`${schema.schemas.createdAt} desc`) - .limit(pageLimit) - .offset(pageOffset); - - const schemaIds = rows.map((r) => r.id); - const allLabels = schemaIds.length > 0 - ? await db - .select({ schemaId: schema.schemaLabels.schemaId, label: schema.schemaLabels.label }) - .from(schema.schemaLabels) - .where(inArray(schema.schemaLabels.schemaId, schemaIds)) - : []; - - const labelsMap = new Map(); - for (const l of allLabels) { - if (!labelsMap.has(l.schemaId)) labelsMap.set(l.schemaId, []); - labelsMap.get(l.schemaId)!.push(l.label); - } - - return rows.map((s) => ({ - ...s, - labels: labelsMap.get(s.id) ?? [], - })); - }); - - // --- Single schema by ID --- - // GET /schemas/:id - app.get("/schemas/:id", async (request, reply) => { - const { id } = request.params as { id: string }; - - const [row] = await db - .select() - .from(schema.schemas) - .where(eq(schema.schemas.id, id)) - .limit(1); - - if (!row) return reply.status(404).send({ error: "Schema not found", statusCode: 404 }); - - const labels = await db - .select({ label: schema.schemaLabels.label, createdAt: schema.schemaLabels.createdAt }) - .from(schema.schemaLabels) - .where(eq(schema.schemaLabels.schemaId, id)); - - // Usage: which collections/versions reference this schema - const usage = await db - .select({ - slug: schema.versionSchemas.slug, - semver: schema.versions.semver, - versionNumber: schema.versions.number, - collectionSlug: schema.collections.slug, - owner: schema.accounts.slug, - isPublic: schema.collections.public, - }) - .from(schema.versionSchemas) - .innerJoin(schema.versions, eq(schema.versionSchemas.versionId, schema.versions.id)) - .innerJoin(schema.collections, eq(schema.versions.collectionId, schema.collections.id)) - .innerJoin(schema.accounts, eq(schema.collections.accountId, schema.accounts.id)) - .where(and(eq(schema.versionSchemas.schemaId, id), eq(schema.collections.public, true))) - .orderBy(sql`${schema.versions.createdAt} desc`) - .limit(50); - - return { - ...row, - labels: labels.map((l) => ({ label: l.label, createdAt: l.createdAt })), - usage: usage.map((u) => ({ - slug: u.slug, - semver: u.semver, - versionNumber: u.versionNumber, - collection: `${u.owner}/${u.collectionSlug}`, - })), - }; - }); - - // --- Collection schemas (for a specific version or latest) --- - // GET /collections/:owner/:slug/schemas?version=N - app.get("/collections/:owner/:slug/schemas", async (request, reply) => { - const { owner, slug } = request.params as { owner: string; slug: string }; - const { version: versionParam, raw } = request.query as { version?: string; raw?: string }; - - // Resolve collection - const [collection] = await db - .select({ - id: schema.collections.id, - accountId: schema.collections.accountId, - public: schema.collections.public, - }) - .from(schema.collections) - .innerJoin(schema.accounts, eq(schema.collections.accountId, schema.accounts.id)) - .where(and(eq(schema.accounts.slug, owner), eq(schema.collections.slug, slug))) - .limit(1); - - if (!collection) return reply.status(404).send({ error: "Collection not found", statusCode: 404 }); - - // Visibility check - if (!collection.public && request.accountId !== collection.accountId) { - return reply.status(404).send({ error: "Collection not found", statusCode: 404 }); - } - - // Resolve version - const versionConditions = [eq(schema.versions.collectionId, collection.id)]; - if (versionParam) { - versionConditions.push(eq(schema.versions.number, parseInt(versionParam, 10))); - } - - const [version] = await db - .select({ id: schema.versions.id, number: schema.versions.number, semver: schema.versions.semver }) - .from(schema.versions) - .where(and(...versionConditions)) - .orderBy(sql`${schema.versions.number} desc`) - .limit(1); - - if (!version) return reply.status(404).send({ error: "No versions found", statusCode: 404 }); - - // Load schemas for this version - const entries = await db - .select({ - slug: schema.versionSchemas.slug, - schemaId: schema.versionSchemas.schemaId, - schemaBody: schema.schemas.schema, - schemaHash: schema.schemas.schemaHash, - }) - .from(schema.versionSchemas) - .innerJoin(schema.schemas, eq(schema.versionSchemas.schemaId, schema.schemas.id)) - .where(eq(schema.versionSchemas.versionId, version.id)); - - // Load labels for all referenced schemas (unless raw mode) - let labelsMap = new Map(); - if (raw !== "true" && entries.length > 0) { - const schemaIds = entries.map((e) => e.schemaId); - const allLabels = await db - .select({ schemaId: schema.schemaLabels.schemaId, label: schema.schemaLabels.label }) - .from(schema.schemaLabels) - .where(inArray(schema.schemaLabels.schemaId, schemaIds)); - - for (const l of allLabels) { - if (!labelsMap.has(l.schemaId)) labelsMap.set(l.schemaId, []); - labelsMap.get(l.schemaId)!.push(l.label); - } - } - - return { - version: version.number, - semver: version.semver, - schemas: entries.map((e) => { - const labels = labelsMap.get(e.schemaId) ?? []; - const body = raw === "true" - ? e.schemaBody - : labels.length > 0 - ? { ...(e.schemaBody as object), "x-underlay-labels": labels } - : e.schemaBody; - - return { - slug: e.slug, - schemaId: e.schemaId, - schemaHash: e.schemaHash, - schema: body, - }; - }), - }; - }); - - // --- Label management --- - - // Add a label to a schema - // POST /schemas/:id/labels { label: "schema.org/Person" } - app.post( - "/schemas/:id/labels", - { preHandler: [requireAuth("write")] }, - async (request, reply) => { - const { id } = request.params as { id: string }; - const { label } = request.body as { label: string }; - - if (!label || typeof label !== "string" || label.trim().length === 0) { - return reply.status(400).send({ error: "Label is required", statusCode: 400 }); - } - - // Verify schema exists - const [existing] = await db - .select({ id: schema.schemas.id }) - .from(schema.schemas) - .where(eq(schema.schemas.id, id)) - .limit(1); - - if (!existing) { - return reply.status(404).send({ error: "Schema not found", statusCode: 404 }); - } - - // Upsert label (ignore conflict on duplicate) - try { - const [inserted] = await db - .insert(schema.schemaLabels) - .values({ schemaId: id, label: label.trim() }) - .onConflictDoNothing() - .returning(); - - if (!inserted) { - return { status: "exists", schemaId: id, label: label.trim() }; - } - - return reply.status(201).send({ status: "created", schemaId: id, label: label.trim() }); - } catch (err: any) { - return reply.status(500).send({ error: "Failed to add label", statusCode: 500 }); - } - }, - ); - - // Remove a label from a schema - // DELETE /schemas/:id/labels/:label - app.delete( - "/schemas/:id/labels/:label", - { preHandler: [requireAuth("admin")] }, - async (request, reply) => { - const { id, label } = request.params as { id: string; label: string }; - - const result = await db - .delete(schema.schemaLabels) - .where(and(eq(schema.schemaLabels.schemaId, id), eq(schema.schemaLabels.label, label))) - .returning(); - - if (result.length === 0) { - return reply.status(404).send({ error: "Label not found", statusCode: 404 }); - } - - return { status: "deleted", schemaId: id, label }; - }, - ); -} - -// --- Helpers --- - -async function getUsageCount(schemaId: string): Promise { - const [result] = await db - .select({ count: sql`count(distinct ${schema.versionSchemas.versionId})::int` }) - .from(schema.versionSchemas) - .where(eq(schema.versionSchemas.schemaId, schemaId)); - return result?.count ?? 0; -} diff --git a/src/api/routes/uploads.ts b/src/api/routes/uploads.ts deleted file mode 100644 index abe5e22..0000000 --- a/src/api/routes/uploads.ts +++ /dev/null @@ -1,911 +0,0 @@ -import type { FastifyInstance } from "fastify"; -import { eq, and, sql, inArray } from "drizzle-orm"; -import { db, schema } from "../../db/index.js"; -import { requireAuth } from "../plugins/auth.js"; -import { createHash } from "node:crypto"; -import { getS3ObjectMeta } from "../../lib/s3.js"; -import Ajv from "ajv"; -import addFormats from "ajv-formats"; - -const ajv = new Ajv({ allErrors: true, strict: false }); -addFormats(ajv); - -/** Session expiry: 1 hour from creation */ -const SESSION_TTL_MS = 60 * 60 * 1000; - -/** Max records per batch request */ -const MAX_BATCH_SIZE = 10_000; - -// --- Helpers (shared with versions.ts logic) --- - -type SchemaEntry = { slug: string; schemaId: string; schema: Record; schemaHash: string }; - -async function loadVersionSchemas(versionId: number): Promise { - const rows = await db - .select({ - slug: schema.versionSchemas.slug, - schemaId: schema.versionSchemas.schemaId, - schema: schema.schemas.schema, - schemaHash: schema.schemas.schemaHash, - }) - .from(schema.versionSchemas) - .innerJoin(schema.schemas, eq(schema.versionSchemas.schemaId, schema.schemas.id)) - .where(eq(schema.versionSchemas.versionId, versionId)); - return rows as SchemaEntry[]; -} - -function getPrivateTypes(schemaEntries: SchemaEntry[]): Set { - const types = new Set(); - for (const entry of schemaEntries) { - if ((entry.schema as any)?.private === true) types.add(entry.slug); - } - return types; -} - -function getPrivateFields(typeSchema: Record): Set { - const fields = new Set(); - const props = typeSchema?.properties as Record | undefined; - if (!props) return fields; - for (const [fieldName, fieldDef] of Object.entries(props)) { - if (fieldDef?.private === true) fields.add(fieldName); - } - return fields; -} - -function filterRecordData(data: unknown, privateFields: Set): unknown { - if (privateFields.size === 0 || typeof data !== "object" || data === null) return data; - const filtered: Record = {}; - for (const [key, value] of Object.entries(data as Record)) { - if (!privateFields.has(key)) filtered[key] = value; - } - return filtered; -} - -function filterTypeSchema(typeSchema: Record): Record { - const props = typeSchema?.properties as Record | undefined; - if (!props) return typeSchema; - const publicProps: Record = {}; - for (const [fieldName, fieldDef] of Object.entries(props)) { - if ((fieldDef as any)?.private === true) continue; - publicProps[fieldName] = fieldDef; - } - const required = (typeSchema.required as string[] | undefined)?.filter( - (f: string) => !((props[f] as any)?.private === true), - ); - return { ...typeSchema, properties: publicProps, required }; -} - -function hashSchema(schemaBody: unknown): string { - return createHash("sha256").update(JSON.stringify(schemaBody)).digest("hex"); -} - -function deriveSemver( - prevSemver: string | null, - schemaChanged: boolean, - recordsChanged: boolean, -): string { - if (!prevSemver) return "v1.0.0"; - const parts = prevSemver.replace(/^v/, "").split(".").map(Number); - const [major, minor, patch] = [parts[0] ?? 1, parts[1] ?? 0, parts[2] ?? 0]; - if (schemaChanged) return `v${major + 1}.0.0`; - if (recordsChanged) return `v${major}.${minor + 1}.0`; - return `v${major}.${minor}.${patch + 1}`; -} - -async function resolveCollection(owner: string, slug: string) { - const [result] = await db - .select({ - id: schema.collections.id, - accountId: schema.collections.accountId, - slug: schema.collections.slug, - }) - .from(schema.collections) - .innerJoin(schema.accounts, eq(schema.collections.accountId, schema.accounts.id)) - .where(and(eq(schema.accounts.slug, owner), eq(schema.collections.slug, slug))) - .limit(1); - return result ?? null; -} - -export async function uploadRoutes(app: FastifyInstance) { - // --- Start a chunked upload session --- - app.post( - "/collections/:owner/:slug/versions/upload", - { preHandler: [requireAuth("write")] }, - async (request, reply) => { - const { owner, slug } = request.params as { owner: string; slug: string }; - const body = request.body as { - base_version: number | null; - message?: string; - readme?: string; - app_id?: string; - actor_id?: string; - schemas?: Record; - }; - - const collection = await resolveCollection(owner, slug); - if (!collection) return reply.status(404).send({ error: "Collection not found", statusCode: 404 }); - - // Verify the caller owns this collection - if (request.accountId !== collection.accountId) { - return reply.status(403).send({ error: "Not authorized for this collection", statusCode: 403 }); - } - - // Optimistic lock check at session creation time - const [latest] = await db - .select({ number: schema.versions.number }) - .from(schema.versions) - .where(eq(schema.versions.collectionId, collection.id)) - .orderBy(sql`${schema.versions.number} desc`) - .limit(1); - - const currentNumber = latest?.number ?? 0; - if (body.base_version !== null && body.base_version !== currentNumber) { - return reply.status(409).send({ - error: "Version conflict", - currentVersion: currentNumber, - statusCode: 409, - }); - } - - const expiresAt = new Date(Date.now() + SESSION_TTL_MS); - - const [session] = await db - .insert(schema.uploadSessions) - .values({ - collectionId: collection.id, - accountId: request.accountId!, - baseVersion: body.base_version ?? null, - message: body.message ?? null, - readme: body.readme ?? null, - appId: body.app_id ?? null, - actorId: body.actor_id ?? null, - schemas: body.schemas ? (body.schemas as any) : null, - status: "open", - recordCount: 0, - expiresAt, - }) - .returning({ id: schema.uploadSessions.id }); - - return reply.status(201).send({ - sessionId: session!.id, - expiresAt: expiresAt.toISOString(), - }); - }, - ); - - // --- Append a batch of changes to a session --- - app.put( - "/collections/:owner/:slug/versions/upload/:sessionId", - { preHandler: [requireAuth("write")] }, - async (request, reply) => { - const { owner, slug, sessionId } = request.params as { - owner: string; - slug: string; - sessionId: string; - }; - const body = request.body as { - changes: { - added?: { id: string; type: string; data: unknown; private?: boolean }[]; - updated?: { id: string; type: string; data: unknown; private?: boolean }[]; - removed?: string[]; - }; - }; - - // Validate session exists and belongs to caller - const [session] = await db - .select() - .from(schema.uploadSessions) - .where(eq(schema.uploadSessions.id, sessionId)) - .limit(1); - - if (!session) { - return reply.status(404).send({ error: "Upload session not found", statusCode: 404 }); - } - if (session.accountId !== request.accountId) { - return reply.status(403).send({ error: "Not authorized for this session", statusCode: 403 }); - } - if (session.status !== "open") { - return reply.status(409).send({ - error: "Session is not open", - status: session.status, - statusCode: 409, - }); - } - if (new Date(session.expiresAt) < new Date()) { - await db - .update(schema.uploadSessions) - .set({ status: "expired" }) - .where(eq(schema.uploadSessions.id, sessionId)); - return reply.status(410).send({ error: "Upload session expired", statusCode: 410 }); - } - - // Verify collection matches - const collection = await resolveCollection(owner, slug); - if (!collection || collection.id !== session.collectionId) { - return reply.status(404).send({ error: "Collection mismatch", statusCode: 404 }); - } - - // Count total records in this batch - const addedCount = body.changes.added?.length ?? 0; - const updatedCount = body.changes.updated?.length ?? 0; - const removedCount = body.changes.removed?.length ?? 0; - const batchSize = addedCount + updatedCount + removedCount; - - if (batchSize === 0) { - return reply.status(400).send({ error: "Empty batch", statusCode: 400 }); - } - if (batchSize > MAX_BATCH_SIZE) { - return reply.status(400).send({ - error: `Batch too large. Maximum ${MAX_BATCH_SIZE} records per batch.`, - statusCode: 400, - }); - } - - // Insert records into staging table (upsert to handle re-sends) - const rows: { - sessionId: string; - recordId: string; - type: string | null; - data: any; - private: boolean; - operation: "add" | "update" | "remove"; - }[] = []; - - for (const rec of body.changes.added ?? []) { - rows.push({ - sessionId, - recordId: rec.id, - type: rec.type, - data: rec.data, - private: rec.private ?? false, - operation: "add", - }); - } - for (const rec of body.changes.updated ?? []) { - rows.push({ - sessionId, - recordId: rec.id, - type: rec.type, - data: rec.data, - private: rec.private ?? false, - operation: "update", - }); - } - for (const id of body.changes.removed ?? []) { - rows.push({ - sessionId, - recordId: id, - type: null, - data: null, - private: false, - operation: "remove", - }); - } - - // Batch insert (upsert: last write wins for same recordId) - const BATCH = 1000; - for (let i = 0; i < rows.length; i += BATCH) { - const batch = rows.slice(i, i + BATCH); - await db - .insert(schema.uploadRecords) - .values(batch) - .onConflictDoUpdate({ - target: [schema.uploadRecords.sessionId, schema.uploadRecords.recordId], - set: { - type: sql`excluded.type`, - data: sql`excluded.data`, - private: sql`excluded.private`, - operation: sql`excluded.operation`, - }, - }); - } - - // Update session record count - const [countResult] = await db - .select({ count: sql`count(*)` }) - .from(schema.uploadRecords) - .where(eq(schema.uploadRecords.sessionId, sessionId)); - - await db - .update(schema.uploadSessions) - .set({ recordCount: countResult?.count ?? 0 }) - .where(eq(schema.uploadSessions.id, sessionId)); - - return { - received: { added: addedCount, updated: updatedCount, removed: removedCount }, - totalStaged: countResult?.count ?? 0, - }; - }, - ); - - // --- Get session status --- - app.get( - "/collections/:owner/:slug/versions/upload/:sessionId", - { preHandler: [requireAuth("read")] }, - async (request, reply) => { - const { sessionId } = request.params as { sessionId: string }; - - const [session] = await db - .select() - .from(schema.uploadSessions) - .where(eq(schema.uploadSessions.id, sessionId)) - .limit(1); - - if (!session) { - return reply.status(404).send({ error: "Upload session not found", statusCode: 404 }); - } - if (session.accountId !== request.accountId) { - return reply.status(403).send({ error: "Not authorized for this session", statusCode: 403 }); - } - - return { - sessionId: session.id, - status: session.status, - recordCount: session.recordCount, - baseVersion: session.baseVersion, - expiresAt: session.expiresAt, - createdAt: session.createdAt, - }; - }, - ); - - // --- Finalize: build the version from staged records --- - app.post( - "/collections/:owner/:slug/versions/upload/:sessionId/finalize", - { preHandler: [requireAuth("write")] }, - async (request, reply) => { - const { owner, slug, sessionId } = request.params as { - owner: string; - slug: string; - sessionId: string; - }; - - // Load and validate session - const [session] = await db - .select() - .from(schema.uploadSessions) - .where(eq(schema.uploadSessions.id, sessionId)) - .limit(1); - - if (!session) { - return reply.status(404).send({ error: "Upload session not found", statusCode: 404 }); - } - if (session.accountId !== request.accountId) { - return reply.status(403).send({ error: "Not authorized for this session", statusCode: 403 }); - } - if (session.status !== "open") { - return reply.status(409).send({ - error: `Session cannot be finalized (status: ${session.status})`, - statusCode: 409, - }); - } - if (new Date(session.expiresAt) < new Date()) { - await db - .update(schema.uploadSessions) - .set({ status: "expired" }) - .where(eq(schema.uploadSessions.id, sessionId)); - return reply.status(410).send({ error: "Upload session expired", statusCode: 410 }); - } - - const collection = await resolveCollection(owner, slug); - if (!collection || collection.id !== session.collectionId) { - return reply.status(404).send({ error: "Collection mismatch", statusCode: 404 }); - } - - // Mark session as finalizing - await db - .update(schema.uploadSessions) - .set({ status: "finalizing" }) - .where(eq(schema.uploadSessions.id, sessionId)); - - try { - // Re-check optimistic lock - const [latest] = await db - .select() - .from(schema.versions) - .where(eq(schema.versions.collectionId, collection.id)) - .orderBy(sql`${schema.versions.number} desc`) - .limit(1); - - const currentNumber = latest?.number ?? 0; - if (session.baseVersion !== null && session.baseVersion !== currentNumber) { - await db - .update(schema.uploadSessions) - .set({ status: "failed" }) - .where(eq(schema.uploadSessions.id, sessionId)); - return reply.status(409).send({ - error: "Version conflict", - currentVersion: currentNumber, - statusCode: 409, - }); - } - - // --- Resolve schemas --- - let prevSchemaEntries: SchemaEntry[] = []; - if (latest) { - prevSchemaEntries = await loadVersionSchemas(latest.id); - } - - let schemasInput: Record; - if (session.schemas && Object.keys(session.schemas as object).length > 0) { - schemasInput = session.schemas as Record; - } else if (prevSchemaEntries.length > 0) { - schemasInput = Object.fromEntries(prevSchemaEntries.map((e) => [e.slug, e.schema])); - } else { - await db - .update(schema.uploadSessions) - .set({ status: "failed" }) - .where(eq(schema.uploadSessions.id, sessionId)); - return reply.status(422).send({ - error: "Schemas required", - message: "First version must include a `schemas` map with at least one type definition.", - statusCode: 422, - }); - } - - // Hash and upsert schemas - const newSchemaSet: { slug: string; schemaId: string; schemaHash: string; schema: Record }[] = []; - for (const [typeSlug, typeSchema] of Object.entries(schemasInput)) { - const hash = hashSchema(typeSchema); - const [existing] = await db - .select({ id: schema.schemas.id }) - .from(schema.schemas) - .where(eq(schema.schemas.schemaHash, hash)) - .limit(1); - - let schemaId: string; - if (existing) { - schemaId = existing.id; - } else { - const [inserted] = await db - .insert(schema.schemas) - .values({ schema: typeSchema as any, schemaHash: hash }) - .returning({ id: schema.schemas.id }); - schemaId = inserted!.id; - } - newSchemaSet.push({ slug: typeSlug, schemaId, schemaHash: hash, schema: typeSchema as Record }); - } - - // Check schema changes - const prevSchemaMap = new Map(prevSchemaEntries.map((e) => [e.slug, e.schemaHash])); - const newSchemaMap = new Map(newSchemaSet.map((e) => [e.slug, e.schemaHash])); - let schemaChanged = prevSchemaMap.size !== newSchemaMap.size; - if (!schemaChanged) { - for (const [s, hash] of newSchemaMap) { - if (prevSchemaMap.get(s) !== hash) { - schemaChanged = true; - break; - } - } - } - - // Build validators - const validators = new Map>(); - for (const entry of newSchemaSet) { - validators.set(entry.slug, ajv.compile(entry.schema as object)); - } - - // Get file hashes from previous version - let existingFileHashes: string[] = []; - if (latest) { - const vf = await db - .select({ hash: schema.versionFiles.fileHash }) - .from(schema.versionFiles) - .where(eq(schema.versionFiles.versionId, latest.id)); - existingFileHashes = vf.map((f) => f.hash); - } - - // --- Streaming finalize --- - // Instead of loading all records into memory, we: - // 1. Materialize the merged record set into a temp table in Postgres - // 2. Stream through it in sorted batches for validation, hash computation, and insertion - // - // The temp table approach lets Postgres handle the merge (existing + staged changes) - // and gives us sorted cursor access without holding everything in Node memory. - - // Create a temp table with the merged result - await db.execute(sql` - CREATE TEMP TABLE _finalize_records ( - record_id text PRIMARY KEY, - type text NOT NULL, - data jsonb NOT NULL, - private boolean NOT NULL DEFAULT false - ) ON COMMIT DROP - `); - - // Insert existing records from base version (if any) - if (latest) { - await db.execute(sql` - INSERT INTO _finalize_records (record_id, type, data, private) - SELECT record_id, type, data, private - FROM records - WHERE version_id = ${latest.id} - `); - } - - // Apply staged changes (upserts and deletes) - await db.execute(sql` - INSERT INTO _finalize_records (record_id, type, data, private) - SELECT record_id, type, data, COALESCE(private, false) - FROM upload_records - WHERE session_id = ${sessionId} - AND operation IN ('add', 'update') - ON CONFLICT (record_id) DO UPDATE SET - type = EXCLUDED.type, - data = EXCLUDED.data, - private = EXCLUDED.private - `); - - // Remove deleted records - await db.execute(sql` - DELETE FROM _finalize_records - WHERE record_id IN ( - SELECT record_id FROM upload_records - WHERE session_id = ${sessionId} AND operation = 'remove' - ) - `); - - // Get total count - const [countResult] = await db.execute(sql`SELECT count(*) as cnt FROM _finalize_records`); - const totalRecordCount = Number((countResult as any).cnt); - - // Check all record types have schemas - const [typesResult] = await db.execute(sql`SELECT DISTINCT type FROM _finalize_records`); - // typesResult is an array of rows - const allTypes: string[] = (Array.isArray(typesResult) ? typesResult : [typesResult]) - .filter(Boolean) - .map((r: any) => r.type); - const missingSchemas = allTypes.filter((t) => !(t in schemasInput)); - if (missingSchemas.length > 0) { - await db.update(schema.uploadSessions).set({ status: "failed" }).where(eq(schema.uploadSessions.id, sessionId)); - await db.execute(sql`DROP TABLE IF EXISTS _finalize_records`); - return reply.status(422).send({ - error: "Missing schemas for record types", - types: missingSchemas, - statusCode: 422, - }); - } - - // --- Stream through records in sorted batches --- - // We compute hashes incrementally and validate + collect file refs + insert records - const STREAM_BATCH = 5000; - const privateTypes = getPrivateTypes(newSchemaSet as SchemaEntry[]); - - // Streaming hash state - const privateHasher = createHash("sha256"); - const publicHasher = createHash("sha256"); - - // We build the canonical hash as: {"schemas":{...},"records":[],"files":[...],"readme":...} - // For streaming, we compute records portion incrementally - const schemaSetForHash = newSchemaSet - .map((e) => ({ slug: e.slug, schemaHash: e.schemaHash })) - .sort((a, b) => a.slug.localeCompare(b.slug)); - const publicSchemaSet = newSchemaSet - .filter((e) => !privateTypes.has(e.slug)) - .map((e) => ({ slug: e.slug, schemaHash: hashSchema(filterTypeSchema(e.schema)) })) - .sort((a, b) => a.slug.localeCompare(b.slug)); - - // We'll collect all record canonical forms for hashing - // Using incremental approach: hash prefix, then each record, then suffix - const schemasCanonical = JSON.stringify( - Object.fromEntries(schemaSetForHash.map((s) => [s.slug, s.schemaHash])), - ); - const publicSchemasCanonical = JSON.stringify( - Object.fromEntries(publicSchemaSet.map((s) => [s.slug, s.schemaHash])), - ); - - // Start building canonical: {"schemas":...,"records":[ - privateHasher.update(`{"schemas":${schemasCanonical},"records":[`); - publicHasher.update(`{"schemas":${publicSchemasCanonical},"records":[`); - - const referencedHashes = new Set(existingFileHashes); - const validationErrors: { recordId: string; type: string; errors: string[] }[] = []; - let totalBytes = 0; - let recordCount = 0; - let publicRecordCount = 0; - let hasChanges = false; - let cursor = ""; - let hasMore = true; - - // Check if staged records exist (indicates changes) - const [stagedCount] = await db - .select({ count: sql`count(*)` }) - .from(schema.uploadRecords) - .where(eq(schema.uploadRecords.sessionId, sessionId)); - hasChanges = (stagedCount?.count ?? 0) > 0; - - // Insert the new version early to get its ID for record insertion - // We'll update the hash fields after streaming - const readmeValue = session.readme !== null ? session.readme : (latest?.readme ?? null); - const semver = deriveSemver(latest?.semver ?? null, schemaChanged, hasChanges); - const newNumber = currentNumber + 1; - - // We need to process all records before we can insert the version (need hashes) - // So we stream in two phases: - // Phase 1: validate + compute hashes + collect file refs + count bytes - // Phase 2: insert records (re-stream from temp table) - - while (hasMore) { - const batch = await db.execute(sql` - SELECT record_id, type, data, private - FROM _finalize_records - WHERE record_id > ${cursor} - ORDER BY record_id ASC - LIMIT ${STREAM_BATCH} - `) as any[]; - - const rows = Array.isArray(batch) ? batch : []; - if (rows.length === 0) { - hasMore = false; - break; - } - - for (const rec of rows) { - // Validate - const validate = validators.get(rec.type); - if (!validate) { - validationErrors.push({ - recordId: rec.record_id, - type: rec.type, - errors: [`No schema defined for record type "${rec.type}"`], - }); - } else if (!validate(rec.data)) { - validationErrors.push({ - recordId: rec.record_id, - type: rec.type, - errors: (validate.errors ?? []).map( - (e) => `${e.instancePath || "/"} ${e.message ?? "validation failed"}`, - ), - }); - } - - // Feed into private hash (all records) - const recCanonical = JSON.stringify({ id: rec.record_id, type: rec.type, data: rec.data }); - if (recordCount > 0) privateHasher.update(","); - privateHasher.update(recCanonical); - recordCount++; - - // Feed into public hash (non-private records only, with private fields stripped) - const isPrivateRecord = rec.private === true; - const isPrivateType = privateTypes.has(rec.type); - if (!isPrivateRecord && !isPrivateType) { - const entry = newSchemaSet.find((e) => e.slug === rec.type); - const privFields = entry ? getPrivateFields(entry.schema) : new Set(); - const pubData = privFields.size > 0 ? filterRecordData(rec.data, privFields) : rec.data; - const pubCanonical = JSON.stringify({ id: rec.record_id, type: rec.type, data: pubData }); - if (publicRecordCount > 0) publicHasher.update(","); - publicHasher.update(pubCanonical); - publicRecordCount++; - } - - // Compute bytes - totalBytes += Buffer.byteLength(JSON.stringify(rec.data), "utf-8"); - - // Scan for $file references - const data = rec.data as Record; - for (const val of Object.values(data)) { - if ( - typeof val === "object" && - val !== null && - "$file" in val && - typeof (val as { $file: string }).$file === "string" - ) { - const fileHash = (val as { $file: string }).$file.replace("sha256:", ""); - referencedHashes.add(fileHash); - } - } - } - - cursor = rows[rows.length - 1].record_id; - if (rows.length < STREAM_BATCH) hasMore = false; - } - - // Bail on validation errors - if (validationErrors.length > 0) { - await db.update(schema.uploadSessions).set({ status: "failed" }).where(eq(schema.uploadSessions.id, sessionId)); - await db.execute(sql`DROP TABLE IF EXISTS _finalize_records`); - return reply.status(422).send({ - error: "Schema validation failed", - validationErrors: validationErrors.slice(0, 100), // cap error list - statusCode: 422, - }); - } - - // Check all referenced files exist - const allFileHashes = Array.from(referencedHashes); - if (allFileHashes.length > 0) { - const existingFiles = await db - .select({ hash: schema.files.hash }) - .from(schema.files) - .where(inArray(schema.files.hash, allFileHashes)); - const existingSet = new Set(existingFiles.map((f) => f.hash)); - let filesNeeded = allFileHashes.filter((h) => !existingSet.has(h)); - - // For files not in local DB, check if they exist in S3 (shared bucket) - if (filesNeeded.length > 0) { - const stillNeeded: string[] = []; - for (const h of filesNeeded) { - const key = `files/${h.slice(0, 2)}/${h.slice(2, 4)}/${h}`; - const meta = await getS3ObjectMeta(key); - if (meta !== null) { - await db.insert(schema.files).values({ - hash: h, - size: meta.size, - mimeType: meta.contentType, - storageKey: key, - }).onConflictDoNothing(); - } else { - stillNeeded.push(h); - } - } - filesNeeded = stillNeeded; - } - - if (filesNeeded.length > 0) { - await db.update(schema.uploadSessions).set({ status: "failed" }).where(eq(schema.uploadSessions.id, sessionId)); - await db.execute(sql`DROP TABLE IF EXISTS _finalize_records`); - return reply.status(422).send({ - error: "Missing files", - filesNeeded: filesNeeded.map((h) => `sha256:${h}`), - statusCode: 422, - }); - } - } - - // Finalize hash computation - const sortedFileHashes = allFileHashes.sort(); - const filesCanonical = JSON.stringify(sortedFileHashes); - const readmeCanonical = JSON.stringify(readmeValue ?? null); - - privateHasher.update(`],"files":${filesCanonical},"readme":${readmeCanonical}}`); - publicHasher.update(`],"files":${filesCanonical},"readme":${readmeCanonical}}`); - - const versionHash = "private:" + privateHasher.digest("hex"); - const publicHash = "public:" + publicHasher.digest("hex"); - - // Check for duplicate hash - const [existingHash] = await db - .select({ number: schema.versions.number }) - .from(schema.versions) - .where( - and( - eq(schema.versions.collectionId, collection.id), - eq(schema.versions.hash, versionHash), - ), - ) - .limit(1); - - if (existingHash) { - await db.update(schema.uploadSessions).set({ status: "failed" }).where(eq(schema.uploadSessions.id, sessionId)); - await db.execute(sql`DROP TABLE IF EXISTS _finalize_records`); - return reply.status(409).send({ - error: "No changes detected", - message: `Version ${existingHash.number} already has identical content`, - existingVersion: existingHash.number, - }); - } - - // Add file sizes to totalBytes - if (allFileHashes.length > 0) { - const [fileSizeSum] = await db - .select({ total: sql`coalesce(sum(${schema.files.size}), 0)` }) - .from(schema.files) - .where(inArray(schema.files.hash, allFileHashes)); - totalBytes += Number(fileSizeSum?.total ?? 0); - } - - // Insert version - const [version] = await db - .insert(schema.versions) - .values({ - collectionId: collection.id, - number: newNumber, - semver, - hash: versionHash, - publicHash, - baseNumber: session.baseVersion, - message: session.message ?? null, - readme: readmeValue, - pushedBy: request.accountId ?? null, - appId: session.appId ?? null, - actorId: session.actorId ?? null, - recordCount, - fileCount: allFileHashes.length, - totalBytes, - }) - .returning(); - - // Phase 2: Insert records from temp table into the real records table (in batches) - await db.execute(sql` - INSERT INTO records (version_id, record_id, type, data, private) - SELECT ${version!.id}, record_id, type, data, private - FROM _finalize_records - `); - - // Clean up temp table - await db.execute(sql`DROP TABLE IF EXISTS _finalize_records`); - - // Insert version_files - if (allFileHashes.length > 0) { - await db.insert(schema.versionFiles).values( - allFileHashes.map((hash) => ({ - versionId: version!.id, - fileHash: hash, - })), - ); - } - - // Insert version_schemas - await db.insert(schema.versionSchemas).values( - newSchemaSet.map((entry) => ({ - versionId: version!.id, - slug: entry.slug, - schemaId: entry.schemaId, - })), - ); - - // Update collection timestamp - await db - .update(schema.collections) - .set({ updatedAt: new Date() }) - .where(eq(schema.collections.id, collection.id)); - - // Clean up: delete staged records and the session itself - await db - .delete(schema.uploadRecords) - .where(eq(schema.uploadRecords.sessionId, sessionId)); - await db - .delete(schema.uploadSessions) - .where(eq(schema.uploadSessions.id, sessionId)); - - return reply.status(201).send({ - version: newNumber, - semver, - hash: versionHash, - recordCount, - fileCount: allFileHashes.length, - }); - } catch (err) { - // Mark session as failed on unexpected error - await db.execute(sql`DROP TABLE IF EXISTS _finalize_records`); - await db - .update(schema.uploadSessions) - .set({ status: "failed" }) - .where(eq(schema.uploadSessions.id, sessionId)); - throw err; - } - }, - ); - - // --- Abort/cancel a session --- - app.delete( - "/collections/:owner/:slug/versions/upload/:sessionId", - { preHandler: [requireAuth("write")] }, - async (request, reply) => { - const { sessionId } = request.params as { sessionId: string }; - - const [session] = await db - .select() - .from(schema.uploadSessions) - .where(eq(schema.uploadSessions.id, sessionId)) - .limit(1); - - if (!session) { - return reply.status(404).send({ error: "Upload session not found", statusCode: 404 }); - } - if (session.accountId !== request.accountId) { - return reply.status(403).send({ error: "Not authorized for this session", statusCode: 403 }); - } - - // Delete staged records and session - await db - .delete(schema.uploadRecords) - .where(eq(schema.uploadRecords.sessionId, sessionId)); - await db - .delete(schema.uploadSessions) - .where(eq(schema.uploadSessions.id, sessionId)); - - return reply.status(204).send(); - }, - ); -} diff --git a/src/api/routes/versions.ts b/src/api/routes/versions.ts deleted file mode 100644 index 7e1fb52..0000000 --- a/src/api/routes/versions.ts +++ /dev/null @@ -1,1017 +0,0 @@ -import type { FastifyInstance } from "fastify"; -import { eq, and, sql, inArray } from "drizzle-orm"; -import { db, schema } from "../../db/index.js"; -import { requireAuth } from "../plugins/auth.js"; -import { createHash } from "node:crypto"; -import Ajv from "ajv"; -import addFormats from "ajv-formats"; -import { DEFAULT_NAAN, buildArkUrl } from "../../lib/ark.js"; - -const ajv = new Ajv({ allErrors: true, strict: false }); -addFormats(ajv); - -// --- Schema helpers --- - -type SchemaEntry = { slug: string; schemaId: string; schema: Record; schemaHash: string }; - -/** Load the full schema set for a version (slug → schema body + metadata) */ -async function loadVersionSchemas(versionId: number): Promise { - const rows = await db - .select({ - slug: schema.versionSchemas.slug, - schemaId: schema.versionSchemas.schemaId, - schema: schema.schemas.schema, - schemaHash: schema.schemas.schemaHash, - }) - .from(schema.versionSchemas) - .innerJoin(schema.schemas, eq(schema.versionSchemas.schemaId, schema.schemas.id)) - .where(eq(schema.versionSchemas.versionId, versionId)); - - return rows as SchemaEntry[]; -} - -// --- Visibility helpers --- - -/** Get the set of private type slugs from a version's schemas */ -function getPrivateTypes(schemaEntries: SchemaEntry[]): Set { - const types = new Set(); - for (const entry of schemaEntries) { - if ((entry.schema as any)?.private === true) { - types.add(entry.slug); - } - } - return types; -} - -/** Get the set of private field names for a given type schema */ -function getPrivateFields(typeSchema: Record): Set { - const fields = new Set(); - const props = typeSchema?.properties as Record | undefined; - if (!props) return fields; - for (const [fieldName, fieldDef] of Object.entries(props)) { - if (fieldDef?.private === true) fields.add(fieldName); - } - return fields; -} - -/** Strip private fields from a record's data */ -function filterRecordData(data: unknown, privateFields: Set): unknown { - if (privateFields.size === 0 || typeof data !== "object" || data === null) return data; - const filtered: Record = {}; - for (const [key, value] of Object.entries(data as Record)) { - if (!privateFields.has(key)) filtered[key] = value; - } - return filtered; -} - -/** Filter a type schema for public view: strip private fields from properties */ -function filterTypeSchema(typeSchema: Record): Record { - const props = typeSchema?.properties as Record | undefined; - if (!props) return typeSchema; - - const publicProps: Record = {}; - for (const [fieldName, fieldDef] of Object.entries(props)) { - if ((fieldDef as any)?.private === true) continue; - publicProps[fieldName] = fieldDef; - } - - const required = (typeSchema.required as string[] | undefined)?.filter( - (f: string) => !((props[f] as any)?.private === true), - ); - - return { ...typeSchema, properties: publicProps, required }; -} - -/** Build a public-facing schemas map (excluding private types, stripping private fields) */ -function filterSchemasForPublic(schemaEntries: SchemaEntry[]): Record { - const result: Record = {}; - for (const entry of schemaEntries) { - if ((entry.schema as any)?.private === true) continue; - result[entry.slug] = filterTypeSchema(entry.schema); - } - return result; -} - -/** Check if requester is the owner of a collection */ -function isOwner(request: any, collectionAccountId: string): boolean { - return request.accountId != null && request.accountId === collectionAccountId; -} - -/** Compute SHA-256 hash of a schema JSON body (canonical stringified) */ -function hashSchema(schemaBody: unknown): string { - return createHash("sha256").update(JSON.stringify(schemaBody)).digest("hex"); -} - -function computeVersionHash( - schemaSet: { slug: string; schemaHash: string }[], - recordRows: { recordId: string; type: string; data: unknown }[], - fileHashes: string[], - readme: string | null, -): string { - const canonical = JSON.stringify({ - schemas: Object.fromEntries( - schemaSet.sort((a, b) => a.slug.localeCompare(b.slug)).map((s) => [s.slug, s.schemaHash]), - ), - records: recordRows - .sort((a, b) => a.recordId.localeCompare(b.recordId)) - .map((r) => ({ id: r.recordId, type: r.type, data: r.data })), - files: fileHashes.sort(), - readme: readme ?? null, - }); - return "private:" + createHash("sha256").update(canonical).digest("hex"); -} - -/** Compute a public hash that only covers non-private content */ -function computePublicHash( - schemaEntries: SchemaEntry[], - recordRows: { recordId: string; type: string; data: unknown; private: boolean }[], - fileHashes: string[], - readme: string | null, -): string { - const privateTypes = getPrivateTypes(schemaEntries); - - // Build public schema set (non-private types, with private fields stripped) - const publicSchemaSet: { slug: string; schemaHash: string }[] = []; - for (const entry of schemaEntries) { - if (privateTypes.has(entry.slug)) continue; - const filtered = filterTypeSchema(entry.schema); - publicSchemaSet.push({ slug: entry.slug, schemaHash: hashSchema(filtered) }); - } - - // Filter to public records only, and strip private fields - const publicRecords = recordRows - .filter((r) => !r.private && !privateTypes.has(r.type)) - .map((r) => { - const entry = schemaEntries.find((e) => e.slug === r.type); - const privateFields = entry ? getPrivateFields(entry.schema) : new Set(); - const data = privateFields.size > 0 ? filterRecordData(r.data, privateFields) : r.data; - return { id: r.recordId, type: r.type, data }; - }) - .sort((a, b) => a.id.localeCompare(b.id)); - - const canonical = JSON.stringify({ - schemas: Object.fromEntries( - publicSchemaSet.sort((a, b) => a.slug.localeCompare(b.slug)).map((s) => [s.slug, s.schemaHash]), - ), - records: publicRecords, - files: fileHashes.sort(), - readme: readme ?? null, - }); - return "public:" + createHash("sha256").update(canonical).digest("hex"); -} - -function deriveSemver( - prevSemver: string | null, - schemaChanged: boolean, - recordsChanged: boolean, -): string { - if (!prevSemver) return "v1.0.0"; - - const parts = prevSemver.replace(/^v/, "").split(".").map(Number); - const [major, minor, patch] = [parts[0] ?? 1, parts[1] ?? 0, parts[2] ?? 0]; - - if (schemaChanged) return `v${major + 1}.0.0`; - if (recordsChanged) return `v${major}.${minor + 1}.0`; - return `v${major}.${minor}.${patch + 1}`; -} - -export async function versionRoutes(app: FastifyInstance) { - // Lazily backfill totalBytes for versions that were created before we tracked it - // or where the value was corrupted by a string concatenation bug - async function backfillTotalBytes(version: { id: number; totalBytes: number; recordCount: number }) { - // Skip recomputation if totalBytes looks reasonable (> 0 and < 1TB) - if (version.totalBytes > 0 && version.totalBytes < 1_099_511_627_776 || version.recordCount === 0) return version.totalBytes; - - const records = await db - .select({ data: schema.records.data }) - .from(schema.records) - .where(eq(schema.records.versionId, version.id)); - - let totalBytes = 0; - for (const r of records) { - totalBytes += Buffer.byteLength(JSON.stringify(r.data), "utf-8"); - } - - const [fileSizeResult] = await db - .select({ total: sql`coalesce(sum(${schema.files.size}), 0)` }) - .from(schema.versionFiles) - .innerJoin(schema.files, eq(schema.versionFiles.fileHash, schema.files.hash)) - .where(eq(schema.versionFiles.versionId, version.id)); - totalBytes += Number(fileSizeResult?.total ?? 0); - - // Persist so we don't recompute next time - await db - .update(schema.versions) - .set({ totalBytes }) - .where(eq(schema.versions.id, version.id)); - - return totalBytes; - } - // List versions - app.get("/collections/:owner/:slug/versions", async (request, reply) => { - const { owner, slug } = request.params as { owner: string; slug: string }; - const { limit, offset } = request.query as { limit?: string; offset?: string }; - - const collection = await resolveCollection(owner, slug); - if (!collection) return reply.status(404).send({ error: "Collection not found", statusCode: 404 }); - - const ownerAccess = isOwner(request, collection.accountId); - const arkInfo = await getCollectionArkInfo(collection.id).catch(() => null); - - const rows = await db - .select({ - number: schema.versions.number, - semver: schema.versions.semver, - hash: schema.versions.hash, - publicHash: schema.versions.publicHash, - message: schema.versions.message, - appId: schema.versions.appId, - actorId: schema.versions.actorId, - recordCount: schema.versions.recordCount, - fileCount: schema.versions.fileCount, - totalBytes: schema.versions.totalBytes, - createdAt: schema.versions.createdAt, - }) - .from(schema.versions) - .where(eq(schema.versions.collectionId, collection.id)) - .orderBy(sql`${schema.versions.number} desc`) - .limit(Math.min(parseInt(limit ?? "50", 10), 100)) - .offset(parseInt(offset ?? "0", 10)); - - return rows.map((row) => ({ - number: row.number, - semver: row.semver, - hash: ownerAccess ? row.hash : (row.publicHash ?? row.hash), - message: row.message, - appId: row.appId, - actorId: row.actorId, - recordCount: row.recordCount, - fileCount: row.fileCount, - totalBytes: row.totalBytes, - createdAt: row.createdAt, - ark: arkInfo ? buildArkUrl(arkInfo.naan, arkInfo.shoulder, arkInfo.arkId, row.number) : null, - })); - }); - - // Latest version - app.get("/collections/:owner/:slug/versions/latest", async (request, reply) => { - const { owner, slug } = request.params as { owner: string; slug: string }; - const collection = await resolveCollection(owner, slug); - if (!collection) return reply.status(404).send({ error: "Collection not found", statusCode: 404 }); - - const [version] = await db - .select() - .from(schema.versions) - .where(eq(schema.versions.collectionId, collection.id)) - .orderBy(sql`${schema.versions.number} desc`) - .limit(1); - - if (!version) return reply.status(404).send({ error: "No versions", statusCode: 404 }); - version.totalBytes = await backfillTotalBytes(version); - - const schemaEntries = await loadVersionSchemas(version.id); - const ownerAccess = isOwner(request, collection.accountId); - const arkInfo = await getCollectionArkInfo(collection.id).catch(() => null); - - const schemasMap = ownerAccess - ? Object.fromEntries(schemaEntries.map((e) => [e.slug, e.schema])) - : filterSchemasForPublic(schemaEntries); - - return { - ...version, - hash: ownerAccess ? version.hash : (version.publicHash ?? version.hash), - schemas: schemasMap, - ark: arkInfo ? buildArkUrl(arkInfo.naan, arkInfo.shoulder, arkInfo.arkId, version.number) : null, - }; - }); - - // Get version by number - app.get("/collections/:owner/:slug/versions/:n", async (request, reply) => { - const { owner, slug, n } = request.params as { owner: string; slug: string; n: string }; - const collection = await resolveCollection(owner, slug); - if (!collection) return reply.status(404).send({ error: "Collection not found", statusCode: 404 }); - - const [version] = await db - .select() - .from(schema.versions) - .where( - and(eq(schema.versions.collectionId, collection.id), eq(schema.versions.number, parseInt(n, 10))), - ) - .limit(1); - - if (!version) return reply.status(404).send({ error: "Version not found", statusCode: 404 }); - version.totalBytes = await backfillTotalBytes(version); - - const schemaEntries = await loadVersionSchemas(version.id); - const ownerAccess = isOwner(request, collection.accountId); - const arkInfo = await getCollectionArkInfo(collection.id).catch(() => null); - - const schemasMap = ownerAccess - ? Object.fromEntries(schemaEntries.map((e) => [e.slug, e.schema])) - : filterSchemasForPublic(schemaEntries); - - return { - ...version, - hash: ownerAccess ? version.hash : (version.publicHash ?? version.hash), - schemas: schemasMap, - ark: arkInfo ? buildArkUrl(arkInfo.naan, arkInfo.shoulder, arkInfo.arkId, version.number) : null, - }; - }); - - // Get records for a version - app.get("/collections/:owner/:slug/versions/:n/records", async (request, reply) => { - const { owner, slug, n } = request.params as { owner: string; slug: string; n: string }; - const { type, limit, offset, after } = request.query as { - type?: string; - limit?: string; - offset?: string; - after?: string; - }; - - const collection = await resolveCollection(owner, slug); - if (!collection) return reply.status(404).send({ error: "Collection not found", statusCode: 404 }); - - const [version] = await db - .select() - .from(schema.versions) - .where( - and(eq(schema.versions.collectionId, collection.id), eq(schema.versions.number, parseInt(n, 10))), - ) - .limit(1); - - if (!version) return reply.status(404).send({ error: "Version not found", statusCode: 404 }); - - const conditions = [eq(schema.records.versionId, version.id)]; - if (type) conditions.push(eq(schema.records.type, type)); - - // Cursor-based pagination: ?after=recordId (keyset pagination) - if (after) { - conditions.push(sql`${schema.records.recordId} > ${after}`); - } - - // Determine visibility - const ownerAccess = isOwner(request, collection.accountId); - - let privateTypes = new Set(); - let schemaEntries: SchemaEntry[] = []; - if (!ownerAccess) { - schemaEntries = await loadVersionSchemas(version.id); - privateTypes = getPrivateTypes(schemaEntries); - - if (privateTypes.size > 0) { - if (type && privateTypes.has(type)) { - return []; // requesting a private type as non-owner - } - for (const pt of privateTypes) { - conditions.push(sql`${schema.records.type} != ${pt}`); - } - } - // Exclude record-level private records - conditions.push(eq(schema.records.private, false)); - } - - const pageLimit = Math.min(parseInt(limit ?? "100", 10), 1000); - - const records = await db - .select({ - id: schema.records.recordId, - type: schema.records.type, - data: schema.records.data, - }) - .from(schema.records) - .where(and(...conditions)) - .orderBy(schema.records.recordId) - .limit(pageLimit + 1) - .offset(after ? 0 : parseInt(offset ?? "0", 10)); - - // Determine if there's a next page - const hasMore = records.length > pageLimit; - const page = hasMore ? records.slice(0, pageLimit) : records; - const nextCursor = hasMore ? page[page.length - 1]!.id : null; - - // Strip private fields if not owner - let resultRecords = page; - if (!ownerAccess) { - const fieldCache = new Map>(); - resultRecords = page.map((rec) => { - if (!fieldCache.has(rec.type)) { - const entry = schemaEntries.find((e) => e.slug === rec.type); - fieldCache.set(rec.type, entry ? getPrivateFields(entry.schema) : new Set()); - } - const privateFields = fieldCache.get(rec.type)!; - return privateFields.size > 0 - ? { ...rec, data: filterRecordData(rec.data, privateFields) } - : rec; - }); - } - - // Add ARK URLs for record types that have ARKs enabled - const arkInfo = await getCollectionArkInfo(collection.id).catch(() => null); - let arkEnabledTypes = new Map(); // recordType → redirectUrlField - if (arkInfo) { - const artRows = await db - .select({ recordType: schema.arkRecordTypes.recordType, redirectUrlField: schema.arkRecordTypes.redirectUrlField }) - .from(schema.arkRecordTypes) - .where(eq(schema.arkRecordTypes.collectionId, collection.id)); - for (const r of artRows) arkEnabledTypes.set(r.recordType, r.redirectUrlField); - } - - const recordsWithArk = resultRecords.map((rec) => { - const ark = arkInfo && arkEnabledTypes.has(rec.type) - ? buildArkUrl(arkInfo.naan, arkInfo.shoulder, arkInfo.arkId, version.number, rec.type, rec.id) - : null; - return ark ? { ...rec, ark } : rec; - }); - - return { - records: recordsWithArk, - pagination: { - limit: pageLimit, - hasMore, - nextCursor, - total: version.recordCount, - }, - }; - }); - - // List files for a version - app.get("/collections/:owner/:slug/versions/:n/files", async (request, reply) => { - const { owner, slug, n } = request.params as { owner: string; slug: string; n: string }; - const collection = await resolveCollection(owner, slug); - if (!collection) return reply.status(404).send({ error: "Collection not found", statusCode: 404 }); - - const [version] = await db - .select() - .from(schema.versions) - .where( - and(eq(schema.versions.collectionId, collection.id), eq(schema.versions.number, parseInt(n, 10))), - ) - .limit(1); - - if (!version) return reply.status(404).send({ error: "Version not found", statusCode: 404 }); - - const fileRows = await db - .select({ - hash: schema.versionFiles.fileHash, - size: schema.files.size, - mimeType: schema.files.mimeType, - createdAt: schema.files.createdAt, - }) - .from(schema.versionFiles) - .innerJoin(schema.files, eq(schema.versionFiles.fileHash, schema.files.hash)) - .where(eq(schema.versionFiles.versionId, version.id)); - - // Build file→record reference map by scanning record data for $file refs - const allRecords = await db - .select({ recordId: schema.records.recordId, type: schema.records.type, data: schema.records.data }) - .from(schema.records) - .where(eq(schema.records.versionId, version.id)); - - const fileRefs = new Map(); - for (const rec of allRecords) { - const data = rec.data as Record; - for (const [field, val] of Object.entries(data)) { - if (val && typeof val === "object" && "$file" in (val as any)) { - const hash = ((val as any).$file as string).replace("sha256:", ""); - if (!fileRefs.has(hash)) fileRefs.set(hash, []); - fileRefs.get(hash)!.push({ recordId: rec.recordId, type: rec.type, field }); - } - } - } - - return fileRows.map((f) => ({ - ...f, - references: fileRefs.get(f.hash) ?? [], - })); - }); - - // Get manifest for a version - app.get("/collections/:owner/:slug/versions/:n/manifest", async (request, reply) => { - const { owner, slug, n } = request.params as { owner: string; slug: string; n: string }; - const collection = await resolveCollection(owner, slug); - if (!collection) return reply.status(404).send({ error: "Collection not found", statusCode: 404 }); - - const [version] = await db - .select() - .from(schema.versions) - .where( - and(eq(schema.versions.collectionId, collection.id), eq(schema.versions.number, parseInt(n, 10))), - ) - .limit(1); - - if (!version) return reply.status(404).send({ error: "Version not found", statusCode: 404 }); - - const recordIds = await db - .select({ id: schema.records.recordId, type: schema.records.type }) - .from(schema.records) - .where(eq(schema.records.versionId, version.id)); - - const fileHashes = await db - .select({ hash: schema.versionFiles.fileHash }) - .from(schema.versionFiles) - .where(eq(schema.versionFiles.versionId, version.id)); - - const schemaEntries = await loadVersionSchemas(version.id); - - return { - version: version.number, - semver: version.semver, - hash: version.hash, - schemas: Object.fromEntries(schemaEntries.map((e) => [e.slug, e.schemaHash])), - records: recordIds, - files: fileHashes.map((f) => f.hash), - }; - }); - - // Push a new version - app.post( - "/collections/:owner/:slug/versions", - { preHandler: [requireAuth("write")] }, - async (request, reply) => { - const { owner, slug } = request.params as { owner: string; slug: string }; - const body = request.body as { - base_version: number | null; - name?: string; - description?: string; - message?: string; - readme?: string; - app_id?: string; - actor_id?: string; - schemas?: Record; - changes: { - added?: { id: string; type: string; data: unknown; private?: boolean }[]; - updated?: { id: string; type: string; data: unknown; private?: boolean }[]; - removed?: string[]; - }; - }; - - const collection = await resolveCollection(owner, slug); - if (!collection) return reply.status(404).send({ error: "Collection not found", statusCode: 404 }); - - // Get latest version - const [latest] = await db - .select() - .from(schema.versions) - .where(eq(schema.versions.collectionId, collection.id)) - .orderBy(sql`${schema.versions.number} desc`) - .limit(1); - - const currentNumber = latest?.number ?? 0; - - // Optimistic lock - if (body.base_version !== null && body.base_version !== currentNumber) { - return reply.status(409).send({ - error: "Version conflict", - currentVersion: currentNumber, - statusCode: 409, - }); - } - - // Build the full record set for this version - let existingRecords: { recordId: string; type: string; data: unknown; private: boolean }[] = []; - if (latest) { - existingRecords = await db - .select({ - recordId: schema.records.recordId, - type: schema.records.type, - data: schema.records.data, - private: schema.records.private, - }) - .from(schema.records) - .where(eq(schema.records.versionId, latest.id)); - } - - // Apply changes - const recordMap = new Map(existingRecords.map((r) => [r.recordId, r])); - - for (const rec of body.changes.added ?? []) { - recordMap.set(rec.id, { recordId: rec.id, type: rec.type, data: rec.data, private: rec.private ?? false }); - } - for (const rec of body.changes.updated ?? []) { - recordMap.set(rec.id, { recordId: rec.id, type: rec.type, data: rec.data, private: rec.private ?? false }); - } - for (const id of body.changes.removed ?? []) { - recordMap.delete(id); - } - - const newRecords = Array.from(recordMap.values()); - - // --- Resolve schemas --- - let prevSchemaEntries: SchemaEntry[] = []; - if (latest) { - prevSchemaEntries = await loadVersionSchemas(latest.id); - } - - // Determine the schema set for this version - let schemasInput: Record; - if (body.schemas && Object.keys(body.schemas).length > 0) { - schemasInput = body.schemas; - } else if (prevSchemaEntries.length > 0) { - // Carry forward previous schemas - schemasInput = Object.fromEntries(prevSchemaEntries.map((e) => [e.slug, e.schema])); - } else { - return reply.status(422).send({ - error: "Schemas required", - message: "First version must include a `schemas` map with at least one type definition.", - statusCode: 422, - }); - } - - // Ensure every record type has a schema - const recordTypes = new Set(newRecords.map((r) => r.type)); - const missingSchemas = [...recordTypes].filter((t) => !(t in schemasInput)); - if (missingSchemas.length > 0) { - return reply.status(422).send({ - error: "Missing schemas for record types", - types: missingSchemas, - message: `Every record type must have a corresponding schema. Missing: ${missingSchemas.join(", ")}`, - statusCode: 422, - }); - } - - // Hash and upsert each schema into the global schemas table - const newSchemaSet: { slug: string; schemaId: string; schemaHash: string; schema: Record }[] = []; - for (const [typeSlug, typeSchema] of Object.entries(schemasInput)) { - const hash = hashSchema(typeSchema); - - const [existing] = await db - .select({ id: schema.schemas.id }) - .from(schema.schemas) - .where(eq(schema.schemas.schemaHash, hash)) - .limit(1); - - let schemaId: string; - if (existing) { - schemaId = existing.id; - } else { - const [inserted] = await db - .insert(schema.schemas) - .values({ schema: typeSchema as any, schemaHash: hash }) - .returning({ id: schema.schemas.id }); - schemaId = inserted!.id; - } - - newSchemaSet.push({ slug: typeSlug, schemaId, schemaHash: hash, schema: typeSchema as Record }); - } - - // Validate records against their type's schema - const validationErrors: { recordId: string; type: string; errors: string[] }[] = []; - const validators = new Map>(); - for (const entry of newSchemaSet) { - validators.set(entry.slug, ajv.compile(entry.schema as object)); - } - - for (const rec of newRecords) { - const validate = validators.get(rec.type); - if (!validate) { - validationErrors.push({ - recordId: rec.recordId, - type: rec.type, - errors: [`No schema defined for record type "${rec.type}"`], - }); - continue; - } - if (!validate(rec.data)) { - validationErrors.push({ - recordId: rec.recordId, - type: rec.type, - errors: (validate.errors ?? []).map( - (e) => `${e.instancePath || "/"} ${e.message ?? "validation failed"}`, - ), - }); - } - } - - if (validationErrors.length > 0) { - return reply.status(422).send({ - error: "Schema validation failed", - validationErrors, - statusCode: 422, - }); - } - - // Determine if schema set changed - const prevSchemaMap = new Map(prevSchemaEntries.map((e) => [e.slug, e.schemaHash])); - const newSchemaMap = new Map(newSchemaSet.map((e) => [e.slug, e.schemaHash])); - let schemaChanged = prevSchemaMap.size !== newSchemaMap.size; - if (!schemaChanged) { - for (const [s, hash] of newSchemaMap) { - if (prevSchemaMap.get(s) !== hash) { - schemaChanged = true; - break; - } - } - } - - const recordsChanged = - (body.changes.added?.length ?? 0) > 0 || - (body.changes.updated?.length ?? 0) > 0 || - (body.changes.removed?.length ?? 0) > 0; - - // Get file hashes from existing version + any new references - let existingFileHashes: string[] = []; - if (latest) { - const vf = await db - .select({ hash: schema.versionFiles.fileHash }) - .from(schema.versionFiles) - .where(eq(schema.versionFiles.versionId, latest.id)); - existingFileHashes = vf.map((f) => f.hash); - } - - // Scan new records for $file references - const referencedHashes = new Set(existingFileHashes); - for (const rec of newRecords) { - const data = rec.data as Record; - for (const val of Object.values(data)) { - if ( - typeof val === "object" && - val !== null && - "$file" in val && - typeof (val as { $file: string }).$file === "string" - ) { - const hash = (val as { $file: string }).$file.replace("sha256:", ""); - referencedHashes.add(hash); - } - } - } - - // Check all referenced files exist - const allFileHashes = Array.from(referencedHashes); - if (allFileHashes.length > 0) { - const existingFiles = await db - .select({ hash: schema.files.hash }) - .from(schema.files) - .where(inArray(schema.files.hash, allFileHashes)); - const existingSet = new Set(existingFiles.map((f) => f.hash)); - const filesNeeded = allFileHashes.filter((h) => !existingSet.has(h)); - - if (filesNeeded.length > 0) { - return reply.status(422).send({ - error: "Missing files", - filesNeeded: filesNeeded.map((h) => `sha256:${h}`), - statusCode: 422, - }); - } - } - - // Resolve readme (carry forward from base version if not provided) - const readmeValue = body.readme !== undefined ? body.readme : (latest?.readme ?? null); - - // Compute hashes and semver - const schemaSetForHash = newSchemaSet.map((e) => ({ slug: e.slug, schemaHash: e.schemaHash })); - const versionHash = computeVersionHash(schemaSetForHash, newRecords, allFileHashes, readmeValue); - - const schemaEntriesForPublicHash: SchemaEntry[] = newSchemaSet.map((e) => ({ - slug: e.slug, - schemaId: e.schemaId, - schema: e.schema, - schemaHash: e.schemaHash, - })); - const publicHash = computePublicHash(schemaEntriesForPublicHash, newRecords, allFileHashes, readmeValue); - - const semver = deriveSemver(latest?.semver ?? null, schemaChanged, recordsChanged); - const newNumber = currentNumber + 1; - - // Check for duplicate hash - const [existingHash] = await db - .select({ number: schema.versions.number }) - .from(schema.versions) - .where( - and( - eq(schema.versions.collectionId, collection.id), - eq(schema.versions.hash, versionHash), - ), - ) - .limit(1); - if (existingHash) { - return reply.status(409).send({ - error: "No changes detected", - message: `Version ${existingHash.number} already has identical content (hash: ${versionHash.slice(0, 12)}...)`, - existingVersion: existingHash.number, - }); - } - - // Compute total bytes - let totalBytes = 0; - for (const rec of newRecords) { - totalBytes += Buffer.byteLength(JSON.stringify(rec.data), "utf-8"); - } - if (allFileHashes.length > 0) { - const [fileSizeSum] = await db - .select({ total: sql`coalesce(sum(${schema.files.size}), 0)` }) - .from(schema.files) - .where(inArray(schema.files.hash, allFileHashes)); - totalBytes += Number(fileSizeSum?.total ?? 0); - } - - // Insert version - const [version] = await db - .insert(schema.versions) - .values({ - collectionId: collection.id, - number: newNumber, - semver, - hash: versionHash, - publicHash, - baseNumber: body.base_version, - message: body.message ?? null, - readme: readmeValue, - pushedBy: request.accountId ?? null, - appId: body.app_id ?? null, - actorId: body.actor_id ?? null, - recordCount: newRecords.length, - fileCount: allFileHashes.length, - totalBytes, - }) - .returning(); - - // Insert records (in batches) - if (newRecords.length > 0) { - const RECORD_BATCH = 1000; - for (let i = 0; i < newRecords.length; i += RECORD_BATCH) { - const batch = newRecords.slice(i, i + RECORD_BATCH); - await db.insert(schema.records).values( - batch.map((r) => ({ - versionId: version!.id, - recordId: r.recordId, - type: r.type, - data: r.data as any, - private: r.private, - })), - ); - } - } - - // Insert version_files - if (allFileHashes.length > 0) { - await db.insert(schema.versionFiles).values( - allFileHashes.map((hash) => ({ - versionId: version!.id, - fileHash: hash, - })), - ); - } - - // Insert version_schemas - await db.insert(schema.versionSchemas).values( - newSchemaSet.map((entry) => ({ - versionId: version!.id, - slug: entry.slug, - schemaId: entry.schemaId, - })), - ); - - // Update collection timestamp + optional name/description - const collectionUpdates: Record = { updatedAt: new Date() }; - if (body.name) collectionUpdates.name = body.name; - if (body.description !== undefined) collectionUpdates.description = body.description; - await db - .update(schema.collections) - .set(collectionUpdates) - .where(eq(schema.collections.id, collection.id)); - - return reply.status(201).send({ - version: newNumber, - semver, - hash: versionHash, - recordCount: newRecords.length, - fileCount: allFileHashes.length, - }); - }, - ); - - // Diff between versions - app.get("/collections/:owner/:slug/versions/:n/diff", async (request, reply) => { - const { owner, slug, n } = request.params as { owner: string; slug: string; n: string }; - const { from } = request.query as { from?: string }; - - const collection = await resolveCollection(owner, slug); - if (!collection) return reply.status(404).send({ error: "Collection not found", statusCode: 404 }); - - const targetNum = parseInt(n, 10); - const fromNum = from ? parseInt(from, 10) : targetNum - 1; - - const [targetVersion] = await db - .select() - .from(schema.versions) - .where(and(eq(schema.versions.collectionId, collection.id), eq(schema.versions.number, targetNum))) - .limit(1); - - if (!targetVersion) { - return reply.status(404).send({ error: "Version not found", statusCode: 404 }); - } - - const targetRecords = await db - .select() - .from(schema.records) - .where(eq(schema.records.versionId, targetVersion.id)); - - let fromVersion: typeof targetVersion | null = null; - let fromRecords: typeof targetRecords = []; - if (fromNum > 0) { - const [fv] = await db - .select() - .from(schema.versions) - .where(and(eq(schema.versions.collectionId, collection.id), eq(schema.versions.number, fromNum))) - .limit(1); - - if (fv) { - fromVersion = fv; - fromRecords = await db - .select() - .from(schema.records) - .where(eq(schema.records.versionId, fv.id)); - } - } - - const fromMap = new Map(fromRecords.map((r) => [r.recordId, r])); - const targetMap = new Map(targetRecords.map((r) => [r.recordId, r])); - - const added = targetRecords.filter((r) => !fromMap.has(r.recordId)); - const removed = fromRecords.filter((r) => !targetMap.has(r.recordId)); - const updated = targetRecords.filter((r) => { - const prev = fromMap.get(r.recordId); - return prev && JSON.stringify(prev.data) !== JSON.stringify(r.data); - }); - - // Compare schema sets - const targetSchemas = await loadVersionSchemas(targetVersion.id); - const fromSchemas = fromVersion ? await loadVersionSchemas(fromVersion.id) : []; - const targetSchemaMap = new Map(targetSchemas.map((e) => [e.slug, e.schemaHash])); - const fromSchemaMap = new Map(fromSchemas.map((e) => [e.slug, e.schemaHash])); - let schemaChanged = targetSchemaMap.size !== fromSchemaMap.size; - if (!schemaChanged) { - for (const [s, hash] of targetSchemaMap) { - if (fromSchemaMap.get(s) !== hash) { - schemaChanged = true; - break; - } - } - } - - const readmeChanged = (targetVersion.readme ?? null) !== (fromVersion?.readme ?? null); - - // Compare file sets - const targetFiles = await db - .select({ hash: schema.versionFiles.fileHash }) - .from(schema.versionFiles) - .where(eq(schema.versionFiles.versionId, targetVersion.id)); - const fromFiles = fromVersion ? await db - .select({ hash: schema.versionFiles.fileHash }) - .from(schema.versionFiles) - .where(eq(schema.versionFiles.versionId, fromVersion.id)) : []; - const targetFileSet = new Set(targetFiles.map((f) => f.hash)); - const fromFileSet = new Set(fromFiles.map((f) => f.hash)); - const filesAdded = targetFiles.filter((f) => !fromFileSet.has(f.hash)).map((f) => f.hash); - const filesRemoved = fromFiles.filter((f) => !targetFileSet.has(f.hash)).map((f) => f.hash); - - return { - from: fromNum, - to: targetNum, - added: added.map((r) => ({ id: r.recordId, type: r.type, data: r.data })), - updated: updated.map((r) => ({ id: r.recordId, type: r.type, data: r.data })), - removed: removed.map((r) => r.recordId), - meta: { - schemaChanged, - readmeChanged, - readmeFrom: readmeChanged ? (fromVersion?.readme?.slice(0, 100) ?? null) : undefined, - readmeTo: readmeChanged ? (targetVersion.readme?.slice(0, 100) ?? null) : undefined, - filesAdded: filesAdded.length, - filesRemoved: filesRemoved.length, - }, - }; - }); -} - -async function resolveCollection(owner: string, slug: string) { - const [result] = await db - .select({ - id: schema.collections.id, - accountId: schema.collections.accountId, - slug: schema.collections.slug, - }) - .from(schema.collections) - .innerJoin(schema.accounts, eq(schema.collections.accountId, schema.accounts.id)) - .where(and(eq(schema.accounts.slug, owner), eq(schema.collections.slug, slug))) - .limit(1); - return result ?? null; -} - -async function getCollectionArkInfo( - collectionId: string, -): Promise<{ shoulder: string; arkId: string; naan: string } | null> { - const [row] = await db - .select({ - shoulder: schema.arkShoulders.shoulder, - arkId: schema.arkCollections.arkId, - naan: schema.accounts.arkNaan, - }) - .from(schema.arkCollections) - .innerJoin(schema.collections, eq(schema.arkCollections.collectionId, schema.collections.id)) - .innerJoin(schema.accounts, eq(schema.collections.accountId, schema.accounts.id)) - .innerJoin(schema.arkShoulders, eq(schema.arkShoulders.accountId, schema.accounts.id)) - .where(and(eq(schema.arkCollections.collectionId, collectionId), eq(schema.arkCollections.enabled, true))) - .limit(1); - if (!row) return null; - return { shoulder: row.shoulder, arkId: row.arkId, naan: row.naan ?? DEFAULT_NAAN }; -} diff --git a/src/api/schemas.ts b/src/api/schemas.ts new file mode 100644 index 0000000..8bfa96c --- /dev/null +++ b/src/api/schemas.ts @@ -0,0 +1,382 @@ +import { Hono } from 'hono'; +import { eq, and, sql, inArray, ilike } from 'drizzle-orm'; +import { db, schema } from '../db/client.server.js'; +import { requireAuth, type AuthEnv } from './auth.server.js'; + +const app = new Hono(); + +// --- Global schema search --- +// GET /schemas?q=...&slug=...&label=...&schema_hash=...&limit=...&offset=... +app.get('/schemas', async (c) => { + const q = c.req.query('q'); + const slugFilter = c.req.query('slug'); + const label = c.req.query('label'); + const schema_hash = c.req.query('schema_hash'); + const limit = c.req.query('limit'); + const offset = c.req.query('offset'); + + const pageLimit = Math.min(parseInt(limit ?? '50', 10), 100); + const pageOffset = parseInt(offset ?? '0', 10); + + // Search by exact hash + if (schema_hash) { + const [row] = await db + .select() + .from(schema.schemas) + .where(eq(schema.schemas.schemaHash, schema_hash)) + .limit(1); + + if (!row) return c.json({ error: 'Schema not found', statusCode: 404 }, 404); + + const labels = await db + .select({ label: schema.schemaLabels.label }) + .from(schema.schemaLabels) + .where(eq(schema.schemaLabels.schemaId, row.id)); + + const usageCount = await getUsageCount(row.id); + + return c.json({ + ...row, + labels: labels.map((l) => l.label), + usageCount, + }); + } + + // Search by slug (find schemas used as a particular type name) + if (slugFilter) { + const vsRows = await db + .select({ schemaId: schema.versionSchemas.schemaId }) + .from(schema.versionSchemas) + .where(eq(schema.versionSchemas.slug, slugFilter)) + .groupBy(schema.versionSchemas.schemaId) + .limit(pageLimit) + .offset(pageOffset); + + if (vsRows.length === 0) return c.json([]); + + const schemaIds = vsRows.map((r) => r.schemaId); + const schemaRows = await db + .select() + .from(schema.schemas) + .where(inArray(schema.schemas.id, schemaIds)); + + const allLabels = await db + .select({ schemaId: schema.schemaLabels.schemaId, label: schema.schemaLabels.label }) + .from(schema.schemaLabels) + .where(inArray(schema.schemaLabels.schemaId, schemaIds)); + + const labelsMap = new Map(); + for (const l of allLabels) { + if (!labelsMap.has(l.schemaId)) labelsMap.set(l.schemaId, []); + labelsMap.get(l.schemaId)!.push(l.label); + } + + return c.json(schemaRows.map((s) => ({ + ...s, + labels: labelsMap.get(s.id) ?? [], + }))); + } + + // Search by label + if (label) { + const labelRows = await db + .select({ + schemaId: schema.schemaLabels.schemaId, + label: schema.schemaLabels.label, + }) + .from(schema.schemaLabels) + .where(ilike(schema.schemaLabels.label, `%${label}%`)) + .limit(pageLimit) + .offset(pageOffset); + + if (labelRows.length === 0) return c.json([]); + + const schemaIds = [...new Set(labelRows.map((r) => r.schemaId))]; + const schemaRows = await db + .select() + .from(schema.schemas) + .where(inArray(schema.schemas.id, schemaIds)); + + // Gather all labels for these schemas + const allLabels = await db + .select({ schemaId: schema.schemaLabels.schemaId, label: schema.schemaLabels.label }) + .from(schema.schemaLabels) + .where(inArray(schema.schemaLabels.schemaId, schemaIds)); + + const labelsMap = new Map(); + for (const l of allLabels) { + if (!labelsMap.has(l.schemaId)) labelsMap.set(l.schemaId, []); + labelsMap.get(l.schemaId)!.push(l.label); + } + + return c.json(schemaRows.map((s) => ({ + ...s, + labels: labelsMap.get(s.id) ?? [], + }))); + } + + // Full-text search across schema JSON (search for field names, types, etc.) + if (q) { + const rows = await db + .select() + .from(schema.schemas) + .where(sql`${schema.schemas.schema}::text ILIKE ${'%' + q + '%'}`) + .limit(pageLimit) + .offset(pageOffset); + + const schemaIds = rows.map((r) => r.id); + const allLabels = schemaIds.length > 0 + ? await db + .select({ schemaId: schema.schemaLabels.schemaId, label: schema.schemaLabels.label }) + .from(schema.schemaLabels) + .where(inArray(schema.schemaLabels.schemaId, schemaIds)) + : []; + + const labelsMap = new Map(); + for (const l of allLabels) { + if (!labelsMap.has(l.schemaId)) labelsMap.set(l.schemaId, []); + labelsMap.get(l.schemaId)!.push(l.label); + } + + return c.json(rows.map((s) => ({ + ...s, + labels: labelsMap.get(s.id) ?? [], + }))); + } + + // No filter: list all schemas + const rows = await db + .select() + .from(schema.schemas) + .orderBy(sql`${schema.schemas.createdAt} desc`) + .limit(pageLimit) + .offset(pageOffset); + + const schemaIds = rows.map((r) => r.id); + const allLabels = schemaIds.length > 0 + ? await db + .select({ schemaId: schema.schemaLabels.schemaId, label: schema.schemaLabels.label }) + .from(schema.schemaLabels) + .where(inArray(schema.schemaLabels.schemaId, schemaIds)) + : []; + + const labelsMap = new Map(); + for (const l of allLabels) { + if (!labelsMap.has(l.schemaId)) labelsMap.set(l.schemaId, []); + labelsMap.get(l.schemaId)!.push(l.label); + } + + return c.json(rows.map((s) => ({ + ...s, + labels: labelsMap.get(s.id) ?? [], + }))); +}); + +// --- Single schema by ID --- +// GET /schemas/:id +app.get('/schemas/:id', async (c) => { + const id = c.req.param('id'); + + const [row] = await db + .select() + .from(schema.schemas) + .where(eq(schema.schemas.id, id)) + .limit(1); + + if (!row) return c.json({ error: 'Schema not found', statusCode: 404 }, 404); + + const labels = await db + .select({ label: schema.schemaLabels.label, createdAt: schema.schemaLabels.createdAt }) + .from(schema.schemaLabels) + .where(eq(schema.schemaLabels.schemaId, id)); + + // Usage: which collections/versions reference this schema + const usage = await db + .select({ + slug: schema.versionSchemas.slug, + semver: schema.versions.semver, + versionNumber: schema.versions.number, + collectionSlug: schema.collections.slug, + owner: schema.accounts.slug, + isPublic: schema.collections.public, + }) + .from(schema.versionSchemas) + .innerJoin(schema.versions, eq(schema.versionSchemas.versionId, schema.versions.id)) + .innerJoin(schema.collections, eq(schema.versions.collectionId, schema.collections.id)) + .innerJoin(schema.accounts, eq(schema.collections.accountId, schema.accounts.id)) + .where(and(eq(schema.versionSchemas.schemaId, id), eq(schema.collections.public, true))) + .orderBy(sql`${schema.versions.createdAt} desc`) + .limit(50); + + return c.json({ + ...row, + labels: labels.map((l) => ({ label: l.label, createdAt: l.createdAt })), + usage: usage.map((u) => ({ + slug: u.slug, + semver: u.semver, + versionNumber: u.versionNumber, + collection: `${u.owner}/${u.collectionSlug}`, + })), + }); +}); + +// --- Collection schemas (for a specific version or latest) --- +// GET /collections/:owner/:slug/schemas?version=N +app.get('/collections/:owner/:slug/schemas', async (c) => { + const owner = c.req.param('owner'); + const slug = c.req.param('slug'); + const versionParam = c.req.query('version'); + const raw = c.req.query('raw'); + + // Resolve collection + const [collection] = await db + .select({ + id: schema.collections.id, + accountId: schema.collections.accountId, + public: schema.collections.public, + }) + .from(schema.collections) + .innerJoin(schema.accounts, eq(schema.collections.accountId, schema.accounts.id)) + .where(and(eq(schema.accounts.slug, owner), eq(schema.collections.slug, slug))) + .limit(1); + + if (!collection) return c.json({ error: 'Collection not found', statusCode: 404 }, 404); + + // Visibility check + if (!collection.public && c.get('accountId') !== collection.accountId) { + return c.json({ error: 'Collection not found', statusCode: 404 }, 404); + } + + // Resolve version + const versionConditions = [eq(schema.versions.collectionId, collection.id)]; + if (versionParam) { + versionConditions.push(eq(schema.versions.number, parseInt(versionParam, 10))); + } + + const [version] = await db + .select({ id: schema.versions.id, number: schema.versions.number, semver: schema.versions.semver }) + .from(schema.versions) + .where(and(...versionConditions)) + .orderBy(sql`${schema.versions.number} desc`) + .limit(1); + + if (!version) return c.json({ error: 'No versions found', statusCode: 404 }, 404); + + // Load schemas for this version + const entries = await db + .select({ + slug: schema.versionSchemas.slug, + schemaId: schema.versionSchemas.schemaId, + schemaBody: schema.schemas.schema, + schemaHash: schema.schemas.schemaHash, + }) + .from(schema.versionSchemas) + .innerJoin(schema.schemas, eq(schema.versionSchemas.schemaId, schema.schemas.id)) + .where(eq(schema.versionSchemas.versionId, version.id)); + + // Load labels for all referenced schemas (unless raw mode) + let labelsMap = new Map(); + if (raw !== 'true' && entries.length > 0) { + const schemaIds = entries.map((e) => e.schemaId); + const allLabels = await db + .select({ schemaId: schema.schemaLabels.schemaId, label: schema.schemaLabels.label }) + .from(schema.schemaLabels) + .where(inArray(schema.schemaLabels.schemaId, schemaIds)); + + for (const l of allLabels) { + if (!labelsMap.has(l.schemaId)) labelsMap.set(l.schemaId, []); + labelsMap.get(l.schemaId)!.push(l.label); + } + } + + return c.json({ + version: version.number, + semver: version.semver, + schemas: entries.map((e) => { + const labels = labelsMap.get(e.schemaId) ?? []; + const body = raw === 'true' + ? e.schemaBody + : labels.length > 0 + ? { ...(e.schemaBody as object), 'x-underlay-labels': labels } + : e.schemaBody; + + return { + slug: e.slug, + schemaId: e.schemaId, + schemaHash: e.schemaHash, + schema: body, + }; + }), + }); +}); + +// --- Label management --- + +// Add a label to a schema +// POST /schemas/:id/labels { label: "schema.org/Person" } +app.post('/schemas/:id/labels', requireAuth('write'), async (c) => { + const id = c.req.param('id'); + const { label } = await c.req.json(); + + if (!label || typeof label !== 'string' || label.trim().length === 0) { + return c.json({ error: 'Label is required', statusCode: 400 }, 400); + } + + // Verify schema exists + const [existing] = await db + .select({ id: schema.schemas.id }) + .from(schema.schemas) + .where(eq(schema.schemas.id, id)) + .limit(1); + + if (!existing) { + return c.json({ error: 'Schema not found', statusCode: 404 }, 404); + } + + // Upsert label (ignore conflict on duplicate) + try { + const [inserted] = await db + .insert(schema.schemaLabels) + .values({ schemaId: id, label: label.trim() }) + .onConflictDoNothing() + .returning(); + + if (!inserted) { + return c.json({ status: 'exists', schemaId: id, label: label.trim() }); + } + + return c.json({ status: 'created', schemaId: id, label: label.trim() }, 201); + } catch (err: any) { + return c.json({ error: 'Failed to add label', statusCode: 500 }, 500); + } +}); + +// Remove a label from a schema +// DELETE /schemas/:id/labels/:label +app.delete('/schemas/:id/labels/:label', requireAuth('admin'), async (c) => { + const id = c.req.param('id'); + const label = c.req.param('label'); + + const result = await db + .delete(schema.schemaLabels) + .where(and(eq(schema.schemaLabels.schemaId, id), eq(schema.schemaLabels.label, label))) + .returning(); + + if (result.length === 0) { + return c.json({ error: 'Label not found', statusCode: 404 }, 404); + } + + return c.json({ status: 'deleted', schemaId: id, label }); +}); + +// --- Helpers --- + +async function getUsageCount(schemaId: string): Promise { + const [result] = await db + .select({ count: sql`count(distinct ${schema.versionSchemas.versionId})::int` }) + .from(schema.versionSchemas) + .where(eq(schema.versionSchemas.schemaId, schemaId)); + return result?.count ?? 0; +} + +export const schemaRoutes = app; diff --git a/src/api/server.ts b/src/api/server.ts deleted file mode 100644 index b63fa56..0000000 --- a/src/api/server.ts +++ /dev/null @@ -1,92 +0,0 @@ -import Fastify from "fastify"; -import cookie from "@fastify/cookie"; -import cors from "@fastify/cors"; -import multipart from "@fastify/multipart"; -import rateLimit from "@fastify/rate-limit"; - -import authPlugin from "./plugins/auth.js"; -import { healthRoutes } from "./routes/health.js"; -import { accountRoutes } from "./routes/accounts.js"; -import { collectionsRoutes } from "./routes/collections.js"; -import { versionRoutes } from "./routes/versions.js"; -import { uploadRoutes } from "./routes/uploads.js"; -import { fileRoutes } from "./routes/files.js"; -import { schemaRoutes } from "./routes/schemas.js"; -import { adminRoutes } from "./routes/admin.js"; -import { queryRoutes } from "./routes/query.js"; -import { arkRoutes } from "./routes/ark.js"; - -export async function buildApp() { - const app = Fastify({ - logger: { - level: process.env.NODE_ENV === "production" ? "info" : "debug", - }, - bodyLimit: 100 * 1024 * 1024, // 100 MB — version pushes and file uploads can be large - }); - - // Core plugins - await app.register(cookie, { - secret: process.env.SESSION_SECRET ?? "dev-secret-change-me", - }); - - await app.register(cors, { - origin: true, - credentials: true, - }); - - // Rate limiting: unauthenticated gets 60/min, authenticated gets 5000/min - await app.register(rateLimit, { - max: (request) => request.accountId ? 5000 : 60, - timeWindow: "1 minute", - keyGenerator: (request) => { - if (request.accountId) return `acct:${request.accountId}`; - return request.ip; - }, - hook: "preHandler", // runs after onRequest (auth), so accountId is set - errorResponseBuilder: (_request, context) => ({ - statusCode: 429, - error: "Too Many Requests", - message: `Rate limit exceeded. Try again in ${Math.ceil(context.ttl / 1000)} seconds.`, - retryAfter: Math.ceil(context.ttl / 1000), - }), - }); - - await app.register(multipart, { - limits: { fileSize: 500 * 1024 * 1024 }, // 500MB for large files - }); - - // Allow raw binary uploads (PDFs, HTML, etc.) - app.addContentTypeParser("application/pdf", { parseAs: "buffer" }, (_req, body, done) => { - done(null, body); - }); - app.addContentTypeParser("application/octet-stream", { parseAs: "buffer" }, (_req, body, done) => { - done(null, body); - }); - app.addContentTypeParser("text/html", { parseAs: "buffer" }, (_req, body, done) => { - done(null, body); - }); - - // Auth - await app.register(authPlugin); - - // Routes - await app.register(healthRoutes, { prefix: "/api" }); - await app.register(accountRoutes, { prefix: "/api" }); - await app.register(collectionsRoutes, { prefix: "/api" }); - await app.register(versionRoutes, { prefix: "/api" }); - await app.register(uploadRoutes, { prefix: "/api" }); - await app.register(fileRoutes, { prefix: "/api" }); - await app.register(schemaRoutes, { prefix: "/api" }); - await app.register(adminRoutes, { prefix: "/api" }); - await app.register(queryRoutes, { prefix: "/api" }); - await app.register(arkRoutes, { prefix: "/api" }); - return app; -} - -const isMain = - process.argv[1]?.endsWith("server.ts") || process.argv[1]?.endsWith("server.js"); - -if (isMain) { - const app = await buildApp(); - await app.listen({ port: 3000, host: "0.0.0.0" }); -} diff --git a/src/api/uploads.ts b/src/api/uploads.ts new file mode 100644 index 0000000..61105a4 --- /dev/null +++ b/src/api/uploads.ts @@ -0,0 +1,910 @@ +import { Hono } from "hono"; +import { eq, and, sql, inArray } from "drizzle-orm"; +import { db, schema } from "../db/client.server.js"; +import { requireAuth, type AuthEnv } from "./auth.server.js"; +import { createHash } from "node:crypto"; +import { getS3ObjectMeta } from "../lib/s3.js"; +import Ajv from "ajv"; +import addFormats from "ajv-formats"; + +const ajv = new Ajv({ allErrors: true, strict: false }); +addFormats(ajv); + +/** Session expiry: 1 hour from creation */ +const SESSION_TTL_MS = 60 * 60 * 1000; + +/** Max records per batch request */ +const MAX_BATCH_SIZE = 10_000; + +// --- Helpers (shared with versions.ts logic) --- + +type SchemaEntry = { slug: string; schemaId: string; schema: Record; schemaHash: string }; + +async function loadVersionSchemas(versionId: number): Promise { + const rows = await db + .select({ + slug: schema.versionSchemas.slug, + schemaId: schema.versionSchemas.schemaId, + schema: schema.schemas.schema, + schemaHash: schema.schemas.schemaHash, + }) + .from(schema.versionSchemas) + .innerJoin(schema.schemas, eq(schema.versionSchemas.schemaId, schema.schemas.id)) + .where(eq(schema.versionSchemas.versionId, versionId)); + return rows as SchemaEntry[]; +} + +function getPrivateTypes(schemaEntries: SchemaEntry[]): Set { + const types = new Set(); + for (const entry of schemaEntries) { + if ((entry.schema as any)?.private === true) types.add(entry.slug); + } + return types; +} + +function getPrivateFields(typeSchema: Record): Set { + const fields = new Set(); + const props = typeSchema?.properties as Record | undefined; + if (!props) return fields; + for (const [fieldName, fieldDef] of Object.entries(props)) { + if (fieldDef?.private === true) fields.add(fieldName); + } + return fields; +} + +function filterRecordData(data: unknown, privateFields: Set): unknown { + if (privateFields.size === 0 || typeof data !== "object" || data === null) return data; + const filtered: Record = {}; + for (const [key, value] of Object.entries(data as Record)) { + if (!privateFields.has(key)) filtered[key] = value; + } + return filtered; +} + +function filterTypeSchema(typeSchema: Record): Record { + const props = typeSchema?.properties as Record | undefined; + if (!props) return typeSchema; + const publicProps: Record = {}; + for (const [fieldName, fieldDef] of Object.entries(props)) { + if ((fieldDef as any)?.private === true) continue; + publicProps[fieldName] = fieldDef; + } + const required = (typeSchema.required as string[] | undefined)?.filter( + (f: string) => !((props[f] as any)?.private === true), + ); + return { ...typeSchema, properties: publicProps, required }; +} + +function hashSchema(schemaBody: unknown): string { + return createHash("sha256").update(JSON.stringify(schemaBody)).digest("hex"); +} + +function deriveSemver( + prevSemver: string | null, + schemaChanged: boolean, + recordsChanged: boolean, +): string { + if (!prevSemver) return "v1.0.0"; + const parts = prevSemver.replace(/^v/, "").split(".").map(Number); + const [major, minor, patch] = [parts[0] ?? 1, parts[1] ?? 0, parts[2] ?? 0]; + if (schemaChanged) return `v${major + 1}.0.0`; + if (recordsChanged) return `v${major}.${minor + 1}.0`; + return `v${major}.${minor}.${patch + 1}`; +} + +async function resolveCollection(owner: string, slug: string) { + const [result] = await db + .select({ + id: schema.collections.id, + accountId: schema.collections.accountId, + slug: schema.collections.slug, + }) + .from(schema.collections) + .innerJoin(schema.accounts, eq(schema.collections.accountId, schema.accounts.id)) + .where(and(eq(schema.accounts.slug, owner), eq(schema.collections.slug, slug))) + .limit(1); + return result ?? null; +} + +const app = new Hono(); + +// --- Start a chunked upload session --- +app.post( + "/collections/:owner/:slug/versions/upload", + requireAuth("write"), + async (c) => { + const owner = c.req.param("owner"); + const slug = c.req.param("slug"); + const body = await c.req.json<{ + base_version: number | null; + message?: string; + readme?: string; + app_id?: string; + actor_id?: string; + schemas?: Record; + }>(); + + const collection = await resolveCollection(owner, slug); + if (!collection) return c.json({ error: "Collection not found", statusCode: 404 }, 404); + + // Verify the caller owns this collection + if (c.get("accountId") !== collection.accountId) { + return c.json({ error: "Not authorized for this collection", statusCode: 403 }, 403); + } + + // Optimistic lock check at session creation time + const [latest] = await db + .select({ number: schema.versions.number }) + .from(schema.versions) + .where(eq(schema.versions.collectionId, collection.id)) + .orderBy(sql`${schema.versions.number} desc`) + .limit(1); + + const currentNumber = latest?.number ?? 0; + if (body.base_version !== null && body.base_version !== currentNumber) { + return c.json({ + error: "Version conflict", + currentVersion: currentNumber, + statusCode: 409, + }, 409); + } + + const expiresAt = new Date(Date.now() + SESSION_TTL_MS); + + const [session] = await db + .insert(schema.uploadSessions) + .values({ + collectionId: collection.id, + accountId: c.get("accountId")!, + baseVersion: body.base_version ?? null, + message: body.message ?? null, + readme: body.readme ?? null, + appId: body.app_id ?? null, + actorId: body.actor_id ?? null, + schemas: body.schemas ? (body.schemas as any) : null, + status: "open", + recordCount: 0, + expiresAt, + }) + .returning({ id: schema.uploadSessions.id }); + + return c.json({ + sessionId: session!.id, + expiresAt: expiresAt.toISOString(), + }, 201); + }, +); + +// --- Append a batch of changes to a session --- +app.put( + "/collections/:owner/:slug/versions/upload/:sessionId", + requireAuth("write"), + async (c) => { + const owner = c.req.param("owner"); + const slug = c.req.param("slug"); + const sessionId = c.req.param("sessionId"); + const body = await c.req.json<{ + changes: { + added?: { id: string; type: string; data: unknown; private?: boolean }[]; + updated?: { id: string; type: string; data: unknown; private?: boolean }[]; + removed?: string[]; + }; + }>(); + + // Validate session exists and belongs to caller + const [session] = await db + .select() + .from(schema.uploadSessions) + .where(eq(schema.uploadSessions.id, sessionId)) + .limit(1); + + if (!session) { + return c.json({ error: "Upload session not found", statusCode: 404 }, 404); + } + if (session.accountId !== c.get("accountId")) { + return c.json({ error: "Not authorized for this session", statusCode: 403 }, 403); + } + if (session.status !== "open") { + return c.json({ + error: "Session is not open", + status: session.status, + statusCode: 409, + }, 409); + } + if (new Date(session.expiresAt) < new Date()) { + await db + .update(schema.uploadSessions) + .set({ status: "expired" }) + .where(eq(schema.uploadSessions.id, sessionId)); + return c.json({ error: "Upload session expired", statusCode: 410 }, 410); + } + + // Verify collection matches + const collection = await resolveCollection(owner, slug); + if (!collection || collection.id !== session.collectionId) { + return c.json({ error: "Collection mismatch", statusCode: 404 }, 404); + } + + // Count total records in this batch + const addedCount = body.changes.added?.length ?? 0; + const updatedCount = body.changes.updated?.length ?? 0; + const removedCount = body.changes.removed?.length ?? 0; + const batchSize = addedCount + updatedCount + removedCount; + + if (batchSize === 0) { + return c.json({ error: "Empty batch", statusCode: 400 }, 400); + } + if (batchSize > MAX_BATCH_SIZE) { + return c.json({ + error: `Batch too large. Maximum ${MAX_BATCH_SIZE} records per batch.`, + statusCode: 400, + }, 400); + } + + // Insert records into staging table (upsert to handle re-sends) + const rows: { + sessionId: string; + recordId: string; + type: string | null; + data: any; + private: boolean; + operation: "add" | "update" | "remove"; + }[] = []; + + for (const rec of body.changes.added ?? []) { + rows.push({ + sessionId, + recordId: rec.id, + type: rec.type, + data: rec.data, + private: rec.private ?? false, + operation: "add", + }); + } + for (const rec of body.changes.updated ?? []) { + rows.push({ + sessionId, + recordId: rec.id, + type: rec.type, + data: rec.data, + private: rec.private ?? false, + operation: "update", + }); + } + for (const id of body.changes.removed ?? []) { + rows.push({ + sessionId, + recordId: id, + type: null, + data: null, + private: false, + operation: "remove", + }); + } + + // Batch insert (upsert: last write wins for same recordId) + const BATCH = 1000; + for (let i = 0; i < rows.length; i += BATCH) { + const batch = rows.slice(i, i + BATCH); + await db + .insert(schema.uploadRecords) + .values(batch) + .onConflictDoUpdate({ + target: [schema.uploadRecords.sessionId, schema.uploadRecords.recordId], + set: { + type: sql`excluded.type`, + data: sql`excluded.data`, + private: sql`excluded.private`, + operation: sql`excluded.operation`, + }, + }); + } + + // Update session record count + const [countResult] = await db + .select({ count: sql`count(*)` }) + .from(schema.uploadRecords) + .where(eq(schema.uploadRecords.sessionId, sessionId)); + + await db + .update(schema.uploadSessions) + .set({ recordCount: countResult?.count ?? 0 }) + .where(eq(schema.uploadSessions.id, sessionId)); + + return c.json({ + received: { added: addedCount, updated: updatedCount, removed: removedCount }, + totalStaged: countResult?.count ?? 0, + }); + }, +); + +// --- Get session status --- +app.get( + "/collections/:owner/:slug/versions/upload/:sessionId", + requireAuth("read"), + async (c) => { + const sessionId = c.req.param("sessionId"); + + const [session] = await db + .select() + .from(schema.uploadSessions) + .where(eq(schema.uploadSessions.id, sessionId)) + .limit(1); + + if (!session) { + return c.json({ error: "Upload session not found", statusCode: 404 }, 404); + } + if (session.accountId !== c.get("accountId")) { + return c.json({ error: "Not authorized for this session", statusCode: 403 }, 403); + } + + return c.json({ + sessionId: session.id, + status: session.status, + recordCount: session.recordCount, + baseVersion: session.baseVersion, + expiresAt: session.expiresAt, + createdAt: session.createdAt, + }); + }, +); + +// --- Finalize: build the version from staged records --- +app.post( + "/collections/:owner/:slug/versions/upload/:sessionId/finalize", + requireAuth("write"), + async (c) => { + const owner = c.req.param("owner"); + const slug = c.req.param("slug"); + const sessionId = c.req.param("sessionId"); + + // Load and validate session + const [session] = await db + .select() + .from(schema.uploadSessions) + .where(eq(schema.uploadSessions.id, sessionId)) + .limit(1); + + if (!session) { + return c.json({ error: "Upload session not found", statusCode: 404 }, 404); + } + if (session.accountId !== c.get("accountId")) { + return c.json({ error: "Not authorized for this session", statusCode: 403 }, 403); + } + if (session.status !== "open") { + return c.json({ + error: `Session cannot be finalized (status: ${session.status})`, + statusCode: 409, + }, 409); + } + if (new Date(session.expiresAt) < new Date()) { + await db + .update(schema.uploadSessions) + .set({ status: "expired" }) + .where(eq(schema.uploadSessions.id, sessionId)); + return c.json({ error: "Upload session expired", statusCode: 410 }, 410); + } + + const collection = await resolveCollection(owner, slug); + if (!collection || collection.id !== session.collectionId) { + return c.json({ error: "Collection mismatch", statusCode: 404 }, 404); + } + + // Mark session as finalizing + await db + .update(schema.uploadSessions) + .set({ status: "finalizing" }) + .where(eq(schema.uploadSessions.id, sessionId)); + + try { + // Re-check optimistic lock + const [latest] = await db + .select() + .from(schema.versions) + .where(eq(schema.versions.collectionId, collection.id)) + .orderBy(sql`${schema.versions.number} desc`) + .limit(1); + + const currentNumber = latest?.number ?? 0; + if (session.baseVersion !== null && session.baseVersion !== currentNumber) { + await db + .update(schema.uploadSessions) + .set({ status: "failed" }) + .where(eq(schema.uploadSessions.id, sessionId)); + return c.json({ + error: "Version conflict", + currentVersion: currentNumber, + statusCode: 409, + }, 409); + } + + // --- Resolve schemas --- + let prevSchemaEntries: SchemaEntry[] = []; + if (latest) { + prevSchemaEntries = await loadVersionSchemas(latest.id); + } + + let schemasInput: Record; + if (session.schemas && Object.keys(session.schemas as object).length > 0) { + schemasInput = session.schemas as Record; + } else if (prevSchemaEntries.length > 0) { + schemasInput = Object.fromEntries(prevSchemaEntries.map((e) => [e.slug, e.schema])); + } else { + await db + .update(schema.uploadSessions) + .set({ status: "failed" }) + .where(eq(schema.uploadSessions.id, sessionId)); + return c.json({ + error: "Schemas required", + message: "First version must include a `schemas` map with at least one type definition.", + statusCode: 422, + }, 422); + } + + // Hash and upsert schemas + const newSchemaSet: { slug: string; schemaId: string; schemaHash: string; schema: Record }[] = []; + for (const [typeSlug, typeSchema] of Object.entries(schemasInput)) { + const hash = hashSchema(typeSchema); + const [existing] = await db + .select({ id: schema.schemas.id }) + .from(schema.schemas) + .where(eq(schema.schemas.schemaHash, hash)) + .limit(1); + + let schemaId: string; + if (existing) { + schemaId = existing.id; + } else { + const [inserted] = await db + .insert(schema.schemas) + .values({ schema: typeSchema as any, schemaHash: hash }) + .returning({ id: schema.schemas.id }); + schemaId = inserted!.id; + } + newSchemaSet.push({ slug: typeSlug, schemaId, schemaHash: hash, schema: typeSchema as Record }); + } + + // Check schema changes + const prevSchemaMap = new Map(prevSchemaEntries.map((e) => [e.slug, e.schemaHash])); + const newSchemaMap = new Map(newSchemaSet.map((e) => [e.slug, e.schemaHash])); + let schemaChanged = prevSchemaMap.size !== newSchemaMap.size; + if (!schemaChanged) { + for (const [s, hash] of newSchemaMap) { + if (prevSchemaMap.get(s) !== hash) { + schemaChanged = true; + break; + } + } + } + + // Build validators + const validators = new Map>(); + for (const entry of newSchemaSet) { + validators.set(entry.slug, ajv.compile(entry.schema as object)); + } + + // Get file hashes from previous version + let existingFileHashes: string[] = []; + if (latest) { + const vf = await db + .select({ hash: schema.versionFiles.fileHash }) + .from(schema.versionFiles) + .where(eq(schema.versionFiles.versionId, latest.id)); + existingFileHashes = vf.map((f) => f.hash); + } + + // --- Streaming finalize --- + // Instead of loading all records into memory, we: + // 1. Materialize the merged record set into a temp table in Postgres + // 2. Stream through it in sorted batches for validation, hash computation, and insertion + // + // The temp table approach lets Postgres handle the merge (existing + staged changes) + // and gives us sorted cursor access without holding everything in Node memory. + + // Create a temp table with the merged result + await db.execute(sql` + CREATE TEMP TABLE _finalize_records ( + record_id text PRIMARY KEY, + type text NOT NULL, + data jsonb NOT NULL, + private boolean NOT NULL DEFAULT false + ) ON COMMIT DROP + `); + + // Insert existing records from base version (if any) + if (latest) { + await db.execute(sql` + INSERT INTO _finalize_records (record_id, type, data, private) + SELECT record_id, type, data, private + FROM records + WHERE version_id = ${latest.id} + `); + } + + // Apply staged changes (upserts and deletes) + await db.execute(sql` + INSERT INTO _finalize_records (record_id, type, data, private) + SELECT record_id, type, data, COALESCE(private, false) + FROM upload_records + WHERE session_id = ${sessionId} + AND operation IN ('add', 'update') + ON CONFLICT (record_id) DO UPDATE SET + type = EXCLUDED.type, + data = EXCLUDED.data, + private = EXCLUDED.private + `); + + // Remove deleted records + await db.execute(sql` + DELETE FROM _finalize_records + WHERE record_id IN ( + SELECT record_id FROM upload_records + WHERE session_id = ${sessionId} AND operation = 'remove' + ) + `); + + // Get total count + const [countResult] = await db.execute(sql`SELECT count(*) as cnt FROM _finalize_records`); + const totalRecordCount = Number((countResult as any).cnt); + + // Check all record types have schemas + const [typesResult] = await db.execute(sql`SELECT DISTINCT type FROM _finalize_records`); + // typesResult is an array of rows + const allTypes: string[] = (Array.isArray(typesResult) ? typesResult : [typesResult]) + .filter(Boolean) + .map((r: any) => r.type); + const missingSchemas = allTypes.filter((t) => !(t in schemasInput)); + if (missingSchemas.length > 0) { + await db.update(schema.uploadSessions).set({ status: "failed" }).where(eq(schema.uploadSessions.id, sessionId)); + await db.execute(sql`DROP TABLE IF EXISTS _finalize_records`); + return c.json({ + error: "Missing schemas for record types", + types: missingSchemas, + statusCode: 422, + }, 422); + } + + // --- Stream through records in sorted batches --- + // We compute hashes incrementally and validate + collect file refs + insert records + const STREAM_BATCH = 5000; + const privateTypes = getPrivateTypes(newSchemaSet as SchemaEntry[]); + + // Streaming hash state + const privateHasher = createHash("sha256"); + const publicHasher = createHash("sha256"); + + // We build the canonical hash as: {"schemas":{...},"records":[],"files":[...],"readme":...} + // For streaming, we compute records portion incrementally + const schemaSetForHash = newSchemaSet + .map((e) => ({ slug: e.slug, schemaHash: e.schemaHash })) + .sort((a, b) => a.slug.localeCompare(b.slug)); + const publicSchemaSet = newSchemaSet + .filter((e) => !privateTypes.has(e.slug)) + .map((e) => ({ slug: e.slug, schemaHash: hashSchema(filterTypeSchema(e.schema)) })) + .sort((a, b) => a.slug.localeCompare(b.slug)); + + // We'll collect all record canonical forms for hashing + // Using incremental approach: hash prefix, then each record, then suffix + const schemasCanonical = JSON.stringify( + Object.fromEntries(schemaSetForHash.map((s) => [s.slug, s.schemaHash])), + ); + const publicSchemasCanonical = JSON.stringify( + Object.fromEntries(publicSchemaSet.map((s) => [s.slug, s.schemaHash])), + ); + + // Start building canonical: {"schemas":...,"records":[ + privateHasher.update(`{"schemas":${schemasCanonical},"records":[`); + publicHasher.update(`{"schemas":${publicSchemasCanonical},"records":[`); + + const referencedHashes = new Set(existingFileHashes); + const validationErrors: { recordId: string; type: string; errors: string[] }[] = []; + let totalBytes = 0; + let recordCount = 0; + let publicRecordCount = 0; + let hasChanges = false; + let cursor = ""; + let hasMore = true; + + // Check if staged records exist (indicates changes) + const [stagedCount] = await db + .select({ count: sql`count(*)` }) + .from(schema.uploadRecords) + .where(eq(schema.uploadRecords.sessionId, sessionId)); + hasChanges = (stagedCount?.count ?? 0) > 0; + + // Insert the new version early to get its ID for record insertion + // We'll update the hash fields after streaming + const readmeValue = session.readme !== null ? session.readme : (latest?.readme ?? null); + const semver = deriveSemver(latest?.semver ?? null, schemaChanged, hasChanges); + const newNumber = currentNumber + 1; + + // We need to process all records before we can insert the version (need hashes) + // So we stream in two phases: + // Phase 1: validate + compute hashes + collect file refs + count bytes + // Phase 2: insert records (re-stream from temp table) + + while (hasMore) { + const batch = await db.execute(sql` + SELECT record_id, type, data, private + FROM _finalize_records + WHERE record_id > ${cursor} + ORDER BY record_id ASC + LIMIT ${STREAM_BATCH} + `) as any[]; + + const rows = Array.isArray(batch) ? batch : []; + if (rows.length === 0) { + hasMore = false; + break; + } + + for (const rec of rows) { + // Validate + const validate = validators.get(rec.type); + if (!validate) { + validationErrors.push({ + recordId: rec.record_id, + type: rec.type, + errors: [`No schema defined for record type "${rec.type}"`], + }); + } else if (!validate(rec.data)) { + validationErrors.push({ + recordId: rec.record_id, + type: rec.type, + errors: (validate.errors ?? []).map( + (e) => `${e.instancePath || "/"} ${e.message ?? "validation failed"}`, + ), + }); + } + + // Feed into private hash (all records) + const recCanonical = JSON.stringify({ id: rec.record_id, type: rec.type, data: rec.data }); + if (recordCount > 0) privateHasher.update(","); + privateHasher.update(recCanonical); + recordCount++; + + // Feed into public hash (non-private records only, with private fields stripped) + const isPrivateRecord = rec.private === true; + const isPrivateType = privateTypes.has(rec.type); + if (!isPrivateRecord && !isPrivateType) { + const entry = newSchemaSet.find((e) => e.slug === rec.type); + const privFields = entry ? getPrivateFields(entry.schema) : new Set(); + const pubData = privFields.size > 0 ? filterRecordData(rec.data, privFields) : rec.data; + const pubCanonical = JSON.stringify({ id: rec.record_id, type: rec.type, data: pubData }); + if (publicRecordCount > 0) publicHasher.update(","); + publicHasher.update(pubCanonical); + publicRecordCount++; + } + + // Compute bytes + totalBytes += Buffer.byteLength(JSON.stringify(rec.data), "utf-8"); + + // Scan for $file references + const data = rec.data as Record; + for (const val of Object.values(data)) { + if ( + typeof val === "object" && + val !== null && + "$file" in val && + typeof (val as { $file: string }).$file === "string" + ) { + const fileHash = (val as { $file: string }).$file.replace("sha256:", ""); + referencedHashes.add(fileHash); + } + } + } + + cursor = rows[rows.length - 1].record_id; + if (rows.length < STREAM_BATCH) hasMore = false; + } + + // Bail on validation errors + if (validationErrors.length > 0) { + await db.update(schema.uploadSessions).set({ status: "failed" }).where(eq(schema.uploadSessions.id, sessionId)); + await db.execute(sql`DROP TABLE IF EXISTS _finalize_records`); + return c.json({ + error: "Schema validation failed", + validationErrors: validationErrors.slice(0, 100), // cap error list + statusCode: 422, + }, 422); + } + + // Check all referenced files exist + const allFileHashes = Array.from(referencedHashes); + if (allFileHashes.length > 0) { + const existingFiles = await db + .select({ hash: schema.files.hash }) + .from(schema.files) + .where(inArray(schema.files.hash, allFileHashes)); + const existingSet = new Set(existingFiles.map((f) => f.hash)); + let filesNeeded = allFileHashes.filter((h) => !existingSet.has(h)); + + // For files not in local DB, check if they exist in S3 (shared bucket) + if (filesNeeded.length > 0) { + const stillNeeded: string[] = []; + for (const h of filesNeeded) { + const key = `files/${h.slice(0, 2)}/${h.slice(2, 4)}/${h}`; + const meta = await getS3ObjectMeta(key); + if (meta !== null) { + await db.insert(schema.files).values({ + hash: h, + size: meta.size, + mimeType: meta.contentType, + storageKey: key, + }).onConflictDoNothing(); + } else { + stillNeeded.push(h); + } + } + filesNeeded = stillNeeded; + } + + if (filesNeeded.length > 0) { + await db.update(schema.uploadSessions).set({ status: "failed" }).where(eq(schema.uploadSessions.id, sessionId)); + await db.execute(sql`DROP TABLE IF EXISTS _finalize_records`); + return c.json({ + error: "Missing files", + filesNeeded: filesNeeded.map((h) => `sha256:${h}`), + statusCode: 422, + }, 422); + } + } + + // Finalize hash computation + const sortedFileHashes = allFileHashes.sort(); + const filesCanonical = JSON.stringify(sortedFileHashes); + const readmeCanonical = JSON.stringify(readmeValue ?? null); + + privateHasher.update(`],"files":${filesCanonical},"readme":${readmeCanonical}}`); + publicHasher.update(`],"files":${filesCanonical},"readme":${readmeCanonical}}`); + + const versionHash = "private:" + privateHasher.digest("hex"); + const publicHash = "public:" + publicHasher.digest("hex"); + + // Check for duplicate hash + const [existingHash] = await db + .select({ number: schema.versions.number }) + .from(schema.versions) + .where( + and( + eq(schema.versions.collectionId, collection.id), + eq(schema.versions.hash, versionHash), + ), + ) + .limit(1); + + if (existingHash) { + await db.update(schema.uploadSessions).set({ status: "failed" }).where(eq(schema.uploadSessions.id, sessionId)); + await db.execute(sql`DROP TABLE IF EXISTS _finalize_records`); + return c.json({ + error: "No changes detected", + message: `Version ${existingHash.number} already has identical content`, + existingVersion: existingHash.number, + }, 409); + } + + // Add file sizes to totalBytes + if (allFileHashes.length > 0) { + const [fileSizeSum] = await db + .select({ total: sql`coalesce(sum(${schema.files.size}), 0)` }) + .from(schema.files) + .where(inArray(schema.files.hash, allFileHashes)); + totalBytes += Number(fileSizeSum?.total ?? 0); + } + + // Insert version + const [version] = await db + .insert(schema.versions) + .values({ + collectionId: collection.id, + number: newNumber, + semver, + hash: versionHash, + publicHash, + baseNumber: session.baseVersion, + message: session.message ?? null, + readme: readmeValue, + pushedBy: c.get("accountId") ?? null, + appId: session.appId ?? null, + actorId: session.actorId ?? null, + recordCount, + fileCount: allFileHashes.length, + totalBytes, + }) + .returning(); + + // Phase 2: Insert records from temp table into the real records table (in batches) + await db.execute(sql` + INSERT INTO records (version_id, record_id, type, data, private) + SELECT ${version!.id}, record_id, type, data, private + FROM _finalize_records + `); + + // Clean up temp table + await db.execute(sql`DROP TABLE IF EXISTS _finalize_records`); + + // Insert version_files + if (allFileHashes.length > 0) { + await db.insert(schema.versionFiles).values( + allFileHashes.map((hash) => ({ + versionId: version!.id, + fileHash: hash, + })), + ); + } + + // Insert version_schemas + await db.insert(schema.versionSchemas).values( + newSchemaSet.map((entry) => ({ + versionId: version!.id, + slug: entry.slug, + schemaId: entry.schemaId, + })), + ); + + // Update collection timestamp + await db + .update(schema.collections) + .set({ updatedAt: new Date() }) + .where(eq(schema.collections.id, collection.id)); + + // Clean up: delete staged records and the session itself + await db + .delete(schema.uploadRecords) + .where(eq(schema.uploadRecords.sessionId, sessionId)); + await db + .delete(schema.uploadSessions) + .where(eq(schema.uploadSessions.id, sessionId)); + + return c.json({ + version: newNumber, + semver, + hash: versionHash, + recordCount, + fileCount: allFileHashes.length, + }, 201); + } catch (err) { + // Mark session as failed on unexpected error + await db.execute(sql`DROP TABLE IF EXISTS _finalize_records`); + await db + .update(schema.uploadSessions) + .set({ status: "failed" }) + .where(eq(schema.uploadSessions.id, sessionId)); + throw err; + } + }, +); + +// --- Abort/cancel a session --- +app.delete( + "/collections/:owner/:slug/versions/upload/:sessionId", + requireAuth("write"), + async (c) => { + const sessionId = c.req.param("sessionId"); + + const [session] = await db + .select() + .from(schema.uploadSessions) + .where(eq(schema.uploadSessions.id, sessionId)) + .limit(1); + + if (!session) { + return c.json({ error: "Upload session not found", statusCode: 404 }, 404); + } + if (session.accountId !== c.get("accountId")) { + return c.json({ error: "Not authorized for this session", statusCode: 403 }, 403); + } + + // Delete staged records and session + await db + .delete(schema.uploadRecords) + .where(eq(schema.uploadRecords.sessionId, sessionId)); + await db + .delete(schema.uploadSessions) + .where(eq(schema.uploadSessions.id, sessionId)); + + return c.body(null, 204); + }, +); + +export const uploadRoutes = app; diff --git a/src/api/versions.ts b/src/api/versions.ts new file mode 100644 index 0000000..206b6f1 --- /dev/null +++ b/src/api/versions.ts @@ -0,0 +1,1036 @@ +import { Hono } from "hono"; +import { eq, and, sql, inArray } from "drizzle-orm"; +import { db, schema } from "../db/client.server.js"; +import { requireAuth, type AuthEnv } from "./auth.server.js"; +import { createHash } from "node:crypto"; +import Ajv from "ajv"; +import addFormats from "ajv-formats"; +import { DEFAULT_NAAN, buildArkUrl } from "../lib/ark.js"; + +const ajv = new Ajv({ allErrors: true, strict: false }); +addFormats(ajv); + +// --- Schema helpers --- + +type SchemaEntry = { slug: string; schemaId: string; schema: Record; schemaHash: string }; + +/** Load the full schema set for a version (slug → schema body + metadata) */ +async function loadVersionSchemas(versionId: number): Promise { + const rows = await db + .select({ + slug: schema.versionSchemas.slug, + schemaId: schema.versionSchemas.schemaId, + schema: schema.schemas.schema, + schemaHash: schema.schemas.schemaHash, + }) + .from(schema.versionSchemas) + .innerJoin(schema.schemas, eq(schema.versionSchemas.schemaId, schema.schemas.id)) + .where(eq(schema.versionSchemas.versionId, versionId)); + + return rows as SchemaEntry[]; +} + +// --- Visibility helpers --- + +/** Get the set of private type slugs from a version's schemas */ +function getPrivateTypes(schemaEntries: SchemaEntry[]): Set { + const types = new Set(); + for (const entry of schemaEntries) { + if ((entry.schema as any)?.private === true) { + types.add(entry.slug); + } + } + return types; +} + +/** Get the set of private field names for a given type schema */ +function getPrivateFields(typeSchema: Record): Set { + const fields = new Set(); + const props = typeSchema?.properties as Record | undefined; + if (!props) return fields; + for (const [fieldName, fieldDef] of Object.entries(props)) { + if (fieldDef?.private === true) fields.add(fieldName); + } + return fields; +} + +/** Strip private fields from a record's data */ +function filterRecordData(data: unknown, privateFields: Set): unknown { + if (privateFields.size === 0 || typeof data !== "object" || data === null) return data; + const filtered: Record = {}; + for (const [key, value] of Object.entries(data as Record)) { + if (!privateFields.has(key)) filtered[key] = value; + } + return filtered; +} + +/** Filter a type schema for public view: strip private fields from properties */ +function filterTypeSchema(typeSchema: Record): Record { + const props = typeSchema?.properties as Record | undefined; + if (!props) return typeSchema; + + const publicProps: Record = {}; + for (const [fieldName, fieldDef] of Object.entries(props)) { + if ((fieldDef as any)?.private === true) continue; + publicProps[fieldName] = fieldDef; + } + + const required = (typeSchema.required as string[] | undefined)?.filter( + (f: string) => !((props[f] as any)?.private === true), + ); + + return { ...typeSchema, properties: publicProps, required }; +} + +/** Build a public-facing schemas map (excluding private types, stripping private fields) */ +function filterSchemasForPublic(schemaEntries: SchemaEntry[]): Record { + const result: Record = {}; + for (const entry of schemaEntries) { + if ((entry.schema as any)?.private === true) continue; + result[entry.slug] = filterTypeSchema(entry.schema); + } + return result; +} + +/** Check if requester is the owner of a collection */ +function isOwner(accountId: string | undefined, collectionAccountId: string): boolean { + return accountId != null && accountId === collectionAccountId; +} + +/** Compute SHA-256 hash of a schema JSON body (canonical stringified) */ +function hashSchema(schemaBody: unknown): string { + return createHash("sha256").update(JSON.stringify(schemaBody)).digest("hex"); +} + +function computeVersionHash( + schemaSet: { slug: string; schemaHash: string }[], + recordRows: { recordId: string; type: string; data: unknown }[], + fileHashes: string[], + readme: string | null, +): string { + const canonical = JSON.stringify({ + schemas: Object.fromEntries( + schemaSet.sort((a, b) => a.slug.localeCompare(b.slug)).map((s) => [s.slug, s.schemaHash]), + ), + records: recordRows + .sort((a, b) => a.recordId.localeCompare(b.recordId)) + .map((r) => ({ id: r.recordId, type: r.type, data: r.data })), + files: fileHashes.sort(), + readme: readme ?? null, + }); + return "private:" + createHash("sha256").update(canonical).digest("hex"); +} + +/** Compute a public hash that only covers non-private content */ +function computePublicHash( + schemaEntries: SchemaEntry[], + recordRows: { recordId: string; type: string; data: unknown; private: boolean }[], + fileHashes: string[], + readme: string | null, +): string { + const privateTypes = getPrivateTypes(schemaEntries); + + // Build public schema set (non-private types, with private fields stripped) + const publicSchemaSet: { slug: string; schemaHash: string }[] = []; + for (const entry of schemaEntries) { + if (privateTypes.has(entry.slug)) continue; + const filtered = filterTypeSchema(entry.schema); + publicSchemaSet.push({ slug: entry.slug, schemaHash: hashSchema(filtered) }); + } + + // Filter to public records only, and strip private fields + const publicRecords = recordRows + .filter((r) => !r.private && !privateTypes.has(r.type)) + .map((r) => { + const entry = schemaEntries.find((e) => e.slug === r.type); + const privateFields = entry ? getPrivateFields(entry.schema) : new Set(); + const data = privateFields.size > 0 ? filterRecordData(r.data, privateFields) : r.data; + return { id: r.recordId, type: r.type, data }; + }) + .sort((a, b) => a.id.localeCompare(b.id)); + + const canonical = JSON.stringify({ + schemas: Object.fromEntries( + publicSchemaSet.sort((a, b) => a.slug.localeCompare(b.slug)).map((s) => [s.slug, s.schemaHash]), + ), + records: publicRecords, + files: fileHashes.sort(), + readme: readme ?? null, + }); + return "public:" + createHash("sha256").update(canonical).digest("hex"); +} + +function deriveSemver( + prevSemver: string | null, + schemaChanged: boolean, + recordsChanged: boolean, +): string { + if (!prevSemver) return "v1.0.0"; + + const parts = prevSemver.replace(/^v/, "").split(".").map(Number); + const [major, minor, patch] = [parts[0] ?? 1, parts[1] ?? 0, parts[2] ?? 0]; + + if (schemaChanged) return `v${major + 1}.0.0`; + if (recordsChanged) return `v${major}.${minor + 1}.0`; + return `v${major}.${minor}.${patch + 1}`; +} + +const app = new Hono(); + +// Lazily backfill totalBytes for versions that were created before we tracked it +// or where the value was corrupted by a string concatenation bug +async function backfillTotalBytes(version: { id: number; totalBytes: number; recordCount: number }) { + // Skip recomputation if totalBytes looks reasonable (> 0 and < 1TB) + if (version.totalBytes > 0 && version.totalBytes < 1_099_511_627_776 || version.recordCount === 0) return version.totalBytes; + + const records = await db + .select({ data: schema.records.data }) + .from(schema.records) + .where(eq(schema.records.versionId, version.id)); + + let totalBytes = 0; + for (const r of records) { + totalBytes += Buffer.byteLength(JSON.stringify(r.data), "utf-8"); + } + + const [fileSizeResult] = await db + .select({ total: sql`coalesce(sum(${schema.files.size}), 0)` }) + .from(schema.versionFiles) + .innerJoin(schema.files, eq(schema.versionFiles.fileHash, schema.files.hash)) + .where(eq(schema.versionFiles.versionId, version.id)); + totalBytes += Number(fileSizeResult?.total ?? 0); + + // Persist so we don't recompute next time + await db + .update(schema.versions) + .set({ totalBytes }) + .where(eq(schema.versions.id, version.id)); + + return totalBytes; +} + +// List versions +app.get("/collections/:owner/:slug/versions", async (c) => { + const owner = c.req.param("owner"); + const slug = c.req.param("slug"); + const limit = c.req.query("limit"); + const offset = c.req.query("offset"); + + const collection = await resolveCollection(owner, slug); + if (!collection) return c.json({ error: "Collection not found", statusCode: 404 }, 404); + + const accountId = c.get("accountId"); + const ownerAccess = isOwner(accountId, collection.accountId); + const arkInfo = await getCollectionArkInfo(collection.id).catch(() => null); + + const rows = await db + .select({ + number: schema.versions.number, + semver: schema.versions.semver, + hash: schema.versions.hash, + publicHash: schema.versions.publicHash, + message: schema.versions.message, + appId: schema.versions.appId, + actorId: schema.versions.actorId, + recordCount: schema.versions.recordCount, + fileCount: schema.versions.fileCount, + totalBytes: schema.versions.totalBytes, + createdAt: schema.versions.createdAt, + }) + .from(schema.versions) + .where(eq(schema.versions.collectionId, collection.id)) + .orderBy(sql`${schema.versions.number} desc`) + .limit(Math.min(parseInt(limit ?? "50", 10), 100)) + .offset(parseInt(offset ?? "0", 10)); + + return c.json(rows.map((row) => ({ + number: row.number, + semver: row.semver, + hash: ownerAccess ? row.hash : (row.publicHash ?? row.hash), + message: row.message, + appId: row.appId, + actorId: row.actorId, + recordCount: row.recordCount, + fileCount: row.fileCount, + totalBytes: row.totalBytes, + createdAt: row.createdAt, + ark: arkInfo ? buildArkUrl(arkInfo.naan, arkInfo.shoulder, arkInfo.arkId, row.number) : null, + }))); +}); + +// Latest version +app.get("/collections/:owner/:slug/versions/latest", async (c) => { + const owner = c.req.param("owner"); + const slug = c.req.param("slug"); + const collection = await resolveCollection(owner, slug); + if (!collection) return c.json({ error: "Collection not found", statusCode: 404 }, 404); + + const [version] = await db + .select() + .from(schema.versions) + .where(eq(schema.versions.collectionId, collection.id)) + .orderBy(sql`${schema.versions.number} desc`) + .limit(1); + + if (!version) return c.json({ error: "No versions", statusCode: 404 }, 404); + version.totalBytes = await backfillTotalBytes(version); + + const schemaEntries = await loadVersionSchemas(version.id); + const accountId = c.get("accountId"); + const ownerAccess = isOwner(accountId, collection.accountId); + const arkInfo = await getCollectionArkInfo(collection.id).catch(() => null); + + const schemasMap = ownerAccess + ? Object.fromEntries(schemaEntries.map((e) => [e.slug, e.schema])) + : filterSchemasForPublic(schemaEntries); + + return c.json({ + ...version, + hash: ownerAccess ? version.hash : (version.publicHash ?? version.hash), + schemas: schemasMap, + ark: arkInfo ? buildArkUrl(arkInfo.naan, arkInfo.shoulder, arkInfo.arkId, version.number) : null, + }); +}); + +// Get version by number +app.get("/collections/:owner/:slug/versions/:n", async (c) => { + const owner = c.req.param("owner"); + const slug = c.req.param("slug"); + const n = c.req.param("n"); + const collection = await resolveCollection(owner, slug); + if (!collection) return c.json({ error: "Collection not found", statusCode: 404 }, 404); + + const [version] = await db + .select() + .from(schema.versions) + .where( + and(eq(schema.versions.collectionId, collection.id), eq(schema.versions.number, parseInt(n, 10))), + ) + .limit(1); + + if (!version) return c.json({ error: "Version not found", statusCode: 404 }, 404); + version.totalBytes = await backfillTotalBytes(version); + + const schemaEntries = await loadVersionSchemas(version.id); + const accountId = c.get("accountId"); + const ownerAccess = isOwner(accountId, collection.accountId); + const arkInfo = await getCollectionArkInfo(collection.id).catch(() => null); + + const schemasMap = ownerAccess + ? Object.fromEntries(schemaEntries.map((e) => [e.slug, e.schema])) + : filterSchemasForPublic(schemaEntries); + + return c.json({ + ...version, + hash: ownerAccess ? version.hash : (version.publicHash ?? version.hash), + schemas: schemasMap, + ark: arkInfo ? buildArkUrl(arkInfo.naan, arkInfo.shoulder, arkInfo.arkId, version.number) : null, + }); +}); + +// Get records for a version +app.get("/collections/:owner/:slug/versions/:n/records", async (c) => { + const owner = c.req.param("owner"); + const slug = c.req.param("slug"); + const n = c.req.param("n"); + const type = c.req.query("type"); + const limit = c.req.query("limit"); + const offset = c.req.query("offset"); + const after = c.req.query("after"); + + const collection = await resolveCollection(owner, slug); + if (!collection) return c.json({ error: "Collection not found", statusCode: 404 }, 404); + + const [version] = await db + .select() + .from(schema.versions) + .where( + and(eq(schema.versions.collectionId, collection.id), eq(schema.versions.number, parseInt(n, 10))), + ) + .limit(1); + + if (!version) return c.json({ error: "Version not found", statusCode: 404 }, 404); + + const conditions = [eq(schema.records.versionId, version.id)]; + if (type) conditions.push(eq(schema.records.type, type)); + + // Cursor-based pagination: ?after=recordId (keyset pagination) + if (after) { + conditions.push(sql`${schema.records.recordId} > ${after}`); + } + + // Determine visibility + const accountId = c.get("accountId"); + const ownerAccess = isOwner(accountId, collection.accountId); + + let privateTypes = new Set(); + let schemaEntries: SchemaEntry[] = []; + if (!ownerAccess) { + schemaEntries = await loadVersionSchemas(version.id); + privateTypes = getPrivateTypes(schemaEntries); + + if (privateTypes.size > 0) { + if (type && privateTypes.has(type)) { + return c.json([]); // requesting a private type as non-owner + } + for (const pt of privateTypes) { + conditions.push(sql`${schema.records.type} != ${pt}`); + } + } + // Exclude record-level private records + conditions.push(eq(schema.records.private, false)); + } + + const pageLimit = Math.min(parseInt(limit ?? "100", 10), 1000); + + const records = await db + .select({ + id: schema.records.recordId, + type: schema.records.type, + data: schema.records.data, + }) + .from(schema.records) + .where(and(...conditions)) + .orderBy(schema.records.recordId) + .limit(pageLimit + 1) + .offset(after ? 0 : parseInt(offset ?? "0", 10)); + + // Determine if there's a next page + const hasMore = records.length > pageLimit; + const page = hasMore ? records.slice(0, pageLimit) : records; + const nextCursor = hasMore ? page[page.length - 1]!.id : null; + + // Strip private fields if not owner + let resultRecords = page; + if (!ownerAccess) { + const fieldCache = new Map>(); + resultRecords = page.map((rec) => { + if (!fieldCache.has(rec.type)) { + const entry = schemaEntries.find((e) => e.slug === rec.type); + fieldCache.set(rec.type, entry ? getPrivateFields(entry.schema) : new Set()); + } + const privateFields = fieldCache.get(rec.type)!; + return privateFields.size > 0 + ? { ...rec, data: filterRecordData(rec.data, privateFields) } + : rec; + }); + } + + // Add ARK URLs for record types that have ARKs enabled + const arkInfo = await getCollectionArkInfo(collection.id).catch(() => null); + let arkEnabledTypes = new Map(); // recordType → redirectUrlField + if (arkInfo) { + const artRows = await db + .select({ recordType: schema.arkRecordTypes.recordType, redirectUrlField: schema.arkRecordTypes.redirectUrlField }) + .from(schema.arkRecordTypes) + .where(eq(schema.arkRecordTypes.collectionId, collection.id)); + for (const r of artRows) arkEnabledTypes.set(r.recordType, r.redirectUrlField); + } + + const recordsWithArk = resultRecords.map((rec) => { + const ark = arkInfo && arkEnabledTypes.has(rec.type) + ? buildArkUrl(arkInfo.naan, arkInfo.shoulder, arkInfo.arkId, version.number, rec.type, rec.id) + : null; + return ark ? { ...rec, ark } : rec; + }); + + return c.json({ + records: recordsWithArk, + pagination: { + limit: pageLimit, + hasMore, + nextCursor, + total: version.recordCount, + }, + }); +}); + +// List files for a version +app.get("/collections/:owner/:slug/versions/:n/files", async (c) => { + const owner = c.req.param("owner"); + const slug = c.req.param("slug"); + const n = c.req.param("n"); + const collection = await resolveCollection(owner, slug); + if (!collection) return c.json({ error: "Collection not found", statusCode: 404 }, 404); + + const [version] = await db + .select() + .from(schema.versions) + .where( + and(eq(schema.versions.collectionId, collection.id), eq(schema.versions.number, parseInt(n, 10))), + ) + .limit(1); + + if (!version) return c.json({ error: "Version not found", statusCode: 404 }, 404); + + const fileRows = await db + .select({ + hash: schema.versionFiles.fileHash, + size: schema.files.size, + mimeType: schema.files.mimeType, + createdAt: schema.files.createdAt, + }) + .from(schema.versionFiles) + .innerJoin(schema.files, eq(schema.versionFiles.fileHash, schema.files.hash)) + .where(eq(schema.versionFiles.versionId, version.id)); + + // Build file→record reference map by scanning record data for $file refs + const allRecords = await db + .select({ recordId: schema.records.recordId, type: schema.records.type, data: schema.records.data }) + .from(schema.records) + .where(eq(schema.records.versionId, version.id)); + + const fileRefs = new Map(); + for (const rec of allRecords) { + const data = rec.data as Record; + for (const [field, val] of Object.entries(data)) { + if (val && typeof val === "object" && "$file" in (val as any)) { + const hash = ((val as any).$file as string).replace("sha256:", ""); + if (!fileRefs.has(hash)) fileRefs.set(hash, []); + fileRefs.get(hash)!.push({ recordId: rec.recordId, type: rec.type, field }); + } + } + } + + return c.json(fileRows.map((f) => ({ + ...f, + references: fileRefs.get(f.hash) ?? [], + }))); +}); + +// Get manifest for a version +app.get("/collections/:owner/:slug/versions/:n/manifest", async (c) => { + const owner = c.req.param("owner"); + const slug = c.req.param("slug"); + const n = c.req.param("n"); + const collection = await resolveCollection(owner, slug); + if (!collection) return c.json({ error: "Collection not found", statusCode: 404 }, 404); + + const [version] = await db + .select() + .from(schema.versions) + .where( + and(eq(schema.versions.collectionId, collection.id), eq(schema.versions.number, parseInt(n, 10))), + ) + .limit(1); + + if (!version) return c.json({ error: "Version not found", statusCode: 404 }, 404); + + const recordIds = await db + .select({ id: schema.records.recordId, type: schema.records.type }) + .from(schema.records) + .where(eq(schema.records.versionId, version.id)); + + const fileHashes = await db + .select({ hash: schema.versionFiles.fileHash }) + .from(schema.versionFiles) + .where(eq(schema.versionFiles.versionId, version.id)); + + const schemaEntries = await loadVersionSchemas(version.id); + + return c.json({ + version: version.number, + semver: version.semver, + hash: version.hash, + schemas: Object.fromEntries(schemaEntries.map((e) => [e.slug, e.schemaHash])), + records: recordIds, + files: fileHashes.map((f) => f.hash), + }); +}); + +// Push a new version +app.post( + "/collections/:owner/:slug/versions", + requireAuth("write"), + async (c) => { + const owner = c.req.param("owner"); + const slug = c.req.param("slug"); + const body = await c.req.json() as { + base_version: number | null; + name?: string; + description?: string; + message?: string; + readme?: string; + app_id?: string; + actor_id?: string; + schemas?: Record; + changes: { + added?: { id: string; type: string; data: unknown; private?: boolean }[]; + updated?: { id: string; type: string; data: unknown; private?: boolean }[]; + removed?: string[]; + }; + }; + + const collection = await resolveCollection(owner, slug); + if (!collection) return c.json({ error: "Collection not found", statusCode: 404 }, 404); + + // Get latest version + const [latest] = await db + .select() + .from(schema.versions) + .where(eq(schema.versions.collectionId, collection.id)) + .orderBy(sql`${schema.versions.number} desc`) + .limit(1); + + const currentNumber = latest?.number ?? 0; + + // Optimistic lock + if (body.base_version !== null && body.base_version !== currentNumber) { + return c.json({ + error: "Version conflict", + currentVersion: currentNumber, + statusCode: 409, + }, 409); + } + + // Build the full record set for this version + let existingRecords: { recordId: string; type: string; data: unknown; private: boolean }[] = []; + if (latest) { + existingRecords = await db + .select({ + recordId: schema.records.recordId, + type: schema.records.type, + data: schema.records.data, + private: schema.records.private, + }) + .from(schema.records) + .where(eq(schema.records.versionId, latest.id)); + } + + // Apply changes + const recordMap = new Map(existingRecords.map((r) => [r.recordId, r])); + + for (const rec of body.changes.added ?? []) { + recordMap.set(rec.id, { recordId: rec.id, type: rec.type, data: rec.data, private: rec.private ?? false }); + } + for (const rec of body.changes.updated ?? []) { + recordMap.set(rec.id, { recordId: rec.id, type: rec.type, data: rec.data, private: rec.private ?? false }); + } + for (const id of body.changes.removed ?? []) { + recordMap.delete(id); + } + + const newRecords = Array.from(recordMap.values()); + + // --- Resolve schemas --- + let prevSchemaEntries: SchemaEntry[] = []; + if (latest) { + prevSchemaEntries = await loadVersionSchemas(latest.id); + } + + // Determine the schema set for this version + let schemasInput: Record; + if (body.schemas && Object.keys(body.schemas).length > 0) { + schemasInput = body.schemas; + } else if (prevSchemaEntries.length > 0) { + // Carry forward previous schemas + schemasInput = Object.fromEntries(prevSchemaEntries.map((e) => [e.slug, e.schema])); + } else { + return c.json({ + error: "Schemas required", + message: "First version must include a `schemas` map with at least one type definition.", + statusCode: 422, + }, 422); + } + + // Ensure every record type has a schema + const recordTypes = new Set(newRecords.map((r) => r.type)); + const missingSchemas = [...recordTypes].filter((t) => !(t in schemasInput)); + if (missingSchemas.length > 0) { + return c.json({ + error: "Missing schemas for record types", + types: missingSchemas, + message: `Every record type must have a corresponding schema. Missing: ${missingSchemas.join(", ")}`, + statusCode: 422, + }, 422); + } + + // Hash and upsert each schema into the global schemas table + const newSchemaSet: { slug: string; schemaId: string; schemaHash: string; schema: Record }[] = []; + for (const [typeSlug, typeSchema] of Object.entries(schemasInput)) { + const hash = hashSchema(typeSchema); + + const [existing] = await db + .select({ id: schema.schemas.id }) + .from(schema.schemas) + .where(eq(schema.schemas.schemaHash, hash)) + .limit(1); + + let schemaId: string; + if (existing) { + schemaId = existing.id; + } else { + const [inserted] = await db + .insert(schema.schemas) + .values({ schema: typeSchema as any, schemaHash: hash }) + .returning({ id: schema.schemas.id }); + schemaId = inserted!.id; + } + + newSchemaSet.push({ slug: typeSlug, schemaId, schemaHash: hash, schema: typeSchema as Record }); + } + + // Validate records against their type's schema + const validationErrors: { recordId: string; type: string; errors: string[] }[] = []; + const validators = new Map>(); + for (const entry of newSchemaSet) { + validators.set(entry.slug, ajv.compile(entry.schema as object)); + } + + for (const rec of newRecords) { + const validate = validators.get(rec.type); + if (!validate) { + validationErrors.push({ + recordId: rec.recordId, + type: rec.type, + errors: [`No schema defined for record type "${rec.type}"`], + }); + continue; + } + if (!validate(rec.data)) { + validationErrors.push({ + recordId: rec.recordId, + type: rec.type, + errors: (validate.errors ?? []).map( + (e) => `${e.instancePath || "/"} ${e.message ?? "validation failed"}`, + ), + }); + } + } + + if (validationErrors.length > 0) { + return c.json({ + error: "Schema validation failed", + validationErrors, + statusCode: 422, + }, 422); + } + + // Determine if schema set changed + const prevSchemaMap = new Map(prevSchemaEntries.map((e) => [e.slug, e.schemaHash])); + const newSchemaMap = new Map(newSchemaSet.map((e) => [e.slug, e.schemaHash])); + let schemaChanged = prevSchemaMap.size !== newSchemaMap.size; + if (!schemaChanged) { + for (const [s, hash] of newSchemaMap) { + if (prevSchemaMap.get(s) !== hash) { + schemaChanged = true; + break; + } + } + } + + const recordsChanged = + (body.changes.added?.length ?? 0) > 0 || + (body.changes.updated?.length ?? 0) > 0 || + (body.changes.removed?.length ?? 0) > 0; + + // Get file hashes from existing version + any new references + let existingFileHashes: string[] = []; + if (latest) { + const vf = await db + .select({ hash: schema.versionFiles.fileHash }) + .from(schema.versionFiles) + .where(eq(schema.versionFiles.versionId, latest.id)); + existingFileHashes = vf.map((f) => f.hash); + } + + // Scan new records for $file references + const referencedHashes = new Set(existingFileHashes); + for (const rec of newRecords) { + const data = rec.data as Record; + for (const val of Object.values(data)) { + if ( + typeof val === "object" && + val !== null && + "$file" in val && + typeof (val as { $file: string }).$file === "string" + ) { + const hash = (val as { $file: string }).$file.replace("sha256:", ""); + referencedHashes.add(hash); + } + } + } + + // Check all referenced files exist + const allFileHashes = Array.from(referencedHashes); + if (allFileHashes.length > 0) { + const existingFiles = await db + .select({ hash: schema.files.hash }) + .from(schema.files) + .where(inArray(schema.files.hash, allFileHashes)); + const existingSet = new Set(existingFiles.map((f) => f.hash)); + const filesNeeded = allFileHashes.filter((h) => !existingSet.has(h)); + + if (filesNeeded.length > 0) { + return c.json({ + error: "Missing files", + filesNeeded: filesNeeded.map((h) => `sha256:${h}`), + statusCode: 422, + }, 422); + } + } + + // Resolve readme (carry forward from base version if not provided) + const readmeValue = body.readme !== undefined ? body.readme : (latest?.readme ?? null); + + // Compute hashes and semver + const schemaSetForHash = newSchemaSet.map((e) => ({ slug: e.slug, schemaHash: e.schemaHash })); + const versionHash = computeVersionHash(schemaSetForHash, newRecords, allFileHashes, readmeValue); + + const schemaEntriesForPublicHash: SchemaEntry[] = newSchemaSet.map((e) => ({ + slug: e.slug, + schemaId: e.schemaId, + schema: e.schema, + schemaHash: e.schemaHash, + })); + const publicHash = computePublicHash(schemaEntriesForPublicHash, newRecords, allFileHashes, readmeValue); + + const semver = deriveSemver(latest?.semver ?? null, schemaChanged, recordsChanged); + const newNumber = currentNumber + 1; + + // Check for duplicate hash + const [existingHash] = await db + .select({ number: schema.versions.number }) + .from(schema.versions) + .where( + and( + eq(schema.versions.collectionId, collection.id), + eq(schema.versions.hash, versionHash), + ), + ) + .limit(1); + if (existingHash) { + return c.json({ + error: "No changes detected", + message: `Version ${existingHash.number} already has identical content (hash: ${versionHash.slice(0, 12)}...)`, + existingVersion: existingHash.number, + }, 409); + } + + // Compute total bytes + let totalBytes = 0; + for (const rec of newRecords) { + totalBytes += Buffer.byteLength(JSON.stringify(rec.data), "utf-8"); + } + if (allFileHashes.length > 0) { + const [fileSizeSum] = await db + .select({ total: sql`coalesce(sum(${schema.files.size}), 0)` }) + .from(schema.files) + .where(inArray(schema.files.hash, allFileHashes)); + totalBytes += Number(fileSizeSum?.total ?? 0); + } + + // Insert version + const [version] = await db + .insert(schema.versions) + .values({ + collectionId: collection.id, + number: newNumber, + semver, + hash: versionHash, + publicHash, + baseNumber: body.base_version, + message: body.message ?? null, + readme: readmeValue, + pushedBy: c.get("accountId") ?? null, + appId: body.app_id ?? null, + actorId: body.actor_id ?? null, + recordCount: newRecords.length, + fileCount: allFileHashes.length, + totalBytes, + }) + .returning(); + + // Insert records (in batches) + if (newRecords.length > 0) { + const RECORD_BATCH = 1000; + for (let i = 0; i < newRecords.length; i += RECORD_BATCH) { + const batch = newRecords.slice(i, i + RECORD_BATCH); + await db.insert(schema.records).values( + batch.map((r) => ({ + versionId: version!.id, + recordId: r.recordId, + type: r.type, + data: r.data as any, + private: r.private, + })), + ); + } + } + + // Insert version_files + if (allFileHashes.length > 0) { + await db.insert(schema.versionFiles).values( + allFileHashes.map((hash) => ({ + versionId: version!.id, + fileHash: hash, + })), + ); + } + + // Insert version_schemas + await db.insert(schema.versionSchemas).values( + newSchemaSet.map((entry) => ({ + versionId: version!.id, + slug: entry.slug, + schemaId: entry.schemaId, + })), + ); + + // Update collection timestamp + optional name/description + const collectionUpdates: Record = { updatedAt: new Date() }; + if (body.name) collectionUpdates.name = body.name; + if (body.description !== undefined) collectionUpdates.description = body.description; + await db + .update(schema.collections) + .set(collectionUpdates) + .where(eq(schema.collections.id, collection.id)); + + return c.json({ + version: newNumber, + semver, + hash: versionHash, + recordCount: newRecords.length, + fileCount: allFileHashes.length, + }, 201); + }, +); + +// Diff between versions +app.get("/collections/:owner/:slug/versions/:n/diff", async (c) => { + const owner = c.req.param("owner"); + const slug = c.req.param("slug"); + const n = c.req.param("n"); + const from = c.req.query("from"); + + const collection = await resolveCollection(owner, slug); + if (!collection) return c.json({ error: "Collection not found", statusCode: 404 }, 404); + + const targetNum = parseInt(n, 10); + const fromNum = from ? parseInt(from, 10) : targetNum - 1; + + const [targetVersion] = await db + .select() + .from(schema.versions) + .where(and(eq(schema.versions.collectionId, collection.id), eq(schema.versions.number, targetNum))) + .limit(1); + + if (!targetVersion) { + return c.json({ error: "Version not found", statusCode: 404 }, 404); + } + + const targetRecords = await db + .select() + .from(schema.records) + .where(eq(schema.records.versionId, targetVersion.id)); + + let fromVersion: typeof targetVersion | null = null; + let fromRecords: typeof targetRecords = []; + if (fromNum > 0) { + const [fv] = await db + .select() + .from(schema.versions) + .where(and(eq(schema.versions.collectionId, collection.id), eq(schema.versions.number, fromNum))) + .limit(1); + + if (fv) { + fromVersion = fv; + fromRecords = await db + .select() + .from(schema.records) + .where(eq(schema.records.versionId, fv.id)); + } + } + + const fromMap = new Map(fromRecords.map((r) => [r.recordId, r])); + const targetMap = new Map(targetRecords.map((r) => [r.recordId, r])); + + const added = targetRecords.filter((r) => !fromMap.has(r.recordId)); + const removed = fromRecords.filter((r) => !targetMap.has(r.recordId)); + const updated = targetRecords.filter((r) => { + const prev = fromMap.get(r.recordId); + return prev && JSON.stringify(prev.data) !== JSON.stringify(r.data); + }); + + // Compare schema sets + const targetSchemas = await loadVersionSchemas(targetVersion.id); + const fromSchemas = fromVersion ? await loadVersionSchemas(fromVersion.id) : []; + const targetSchemaMap = new Map(targetSchemas.map((e) => [e.slug, e.schemaHash])); + const fromSchemaMap = new Map(fromSchemas.map((e) => [e.slug, e.schemaHash])); + let schemaChanged = targetSchemaMap.size !== fromSchemaMap.size; + if (!schemaChanged) { + for (const [s, hash] of targetSchemaMap) { + if (fromSchemaMap.get(s) !== hash) { + schemaChanged = true; + break; + } + } + } + + const readmeChanged = (targetVersion.readme ?? null) !== (fromVersion?.readme ?? null); + + // Compare file sets + const targetFiles = await db + .select({ hash: schema.versionFiles.fileHash }) + .from(schema.versionFiles) + .where(eq(schema.versionFiles.versionId, targetVersion.id)); + const fromFiles = fromVersion ? await db + .select({ hash: schema.versionFiles.fileHash }) + .from(schema.versionFiles) + .where(eq(schema.versionFiles.versionId, fromVersion.id)) : []; + const targetFileSet = new Set(targetFiles.map((f) => f.hash)); + const fromFileSet = new Set(fromFiles.map((f) => f.hash)); + const filesAdded = targetFiles.filter((f) => !fromFileSet.has(f.hash)).map((f) => f.hash); + const filesRemoved = fromFiles.filter((f) => !targetFileSet.has(f.hash)).map((f) => f.hash); + + return c.json({ + from: fromNum, + to: targetNum, + added: added.map((r) => ({ id: r.recordId, type: r.type, data: r.data })), + updated: updated.map((r) => ({ id: r.recordId, type: r.type, data: r.data })), + removed: removed.map((r) => r.recordId), + meta: { + schemaChanged, + readmeChanged, + readmeFrom: readmeChanged ? (fromVersion?.readme?.slice(0, 100) ?? null) : undefined, + readmeTo: readmeChanged ? (targetVersion.readme?.slice(0, 100) ?? null) : undefined, + filesAdded: filesAdded.length, + filesRemoved: filesRemoved.length, + }, + }); +}); + +async function resolveCollection(owner: string, slug: string) { + const [result] = await db + .select({ + id: schema.collections.id, + accountId: schema.collections.accountId, + slug: schema.collections.slug, + }) + .from(schema.collections) + .innerJoin(schema.accounts, eq(schema.collections.accountId, schema.accounts.id)) + .where(and(eq(schema.accounts.slug, owner), eq(schema.collections.slug, slug))) + .limit(1); + return result ?? null; +} + +async function getCollectionArkInfo( + collectionId: string, +): Promise<{ shoulder: string; arkId: string; naan: string } | null> { + const [row] = await db + .select({ + shoulder: schema.arkShoulders.shoulder, + arkId: schema.arkCollections.arkId, + naan: schema.accounts.arkNaan, + }) + .from(schema.arkCollections) + .innerJoin(schema.collections, eq(schema.arkCollections.collectionId, schema.collections.id)) + .innerJoin(schema.accounts, eq(schema.collections.accountId, schema.accounts.id)) + .innerJoin(schema.arkShoulders, eq(schema.arkShoulders.accountId, schema.accounts.id)) + .where(and(eq(schema.arkCollections.collectionId, collectionId), eq(schema.arkCollections.enabled, true))) + .limit(1); + if (!row) return null; + return { shoulder: row.shoulder, arkId: row.arkId, naan: row.naan ?? DEFAULT_NAAN }; +} + +export { app as versionRoutes }; diff --git a/src/components/BaseLayout.tsx b/src/components/BaseLayout.tsx new file mode 100644 index 0000000..a412295 --- /dev/null +++ b/src/components/BaseLayout.tsx @@ -0,0 +1,70 @@ +import { useSSRData } from '~/lib/ssr-data' +import UserMenu from '~/components/UserMenu' + +interface MirrorConfig { + enabled: boolean + nodeName: string + upstream: string +} + +export default function BaseLayout({ children }: { children: React.ReactNode }) { + const currentUser = useSSRData('currentUser') + const mirrorConfig = useSSRData('mirrorConfig') + + return ( + <> +
+ +
+ +
+ {children} +
+ + + + ) +} diff --git a/src/components/BlogLayout.tsx b/src/components/BlogLayout.tsx new file mode 100644 index 0000000..fb5df2c --- /dev/null +++ b/src/components/BlogLayout.tsx @@ -0,0 +1,31 @@ +import BaseLayout from '~/components/BaseLayout' + +interface BlogLayoutProps { + children: React.ReactNode + title: string + subtitle?: string + date?: string +} + +export default function BlogLayout({ children, title, subtitle, date }: BlogLayoutProps) { + return ( + +
+
+

{title}

+ {subtitle &&

{subtitle}

} + {date && ( + + )} +
+
+ +
+ {children} +
+
+
+ ) +} diff --git a/src/components/CollectionNav.astro b/src/components/CollectionNav.astro deleted file mode 100644 index b05de3a..0000000 --- a/src/components/CollectionNav.astro +++ /dev/null @@ -1,41 +0,0 @@ ---- -interface Props { - owner: string; - collection: string; - isPublic?: boolean; - isOwner?: boolean; - active: "overview" | "versions" | "schemas" | "settings"; - versionLabel?: string; // e.g. "6.0.0" — shown as an extra tab between Versions and Settings -} - -const { owner, collection, isPublic, isOwner = false, active, versionLabel } = Astro.props; - -const linkClass = "px-3 py-2 text-sm font-medium border-b-2 -mb-px transition-colors"; -const activeClass = `${linkClass} border-ink text-ink`; -const inactiveClass = `${linkClass} border-transparent text-ink-muted hover:text-ink hover:border-rule`; ---- - - -
- -
- - -
- Overview - Versions - {versionLabel && ( - {versionLabel} - )} - Schemas - {isOwner && ( - Settings - )} -
diff --git a/src/components/DocsLayout.tsx b/src/components/DocsLayout.tsx new file mode 100644 index 0000000..0b36c14 --- /dev/null +++ b/src/components/DocsLayout.tsx @@ -0,0 +1,74 @@ +import { useLocation } from 'react-router' +import BaseLayout from '~/components/BaseLayout' +import DocsSearch from '~/components/DocsSearch' + +const nav = [ + { + section: 'Getting started', + items: [ + { label: 'Overview', href: '/docs' }, + { label: 'Concepts', href: '/docs/concepts' }, + { label: 'Quickstart', href: '/docs/quickstart' }, + { label: 'Integration Guide', href: '/docs/integration' }, + ], + }, + { + section: 'API reference', + items: [ + { label: 'Accounts', href: '/docs/api/accounts' }, + { label: 'Collections', href: '/docs/api/collections' }, + { label: 'Versions', href: '/docs/api/versions' }, + { label: 'Files', href: '/docs/api/files' }, + ], + }, + { + section: 'Infrastructure', + items: [ + { label: 'Self-hosting', href: '/docs/self-host' }, + ], + }, +] + +export default function DocsLayout({ children, title }: { children: React.ReactNode; title: string }) { + const location = useLocation() + const currentPath = location.pathname.replace(/\/$/, '') + + return ( + +
+ + +
+

{title}

+
+ {children} +
+
+
+
+ ) +} diff --git a/src/components/QueryExplorer.tsx b/src/components/QueryExplorer.tsx index bdde7f8..89d8515 100644 --- a/src/components/QueryExplorer.tsx +++ b/src/components/QueryExplorer.tsx @@ -675,7 +675,7 @@ export default function QueryExplorer() { // Clear the hash so it doesn't reload on refresh window.history.replaceState(null, "", window.location.pathname); } catch { /* invalid hash, ignore */ } - }, [sqlJs]); // eslint-disable-line react-hooks/exhaustive-deps + }, [sqlJs]); return (
diff --git a/src/db/client.server.ts b/src/db/client.server.ts new file mode 100644 index 0000000..7af0c92 --- /dev/null +++ b/src/db/client.server.ts @@ -0,0 +1,10 @@ +import postgres from 'postgres' +import { drizzle } from 'drizzle-orm/postgres-js' +import * as schema from './schema.js' + +const connectionString = + process.env.DATABASE_URL ?? 'postgresql://underlay:underlay@localhost:5432/underlay' + +const client = postgres(connectionString) +export const db = drizzle(client, { schema }) +export { schema } diff --git a/src/db/index.ts b/src/db/index.ts deleted file mode 100644 index 120cea8..0000000 --- a/src/db/index.ts +++ /dev/null @@ -1,10 +0,0 @@ -import postgres from "postgres"; -import { drizzle } from "drizzle-orm/postgres-js"; -import * as schema from "./schema.js"; - -const connectionString = - process.env.DATABASE_URL ?? "postgresql://underlay:underlay@localhost:5432/underlay"; - -const client = postgres(connectionString); -export const db = drizzle(client, { schema }); -export { schema }; diff --git a/src/db/migrate.ts b/src/db/migrate.ts index 0b03c46..37c8bd2 100644 --- a/src/db/migrate.ts +++ b/src/db/migrate.ts @@ -5,7 +5,7 @@ import postgres from "postgres"; const connectionString = process.env.DATABASE_URL ?? "postgresql://underlay:underlay@localhost:5432/underlay"; -async function runMigrations(retries = 5, delay = 3000) { +export async function runMigrations(retries = 5, delay = 3000) { const client = postgres(connectionString, { max: 1 }); const db = drizzle(client); @@ -33,9 +33,15 @@ async function runMigrations(retries = 5, delay = 3000) { } } -runMigrations() - .then(() => process.exit(0)) - .catch((err) => { - console.error("[migrate] Failed:", err); - process.exit(1); - }); +// Run directly if invoked as a script +const isMain = + process.argv[1]?.endsWith("migrate.ts") || process.argv[1]?.endsWith("migrate.js"); + +if (isMain) { + runMigrations() + .then(() => process.exit(0)) + .catch((err) => { + console.error("[migrate] Failed:", err); + process.exit(1); + }); +} diff --git a/src/db/seed.ts b/src/db/seed.ts index 6da8a0c..56ad53c 100644 --- a/src/db/seed.ts +++ b/src/db/seed.ts @@ -1,4 +1,4 @@ -import { db, schema } from "./index.js"; +import { db, schema } from "./client.server.js"; import { eq } from "drizzle-orm"; import { v4 as uuidv4 } from "uuid"; import bcrypt from "bcrypt"; diff --git a/src/entry-client.tsx b/src/entry-client.tsx new file mode 100644 index 0000000..4bf9b3a --- /dev/null +++ b/src/entry-client.tsx @@ -0,0 +1,16 @@ +import { hydrateRoot } from 'react-dom/client' +import { BrowserRouter } from 'react-router' +import App from '~/App' +import { SSRDataProvider, getClientSSRData } from '~/lib/ssr-data' +import '~/global.css' + +const ssrData = getClientSSRData() + +hydrateRoot( + document.getElementById('root')!, + + + + + , +) diff --git a/src/entry-server.tsx b/src/entry-server.tsx new file mode 100644 index 0000000..aecddfc --- /dev/null +++ b/src/entry-server.tsx @@ -0,0 +1,101 @@ +import { renderToPipeableStream } from 'react-dom/server' +import { StaticRouter } from 'react-router' +import { PassThrough } from 'node:stream' +import App from '~/App' +import { SSRDataProvider } from '~/lib/ssr-data' +import { routes } from '~/routes' +import { runLoaders } from '~/loaders.server' + +function matchPath(pattern: string, pathname: string): Record | null { + const patternParts = pattern.split('/').filter(Boolean) + const pathParts = pathname.split('/').filter(Boolean) + + if (patternParts.length !== pathParts.length) return null + + const params: Record = {} + for (let i = 0; i < patternParts.length; i++) { + const pat = patternParts[i]! + const val = pathParts[i]! + if (pat.startsWith(':')) { + params[pat.slice(1)] = val + } else if (pat !== val) { + return null + } + } + return params +} + +function matchRoutes(url: string) { + const pathname = new URL(url, 'http://localhost').pathname + const matched: { route: (typeof routes)[number]; params: Record }[] = [] + + for (const route of routes) { + const params = matchPath(route.path, pathname) + if (params !== null) { + matched.push({ route, params }) + break // first match wins + } + } + return matched +} + +export async function render( + request: Request, +): Promise<{ + html: string + ssrData: Record + redirect?: string + statusCode?: number + title?: string + description?: string +}> { + const url = request.url + const pathname = new URL(url, 'http://localhost').pathname + const matchedRoutes = matchRoutes(url) + + let ssrData: Record + let redirect: string | undefined + let statusCode: number | undefined + let title: string | undefined + let description: string | undefined + + try { + const result = await runLoaders(matchedRoutes, request) + ssrData = result.data + redirect = result.redirect + statusCode = result.statusCode + title = result.title + description = result.description + } catch (err) { + console.error('Loader error:', err) + ssrData = {} + statusCode = 500 + } + + if (redirect) { + return { html: '', ssrData: {}, redirect, statusCode: statusCode ?? 302 } + } + + return new Promise((resolve, reject) => { + let html = '' + const passthrough = new PassThrough() + passthrough.on('data', (chunk) => { + html += chunk.toString() + }) + + const { pipe } = renderToPipeableStream( + + + + + , + { + onAllReady() { + pipe(passthrough) + passthrough.on('end', () => resolve({ html, ssrData, ...(statusCode !== undefined && { statusCode }), ...(title !== undefined && { title }), ...(description !== undefined && { description }) })) + }, + onError: reject, + }, + ) + }) +} diff --git a/src/env.d.ts b/src/env.d.ts new file mode 100644 index 0000000..4f7157b --- /dev/null +++ b/src/env.d.ts @@ -0,0 +1,7 @@ +/// + +declare module '*.css' {} +declare module 'sql.js' { + const initSqlJs: any + export default initSqlJs +} diff --git a/src/global.css b/src/global.css new file mode 100644 index 0000000..899d384 --- /dev/null +++ b/src/global.css @@ -0,0 +1,260 @@ +@import 'tailwindcss'; + +@theme { + --font-mono: 'JetBrains Mono', 'Fira Code', 'SF Mono', 'Cascadia Code', ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace; + --font-sans: 'Inter', ui-sans-serif, system-ui, -apple-system, sans-serif; + + --color-parchment: #f5f0e8; + --color-parchment-dark: #ebe4d6; + --color-ink: #1a1a18; + --color-ink-light: #3d3d38; + --color-ink-muted: #6b6b63; + --color-rule: #c9c1b0; + --color-accent: #2d5a27; + --color-accent-light: #3a7433; + --color-link: #1a4a6e; +} + +@layer base { + a { + color: inherit; + text-decoration: none; + } + a:visited { + color: inherit; + } +} + +/* Prose styles for rendered markdown (README, etc.) */ +.prose h1 { font-size: 1.5rem; font-weight: 600; margin-top: 1.5rem; margin-bottom: 0.75rem; line-height: 1.3; } +.prose h2 { font-size: 1.25rem; font-weight: 600; margin-top: 1.5rem; margin-bottom: 0.5rem; line-height: 1.3; border-bottom: 1px solid var(--color-rule); padding-bottom: 0.3rem; } +.prose h3 { font-size: 1.1rem; font-weight: 600; margin-top: 1.25rem; margin-bottom: 0.5rem; } +.prose p { margin-top: 0.5rem; margin-bottom: 0.5rem; } +.prose ul, .prose ol { margin-top: 0.5rem; margin-bottom: 0.5rem; padding-left: 1.5rem; } +.prose ul { list-style-type: disc; } +.prose ol { list-style-type: decimal; } +.prose li { margin-top: 0.25rem; margin-bottom: 0.25rem; } +.prose code { font-family: var(--font-mono); font-size: 0.85em; background: var(--color-parchment-dark); padding: 0.15rem 0.35rem; border-radius: 3px; } +.prose pre { background: var(--color-ink); color: var(--color-parchment); padding: 1rem; overflow-x: auto; border-radius: 4px; margin-top: 0.75rem; margin-bottom: 0.75rem; } +.prose pre code { background: none; padding: 0; font-size: 0.8rem; } +.prose a { color: var(--color-link); text-decoration: underline; } +.prose blockquote { border-left: 3px solid var(--color-rule); padding-left: 1rem; color: var(--color-ink-muted); margin-top: 0.75rem; margin-bottom: 0.75rem; } +.prose table { border-collapse: collapse; width: 100%; margin-top: 0.75rem; margin-bottom: 0.75rem; } +.prose th, .prose td { border: 1px solid var(--color-rule); padding: 0.4rem 0.75rem; text-align: left; font-size: 0.875rem; } +.prose th { background: var(--color-parchment-dark); font-weight: 600; } +.prose hr { border: none; border-top: 1px solid var(--color-rule); margin: 1.5rem 0; } +.prose > *:first-child { margin-top: 0; } + +/* Docs layout */ +.docs-shell { + display: flex; + gap: 0; + max-width: 64rem; + margin: 0 auto; + padding: 2.5rem 1rem 4rem; + align-items: flex-start; +} +.docs-sidebar { + width: 13rem; + flex-shrink: 0; +} +.docs-sidebar-inner { + position: sticky; + top: 2rem; +} +.docs-search-box { + position: relative; + margin-bottom: 1rem; +} +#docs-search-results { + position: absolute; + top: 100%; + left: 0; + right: 0; + background: var(--color-parchment); + border: 1px solid var(--color-rule); + border-top: none; + z-index: 50; + max-height: 20rem; + overflow-y: auto; +} +.docs-search-result { + display: block; + padding: 0.4rem 0.6rem; + text-decoration: none; + border-bottom: 1px solid var(--color-rule); + transition: background 0.1s; +} +.docs-search-result:hover { + background: var(--color-parchment-dark); +} +.docs-search-result:last-child { + border-bottom: none; +} +.docs-search-result-title { + display: block; + font-size: 0.75rem; + font-weight: 600; + color: var(--color-ink); +} +.docs-search-result-context { + display: block; + font-size: 0.7rem; + color: var(--color-ink-muted); + font-family: var(--font-mono); + margin-top: 0.1rem; +} +.docs-search-empty { + padding: 0.5rem 0.6rem; + font-size: 0.75rem; + color: var(--color-ink-muted); +} +.docs-nav { + border-right: 1px solid var(--color-rule); + padding-right: 1rem; +} +.docs-nav-group { + margin-bottom: 1rem; +} +.docs-nav-heading { + font-size: 0.65rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.06em; + color: var(--color-ink-muted); + margin-bottom: 0.35rem; + padding-left: 0.5rem; +} +.docs-nav-group ul { + list-style: none; + padding: 0; + margin: 0; +} +.docs-nav-link { + display: block; + font-size: 0.8rem; + padding: 0.2rem 0.5rem; + color: var(--color-ink-light); + text-decoration: none; + transition: color 0.1s; +} +.docs-nav-link:hover { + color: var(--color-ink); +} +.docs-nav-link.active { + color: var(--color-ink); + font-weight: 500; +} +.docs-main { + flex: 1; + min-width: 0; + padding-left: 2rem; +} +@media (max-width: 768px) { + .docs-shell { + flex-direction: column; + padding: 1.5rem 1rem 3rem; + } + .docs-sidebar { + width: 100%; + margin-bottom: 1.5rem; + } + .docs-sidebar-inner { + position: static; + } + .docs-nav { + border-right: none; + border-bottom: 1px solid var(--color-rule); + padding-right: 0; + padding-bottom: 1rem; + } + .docs-main { + padding-left: 0; + } +} +.docs-prose h2 { + font-family: var(--font-sans); + font-size: 1.1rem; + font-weight: 600; + margin-top: 1.75rem; + margin-bottom: 0.5rem; +} +.docs-prose h2.mono { + font-family: var(--font-mono); + font-size: 0.95rem; +} +.docs-prose h3 { + font-size: 0.8rem; + font-weight: 600; + margin-top: 0.75rem; + margin-bottom: 0.25rem; + text-transform: uppercase; + letter-spacing: 0.05em; + color: var(--color-ink-muted); +} +.docs-prose p { + margin-bottom: 0.75rem; + color: var(--color-ink-light); + font-size: 0.875rem; +} +.docs-prose p.scope { + font-size: 0.75rem; + color: var(--color-accent); + margin-bottom: 0.5rem; +} +.docs-prose ul { + list-style-type: disc; + padding-left: 1.5rem; + margin-bottom: 0.75rem; + font-size: 0.875rem; + color: var(--color-ink-light); +} +.docs-prose ol { + list-style-type: decimal; + padding-left: 1.5rem; + margin-bottom: 0.75rem; + font-size: 0.875rem; + color: var(--color-ink-light); +} +.docs-prose li { + margin-bottom: 0.25rem; +} +.docs-prose code { + background: var(--color-parchment-dark); + padding: 0.1rem 0.3rem; + font-size: 0.8rem; +} +.docs-prose pre code { + background: none; + padding: 0; +} +.docs-prose a { + color: var(--color-link); + text-decoration: underline; +} +.docs-prose strong { + color: var(--color-ink); +} +.docs-prose table { + width: 100%; + font-size: 0.8rem; + margin-bottom: 0.75rem; +} +.docs-prose table td { + padding: 0.25rem 0.5rem; + border-bottom: 1px solid var(--color-rule); + vertical-align: top; +} +.docs-prose table td:first-child { + width: 8rem; +} +.docs-prose hr { + border-color: var(--color-rule); + margin: 1.5rem 0; +} +.docs-prose .endpoint h2 { + font-family: var(--font-mono); + font-size: 0.95rem; + margin-top: 1.5rem; + margin-bottom: 0.25rem; +} diff --git a/src/layouts/Base.astro b/src/layouts/Base.astro deleted file mode 100644 index 75c8a69..0000000 --- a/src/layouts/Base.astro +++ /dev/null @@ -1,102 +0,0 @@ ---- -import UserMenu from "@/components/UserMenu"; -import { apiBase } from "@/lib/page-utils"; -import { getMirrorConfig } from "@/lib/mirror-config"; - -interface Props { - title: string; - description?: string | undefined; -} - -const { title, description = "A public registry for structured knowledge." } = Astro.props; -const mirrorConfig = getMirrorConfig(); - -// Check auth state via API -let currentUser: { slug: string; displayName: string; orgs?: { slug: string; displayName: string }[] } | null = null; -const sessionCookie = Astro.cookies.get("session")?.value; -if (sessionCookie) { - try { - const res = await fetch(`${apiBase}/api/accounts/me`, { - headers: { Cookie: `session=${sessionCookie}` }, - }); - if (res.ok) { - currentUser = await res.json(); - } - } catch { - // API unavailable — treat as logged out - } -} ---- - - - - - - - - - {title} - - - - - - -
- -
- -
- -
- - - - - diff --git a/src/layouts/BlogPost.astro b/src/layouts/BlogPost.astro deleted file mode 100644 index c9cf9b5..0000000 --- a/src/layouts/BlogPost.astro +++ /dev/null @@ -1,86 +0,0 @@ ---- -import Base from "./Base.astro"; - -const { frontmatter } = Astro.props; ---- - - -
-
-

{frontmatter.title}

-

{frontmatter.subtitle}

- {frontmatter.date && ( - - )} -
-
- -
- -
-
- - - diff --git a/src/layouts/Docs.astro b/src/layouts/Docs.astro deleted file mode 100644 index 79413a2..0000000 --- a/src/layouts/Docs.astro +++ /dev/null @@ -1,306 +0,0 @@ ---- -import Base from "./Base.astro"; -import DocsSearch from "@/components/DocsSearch"; - -interface Props { - title: string; - description?: string; - breadcrumbs?: { label: string; href?: string }[]; -} - -const { title, description, breadcrumbs = [] } = Astro.props; - -const currentPath = Astro.url.pathname.replace(/\/$/, ""); - -const nav = [ - { - section: "Getting started", - items: [ - { label: "Overview", href: "/docs" }, - { label: "Concepts", href: "/docs/concepts" }, - { label: "Quickstart", href: "/docs/quickstart" }, - { label: "Integration Guide", href: "/docs/integration" }, - ], - }, - { - section: "API reference", - items: [ - { label: "Accounts", href: "/docs/api/accounts" }, - { label: "Collections", href: "/docs/api/collections" }, - { label: "Versions", href: "/docs/api/versions" }, - { label: "Files", href: "/docs/api/files" }, - ], - }, - { - section: "Infrastructure", - items: [ - { label: "Self-hosting", href: "/docs/self-host" }, - ], - }, -]; ---- - - -
- - -
-

{title}

-
- -
-
-
- - - diff --git a/src/lib/ark.ts b/src/lib/ark.ts index a076d25..5b0f888 100644 --- a/src/lib/ark.ts +++ b/src/lib/ark.ts @@ -1,6 +1,6 @@ import { createHash } from "node:crypto"; import { eq, sql } from "drizzle-orm"; -import { db, schema } from "../db/index.js"; +import { db, schema } from "../db/client.server.js"; export const DEFAULT_NAAN = process.env.ARK_DEFAULT_NAAN ?? "12345"; const SITE_URL = "https://underlay.org"; diff --git a/src/lib/auth.server.ts b/src/lib/auth.server.ts new file mode 100644 index 0000000..dc9e509 --- /dev/null +++ b/src/lib/auth.server.ts @@ -0,0 +1,85 @@ +import { eq } from 'drizzle-orm' +import { db, schema } from '../db/client.server.js' + +export interface SessionUser { + id: string + slug: string + displayName: string | null + email: string | null + type: string + bio: string | null + avatarUrl: string | null + orgs: Array<{ slug: string; displayName: string | null; role: string }> +} + +/** + * Extract the session cookie from a Request object and look up the user. + * Returns the user data or null if not authenticated. + */ +export async function getSessionUser(request: Request): Promise { + const cookieHeader = request.headers.get('cookie') + if (!cookieHeader) return null + + // Parse session cookie + const cookies = Object.fromEntries( + cookieHeader.split(';').map((c) => { + const [key, ...rest] = c.trim().split('=') + return [key, rest.join('=')] as [string, string] + }), + ) + + let sessionId = cookies['session'] + if (!sessionId) return null + + // Strip signature if present (legacy signed cookies) + const dotIdx = sessionId.lastIndexOf('.') + if (dotIdx > 0) { + sessionId = sessionId.slice(0, dotIdx) + } + + // Look up session + const [session] = await db + .select() + .from(schema.sessions) + .where(eq(schema.sessions.id, sessionId)) + .limit(1) + + if (!session || new Date(session.expiresAt) <= new Date()) { + return null + } + + // Look up user + const [user] = await db + .select() + .from(schema.accounts) + .where(eq(schema.accounts.id, session.userId)) + .limit(1) + + if (!user) return null + + // Look up org memberships + const memberships = await db + .select({ + orgSlug: schema.accounts.slug, + orgDisplayName: schema.accounts.displayName, + role: schema.orgMemberships.role, + }) + .from(schema.orgMemberships) + .innerJoin(schema.accounts, eq(schema.orgMemberships.orgId, schema.accounts.id)) + .where(eq(schema.orgMemberships.userId, user.id)) + + return { + id: user.id, + slug: user.slug, + displayName: user.displayName, + email: user.email, + type: user.type, + bio: user.bio, + avatarUrl: user.avatarUrl, + orgs: memberships.map((m) => ({ + slug: m.orgSlug, + displayName: m.orgDisplayName, + role: m.role, + })), + } +} diff --git a/src/lib/mirror-sync.ts b/src/lib/mirror-sync.ts index c64d0b4..d36b017 100644 --- a/src/lib/mirror-sync.ts +++ b/src/lib/mirror-sync.ts @@ -5,7 +5,7 @@ * Underlay server. Designed to be called by cron or triggered manually. */ -import { db, schema } from "../db/index.js"; +import { db, schema } from "../db/client.server.js"; import { eq, and, sql, desc } from "drizzle-orm"; import { uploadToS3, headS3Object } from "./s3.js"; import { createHash } from "node:crypto"; diff --git a/src/lib/page-utils.ts b/src/lib/page-utils.ts deleted file mode 100644 index 392c119..0000000 --- a/src/lib/page-utils.ts +++ /dev/null @@ -1,46 +0,0 @@ -/** - * Shared utilities for Astro pages (server-side). - */ - -/** Internal API base URL (Astro and Fastify are co-located in the same container) */ -export const apiBase = "http://localhost:3000"; - -const internalToken = process.env.INTERNAL_API_TOKEN ?? "internal-dev-token"; - -/** - * Build headers for internal API calls from Astro SSR. - * Uses session cookie if available, otherwise falls back to internal service token. - */ -export function apiHeaders(sessionCookie?: string | null): Record { - if (sessionCookie) { - return { Cookie: `session=${sessionCookie}` }; - } - return { "X-Internal-Token": internalToken }; -} - -/** Format bytes into human-readable size */ -export function formatBytes(bytes: number): string { - if (!bytes || bytes < 0) return "0 B"; - const k = 1024; - const sizes = ["B", "KB", "MB", "GB", "TB", "PB", "EB"]; - const i = Math.min(Math.floor(Math.log(bytes) / Math.log(k)), sizes.length - 1); - return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + " " + sizes[i]; -} - -/** Check if a session user owns or is a member of the given owner account */ -export async function checkOwnership( - sessionCookie: string | undefined, - owner: string, -): Promise { - if (!sessionCookie) return false; - try { - const res = await fetch(`${apiBase}/api/accounts/me`, { - headers: { Cookie: `session=${sessionCookie}` }, - }); - if (!res.ok) return false; - const me = await res.json(); - return me.slug === owner || me.orgs?.some((o: any) => o.slug === owner); - } catch { - return false; - } -} diff --git a/src/lib/ssr-data.tsx b/src/lib/ssr-data.tsx new file mode 100644 index 0000000..6f01614 --- /dev/null +++ b/src/lib/ssr-data.tsx @@ -0,0 +1,27 @@ +import { createContext, useContext } from 'react' + +type SSRData = Record + +const SSRDataContext = createContext({}) + +export function SSRDataProvider({ + data, + children, +}: { + data: SSRData + children: React.ReactNode +}) { + return {children} +} + +export function useSSRData(key: string): T { + const data = useContext(SSRDataContext) + return data[key] as T +} + +export function getClientSSRData(): SSRData { + if (typeof window !== 'undefined' && (window as any).__SSR_DATA__) { + return (window as any).__SSR_DATA__ as SSRData + } + return {} +} diff --git a/src/loaders.server.ts b/src/loaders.server.ts new file mode 100644 index 0000000..9242212 --- /dev/null +++ b/src/loaders.server.ts @@ -0,0 +1,404 @@ +import type { RouteConfig } from '~/routes' +import { getSessionUser } from '~/lib/auth.server' +import { getMirrorConfig } from '~/lib/mirror-config' + +type LoaderContext = { + params: Record + request: Request +} + +type LoaderResult = { + data: Record + redirect?: string + statusCode?: number + title?: string + description?: string +} + +type LoaderFn = (ctx: LoaderContext) => LoaderResult | Promise + +const mirrorConfig = getMirrorConfig() + +// Shared helper: require auth or redirect to login +async function requireUser(request: Request): Promise<{ user: any; redirect?: string }> { + const user = await getSessionUser(request) + if (!user) { + return { user: null, redirect: '/login' } + } + return { user } +} + +const loaders: Record = { + // --- Public pages --- + 'home': async ({ request }) => { + const user = await getSessionUser(request) + return { + data: { currentUser: user, mirrorConfig }, + title: mirrorConfig.enabled + ? `Underlay · ${mirrorConfig.nodeName}` + : 'Underlay — A public registry for structured knowledge', + } + }, + + 'explore': async ({ request }) => { + const user = await getSessionUser(request) + return { + data: { currentUser: user, mirrorConfig }, + title: 'Explore — Underlay', + } + }, + + 'query': async ({ request }) => { + const user = await getSessionUser(request) + return { + data: { currentUser: user, mirrorConfig }, + title: 'Query Explorer — Underlay', + } + }, + + 'schemas': async ({ request }) => { + const user = await getSessionUser(request) + return { + data: { currentUser: user, mirrorConfig }, + title: 'Schemas — Underlay', + } + }, + + 'schema-detail': async ({ params, request }) => { + const user = await getSessionUser(request) + return { + data: { currentUser: user, mirrorConfig, schemaId: params['id'] }, + title: 'Schema — Underlay', + } + }, + + // --- Auth pages --- + 'login': async ({ request }) => { + const user = await getSessionUser(request) + if (user) return { data: {}, redirect: '/dashboard' } + return { + data: { currentUser: null, mirrorConfig }, + title: 'Log in — Underlay', + } + }, + + 'signup': async ({ request }) => { + const user = await getSessionUser(request) + if (user) return { data: {}, redirect: '/dashboard' } + return { + data: { currentUser: null, mirrorConfig }, + title: 'Sign up — Underlay', + } + }, + + 'logout': async () => { + return { data: {}, redirect: '/login' } + }, + + 'forgot-password': async () => { + return { + data: { currentUser: null, mirrorConfig }, + title: 'Forgot password — Underlay', + } + }, + + 'reset-password': async () => { + return { + data: { currentUser: null, mirrorConfig }, + title: 'Reset password — Underlay', + } + }, + + // --- Dashboard/Settings --- + 'dashboard': async ({ request }) => { + const { user, redirect } = await requireUser(request) + if (redirect) return { data: {}, redirect } + return { + data: { currentUser: user, mirrorConfig }, + title: 'Dashboard — Underlay', + } + }, + + 'settings': async ({ request }) => { + const { user, redirect } = await requireUser(request) + if (redirect) return { data: {}, redirect } + return { + data: { currentUser: user, mirrorConfig }, + title: 'Settings — Underlay', + } + }, + + 'settings-keys': async ({ request }) => { + const { user, redirect } = await requireUser(request) + if (redirect) return { data: {}, redirect } + return { + data: { currentUser: user, mirrorConfig }, + title: 'API Keys — Underlay', + } + }, + + 'settings-sessions': async ({ request }) => { + const { user, redirect } = await requireUser(request) + if (redirect) return { data: {}, redirect } + return { + data: { currentUser: user, mirrorConfig }, + title: 'Sessions — Underlay', + } + }, + + 'settings-avatar': async ({ request }) => { + const { user, redirect } = await requireUser(request) + if (redirect) return { data: {}, redirect } + return { + data: { currentUser: user, mirrorConfig }, + title: 'Avatar — Underlay', + } + }, + + 'invitations-accept': async ({ request }) => { + const { user, redirect } = await requireUser(request) + if (redirect) return { data: {}, redirect } + return { + data: { currentUser: user, mirrorConfig }, + title: 'Accept Invitation — Underlay', + } + }, + + 'admin-mirror': async ({ request }) => { + const user = await getSessionUser(request) + return { + data: { currentUser: user, mirrorConfig }, + title: 'Mirror Admin — Underlay', + } + }, + + // --- Blog --- + 'blog': async ({ request }) => { + const user = await getSessionUser(request) + return { + data: { currentUser: user, mirrorConfig }, + title: 'Blog — Underlay', + } + }, + + 'blog-post': async ({ params, request }) => { + const user = await getSessionUser(request) + return { + data: { currentUser: user, mirrorConfig, slug: params['slug'] }, + title: 'Blog — Underlay', + } + }, + + // --- Docs --- + 'docs': async ({ request }) => { + const user = await getSessionUser(request) + return { + data: { currentUser: user, mirrorConfig }, + title: 'Documentation — Underlay', + } + }, + + 'docs-concepts': async ({ request }) => { + const user = await getSessionUser(request) + return { + data: { currentUser: user, mirrorConfig }, + title: 'Core Concepts — Underlay Docs', + } + }, + + 'docs-quickstart': async ({ request }) => { + const user = await getSessionUser(request) + return { + data: { currentUser: user, mirrorConfig }, + title: 'Quickstart — Underlay Docs', + } + }, + + 'docs-integration': async ({ request }) => { + const user = await getSessionUser(request) + return { + data: { currentUser: user, mirrorConfig }, + title: 'Integration — Underlay Docs', + } + }, + + 'docs-self-host': async ({ request }) => { + const user = await getSessionUser(request) + return { + data: { currentUser: user, mirrorConfig }, + title: 'Self-hosting — Underlay Docs', + } + }, + + 'docs-api': async ({ request }) => { + const user = await getSessionUser(request) + return { + data: { currentUser: user, mirrorConfig }, + title: 'API Reference — Underlay Docs', + } + }, + + 'docs-api-accounts': async ({ request }) => { + const user = await getSessionUser(request) + return { + data: { currentUser: user, mirrorConfig }, + title: 'Accounts API — Underlay Docs', + } + }, + + 'docs-api-collections': async ({ request }) => { + const user = await getSessionUser(request) + return { + data: { currentUser: user, mirrorConfig }, + title: 'Collections API — Underlay Docs', + } + }, + + 'docs-api-versions': async ({ request }) => { + const user = await getSessionUser(request) + return { + data: { currentUser: user, mirrorConfig }, + title: 'Versions API — Underlay Docs', + } + }, + + 'docs-api-files': async ({ request }) => { + const user = await getSessionUser(request) + return { + data: { currentUser: user, mirrorConfig }, + title: 'Files API — Underlay Docs', + } + }, + + // --- Dynamic owner/collection --- + 'owner': async ({ params, request }) => { + const user = await getSessionUser(request) + return { + data: { currentUser: user, mirrorConfig, owner: params['owner'] }, + title: `${params['owner']} — Underlay`, + } + }, + + 'owner-settings': async ({ params, request }) => { + const { user, redirect } = await requireUser(request) + if (redirect) return { data: {}, redirect } + return { + data: { currentUser: user, mirrorConfig, owner: params['owner'] }, + title: `Settings — ${params['owner']} — Underlay`, + } + }, + + 'owner-settings-keys': async ({ params, request }) => { + const { user, redirect } = await requireUser(request) + if (redirect) return { data: {}, redirect } + return { + data: { currentUser: user, mirrorConfig, owner: params['owner'] }, + title: `API Keys — ${params['owner']} — Underlay`, + } + }, + + 'owner-settings-members': async ({ params, request }) => { + const { user, redirect } = await requireUser(request) + if (redirect) return { data: {}, redirect } + return { + data: { currentUser: user, mirrorConfig, owner: params['owner'] }, + title: `Members — ${params['owner']} — Underlay`, + } + }, + + 'collection': async ({ params, request }) => { + const user = await getSessionUser(request) + return { + data: { + currentUser: user, + mirrorConfig, + owner: params['owner'], + collection: params['collection'], + }, + title: `${params['owner']}/${params['collection']} — Underlay`, + } + }, + + 'collection-versions': async ({ params, request }) => { + const user = await getSessionUser(request) + return { + data: { + currentUser: user, + mirrorConfig, + owner: params['owner'], + collection: params['collection'], + }, + title: `Versions — ${params['owner']}/${params['collection']} — Underlay`, + } + }, + + 'collection-version': async ({ params, request }) => { + const user = await getSessionUser(request) + return { + data: { + currentUser: user, + mirrorConfig, + owner: params['owner'], + collection: params['collection'], + versionNumber: params['n'], + }, + title: `v${params['n']} — ${params['owner']}/${params['collection']} — Underlay`, + } + }, + + 'collection-schemas': async ({ params, request }) => { + const user = await getSessionUser(request) + return { + data: { + currentUser: user, + mirrorConfig, + owner: params['owner'], + collection: params['collection'], + }, + title: `Schemas — ${params['owner']}/${params['collection']} — Underlay`, + } + }, + + 'collection-diff': async ({ params, request }) => { + const user = await getSessionUser(request) + return { + data: { + currentUser: user, + mirrorConfig, + owner: params['owner'], + collection: params['collection'], + }, + title: `Diff — ${params['owner']}/${params['collection']} — Underlay`, + } + }, + + 'collection-settings': async ({ params, request }) => { + const { user, redirect } = await requireUser(request) + if (redirect) return { data: {}, redirect } + return { + data: { + currentUser: user, + mirrorConfig, + owner: params['owner'], + collection: params['collection'], + }, + title: `Settings — ${params['owner']}/${params['collection']} — Underlay`, + } + }, +} + +export async function runLoaders( + matchedRoutes: { route: RouteConfig; params: Record }[], + request: Request, +): Promise { + // Run the first matched route's loader + for (const { route, params } of matchedRoutes) { + const loader = loaders[route.id] + if (loader) { + return loader({ params, request }) + } + } + + // No loader found — return empty data + return { data: {} } +} diff --git a/src/middleware.ts b/src/middleware.ts deleted file mode 100644 index 025ae93..0000000 --- a/src/middleware.ts +++ /dev/null @@ -1,103 +0,0 @@ -import { defineMiddleware } from "astro:middleware"; -import { DEFAULT_NAAN, buildErc } from "./lib/ark.js"; - -const apiBase = "http://localhost:3000"; - -export const onRequest = defineMiddleware(async (context, next) => { - const { pathname, search } = context.url; - - if (pathname.startsWith("/api")) { - return new Response( - JSON.stringify({ error: "API route not found", statusCode: 404 }), - { status: 404, headers: { "Content-Type": "application/json" } }, - ); - } - - if (pathname.startsWith("/ark:")) { - const fullPath = pathname.slice(1); // strip leading / - - // Check if this is a root NAAN path: "ark:NAAN/" or "ark:NAAN" with nothing after - const afterLabel = fullPath.slice(4); // strip "ark:" - const slashIdx = afterLabel.indexOf("/"); - const naan = slashIdx === -1 ? afterLabel : afterLabel.slice(0, slashIdx); - const afterNaan = slashIdx === -1 ? "" : afterLabel.slice(slashIdx + 1); - - if (!afterNaan.trim()) { - // ARK NAAN root — return naming authority policy statement - return new Response( - [ - `The Underlay assigns identifiers within the ARK domain ${naan} with the following principles:`, - "", - "1. Persistence: ARKs are never reassigned. Once minted, an ARK will always resolve to the same collection or record, or return a tombstone response if the object has been deleted.", - "", - "2. Transparency: Appending ?info or ?? to any ARK returns an Electronic Resource Citation (ERC) describing the identified object.", - "", - "3. Openness: ARKs are free, open identifiers requiring no licensing fees. The Underlay uses the ARK scheme as specified by the ARK Alliance.", - "", - "4. Scope: Underlay ARKs primarily identify versioned data collections and the records within them. Collection ARKs redirect to the collection overview; version-qualified ARKs redirect to specific version pages; record ARKs redirect to the canonical URL of the identified record.", - "", - `For more information, see: https://underlay.org/ark:${naan}/`, - ].join("\n"), - { headers: { "Content-Type": "text/plain; charset=utf-8" } }, - ); - } - - // Resolve the ARK via the API - const params = new URLSearchParams({ path: fullPath }); - let resolveRes: Response; - try { - resolveRes = await fetch(`${apiBase}/api/ark/resolve?${params}`); - } catch { - return new Response("ARK resolver unavailable", { status: 503 }); - } - - if (!resolveRes.ok) { - const body = await resolveRes.json().catch(() => ({})); - if (body?.type === "not_found") { - return new Response("ARK not found", { status: 404 }); - } - return new Response("ARK resolution error", { status: 502 }); - } - - const data = await resolveRes.json(); - - if (data.type === "not_found") { - return new Response("ARK not found", { status: 404 }); - } - - const { metadata } = data; - const resolvedNaan = metadata?.naan ?? DEFAULT_NAAN; - - // Handle inflections - if (search === "?info" || search === "??" || search === "%3F%3F") { - const erc = buildErc({ - type: metadata.type, - who: metadata.who ?? metadata.ownerName ?? "(:unkn)", - what: metadata.what ?? metadata.collectionName ?? "(:unkn)", - when: metadata.when ?? "(:unkn)", - where: metadata.where ?? metadata.arkUrl ?? "(:unkn)", - naan: resolvedNaan, - }); - return new Response(erc, { - headers: { "Content-Type": "text/plain; charset=utf-8" }, - }); - } - - if (search === "?json") { - return new Response(JSON.stringify(metadata, null, 2), { - headers: { "Content-Type": "application/json" }, - }); - } - - // Regular resolution — redirect - const targetUrl = data.url; - // Internal relative URLs get the site origin prepended - const redirectTarget = targetUrl.startsWith("/") - ? `${context.url.origin}${targetUrl}` - : targetUrl; - - return Response.redirect(redirectTarget, 302); - } - - return next(); -}); diff --git a/src/pages/[owner]/[collection]/diff.astro b/src/pages/[owner]/[collection]/diff.astro deleted file mode 100644 index cf1be88..0000000 --- a/src/pages/[owner]/[collection]/diff.astro +++ /dev/null @@ -1,319 +0,0 @@ ---- -import Base from "@/layouts/Base.astro"; -import CollectionNav from "@/components/CollectionNav.astro"; -import { apiBase, apiHeaders, checkOwnership } from "@/lib/page-utils"; - -const { owner, collection } = Astro.params; -const sessionCookie = Astro.cookies.get("session")?.value; - -// Parse query params: ?from=1&to=2 -const toNum = Astro.url.searchParams.get("to"); -const fromNum = Astro.url.searchParams.get("from"); - -// Fetch collection -const collectionRes = await fetch(`${apiBase}/api/collections/${owner}/${collection}`, { headers: apiHeaders(sessionCookie) }); -if (!collectionRes.ok) return Astro.redirect("/404"); -const data = await collectionRes.json(); - -// Fetch all versions for the dropdown selectors -const versionsRes = await fetch(`${apiBase}/api/collections/${owner}/${collection}/versions?limit=100`, { headers: apiHeaders(sessionCookie) }); -const versions: any[] = versionsRes.ok ? await versionsRes.json() : []; - -// Default: latest version diff'd against its predecessor -const latestNum = versions.length > 0 ? versions[0].number : null; -const targetNum = toNum ? parseInt(toNum, 10) : latestNum; -const baseNum = fromNum ? parseInt(fromNum, 10) : (targetNum ? targetNum - 1 : null); - -// Fetch diff -let diff: any = null; -let diffError: string | null = null; -if (targetNum && targetNum > 0) { - const fromParam = baseNum !== null && baseNum >= 0 ? `?from=${baseNum}` : ""; - const diffRes = await fetch(`${apiBase}/api/collections/${owner}/${collection}/versions/${targetNum}/diff${fromParam}`, { headers: apiHeaders(sessionCookie) }); - if (diffRes.ok) { - diff = await diffRes.json(); - } else { - const body = await diffRes.json().catch(() => ({})); - diffError = body.error ?? "Failed to load diff"; - } -} - -// Fetch version metadata for from/to -const targetVersion = versions.find((v: any) => v.number === targetNum); -const baseVersion = versions.find((v: any) => v.number === baseNum); - -// Check if current user is owner -const isOwner = await checkOwnership(sessionCookie, owner!); - -// Group changes by type -function groupByType(records: any[]) { - const groups: Record = {}; - for (const r of records) { - if (!groups[r.type]) groups[r.type] = []; - groups[r.type].push(r); - } - return groups; -} - -const addedByType = diff ? groupByType(diff.added) : {}; -const updatedByType = diff ? groupByType(diff.updated) : {}; -// removed is just IDs, so group differently -const removedCount = diff?.removed?.length ?? 0; - -const totalAdded = diff?.added?.length ?? 0; -const totalUpdated = diff?.updated?.length ?? 0; -const totalRemoved = removedCount; -const totalChanges = totalAdded + totalUpdated + totalRemoved; - -const meta = diff?.meta ?? {}; -const hasMetaChanges = meta.schemaChanged || meta.readmeChanged || meta.filesAdded > 0 || meta.filesRemoved > 0; ---- - - -
- - - -
-
- - - - - -
-
- - {diffError && ( -
- {diffError} -
- )} - - {diff && ( -
- -
- - {baseVersion ? `v${baseVersion.number}` : "∅"} → v{targetVersion?.number} - - · - {totalChanges.toLocaleString()} changes - {totalAdded > 0 && ( - +{totalAdded.toLocaleString()} added - )} - {totalUpdated > 0 && ( - ~{totalUpdated.toLocaleString()} updated - )} - {totalRemoved > 0 && ( - -{totalRemoved.toLocaleString()} removed - )} - {meta.schemaChanged && ( - schema - )} - {meta.readmeChanged && ( - readme - )} - {meta.filesAdded > 0 && ( - +{meta.filesAdded} files - )} - {meta.filesRemoved > 0 && ( - -{meta.filesRemoved} files - )} -
- - - {hasMetaChanges && ( -
-

- - Metadata changes -

-
- - - {meta.schemaChanged && ( - - - - - )} - {meta.readmeChanged && ( - - - - - )} - {meta.filesAdded > 0 && ( - - - - - )} - {meta.filesRemoved > 0 && ( - - - - - )} - -
SchemaModified
README - {!meta.readmeFrom && meta.readmeTo ? ( - Added - ) : meta.readmeFrom && !meta.readmeTo ? ( - Removed - ) : ( - Modified - )} -
Files added+{meta.filesAdded}
Files removed-{meta.filesRemoved}
-
-
- )} - - {totalChanges === 0 && !hasMetaChanges && ( -

No changes between these versions.

- )} - - - {totalAdded > 0 && ( -
-

- - Added ({totalAdded.toLocaleString()}) -

- {Object.entries(addedByType).map(([type, records]: [string, any[]]) => ( -
-
{type} ({records.length})
-
- - - - - - - - - {records.slice(0, 50).map((r: any) => ( - - - - - ))} - {records.length > 50 && ( - - - - )} - -
IDData
- {r.id.length > 30 ? r.id.slice(0, 30) + "…" : r.id} - -
{JSON.stringify(r.data, null, 2)}
-
- … and {records.length - 50} more -
-
-
- ))} -
- )} - - - {totalUpdated > 0 && ( -
-

- - Updated ({totalUpdated.toLocaleString()}) -

- {Object.entries(updatedByType).map(([type, records]: [string, any[]]) => ( -
-
{type} ({records.length})
-
- - - - - - - - - {records.slice(0, 50).map((r: any) => ( - - - - - ))} - {records.length > 50 && ( - - - - )} - -
IDNew data
- {r.id.length > 30 ? r.id.slice(0, 30) + "…" : r.id} - -
{JSON.stringify(r.data, null, 2)}
-
- … and {records.length - 50} more -
-
-
- ))} -
- )} - - - {totalRemoved > 0 && ( -
-

- - Removed ({totalRemoved.toLocaleString()}) -

-
- - - - - - - - {diff.removed.slice(0, 100).map((id: string) => ( - - - - ))} - {diff.removed.length > 100 && ( - - - - )} - -
Record ID
{id}
- … and {diff.removed.length - 100} more -
-
-
- )} -
- )} - - {!diff && !diffError && ( -

Select versions to compare.

- )} -
- diff --git a/src/pages/[owner]/[collection]/index.astro b/src/pages/[owner]/[collection]/index.astro deleted file mode 100644 index 4177165..0000000 --- a/src/pages/[owner]/[collection]/index.astro +++ /dev/null @@ -1,182 +0,0 @@ ---- -import Base from "@/layouts/Base.astro"; -import CollectionNav from "@/components/CollectionNav.astro"; -import { apiBase, apiHeaders, formatBytes, checkOwnership } from "@/lib/page-utils"; -import { getMirrorConfig } from "@/lib/mirror-config"; -import { marked } from "marked"; - -const { owner, collection } = Astro.params; -const sessionCookie = Astro.cookies.get("session")?.value; -const mirrorConfig = getMirrorConfig(); - -// Fetch collection -const collectionRes = await fetch(`${apiBase}/api/collections/${owner}/${collection}`, { headers: apiHeaders(sessionCookie) }); -if (!collectionRes.ok) { - return Astro.redirect("/404"); -} -const data = await collectionRes.json(); - -// Count total versions (just need the count, not the list) -const versionsRes = await fetch(`${apiBase}/api/collections/${owner}/${collection}/versions?limit=1`, { headers: apiHeaders(sessionCookie) }); -const versionsData = versionsRes.ok ? await versionsRes.json() : []; -const totalVersions = versionsData.length > 0 ? data.latestVersion?.number ?? 0 : 0; - -// Check if current user is the owner (for settings link) -const isOwner = await checkOwnership(sessionCookie, owner!); - -// Render readme markdown -const readmeSource = data.latestVersion?.readme || data.latestVersion?.message || null; -const readmeHtml = readmeSource ? marked.parse(readmeSource) : null; - -// Parse type counts for TOC -const typeCounts: { type: string; count: number }[] = data.latestVersion?.typeCounts ?? []; -const allTypes = typeCounts.sort((a: any, b: any) => a.type.localeCompare(b.type)); - -const collectionArkPath: string | null = data.ark ? new URL(data.ark).pathname : null; ---- - - -
- - - {mirrorConfig.enabled && ( - - )} - - -
- -
- - {data.latestVersion && ( -
-
- - {data.latestVersion.semver} - - · - {data.latestVersion.recordCount.toLocaleString()} records - · - {data.latestVersion.fileCount.toLocaleString()} files - · - {formatBytes(data.latestVersion.totalBytes)} -
-
- - {new Date(data.latestVersion.createdAt).toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" })} - - - - {totalVersions} - -
-
- )} - - - {allTypes.length > 0 && ( -
- {allTypes.map((t: any, i: number) => ( - -
- - {t.type} -
- {t.count.toLocaleString()} records -
- ))} -
- )} - - - {readmeHtml ? ( -
-
- README -
-
-
- ) : ( -
- No README yet. -
- )} -
- - - -
-
- diff --git a/src/pages/[owner]/[collection]/schemas.astro b/src/pages/[owner]/[collection]/schemas.astro deleted file mode 100644 index d6583de..0000000 --- a/src/pages/[owner]/[collection]/schemas.astro +++ /dev/null @@ -1,195 +0,0 @@ ---- -import Base from "@/layouts/Base.astro"; -import CollectionNav from "@/components/CollectionNav.astro"; -import { apiBase, apiHeaders, checkOwnership } from "@/lib/page-utils"; - -const { owner, collection } = Astro.params; -const sessionCookie = Astro.cookies.get("session")?.value; - -// Fetch collection metadata -const collectionRes = await fetch(`${apiBase}/api/collections/${owner}/${collection}`, { headers: apiHeaders(sessionCookie) }); -if (!collectionRes.ok) return Astro.redirect("/404"); -const data = await collectionRes.json(); - -const isOwner = await checkOwnership(sessionCookie, owner!); - -// Fetch schemas for this collection (uses latest version by default) -const versionParam = Astro.url.searchParams.get("version"); -const schemasUrl = `${apiBase}/api/collections/${owner}/${collection}/schemas${versionParam ? `?version=${versionParam}` : ""}`; -const schemasRes = await fetch(schemasUrl, { headers: apiHeaders(sessionCookie) }); -const schemasData = schemasRes.ok ? await schemasRes.json() : { schemas: [], version: null, semver: null }; -const schemas: any[] = schemasData.schemas ?? []; - -// Fetch ARK record type settings (owner only) -let arkRecordTypes: Record = {}; // recordType → redirectUrlField -if (isOwner) { - const arkTypesRes = await fetch(`${apiBase}/api/collections/${owner}/${collection}/ark/record-types`, { - headers: apiHeaders(sessionCookie), - }); - if (arkTypesRes.ok) { - const arkTypesData = await arkTypesRes.json(); - for (const entry of arkTypesData) { - arkRecordTypes[entry.recordType] = entry.redirectUrlField; - } - } -} - -let arkSuccess = ""; -let arkError = ""; - -if (Astro.request.method === "POST" && isOwner) { - const form = await Astro.request.formData(); - const action = form.get("action") as string; - - if (action === "update-ark-type") { - const recordType = form.get("recordType") as string; - const redirectUrlField = (form.get("redirectUrlField") as string) || null; - const res = await fetch(`${apiBase}/api/collections/${owner}/${collection}/ark/record-types`, { - method: "PATCH", - headers: { "Content-Type": "application/json", Cookie: `session=${sessionCookie}` }, - body: JSON.stringify({ recordType, redirectUrlField }), - }); - if (res.ok) { - arkSuccess = `ARK settings updated for ${recordType}.`; - if (redirectUrlField) { - arkRecordTypes[recordType] = redirectUrlField; - } else { - delete arkRecordTypes[recordType]; - } - } else { - const body = await res.json().catch(() => ({})); - arkError = body.error ?? "Failed to update ARK settings."; - } - } -} ---- - - -
- - - {arkSuccess &&

{arkSuccess}

} - {arkError &&

{arkError}

} - -
-

- {schemas.length} type{schemas.length !== 1 ? "s" : ""} - {schemasData.semver && in {schemasData.semver}} -

-
- - {schemas.length === 0 ? ( -

No schemas in this version.

- ) : ( -
- {schemas.map((s: any) => { - const properties = (s.schema as any)?.properties ?? {}; - const fields = Object.entries(properties); - const isPrivate = (s.schema as any)?.private === true; - const labels: string[] = (s.schema as any)?.["x-underlay-labels"] ?? []; - - return ( -
- {/* Header */} -
-
- - {s.slug} - {isPrivate && private} -
-
- {labels.length > 0 && ( -
- {labels.map((label: string) => ( - {label} - ))} -
- )} - - {s.schemaHash.slice(0, 10)}… - -
-
- - {/* Fields table */} - - - - - - - - - - {fields.map(([name, def]: [string, any]) => { - const fieldType = def.type ?? "unknown"; - const format = def.format ? ` (${def.format})` : ""; - const refType = def["x-ref-type"]; - const isFieldPrivate = def.private === true; - - return ( - - - - - - ); - })} - -
FieldTypeAnnotations
{name}{fieldType}{format} -
- {refType && ( - - - → {refType} - - )} - {isFieldPrivate && ( - private - )} -
-
- - {/* ARK section for this type (owner only, when URL fields exist) */} - {(() => { - if (!isOwner) return null; - const urlFields = fields.filter(([, def]: [string, any]) => - def.type === "string" && (def.format === "uri" || def.format === "url") - ).map(([name]: [string, any]) => name); - if (urlFields.length === 0) return null; - const currentField = arkRecordTypes[s.slug] ?? ""; - return ( -
-

ARK identifiers for this type

-
- - - - - -
-
- ); - })()} -
- ); - })} -
- )} -
- diff --git a/src/pages/[owner]/[collection]/settings.astro b/src/pages/[owner]/[collection]/settings.astro deleted file mode 100644 index edf1772..0000000 --- a/src/pages/[owner]/[collection]/settings.astro +++ /dev/null @@ -1,270 +0,0 @@ ---- -import Base from "@/layouts/Base.astro"; -import CollectionNav from "@/components/CollectionNav.astro"; -import { apiBase } from "@/lib/page-utils"; - -const { owner, collection } = Astro.params; -const sessionCookie = Astro.cookies.get("session")?.value; - -if (!sessionCookie) { - return Astro.redirect("/login"); -} - -// Verify auth -const meRes = await fetch(`${apiBase}/api/accounts/me`, { - headers: { Cookie: `session=${sessionCookie}` }, -}); -if (!meRes.ok) { - return Astro.redirect("/login"); -} -const me = await meRes.json(); - -// Fetch collection -const collectionRes = await fetch(`${apiBase}/api/collections/${owner}/${collection}`, { - headers: { Cookie: `session=${sessionCookie}` }, -}); -if (!collectionRes.ok) { - return Astro.redirect("/404"); -} -const data = await collectionRes.json(); - -// Basic ownership check (user must own the account or be an org member) -const isOrgMember = me.orgs?.some((o: any) => o.slug === owner); -if (me.slug !== owner && !isOrgMember) { - return Astro.redirect(`/${owner}/${collection}`); -} - -// Fetch ARK settings for this collection -const arkRes = await fetch(`${apiBase}/api/collections/${owner}/${collection}/ark`, { - headers: { Cookie: `session=${sessionCookie}` }, -}); -let arkSettings = arkRes.ok ? await arkRes.json() : { enabled: false, customUrl: null, arkUrl: null }; - -let success = ""; -let error = ""; -let deleted = false; - -if (Astro.request.method === "POST") { - const form = await Astro.request.formData(); - const action = form.get("action") as string; - - if (action === "update-ark") { - const enabled = form.get("arkEnabled") === "on"; - const customUrl = (form.get("arkCustomUrl") as string).trim() || null; - const res = await fetch(`${apiBase}/api/collections/${owner}/${collection}/ark`, { - method: "PATCH", - headers: { "Content-Type": "application/json", Cookie: `session=${sessionCookie}` }, - body: JSON.stringify({ enabled, customUrl }), - }); - if (res.ok) { - success = "ARK settings updated."; - const refreshed = await fetch(`${apiBase}/api/collections/${owner}/${collection}/ark`, { - headers: { Cookie: `session=${sessionCookie}` }, - }); - if (refreshed.ok) arkSettings = await refreshed.json(); - } else { - const body = await res.json().catch(() => ({})); - error = body.error ?? "Failed to update ARK settings."; - } - } - - if (action === "update") { - const name = form.get("name") as string; - const description = form.get("description") as string; - const isPublic = form.get("public") === "on"; - - const res = await fetch(`${apiBase}/api/collections/${owner}/${collection}`, { - method: "PATCH", - headers: { - "Content-Type": "application/json", - Cookie: `session=${sessionCookie}`, - }, - body: JSON.stringify({ name, description, public: isPublic }), - }); - - if (res.ok) { - success = "Collection updated."; - // Refresh data - const refreshed = await fetch(`${apiBase}/api/collections/${owner}/${collection}`, { - headers: { Cookie: `session=${sessionCookie}` }, - }); - if (refreshed.ok) { - const updated = await refreshed.json(); - data.name = updated.name; - data.description = updated.description; - data.public = updated.public; - } - } else { - const body = await res.json().catch(() => ({})); - error = body.error ?? "Update failed."; - } - } - - if (action === "delete") { - const confirmSlug = form.get("confirmSlug") as string; - if (confirmSlug !== collection) { - error = "Collection name does not match. Deletion cancelled."; - } else { - const res = await fetch(`${apiBase}/api/collections/${owner}/${collection}`, { - method: "DELETE", - headers: { Cookie: `session=${sessionCookie}` }, - }); - if (res.ok) { - deleted = true; - } else { - const body = await res.json().catch(() => ({})); - error = body.error ?? "Delete failed."; - } - } - } -} - -if (deleted) { - return Astro.redirect("/dashboard"); -} - -const arkPath: string | null = arkSettings.arkUrl ? new URL(arkSettings.arkUrl).pathname : null; ---- - - -
- - -
- {success &&

{success}

} - {error &&

{error}

} - - -
- - -
- - -
- -
- - -
- -
- - -
- -
- -
-
- - -
-

Export

-

- Download a .tar.gz archive containing the manifest, schema, records, and files for the latest version. -

- - Download archive - -
- - -
-

ARK Identifiers

- {arkPath && arkSettings.enabled && ( -

- Current ARK: {arkPath.slice(1)} -

- )} -
- -
- - -
-
- - -
-
- -
-
-
- - -
-

Danger Zone

-

- Permanently delete this collection and all its versions, records, and files. This cannot be undone. -

-
- Delete this collection… -
- -
- - -
- -
-
-
-
-
- diff --git a/src/pages/[owner]/[collection]/v/[n].astro b/src/pages/[owner]/[collection]/v/[n].astro deleted file mode 100644 index f02679b..0000000 --- a/src/pages/[owner]/[collection]/v/[n].astro +++ /dev/null @@ -1,474 +0,0 @@ ---- -import Base from "@/layouts/Base.astro"; -import CollectionNav from "@/components/CollectionNav.astro"; -import { apiBase, apiHeaders, formatBytes, checkOwnership } from "@/lib/page-utils"; -import { marked } from "marked"; - -const { owner, collection, n } = Astro.params; -const sessionCookie = Astro.cookies.get("session")?.value; -const headers = apiHeaders(sessionCookie); - -// Fetch version -const versionRes = await fetch(`${apiBase}/api/collections/${owner}/${collection}/versions/${n}`, { headers }); -if (!versionRes.ok) { - return Astro.redirect("/404"); -} -const version = await versionRes.json(); - -// Fetch collection metadata (for public badge, etc.) -const collectionRes = await fetch(`${apiBase}/api/collections/${owner}/${collection}`, { headers }); -const collectionData = collectionRes.ok ? await collectionRes.json() : null; - -// Check if current user is the owner (for settings link) -const isOwner = await checkOwnership(sessionCookie, owner!); - -// Render readme if present -const readmeHtml = version.readme ? marked.parse(version.readme) : null; - -// Tab state -const tab = Astro.url.searchParams.get("tab") ?? "records"; -const selectedType = Astro.url.searchParams.get("type") ?? null; - -// Parse schema to get type info -const schemasMap = (version.schemas ?? {}) as Record; -const allTypes = Object.keys(schemasMap).sort(); - -// Fetch records only on records tab -let records: any[] = []; -let totalRecords = version.recordCount ?? 0; -let page = 1; -let pageSize = 100; -let offset = 0; -let totalPages = 1; -let pageNumbers: number[] = []; -let byType: Record = {}; -let currentType = selectedType || (allTypes.length > 0 ? allTypes[0] : null); - -if (tab === "records" && currentType) { - page = parseInt(Astro.url.searchParams.get("page") ?? "1", 10); - pageSize = 100; - offset = (page - 1) * pageSize; - const recordsRes = await fetch(`${apiBase}/api/collections/${owner}/${collection}/versions/${n}/records?type=${currentType}&limit=${pageSize}&offset=${offset}`, { headers }); - const recordsBody = recordsRes.ok ? await recordsRes.json() : { records: [], pagination: {} }; - records = recordsBody.records ?? recordsBody; - totalRecords = recordsBody.pagination?.total ?? totalRecords; - - // We need the total count for this type — use pagination.total from the response - totalPages = Math.ceil(totalRecords / pageSize) || 1; - - pageNumbers = []; - if (totalPages > 1) { - const maxVisible = Math.min(totalPages, 7); - for (let i = 0; i < maxVisible; i++) { - let p: number; - if (totalPages <= 7) { - p = i + 1; - } else if (page <= 4) { - p = i + 1; - } else if (page >= totalPages - 3) { - p = totalPages - 6 + i; - } else { - p = page - 3 + i; - } - pageNumbers.push(p); - } - } -} - -// Compute record fields once — used in the template without needing an IIFE -const currentTypeFields: string[] = currentType - ? (schemasMap[currentType]?.properties - ? Object.keys(schemasMap[currentType].properties) - : records.length > 0 - ? Object.keys(records[0].data ?? {}) - : []) - : []; - -const versionArkPath: string | null = version.ark ? new URL(version.ark).pathname : null; - -// Fetch files only on files tab -let files: any[] = []; -if (tab === "files") { - const filesRes = await fetch(`${apiBase}/api/collections/${owner}/${collection}/versions/${n}/files`, { headers }); - files = filesRes.ok ? await filesRes.json() : []; -} - -// Build tab URL helper -function tabUrl(t: string, extra?: Record) { - const base = `/${owner}/${collection}/v/${n}`; - const params = new URLSearchParams(); - if (t !== "records") params.set("tab", t); - if (extra) { - for (const [k, v] of Object.entries(extra)) params.set(k, v); - } - const qs = params.toString(); - return qs ? `${base}?${qs}` : base; -} ---- - - -
- - - {version.message &&

{version.message}

} - {!version.message &&
} - - -
-
- {version.recordCount.toLocaleString()} records - {version.fileCount.toLocaleString()} files - {formatBytes(version.totalBytes)} total - {allTypes.length} types -
-
- {version.appId && via {version.appId}} - {new Date(version.createdAt).toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" })} - sha256:{version.hash.slice(0, 12)}… - {versionArkPath && ( - - {versionArkPath.slice(1)} - - )} -
-
- - - - - - {tab === "records" && ( -
- - - - -
- {currentType && records.length > 0 && ( -
-
- - - - - {currentTypeFields.map((f: string) => ( - - ))} - {records[0]?.ark && } - - - - {records.map((r: any) => ( - - - {currentTypeFields.map((f: string) => { - const val = r.data?.[f]; - if (val && typeof val === "object" && "$file" in val) { - const hash = ((val as any).$file as string).replace("sha256:", ""); - const fileUrl = `https://assets.underlay.org/files/${hash.slice(0, 2)}/${hash.slice(2, 4)}/${hash}`; - const label = f === "pdf" ? "PDF" : "File"; - return ( - - ); - } - if (typeof val === "string" && val.match(/^https?:\/\//)) { - return ( - - ); - } - const display = val === null || val === undefined - ? "" - : typeof val === "object" - ? JSON.stringify(val) - : String(val); - return ; - })} - {r.ark && ( - - )} - - ))} - -
id{f}ARK
{r.id} - - - {label} - - - {val} - {display} - ark -
-
- - {totalPages > 1 && ( - - )} -
- )} - {(!currentType || records.length === 0) && ( -

Select a type to view records.

- )} -
-
- )} - - - {tab === "schema" && ( -
-
-
- )} - - - {tab === "files" && ( -
- {files.length === 0 ? ( -

No files in this version.

- ) : ( -
-

{files.length} file{files.length !== 1 ? "s" : ""} · {formatBytes(files.reduce((sum: number, f: any) => sum + (f.size ?? 0), 0))} total

-
- - - - - - - - - - - {files.map((f: any) => { - const refs: any[] = f.references ?? []; - const ext = f.mimeType?.split("/")[1] ?? ""; - const isPdf = f.mimeType === "application/pdf"; - const isImage = f.mimeType?.startsWith("image/"); - return ( - - - - - - - ); - })} - -
FileReferenced bySize
-
- {isPdf ? ( - - ) : isImage ? ( - - ) : ( - - )} -
- {f.hash.slice(0, 12)}… - {f.mimeType} -
-
-
- {refs.length > 0 ? ( -
- {refs.slice(0, 3).map((ref: any) => ( - - {ref.type} - {ref.recordId.length > 20 ? ref.recordId.slice(0, 20) + "…" : ref.recordId} - - ))} - {refs.length > 3 && ( - +{refs.length - 3} more - )} -
- ) : ( - - )} -
{formatBytes(f.size)} - - - -
-
-
- )} -
- )} - - - {tab === "metadata" && ( -
- {/* Collection-level metadata (from latest version / collection record) */} -

Collection

- - - {collectionData?.name && ( - - - - - )} - {collectionData?.description && ( - - - - - )} - - - - - - - - - -
Name{collectionData.name}
Description{collectionData.description}
Owner{collectionData?.ownerName ?? owner} ({owner})
Visibility{collectionData?.public ? "Public" : "Private"}
- - {/* Version-level metadata */} -

Version

- - - - - - - - - - - - - - - {version.baseNumber !== null && version.baseNumber !== undefined && ( - - - - - )} - {version.message && ( - - - - - )} - - - - - - - - - - - - - - - - - -
Version{version.number} ({version.semver})
Hashsha256:{version.hash}
Created{new Date(version.createdAt).toLocaleDateString("en-US", { year: "numeric", month: "long", day: "numeric", hour: "2-digit", minute: "2-digit" })}
Base versionv{version.baseNumber}
Message{version.message}
Records{version.recordCount.toLocaleString()}
Files{version.fileCount.toLocaleString()}
Total size{formatBytes(version.totalBytes)}
Types{allTypes.join(", ") || "—"}
- - {/* Provenance */} -

Provenance

- - - {version.appId && ( - - - - - )} - {version.actorId && ( - - - - - )} - {version.pushedBy && ( - - - - - )} - {version.signature && ( - - - - - )} - -
App ID{version.appId}
Actor ID{version.actorId}
Pushed by{version.pushedBy}
Signature{version.signature}
- - {/* README */} - {version.readme && ( -
-

README

-
-
- )} -
- )} -
- diff --git a/src/pages/[owner]/[collection]/versions.astro b/src/pages/[owner]/[collection]/versions.astro deleted file mode 100644 index a0fcf0f..0000000 --- a/src/pages/[owner]/[collection]/versions.astro +++ /dev/null @@ -1,73 +0,0 @@ ---- -import Base from "@/layouts/Base.astro"; -import CollectionNav from "@/components/CollectionNav.astro"; -import { apiBase, apiHeaders, formatBytes, checkOwnership } from "@/lib/page-utils"; - -const { owner, collection } = Astro.params; -const sessionCookie = Astro.cookies.get("session")?.value; - -// Fetch collection -const collectionRes = await fetch(`${apiBase}/api/collections/${owner}/${collection}`, { headers: apiHeaders(sessionCookie) }); -if (!collectionRes.ok) return Astro.redirect("/404"); -const data = await collectionRes.json(); - -// Fetch all versions -const versionsRes = await fetch(`${apiBase}/api/collections/${owner}/${collection}/versions?limit=100`, { headers: apiHeaders(sessionCookie) }); -const versions = versionsRes.ok ? await versionsRes.json() : []; - -// Check if current user is owner -const isOwner = await checkOwnership(sessionCookie, owner!); ---- - - -
- - -

{versions.length} version{versions.length !== 1 ? "s" : ""}

- - {versions.length === 0 ? ( -

No versions yet.

- ) : ( -
- {versions.map((v: any, i: number) => ( -
- -
- v{v.number} - {v.semver} -
- {v.message && {v.message}} -
-
- {v.recordCount.toLocaleString()} records - {v.fileCount.toLocaleString()} files - {formatBytes(v.totalBytes)} - {new Date(v.createdAt).toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" })} - {v.hash.slice(0, 10)}… - {v.ark && ( - - ark - - )} - {v.number > 1 ? ( - diff - ) : ( - - )} -
-
- ))} -
- )} -
- diff --git a/src/pages/[owner]/index.astro b/src/pages/[owner]/index.astro deleted file mode 100644 index b8366bf..0000000 --- a/src/pages/[owner]/index.astro +++ /dev/null @@ -1,181 +0,0 @@ ---- -import Base from "@/layouts/Base.astro"; -import { apiBase, apiHeaders } from "@/lib/page-utils"; - -const { owner } = Astro.params; -const sessionCookie = Astro.cookies.get("session")?.value; - -// Fetch account profile -const accountRes = await fetch(`${apiBase}/api/accounts/${owner}`, { headers: apiHeaders(sessionCookie) }); -if (!accountRes.ok) { - return Astro.redirect("/404"); -} -const account = await accountRes.json(); - -// Fetch their public collections -const collectionsRes = await fetch(`${apiBase}/api/accounts/${owner}/collections`, { headers: apiHeaders(sessionCookie) }); -const collections = collectionsRes.ok ? await collectionsRes.json() : []; - -// Fetch members for orgs (requires auth — only members can view member list) -let members: any[] = []; -if (account.type === "org" && sessionCookie) { - const membersRes = await fetch(`${apiBase}/api/accounts/${owner}/members`, { - headers: { Cookie: `session=${sessionCookie}` }, - }); - if (membersRes.ok) { - members = await membersRes.json(); - } -} - -// Check if current user is an org member (for settings link) -let isMember = false; -if (sessionCookie && account.type === "org") { - try { - const meRes = await fetch(`${apiBase}/api/accounts/me`, { - headers: { Cookie: `session=${sessionCookie}` }, - }); - if (meRes.ok) { - const me = await meRes.json(); - isMember = me.orgs?.some((o: any) => o.slug === owner) ?? false; - } - } catch {} -} - -// Count total versions across collections (activity summary) -let totalVersions = 0; -for (const c of collections) { - if (c.versionCount) totalVersions += c.versionCount; -} ---- - - -
-
- - {account.avatarUrl ? ( - {account.displayName} - ) : ( -
- {account.displayName?.charAt(0)?.toUpperCase() ?? "?"} -
- )} - -
-
-

{account.displayName}

- {isMember && Settings} -
-
-

@{account.slug}

- - {account.type === "org" ? "Organization" : "User"} - -
- - {account.bio &&

{account.bio}

} - -
- {account.location && ( - {account.location} - )} - {account.website && ( - - {account.website.replace(/^https?:\/\//, "")} - - )} - Joined {new Date(account.createdAt).toLocaleDateString("en-US", { month: "short", year: "numeric" })} -
- - {/* Activity summary */} -
- {collections.length} collection{collections.length !== 1 ? "s" : ""} - {totalVersions > 0 && {totalVersions} version{totalVersions !== 1 ? "s" : ""}} - {members.length > 0 && {members.length} member{members.length !== 1 ? "s" : ""}} -
- -
-
- - -
-
-
-
- - {/* Public member list for orgs */} - {account.type === "org" && members.length > 0 ? ( -
- {/* Collections - main column */} -
-

- Collections ({collections.length}) -

- - {collections.length === 0 ? ( -

No public collections yet.

- ) : ( - - )} -
- - {/* Members - right sidebar */} -
-

Members

-
- {members.map((m: any) => ( - - {m.slug} - {m.role} - - ))} -
-
-
- ) : ( - <> -

- Collections ({collections.length}) -

- - {collections.length === 0 ? ( -

No public collections yet.

- ) : ( - - )} - - )} -
- diff --git a/src/pages/[owner]/settings/index.astro b/src/pages/[owner]/settings/index.astro deleted file mode 100644 index 3c8c692..0000000 --- a/src/pages/[owner]/settings/index.astro +++ /dev/null @@ -1,238 +0,0 @@ ---- -import Base from "@/layouts/Base.astro"; -import { apiBase } from "@/lib/page-utils"; - -const { owner } = Astro.params; -const sessionCookie = Astro.cookies.get("session")?.value; - -if (!sessionCookie) { - return Astro.redirect("/login"); -} - -const meRes = await fetch(`${apiBase}/api/accounts/me`, { - headers: { Cookie: `session=${sessionCookie}` }, -}); -if (!meRes.ok) return Astro.redirect("/login"); -const me = await meRes.json(); - -const org = me.orgs?.find((o: any) => o.slug === owner); -if (!org) return Astro.redirect(`/${owner}`); - -const orgRes = await fetch(`${apiBase}/api/accounts/${owner}`, { - headers: { Cookie: `session=${sessionCookie}` }, -}); -if (!orgRes.ok) return Astro.redirect("/404"); -const orgData = await orgRes.json(); - -const isOwner = org.role === "owner"; -const isAdmin = org.role === "admin" || isOwner; -let success = ""; -let error = ""; -let deleted = false; - -if (Astro.request.method === "POST") { - const form = await Astro.request.formData(); - const action = form.get("action") as string; - - if (action === "update-profile" && isOwner) { - const displayName = form.get("displayName") as string; - const bio = form.get("bio") as string; - const website = form.get("website") as string; - const location = form.get("location") as string; - - const res = await fetch(`${apiBase}/api/accounts/${owner}`, { - method: "PATCH", - headers: { "Content-Type": "application/json", Cookie: `session=${sessionCookie}` }, - body: JSON.stringify({ displayName, bio, website, location }), - }); - if (res.ok) { - success = "Organization updated."; - orgData.displayName = displayName; - orgData.bio = bio; - orgData.website = website; - orgData.location = location; - } else { - const body = await res.json().catch(() => ({})); - error = body.error ?? "Update failed."; - } - } - - if (action === "update-ark" && isAdmin) { - const naan = (form.get("naan") as string).trim() || null; - const res = await fetch(`${apiBase}/api/accounts/${owner}/ark`, { - method: "PATCH", - headers: { "Content-Type": "application/json", Cookie: `session=${sessionCookie}` }, - body: JSON.stringify({ naan }), - }); - if (res.ok) { - success = "ARK settings updated."; - orgData.arkNaan = naan; - } else { - const body = await res.json().catch(() => ({})); - error = body.error ?? "Failed to update ARK settings."; - } - } - - if (action === "delete-org" && isOwner) { - const confirmSlug = form.get("confirmSlug") as string; - if (confirmSlug !== owner) { - error = "Organization name does not match. Deletion cancelled."; - } else { - const res = await fetch(`${apiBase}/api/accounts/${owner}`, { - method: "DELETE", - headers: { Cookie: `session=${sessionCookie}` }, - }); - if (res.ok) { - deleted = true; - } else { - const body = await res.json().catch(() => ({})); - error = body.error ?? "Delete failed."; - } - } - } -} - -if (deleted) return Astro.redirect("/dashboard"); ---- - - -
- - -

Organization Settings

- - - - {success &&

{success}

} - {error &&

{error}

} - - {isOwner && ( -
- - -
- {orgData.avatarUrl ? ( - Avatar - ) : ( -
- {orgData.displayName?.charAt(0)?.toUpperCase() ?? "?"} -
- )} -
-

{orgData.displayName}

-

@{owner}

-
-
- -
- - -
-
- - -
-
-
- - -
-
- - -
-
-
- -

{owner}

-

Slugs cannot be changed.

-
- -
- )} - - {!isOwner && ( -
-

Only organization owners can update the profile.

-
- )} - - {/* ARK Identifiers */} - {isAdmin && ( -
-

ARK Identifiers

- - {orgData.arkShoulder && ( -
-

Assigned shoulder

- {orgData.arkShoulder} -
- )} - -
- -
- - -

If set, overrides the default NAAN for all ARKs created by this organization.

-
- -
-
- )} - - {/* Danger zone */} - {isOwner && ( -
-

Danger Zone

-

- Permanently delete this organization, all its collections, versions, records, and files. This cannot be undone. -

-
- Delete this organization… -
- -
- - -
- -
-
-
- )} -
- diff --git a/src/pages/[owner]/settings/keys.astro b/src/pages/[owner]/settings/keys.astro deleted file mode 100644 index 01b2912..0000000 --- a/src/pages/[owner]/settings/keys.astro +++ /dev/null @@ -1,244 +0,0 @@ ---- -import Base from "@/layouts/Base.astro"; -import { apiBase } from "@/lib/page-utils"; - -const { owner } = Astro.params; -const sessionCookie = Astro.cookies.get("session")?.value; - -if (!sessionCookie) return Astro.redirect("/login"); - -const meRes = await fetch(`${apiBase}/api/accounts/me`, { - headers: { Cookie: `session=${sessionCookie}` }, -}); -if (!meRes.ok) return Astro.redirect("/login"); -const me = await meRes.json(); - -const org = me.orgs?.find((o: any) => o.slug === owner); -if (!org) return Astro.redirect(`/${owner}`); - -const orgRes = await fetch(`${apiBase}/api/accounts/${owner}`, { - headers: { Cookie: `session=${sessionCookie}` }, -}); -if (!orgRes.ok) return Astro.redirect("/404"); -const orgData = await orgRes.json(); - -const isOwner = org.role === "owner"; -const isAdmin = org.role === "admin" || isOwner; - -// Fetch org API keys -const keysRes = await fetch(`${apiBase}/api/accounts/${owner}/keys`, { - headers: { Cookie: `session=${sessionCookie}` }, -}); -const keys = keysRes.ok ? await keysRes.json() : []; - -// Fetch org collections (for key scoping + playground) -const collectionsRes = await fetch(`${apiBase}/api/accounts/${owner}/collections`, { - headers: { Cookie: `session=${sessionCookie}` }, -}); -const collections = collectionsRes.ok ? await collectionsRes.json() : []; - -let success = ""; -let error = ""; -let newKeyResult: { key: string; label: string } | null = null; - -if (Astro.request.method === "POST") { - const form = await Astro.request.formData(); - const action = form.get("action") as string; - - if (action === "create-key" && isAdmin) { - const label = form.get("label") as string; - const scope = form.get("scope") as string; - const collectionId = form.get("collectionId") as string || undefined; - const expiresIn = form.get("expiresIn") as string; - - const res = await fetch(`${apiBase}/api/accounts/${owner}/keys`, { - method: "POST", - headers: { "Content-Type": "application/json", Cookie: `session=${sessionCookie}` }, - body: JSON.stringify({ - label, - scope, - collectionId: collectionId || undefined, - expiresIn: expiresIn ? parseInt(expiresIn) : undefined, - }), - }); - if (res.ok) { - const data = await res.json(); - newKeyResult = { key: data.key, label: data.label }; - } else { - const body = await res.json().catch(() => ({})); - error = body.error ?? "Failed to create key."; - } - } - - if (action === "delete-key" && isAdmin) { - const keyId = form.get("keyId") as string; - await fetch(`${apiBase}/api/accounts/${owner}/keys/${keyId}`, { - method: "DELETE", - headers: { Cookie: `session=${sessionCookie}` }, - }); - success = "Key revoked."; - } -} - -// Refresh keys after mutations -if (Astro.request.method === "POST" && !newKeyResult) { - const refreshRes = await fetch(`${apiBase}/api/accounts/${owner}/keys`, { - headers: { Cookie: `session=${sessionCookie}` }, - }); - if (refreshRes.ok) { - keys.length = 0; - keys.push(...await refreshRes.json()); - } -} - -function isExpiringSoon(expiresAt: string | null): boolean { - if (!expiresAt) return false; - const daysLeft = (new Date(expiresAt).getTime() - Date.now()) / (1000 * 60 * 60 * 24); - return daysLeft > 0 && daysLeft < 7; -} - -function isExpired(expiresAt: string | null): boolean { - if (!expiresAt) return false; - return new Date(expiresAt) < new Date(); -} ---- - - -
- - -

Organization Settings

- - - - {success &&

{success}

} - {error &&

{error}

} - - {newKeyResult && ( -
-

Key created: {newKeyResult.label}

-

Copy this key now — it won't be shown again.

- {newKeyResult.key} -
- )} - - {isAdmin && ( -
-

Create a new key

- -
-
- - -
-
- - -
-
- - -
-
- - -
-
- -
- )} - -

- Active Keys ({keys.length}) -

- -
- Rate limits: Authenticated requests get 5,000 req/min. Without a key, the API allows 60 req/min per IP. - Learn more → -
- - {keys.length === 0 ? ( -

No API keys for this organization.

- ) : ( -
- {keys.map((k: any) => ( -
-
-
- {k.label} - {k.keyPrefix && {k.keyPrefix}…} -
-
- {k.scope} - Created {new Date(k.createdAt).toLocaleDateString()} - {k.lastUsedAt && · Last used {new Date(k.lastUsedAt).toLocaleDateString()}} - {k.expiresAt && !isExpired(k.expiresAt) && ( - - · Expires {new Date(k.expiresAt).toLocaleDateString()} - - )} - {isExpired(k.expiresAt) && · Expired} -
-
- {isAdmin && ( -
- - - -
- )} -
- ))} -
- )} - - -
-

API Playground

-

Test API calls using your session. Select an example to get started.

-
({ id: c.id, slug: c.slug })))} - >
-
-
- - - diff --git a/src/pages/[owner]/settings/members.astro b/src/pages/[owner]/settings/members.astro deleted file mode 100644 index e23593c..0000000 --- a/src/pages/[owner]/settings/members.astro +++ /dev/null @@ -1,287 +0,0 @@ ---- -import Base from "@/layouts/Base.astro"; -import { apiBase } from "@/lib/page-utils"; - -const { owner } = Astro.params; -const sessionCookie = Astro.cookies.get("session")?.value; - -if (!sessionCookie) return Astro.redirect("/login"); - -const meRes = await fetch(`${apiBase}/api/accounts/me`, { - headers: { Cookie: `session=${sessionCookie}` }, -}); -if (!meRes.ok) return Astro.redirect("/login"); -const me = await meRes.json(); - -const org = me.orgs?.find((o: any) => o.slug === owner); -if (!org) return Astro.redirect(`/${owner}`); - -const orgRes = await fetch(`${apiBase}/api/accounts/${owner}`, { - headers: { Cookie: `session=${sessionCookie}` }, -}); -if (!orgRes.ok) return Astro.redirect("/404"); -const orgData = await orgRes.json(); - -const isOwner = org.role === "owner"; -const isAdmin = org.role === "admin" || isOwner; - -// Fetch members -const membersRes = await fetch(`${apiBase}/api/accounts/${owner}/members`, { - headers: { Cookie: `session=${sessionCookie}` }, -}); -let members = membersRes.ok ? await membersRes.json() : []; - -// Fetch invitations -const invitationsRes = await fetch(`${apiBase}/api/accounts/${owner}/invitations`, { - headers: { Cookie: `session=${sessionCookie}` }, -}); -const invitations = invitationsRes.ok ? await invitationsRes.json() : []; - -let success = ""; -let error = ""; - -if (Astro.request.method === "POST") { - const form = await Astro.request.formData(); - const action = form.get("action") as string; - - if (action === "add-member" && isAdmin) { - const username = form.get("username") as string; - const role = form.get("role") as string; - const res = await fetch(`${apiBase}/api/accounts/${owner}/members`, { - method: "POST", - headers: { "Content-Type": "application/json", Cookie: `session=${sessionCookie}` }, - body: JSON.stringify({ username, role }), - }); - if (res.ok) { - success = `Added ${username} as ${role}.`; - } else { - const body = await res.json().catch(() => ({})); - error = body.error ?? "Failed to add member."; - } - } - - if (action === "change-role" && isOwner) { - const userId = form.get("userId") as string; - const role = form.get("role") as string; - const res = await fetch(`${apiBase}/api/accounts/${owner}/members/${userId}`, { - method: "PATCH", - headers: { "Content-Type": "application/json", Cookie: `session=${sessionCookie}` }, - body: JSON.stringify({ role }), - }); - if (res.ok) { - success = "Role updated."; - } else { - const body = await res.json().catch(() => ({})); - error = body.error ?? "Failed to update role."; - } - } - - if (action === "remove-member" && isAdmin) { - const userId = form.get("userId") as string; - const res = await fetch(`${apiBase}/api/accounts/${owner}/members/${userId}`, { - method: "DELETE", - headers: { Cookie: `session=${sessionCookie}` }, - }); - if (res.ok) { - success = "Member removed."; - } else { - const body = await res.json().catch(() => ({})); - error = body.error ?? "Failed to remove member."; - } - } - - if (action === "leave-org") { - const res = await fetch(`${apiBase}/api/accounts/${owner}/members/${me.id}`, { - method: "DELETE", - headers: { Cookie: `session=${sessionCookie}` }, - }); - if (res.ok) { - return Astro.redirect("/dashboard"); - } else { - const body = await res.json().catch(() => ({})); - error = body.error ?? "Failed to leave organization."; - } - } - - if (action === "invite-member" && isAdmin) { - const email = form.get("email") as string; - const role = form.get("role") as string; - const res = await fetch(`${apiBase}/api/accounts/${owner}/invitations`, { - method: "POST", - headers: { "Content-Type": "application/json", Cookie: `session=${sessionCookie}` }, - body: JSON.stringify({ email, role }), - }); - if (res.ok) { - success = `Invitation sent to ${email}.`; - } else { - const body = await res.json().catch(() => ({})); - error = body.error ?? "Failed to send invitation."; - } - } - - if (action === "cancel-invitation" && isAdmin) { - const invitationId = form.get("invitationId") as string; - const res = await fetch(`${apiBase}/api/accounts/${owner}/invitations/${invitationId}`, { - method: "DELETE", - headers: { Cookie: `session=${sessionCookie}` }, - }); - if (res.ok) { - success = "Invitation cancelled."; - } else { - error = "Failed to cancel invitation."; - } - } - - // Refresh members after mutations - if (action === "add-member" || action === "remove-member" || action === "change-role") { - const refreshRes = await fetch(`${apiBase}/api/accounts/${owner}/members`, { - headers: { Cookie: `session=${sessionCookie}` }, - }); - if (refreshRes.ok) { - members = await refreshRes.json(); - } - } -} ---- - - -
- - -

Organization Settings

- - - - {success &&

{success}

} - {error &&

{error}

} - -

- Members ({members.length}) -

- -
- {members.map((m: any) => ( -
-
- {m.slug} - {m.displayName} - {isOwner && m.userId !== me.id ? ( -
- - - -
- ) : ( - {m.role} - )} -
-
- {isAdmin && m.userId !== me.id && ( -
- - - -
- )} -
-
- ))} -
- - {isAdmin && ( -
-

Add by username

-
- -
- - -
-
- - -
- -
- -

Invite by email

-
- -
- - -
-
- - -
- -
-
- )} - - {/* Pending invitations */} - {invitations.filter((i: any) => !i.acceptedAt).length > 0 && ( -
-

Pending Invitations

-
- {invitations.filter((i: any) => !i.acceptedAt).map((inv: any) => ( -
-
- {inv.email} - {inv.role} - - Expires {new Date(inv.expiresAt).toLocaleDateString()} - -
- {isAdmin && ( -
- - - -
- )} -
- ))} -
-
- )} - - {/* Leave org */} - {!isOwner && ( -
-
- - -
-
- )} -
- diff --git a/src/pages/admin/mirror.astro b/src/pages/admin/mirror.astro deleted file mode 100644 index 2e4e053..0000000 --- a/src/pages/admin/mirror.astro +++ /dev/null @@ -1,46 +0,0 @@ ---- -import Base from "@/layouts/Base.astro"; -import MirrorAdmin from "@/components/MirrorAdmin"; -import { getMirrorConfig } from "@/lib/mirror-config"; -import { apiBase } from "@/lib/page-utils"; - -const config = getMirrorConfig(); - -// Redirect if not in mirror mode -if (!config.enabled) { - return Astro.redirect("/"); -} - -// Require authentication -const sessionCookie = Astro.cookies.get("session")?.value; -let authenticated = false; -if (sessionCookie) { - try { - const res = await fetch(`${apiBase}/api/accounts/me`, { - headers: { Cookie: `session=${sessionCookie}` }, - }); - authenticated = res.ok; - } catch { - // API unavailable - } -} - -if (!authenticated) { - return Astro.redirect("/login"); -} ---- - - -
-

Mirror Administration

-

- Configure and monitor this node's sync with the upstream Underlay server. -

- -
- diff --git a/src/pages/blog/index.astro b/src/pages/blog/index.astro deleted file mode 100644 index fe1a6b6..0000000 --- a/src/pages/blog/index.astro +++ /dev/null @@ -1,40 +0,0 @@ ---- -import Base from "@/layouts/Base.astro"; - -const allPosts = await import.meta.glob<{ - frontmatter: { title: string; subtitle: string; date: string }; - url: string; -}>("./*.md", { eager: true }); - -const posts = Object.values(allPosts) - .filter((p) => p.frontmatter?.title) - .sort((a, b) => new Date(b.frontmatter.date).getTime() - new Date(a.frontmatter.date).getTime()); - -function fmtDate(d: any) { - const date = new Date(d); - return date.toLocaleDateString("en-US", { year: "numeric", month: "short", day: "numeric" }); -} -function isoDate(d: any) { - return new Date(d).toISOString().slice(0, 10); -} ---- - - -
-

Blog

- -
    - {posts.map((post) => ( -
  • - -
    - {post.frontmatter.title} -

    {post.frontmatter.subtitle}

    -
    -
  • - ))} -
-
- diff --git a/src/pages/dashboard.astro b/src/pages/dashboard.astro deleted file mode 100644 index 1945015..0000000 --- a/src/pages/dashboard.astro +++ /dev/null @@ -1,200 +0,0 @@ ---- -import Base from "@/layouts/Base.astro"; -import { apiBase } from "@/lib/page-utils"; - -const sessionCookie = Astro.cookies.get("session")?.value; - -if (!sessionCookie) { - return Astro.redirect("/login"); -} - -// Handle org creation form POST -let orgError = ""; -if (Astro.request.method === "POST") { - const formData = await Astro.request.formData(); - const orgSlug = formData.get("orgSlug")?.toString(); - const orgDisplayName = formData.get("orgDisplayName")?.toString(); - if (orgSlug && orgDisplayName) { - const res = await fetch(`${apiBase}/api/accounts/orgs`, { - method: "POST", - headers: { "Content-Type": "application/json", Cookie: `session=${sessionCookie}` }, - body: JSON.stringify({ slug: orgSlug, displayName: orgDisplayName }), - }); - if (res.ok) { - return Astro.redirect("/dashboard"); - } - const err = await res.json(); - orgError = err.error ?? "Failed to create organization"; - } -} - -// Fetch current user -const meRes = await fetch(`${apiBase}/api/accounts/me`, { - headers: { Cookie: `session=${sessionCookie}` }, -}); -if (!meRes.ok) { - return Astro.redirect("/login"); -} -const me = await meRes.json(); - -// Fetch user's collections -const collectionsRes = await fetch(`${apiBase}/api/accounts/${me.slug}/collections`, { - headers: { Cookie: `session=${sessionCookie}` }, -}); -const collections = collectionsRes.ok ? await collectionsRes.json() : []; - -// Fetch org collections -const orgs: { slug: string; displayName: string; role: string; collections: any[] }[] = []; -if (me.orgs?.length) { - for (const org of me.orgs) { - const orgCollectionsRes = await fetch(`${apiBase}/api/accounts/${org.slug}/collections`, { - headers: { Cookie: `session=${sessionCookie}` }, - }); - orgs.push({ - slug: org.slug, - displayName: org.displayName, - role: org.role, - collections: orgCollectionsRes.ok ? await orgCollectionsRes.json() : [], - }); - } -} ---- - - -
-
-

Dashboard

- Settings -
- -
- {/* Main content area */} -
- -
- -
- - {/* Personal collections */} -
-

- Your Collections ({collections.length}) -

- - {collections.length === 0 ? ( -
-

No collections yet.

-

Use the API to create your first collection.

-
- ) : ( - - )} -
- - {/* Org collections */} - {orgs.map((org) => ( -
-
-

- {org.displayName} - {org.role} -

- settings -
- - {org.collections.length === 0 ? ( -

No collections yet.

- ) : ( - - )} -
- ))} -
- - {/* Right sidebar */} -
- {/* Quick reference */} -
-

Quick Reference

-
-
-

Create collection

- POST /api/accounts/{me.slug}/collections -
-
-

Push version

- POST /api/collections/{me.slug}/<slug>/versions -
- Quickstart guide → -
-
- - {/* Create org */} -
-

New Organization

- {orgError &&

{orgError}

} -
-
- - -
-
- - -
- -
-
- - {/* Links */} - -
-
-
- - - diff --git a/src/pages/docs/api/accounts.astro b/src/pages/docs/api/accounts.astro deleted file mode 100644 index 812d6b9..0000000 --- a/src/pages/docs/api/accounts.astro +++ /dev/null @@ -1,155 +0,0 @@ ---- -import Docs from "@/layouts/Docs.astro"; - -const signupReq = `{ - "email": "user@example.com", - "password": "securepassword", - "username": "jdoe", - "displayName": "Jane Doe" -}`; - -const signupRes = `{ - "id": "uuid", - "slug": "jdoe", - "displayName": "Jane Doe" -}`; - -const loginReq = `{ - "email": "user@example.com", - "password": "securepassword" -}`; - -const loginRes = signupRes; - -const logoutRes = `{"ok": true}`; - -const meRes = `{ - "id": "uuid", - "slug": "jdoe", - "type": "user", - "displayName": "Jane Doe", - "email": "user@example.com", - "createdAt": "2026-01-15T00:00:00.000Z" -}`; - -const accountRes = `{ - "id": "uuid", - "slug": "knowledge-futures", - "type": "org", - "displayName": "Knowledge Futures", - "createdAt": "2026-01-15T00:00:00.000Z" -}`; - -const createKeyReq = `{ - "label": "my-sync-script", - "scope": "write", - "collectionId": "uuid (optional — scope key to one collection)" -}`; - -const createKeyRes = `{ - "id": "uuid", - "key": "ul_a1b2c3d4e5...", - "label": "my-sync-script", - "scope": "write", - "collectionId": null -}`; - -const deleteKeyRes = logoutRes; ---- - - -

Create accounts, authenticate, and manage API keys.

- -

Authentication

-

There are two authentication methods:

-
    -
  • Session cookies — set by login, used by the web UI
  • -
  • API keysAuthorization: Bearer ul_..., used by apps and scripts
  • -
-

API keys have three scopes: read, write, admin. A key can optionally be scoped to a single collection.

- -
- -
-

POST /api/accounts/signup

-

No auth required

-

Create a new user account.

-

Request

-
-

Response 201

-
-

Also sets a session cookie (30-day expiry).

-
- -
- -
-

POST /api/accounts/login

-

No auth required

-

Request

-
-

Response 200

-
-

Sets a session cookie.

-
- -
- -
-

POST /api/accounts/logout

-

No auth required

-

Clears the session cookie and deletes the session from the database.

-

Response 200

-
-
- -
- -
-

GET /api/accounts/me

-

Auth: session or API key (any scope)

-

Get the authenticated account.

-

Response 200

-
-
- -
- -
-

GET /api/accounts/:slug

-

No auth required

-

Get public profile for any account.

-

Response 200

-
-
- -
- -
-

POST /api/accounts/keys

-

Auth: session or API key (any scope)

-

Create a new API key. The raw key is returned only once.

-

Request

-
-

Response 201

-
-
- -
- -
-

GET /api/accounts/keys

-

Auth: session or API key (any scope)

-

List all API keys for the authenticated account. The raw key is not included.

-
- -
- -
-

DELETE /api/accounts/keys/:id

-

Auth: session or API key (any scope)

-

Revoke an API key.

-

Response 200

-
-
-
diff --git a/src/pages/docs/api/index.astro b/src/pages/docs/api/index.astro deleted file mode 100644 index 4516cdb..0000000 --- a/src/pages/docs/api/index.astro +++ /dev/null @@ -1,97 +0,0 @@ ---- -import Docs from "@/layouts/Docs.astro"; ---- - - -

The Underlay API is a JSON REST API served at /api. All request and response bodies are JSON (except file uploads/downloads). A machine-readable reference is available at /.well-known/ai.txt.

- -

Base URL

-
https://underlay.org/api
- -
- -

Authentication

-

All GET requests are public — no authentication required to read public data. All write requests (POST, PATCH, PUT, DELETE) require authentication.

- -

There are two authentication methods:

- -

API Keys (recommended for scripts & apps)

-

Pass your key as a Bearer token:

-
Authorization: Bearer ul_a1b2c3d4e5...
-

Keys have three scopes:

-
    -
  • read — list and download data
  • -
  • write — push versions, upload files
  • -
  • admin — delete collections, manage keys
  • -
-

Keys can optionally be scoped to a single collection. Create keys in your organization settings or via POST /api/accounts/keys.

- -

Session Cookies (browser)

-

The web UI authenticates via signed session cookies set by POST /api/accounts/login. Sessions expire after 30 days.

- -

Invalid Credentials

-

If a Bearer token is provided but does not match any key, the request is immediately rejected with 401 — it will not fall through to anonymous access.

- -
- -

Rate Limits

-

All requests are rate-limited. Authenticated requests get a significantly higher allowance:

- - - - - - - - - - - - - - - - - - -
Auth statusLimit
Unauthenticated (by IP)60 requests / minute
Authenticated (by account)5,000 requests / minute
- -

Every response includes rate limit headers:

-
    -
  • X-RateLimit-Limit — max requests in the current window
  • -
  • X-RateLimit-Remaining — requests remaining
  • -
  • X-RateLimit-Reset — seconds until the window resets
  • -
-

When you exceed the limit, you'll receive a 429 Too Many Requests response with a Retry-After header indicating how long to wait.

-

For any automated or scripted access, always use an API key to get the higher rate limit.

- -
- -

Error Responses

-

Errors return a JSON body with error and statusCode:

-
{`{
-  "error": "Authentication required",
-  "statusCode": 401
-}`}
- -

Common status codes:

-
    -
  • 400 — Bad request (invalid input)
  • -
  • 401 — Authentication required or invalid credentials
  • -
  • 403 — Insufficient permissions (wrong scope)
  • -
  • 404 — Resource not found
  • -
  • 409 — Version conflict (re-fetch and retry)
  • -
  • 422 — Validation error (e.g. missing files)
  • -
  • 429 — Rate limited (wait and retry)
  • -
- -
- -

Endpoints

- -
diff --git a/src/pages/docs/index.astro b/src/pages/docs/index.astro deleted file mode 100644 index b7c9f09..0000000 --- a/src/pages/docs/index.astro +++ /dev/null @@ -1,36 +0,0 @@ ---- -import Docs from "@/layouts/Docs.astro"; ---- - - -

Underlay has a small API surface. These docs are the SDK. Read them, point your LLM at them, or just curl the endpoints. For a machine-readable version, see ai.txt.

- - -
\ No newline at end of file diff --git a/src/pages/docs/integration.astro b/src/pages/docs/integration.astro deleted file mode 100644 index 1d55b8b..0000000 --- a/src/pages/docs/integration.astro +++ /dev/null @@ -1,175 +0,0 @@ ---- -import Docs from "@/layouts/Docs.astro"; - -const pushExample = `{ - "base_version": null, - "message": "Initial import", - "app_id": "my-app", - "schema": { - "type": "object", - "properties": { - "Article": { - "type": "object", - "properties": { - "title": {"type": "string"}, - "body": {"type": "string"}, - "authorId": {"type": "string"}, - "publishedAt": {"type": "string", "format": "date-time"} - } - }, - "Author": { - "type": "object", - "properties": { - "name": {"type": "string"}, - "email": {"type": "string"} - } - } - } - }, - "changes": { - "added": [ - {"id": "author-1", "type": "Author", "data": {"name": "Jane Doe", "email": "jane@example.com"}}, - {"id": "article-1", "type": "Article", "data": {"title": "Hello World", "body": "...", "authorId": "author-1", "publishedAt": "2026-04-01T00:00:00Z"}} - ] - } -}`; - -const fileRef = '{"$file": "sha256:"}'; - -const sqlIntrospect = `-- For each table, generate a JSON Schema type: --- table name → type name --- column name → property name --- column type → JSON Schema type (text→string, integer→integer, etc.) --- foreign keys → note as ID references in the schema description - --- Example: a "publications" table with columns (id, title, doi, author_id) --- becomes a "Publication" type with properties {title: string, doi: string, authorId: string} --- The record id is the primary key value.`; - -const diffPush = `# 1. Get current state -curl https://underlay.org/api/collections/:owner/:slug/versions/latest - -# 2. Upload any new files -HASH=$(shasum -a 256 paper.pdf | cut -d' ' -f1) -curl -X PUT "https://underlay.org/api/collections/:owner/:slug/files/sha256:$HASH" \\ - -H "Authorization: Bearer $KEY" \\ - -H "Content-Type: application/pdf" \\ - --data-binary @paper.pdf - -# 3. Push changes (only what changed since base_version) -curl -X POST https://underlay.org/api/collections/:owner/:slug/versions \\ - -H "Content-Type: application/json" \\ - -H "Authorization: Bearer $KEY" \\ - -d '{ - "base_version": 42, - "message": "Daily sync", - "app_id": "my-app", - "changes": { - "added": [...], - "updated": [...], - "removed": ["old-record-id"] - } - }'`; ---- - - -

Everything a developer or LLM needs to push data to the registry. No SDK required. HTTPS and JSON. For a machine-readable version, see ai.txt.

- -

What is Underlay?

-

Underlay is a versioned registry for structured knowledge. Apps push snapshots of their data; Underlay preserves them, deduplicates files, and serves them via a stable API. Think npm for data, or Docker Hub for structured content.

- -

Core Concepts

-
    -
  • Collection — A named, versioned body of structured data. Identified by :owner/:slug.
  • -
  • Version — An immutable snapshot: JSON Schema + records + file references + metadata. Numbered sequentially.
  • -
  • Record — A flat JSON object with an id, a type, and a data payload conforming to the schema.
  • -
  • File — A binary blob (PDF, image, etc.) stored by SHA-256 hash. Referenced in records via .
  • -
- -

Authentication

-

Create an API key at /settings/keys or via the API. Pass it as:

-
Authorization: Bearer ul_your_key_here
-

Keys are scoped: read, write, or admin. Use write for pushing data.

- -

The Push Flow

-
    -
  1. Get the current latest version number
  2. -
  3. Upload any new binary files by hash
  4. -
  5. Push a version with base_version, schema (if changed), and record changes
  6. -
  7. On 409 Conflict, re-fetch latest and retry
  8. -
-
- -

Record Format

-

Every record has three fields: id (stable string), type (matches schema), and data (the payload).

-
    -
  • Relationships are plain ID strings (e.g. "authorId": "author-1")
  • -
  • Files are referenced as
  • -
  • No joins, no nesting — keep records flat
  • -
- -

First Push Example

-

To push the first version of a collection (creates the initial snapshot):

-
- -

Mapping a SQL Database

-

Most apps store data in SQL. Here's how to map it to Underlay records:

-
-

General rules:

-
    -
  • Each table becomes a record type
  • -
  • Each row becomes a record (primary key → record id)
  • -
  • Foreign keys become string ID references
  • -
  • Binary columns (BLOBs) → upload as files, replace with $file references
  • -
  • Generate a JSON Schema from your column types
  • -
- -

Versioning

-

Versions are numbered sequentially and also carry a semver tag derived automatically:

-
    -
  • Schema changes → major bump
  • -
  • Record changes → minor bump
  • -
  • Metadata-only changes → patch bump
  • -
-

Version 1 is always v1.0.0.

- -

Privacy

-

You can control what's publicly visible at three levels:

-
    -
  • Private types: Add "private": true to a type in the schema. All records of that type are hidden from public readers.
  • -
  • Private fields: Add "private": true to a field in the schema. That field is stripped from public responses.
  • -
  • Private records: Add "private": true to individual records when pushing. Those records are hidden from public queries.
  • -
-

Private content is stored in the same version — the owner always sees everything. Public readers see only the filtered view. The public content hash excludes private data, so verifiers can confirm integrity of the public subset.

- -

API Reference

-

Full API docs are at /docs. The key endpoints:

- - - - - - - -
POST .../versionsPush a new version (up to 100MB)
POST .../versions/uploadStart chunked upload (for large pushes)
GET .../versions/latestGet latest version
GET .../versions/:n/recordsGet records
PUT .../files/:hashUpload a file
GET /api/collectionsBrowse public collections
- -

Large Pushes (Chunked Upload)

-

For pushes exceeding 100MB or containing hundreds of thousands of records, use the chunked upload protocol:

-
    -
  1. Start session: POST .../versions/upload with metadata (base_version, schemas, message). Returns a sessionId.
  2. -
  3. Append batches: PUT .../versions/upload/:sessionId with up to 10,000 records per batch. Repeat as needed.
  4. -
  5. Finalize: POST .../versions/upload/:sessionId/finalize to validate and create the version.
  6. -
-

Sessions expire after 1 hour. If the same record ID appears in multiple batches, last write wins. See the Versions API docs for full details.

- -

Error Handling

-
    -
  • 409 Conflict — Another version was pushed since your base_version. Re-fetch and retry.
  • -
  • 422 Unprocessable — Records reference files that haven't been uploaded. Upload them first.
  • -
  • 400 Bad Request — Schema validation failed or hash mismatch on file upload.
  • -
- -

Source Code

-

Underlay is open source: github.com/knowledgefutures/underlay

-

Built by Knowledge Futures, a 501(c)(3) nonprofit. Contact: team@knowledgefutures.org

-
diff --git a/src/pages/explore.astro b/src/pages/explore.astro deleted file mode 100644 index eb48617..0000000 --- a/src/pages/explore.astro +++ /dev/null @@ -1,13 +0,0 @@ ---- -import Base from "@/layouts/Base.astro"; -import CollectionExplorer from "@/components/CollectionExplorer"; ---- - - -
-

Explore collections

-

Browse public knowledge collections published to Underlay.

- - -
- diff --git a/src/pages/forgot-password.astro b/src/pages/forgot-password.astro deleted file mode 100644 index a3dfafc..0000000 --- a/src/pages/forgot-password.astro +++ /dev/null @@ -1,67 +0,0 @@ ---- -import Base from "@/layouts/Base.astro"; -import { apiBase } from "@/lib/page-utils"; - -let sent = false; -let error = ""; - -if (Astro.request.method === "POST") { - const form = await Astro.request.formData(); - const email = form.get("email") as string; - - const res = await fetch(`${apiBase}/api/accounts/forgot-password`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ email }), - }); - - if (res.ok) { - sent = true; - } else { - // Always show success to avoid email enumeration - sent = true; - } -} ---- - - -
-

Reset your password

-

Enter your email and we'll send you a reset link.

- - {sent ? ( -
-

Check your email

-

If an account exists with that email, you'll receive a password reset link shortly.

-
- ) : ( -
- {error && ( -
- {error} -
- )} -
- - -
- -
- )} - -

- ← Back to login -

-
- diff --git a/src/pages/index.astro b/src/pages/index.astro deleted file mode 100644 index 4b5a388..0000000 --- a/src/pages/index.astro +++ /dev/null @@ -1,198 +0,0 @@ ---- -import Base from "@/layouts/Base.astro"; -import { getMirrorConfig } from "@/lib/mirror-config"; - -const mirrorConfig = getMirrorConfig(); ---- - -{mirrorConfig.enabled ? ( - -
- -
-
-

- {mirrorConfig.nodeName} -

-

- This is a mirror of {mirrorConfig.upstream.replace(/^https?:\/\//, '')}. - It maintains a verified copy of all public collections for long-term preservation and local access. -

-

- Every version, record, and file is content-addressed and hash-verified against the upstream source. - If the primary server becomes unavailable, this mirror serves as an independent, complete archive. -

- -
-
- - -
-

What is this?

-
-
-

A preservation mirror

-

- This server replicates public collections from the canonical Underlay instance. - Each copy is cryptographically verified — tamper-evident by design. -

-
-
-

Independent infrastructure

-

- Running on separate hardware with its own database and storage. - No single point of failure — if the upstream goes down, the data persists here. -

-
-
-

Open and browsable

-

- Browse any collection, inspect any schema, view any record. - The same API works here as on the primary server. -

-
-
-
- - -
-
-
-

Powered by Underlay

-

- Underlay is open-source infrastructure for structured knowledge preservation. - Anyone can run a mirror — same software, different server. -

-
-
-

Built by Knowledge Futures

-

A 501(c)(3) nonprofit building open infrastructure for knowledge sharing.

-
-
-
-
- -) : ( - -
- -
-
-

- A public registry for structured knowledge. -

-

- Apps publish versioned snapshots of their data to Underlay. - Each version is self-describing: a JSON Schema, flat records, content-addressed files. - The structure is the infrastructure. -

- -
-
- - -
-

How it works

-
-
-

1. Push

-

- Your app serializes its current state and pushes a versioned snapshot to Underlay over HTTPS. - A cron job, a webhook, or a button. -

-
-
-

2. Store

-

- Underlay validates records against the JSON Schema, deduplicates files by hash, and stores the version immutably. -

-
-
-

3. Browse

-

- Anyone can browse public collections, view any version, diff between versions, and export full archives. -

-
-
-
- - -
-

Core concepts

-
-
- collection - A named, versioned body of structured data plus its files. The unit of preservation. -
-
- version - An immutable snapshot: JSON Schema + records + files + metadata. -
-
- record - A flat JSON object. One entity, one row. Relationships via ID references. -
-
- file - A binary blob, content-addressed by SHA-256. Stored once, referenced everywhere. -
-
-
- - -
-

The API

-

~13 endpoints. Each one does one thing.

-
POST   /accounts/:owner/collections            # create a collection
-GET    /collections/:owner/:slug               # collection metadata
-POST   /collections/:owner/:slug/versions      # push a version
-GET    /collections/:owner/:slug/versions/:n   # read a version
-GET    .../versions/:n/records                 # browse records
-GET    .../versions/:n/diff?from=:m            # diff versions
-PUT    /collections/:owner/:slug/files/:hash   # upload a file
-GET    /collections/:owner/:slug/files/:hash   # download a file
-GET    /collections/:owner/:slug/export        # full archive
-
- - -
-
-
-

Open source

-

MIT licensed. Run your own instance or push to the canonical host at underlay.org.

-
-
-

Built by Knowledge Futures

-

A 501(c)(3) nonprofit building open infrastructure for knowledge sharing.

-
-
-
-
- -)} diff --git a/src/pages/invitations/accept.astro b/src/pages/invitations/accept.astro deleted file mode 100644 index c059b0c..0000000 --- a/src/pages/invitations/accept.astro +++ /dev/null @@ -1,89 +0,0 @@ ---- -import Base from "@/layouts/Base.astro"; -import { apiBase } from "@/lib/page-utils"; - -const token = Astro.url.searchParams.get("token") ?? ""; -const sessionCookie = Astro.cookies.get("session")?.value; - -let success = false; -let error = ""; -let orgSlug = ""; - -if (!token) { - error = "Invalid or missing invitation token."; -} - -if (Astro.request.method === "POST" && token && sessionCookie) { - const res = await fetch(`${apiBase}/api/accounts/invitations/accept`, { - method: "POST", - headers: { - "Content-Type": "application/json", - Cookie: `session=${sessionCookie}`, - }, - body: JSON.stringify({ token }), - }); - - if (res.ok) { - const data = await res.json(); - orgSlug = data.orgSlug ?? ""; - success = true; - } else { - const data = await res.json().catch(() => null); - error = data?.error ?? "Invitation is invalid or expired."; - } -} ---- - - -
-

Organization Invitation

- - {success ? ( -
-

You've joined the organization!

-

You now have access to the organization's collections.

- {orgSlug ? ( - Go to organization → - ) : ( - Go to dashboard → - )} -
- ) : error ? ( -
-

{error}

- {!sessionCookie && token && ( -

- You may need to log in or - sign up first. -

- )} -
- ) : ( - <> - {!sessionCookie ? ( -
-

You've been invited to join an organization. Please log in or sign up to accept.

- -
- ) : ( -
-

Click below to accept the invitation and join the organization.

- -
- )} - - )} -
- diff --git a/src/pages/login.astro b/src/pages/login.astro deleted file mode 100644 index e76d9ad..0000000 --- a/src/pages/login.astro +++ /dev/null @@ -1,86 +0,0 @@ ---- -import Base from "@/layouts/Base.astro"; -import { apiBase } from "@/lib/page-utils"; - -let loginError = ""; - -if (Astro.request.method === "POST") { - const form = await Astro.request.formData(); - const email = form.get("email") as string; - const password = form.get("password") as string; - - const res = await fetch(`${apiBase}/api/accounts/login`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ email, password }), - }); - - if (res.ok) { - const setCookie = res.headers.get("set-cookie"); - if (setCookie) { - const match = setCookie.match(/session=([^;]+)/); - if (match) { - Astro.cookies.set("session", match[1], { - path: "/", - httpOnly: true, - secure: import.meta.env.PROD, - sameSite: "lax", - maxAge: 30 * 24 * 60 * 60, - }); - } - } - return Astro.redirect("/dashboard"); - } else { - const err = await res.json().catch(() => null); - loginError = err?.error ?? "Invalid email or password."; - } -} ---- - - -
-

Log in

- - {loginError && ( -
- {loginError} -
- )} - -
-
- - -
-
- - -
- -
- -
-

- Don't have an account? Sign up -

- Forgot password? -
-
- diff --git a/src/pages/logout.astro b/src/pages/logout.astro deleted file mode 100644 index 339f4a8..0000000 --- a/src/pages/logout.astro +++ /dev/null @@ -1,15 +0,0 @@ ---- -import { apiBase } from "@/lib/page-utils"; - -const sessionCookie = Astro.cookies.get("session")?.value; -if (sessionCookie) { - try { - await fetch(`${apiBase}/api/accounts/logout`, { - method: "POST", - headers: { Cookie: `session=${sessionCookie}` }, - }); - } catch {} - Astro.cookies.delete("session", { path: "/" }); -} -return Astro.redirect("/"); ---- diff --git a/src/pages/query.astro b/src/pages/query.astro deleted file mode 100644 index 5124114..0000000 --- a/src/pages/query.astro +++ /dev/null @@ -1,75 +0,0 @@ ---- -import UserMenu from "@/components/UserMenu"; -import QueryExplorer from "@/components/QueryExplorer"; -import { apiBase } from "@/lib/page-utils"; - -let currentUser: { slug: string; displayName: string; orgs?: { slug: string; displayName: string }[] } | null = null; -const sessionCookie = Astro.cookies.get("session")?.value; -if (sessionCookie) { - try { - const res = await fetch(`${apiBase}/api/accounts/me`, { - headers: { Cookie: `session=${sessionCookie}` }, - }); - if (res.ok) { - currentUser = await res.json(); - } - } catch {} -} ---- - - - - - - - - - Query — Underlay - - - - - - -
- -
- -
- -
- - - - diff --git a/src/pages/reset-password.astro b/src/pages/reset-password.astro deleted file mode 100644 index 750eb41..0000000 --- a/src/pages/reset-password.astro +++ /dev/null @@ -1,97 +0,0 @@ ---- -import Base from "@/layouts/Base.astro"; -import { apiBase } from "@/lib/page-utils"; - -const token = Astro.url.searchParams.get("token") ?? ""; -const email = Astro.url.searchParams.get("email") ?? ""; -let success = false; -let error = ""; - -if (!token || !email) { - error = "Invalid or missing reset token."; -} - -if (Astro.request.method === "POST" && token && email) { - const form = await Astro.request.formData(); - const password = form.get("password") as string; - const confirm = form.get("confirm") as string; - - if (password !== confirm) { - error = "Passwords do not match."; - } else if (password.length < 8) { - error = "Password must be at least 8 characters."; - } else { - const res = await fetch(`${apiBase}/api/accounts/reset-password`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ email, token, newPassword: password }), - }); - - if (res.ok) { - success = true; - } else { - const data = await res.json().catch(() => null); - error = data?.error ?? "Reset link is invalid or expired."; - } - } -} ---- - - -
-

Set new password

- - {success ? ( -
-

Password updated

-

Your password has been reset. You can now log in.

- Go to login → -
- ) : ( - <> - {error && ( -
- {error} -
- )} - - {(token && email) ? ( -
-
- - -
-
- - -
- -
- ) : ( -

- Request a new reset link → -

- )} - - )} -
- diff --git a/src/pages/schemas/[id].astro b/src/pages/schemas/[id].astro deleted file mode 100644 index 0bf4327..0000000 --- a/src/pages/schemas/[id].astro +++ /dev/null @@ -1,130 +0,0 @@ ---- -import Base from "@/layouts/Base.astro"; -import SchemaLabelManager from "@/components/SchemaLabelManager"; -import { apiBase, apiHeaders } from "@/lib/page-utils"; - -const { id } = Astro.params; -const sessionCookie = Astro.cookies.get("session")?.value; - -// Fetch schema detail (includes labels + usage) -const res = await fetch(`${apiBase}/api/schemas/${id}`, { headers: apiHeaders(sessionCookie) }); -if (!res.ok) return Astro.redirect("/404"); -const schema = await res.json(); - -const properties = (schema.schema as any)?.properties ?? {}; -const fields = Object.entries(properties); -const isPrivate = (schema.schema as any)?.private === true; -const labels: { label: string; createdAt: string }[] = schema.labels ?? []; -const usage: { slug: string; semver: string; versionNumber: number; collection: string }[] = schema.usage ?? []; ---- - - -
- {/* Header */} -
-
-

{schema.schemaHash.slice(0, 16)}…

- {isPrivate && private type} -
-
- Created {new Date(schema.createdAt).toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" })} - {fields.length} field{fields.length !== 1 ? "s" : ""} - {usage.length} collection{usage.length !== 1 ? "s" : ""} using this schema -
-
- - {/* Labels (interactive) */} - - - {/* Two-column layout: fields + JSON */} -
- {/* Fields table */} -
-

Fields

-
- - - - - - - - - - {fields.map(([name, def]: [string, any]) => { - const fieldType = def.type ?? "unknown"; - const format = def.format ? ` (${def.format})` : ""; - const refType = def["x-ref-type"]; - const isFieldPrivate = def.private === true; - - return ( - - - - - - ); - })} - -
NameTypeInfo
{name}{fieldType}{format} -
- {refType && ( - - → {refType} - - )} - {isFieldPrivate && ( - private - )} -
-
-
-
- - {/* Raw JSON */} -
-

JSON Schema

-
-
-
-
-
- - {/* Usage: which collections reference this schema */} -
-

- Used by {usage.length} collection{usage.length !== 1 ? "s" : ""} -

- {usage.length === 0 ? ( -

No public collections reference this schema yet.

- ) : ( -
- {usage.map((u, i) => ( - -
- {u.collection} - as {u.slug} -
- {u.semver} -
- ))} -
- )} -
- - {/* Full hash */} -
-

- Full hash: - {schema.schemaHash} -

-

- ID: - {schema.id} -

-
-
- diff --git a/src/pages/schemas/index.astro b/src/pages/schemas/index.astro deleted file mode 100644 index 6dea7e3..0000000 --- a/src/pages/schemas/index.astro +++ /dev/null @@ -1,13 +0,0 @@ ---- -import Base from "@/layouts/Base.astro"; -import SchemaBrowser from "@/components/SchemaBrowser"; ---- - - -
-

Schemas

-

Browse content-addressed schemas shared across collections. Same shape = same hash = same schema.

- - -
- diff --git a/src/pages/settings/avatar.astro b/src/pages/settings/avatar.astro deleted file mode 100644 index 3361121..0000000 --- a/src/pages/settings/avatar.astro +++ /dev/null @@ -1,97 +0,0 @@ ---- -import Base from "@/layouts/Base.astro"; -import { apiBase } from "@/lib/page-utils"; - -const sessionCookie = Astro.cookies.get("session")?.value; - -if (!sessionCookie) { - return Astro.redirect("/login"); -} - -const meRes = await fetch(`${apiBase}/api/accounts/me`, { - headers: { Cookie: `session=${sessionCookie}` }, -}); -if (!meRes.ok) { - return Astro.redirect("/login"); -} -const me = await meRes.json(); - -let success = ""; -let error = ""; - -if (Astro.request.method === "POST") { - const form = await Astro.request.formData(); - const file = form.get("avatar") as File | null; - - if (!file || file.size === 0) { - error = "Please select a file."; - } else { - const formData = new FormData(); - formData.append("avatar", file); - - const res = await fetch(`${apiBase}/api/accounts/me/avatar`, { - method: "POST", - headers: { Cookie: `session=${sessionCookie}` }, - body: formData, - }); - - if (res.ok) { - const data = await res.json(); - success = "Avatar updated."; - me.avatarUrl = data.avatarUrl; - } else { - const body = await res.json().catch(() => ({})); - error = body.error ?? "Upload failed."; - } - } -} ---- - - -
-

Settings

- - - - - - {success &&

{success}

} - {error &&

{error}

} - -
-

Upload Avatar

- -
- {me.avatarUrl ? ( - Current avatar - ) : ( -
- {me.displayName?.charAt(0)?.toUpperCase() ?? "?"} -
- )} -
-

Accepted formats: JPEG, PNG, GIF, WebP

-

Maximum size: 5 MB

-
-
- -
-
- -
- -
-
-
- diff --git a/src/pages/settings/index.astro b/src/pages/settings/index.astro deleted file mode 100644 index 257b741..0000000 --- a/src/pages/settings/index.astro +++ /dev/null @@ -1,313 +0,0 @@ ---- -import Base from "@/layouts/Base.astro"; -import { apiBase } from "@/lib/page-utils"; - -const sessionCookie = Astro.cookies.get("session")?.value; - -if (!sessionCookie) { - return Astro.redirect("/login"); -} - -const meRes = await fetch(`${apiBase}/api/accounts/me`, { - headers: { Cookie: `session=${sessionCookie}` }, -}); -if (!meRes.ok) { - return Astro.redirect("/login"); -} -const me = await meRes.json(); - -let success = ""; -let error = ""; - -if (Astro.request.method === "POST") { - const form = await Astro.request.formData(); - const action = form.get("action"); - - if (action === "update-profile") { - const displayName = form.get("displayName") as string; - const bio = form.get("bio") as string; - const website = form.get("website") as string; - const location = form.get("location") as string; - - const res = await fetch(`${apiBase}/api/accounts/me`, { - method: "PATCH", - headers: { "Content-Type": "application/json", Cookie: `session=${sessionCookie}` }, - body: JSON.stringify({ displayName, bio, website, location }), - }); - if (res.ok) { - success = "Profile updated."; - me.displayName = displayName; - me.bio = bio; - me.website = website; - me.location = location; - } else { - const body = await res.json().catch(() => ({})); - error = body.error ?? "Update failed."; - } - } - - if (action === "change-email") { - const newEmail = form.get("newEmail") as string; - const password = form.get("password") as string; - - const res = await fetch(`${apiBase}/api/accounts/me/email`, { - method: "POST", - headers: { "Content-Type": "application/json", Cookie: `session=${sessionCookie}` }, - body: JSON.stringify({ newEmail, password }), - }); - if (res.ok) { - success = "Email updated."; - me.email = newEmail; - } else { - const body = await res.json().catch(() => ({})); - error = body.error ?? "Failed to update email."; - } - } - - if (action === "change-password") { - const currentPassword = form.get("currentPassword") as string; - const newPassword = form.get("newPassword") as string; - const confirmPassword = form.get("confirmPassword") as string; - - if (newPassword !== confirmPassword) { - error = "New passwords do not match."; - } else { - const res = await fetch(`${apiBase}/api/accounts/me/password`, { - method: "POST", - headers: { "Content-Type": "application/json", Cookie: `session=${sessionCookie}` }, - body: JSON.stringify({ currentPassword, newPassword }), - }); - if (res.ok) { - success = "Password changed."; - } else { - const body = await res.json().catch(() => ({})); - error = body.error ?? "Failed to change password."; - } - } - } - - if (action === "update-notifications") { - const collectionActivity = form.has("collectionActivity"); - const orgInvitations = form.has("orgInvitations"); - const securityAlerts = form.has("securityAlerts"); - - const res = await fetch(`${apiBase}/api/accounts/me`, { - method: "PATCH", - headers: { "Content-Type": "application/json", Cookie: `session=${sessionCookie}` }, - body: JSON.stringify({ notificationPrefs: { collectionActivity, orgInvitations, securityAlerts } }), - }); - if (res.ok) { - success = "Notification preferences saved."; - } else { - error = "Failed to save preferences."; - } - } - - if (action === "delete-account") { - const confirmSlug = form.get("confirmSlug") as string; - const password = form.get("password") as string; - - const res = await fetch(`${apiBase}/api/accounts/me`, { - method: "DELETE", - headers: { "Content-Type": "application/json", Cookie: `session=${sessionCookie}` }, - body: JSON.stringify({ confirmSlug, password }), - }); - if (res.ok) { - Astro.cookies.delete("session", { path: "/" }); - return Astro.redirect("/"); - } else { - const body = await res.json().catch(() => ({})); - error = body.error ?? "Failed to delete account."; - } - } - - if (action === "logout") { - await fetch(`${apiBase}/api/accounts/logout`, { - method: "POST", - headers: { Cookie: `session=${sessionCookie}` }, - }); - Astro.cookies.delete("session", { path: "/" }); - return Astro.redirect("/"); - } -} - -const notifPrefs = (me.notificationPrefs as Record) ?? {}; ---- - - -
-

Settings

- - - - {success &&

{success}

} - {error &&

{error}

} - - -
- -

Profile

- -
- {me.avatarUrl ? ( - Avatar - ) : ( -
- {me.displayName?.charAt(0)?.toUpperCase() ?? "?"} -
- )} -
-

{me.displayName}

-

@{me.slug}

- Change avatar -
-
- -
- - -
-
- - -
-
-
- - -
-
- - -
-
-
- -

{me.slug}

-

Usernames cannot be changed.

-
- -
- - -
-

Email

-

Current: {me.email}

-
- Change email address -
- -
- - -
-
- - -
- -
-
-
- - -
-

Password

-
- Change password -
- -
- - -
-
- - -
-
- - -
- -
-
-
- - -
-

Notifications

-

Email notifications (requires SMTP configuration).

-
- - - - - -
-
- - -
-

Danger Zone

-

- Permanently delete your account. You must first transfer or delete all your collections. -

-
- Delete my account… -
- -
- - -
-
- - -
- -
-
-
-
- diff --git a/src/pages/settings/keys.astro b/src/pages/settings/keys.astro deleted file mode 100644 index c36af01..0000000 --- a/src/pages/settings/keys.astro +++ /dev/null @@ -1,210 +0,0 @@ ---- -import Base from "@/layouts/Base.astro"; -import { apiBase } from "@/lib/page-utils"; - -const sessionCookie = Astro.cookies.get("session")?.value; - -if (!sessionCookie) { - return Astro.redirect("/login"); -} - -const meRes = await fetch(`${apiBase}/api/accounts/me`, { - headers: { Cookie: `session=${sessionCookie}` }, -}); -if (!meRes.ok) { - return Astro.redirect("/login"); -} -const me = await meRes.json(); - -// Fetch user collections for scoping -const collectionsRes = await fetch(`${apiBase}/api/accounts/${me.slug}/collections`, { - headers: { Cookie: `session=${sessionCookie}` }, -}); -const collections = collectionsRes.ok ? await collectionsRes.json() : []; - -let newKeyResult: { key: string; label: string } | null = null; -let error = ""; - -// Handle create / delete key -if (Astro.request.method === "POST") { - const form = await Astro.request.formData(); - const action = form.get("action"); - - if (action === "create") { - const label = form.get("label") as string; - const scope = form.get("scope") as string; - const collectionId = form.get("collectionId") as string || undefined; - const expiresIn = form.get("expiresIn") as string; - - const res = await fetch(`${apiBase}/api/accounts/keys`, { - method: "POST", - headers: { "Content-Type": "application/json", Cookie: `session=${sessionCookie}` }, - body: JSON.stringify({ - label, - scope, - collectionId: collectionId || undefined, - expiresIn: expiresIn ? parseInt(expiresIn) : undefined, - }), - }); - if (res.ok) { - const data = await res.json(); - newKeyResult = { key: data.key, label: data.label }; - } else { - const body = await res.json().catch(() => ({})); - error = body.error ?? "Failed to create key."; - } - } - - if (action === "delete") { - const keyId = form.get("keyId") as string; - await fetch(`${apiBase}/api/accounts/keys/${keyId}`, { - method: "DELETE", - headers: { Cookie: `session=${sessionCookie}` }, - }); - } -} - -// Fetch keys -const keysRes = await fetch(`${apiBase}/api/accounts/keys`, { - headers: { Cookie: `session=${sessionCookie}` }, -}); -const keys = keysRes.ok ? await keysRes.json() : []; - -function isExpiringSoon(expiresAt: string | null): boolean { - if (!expiresAt) return false; - const daysLeft = (new Date(expiresAt).getTime() - Date.now()) / (1000 * 60 * 60 * 24); - return daysLeft > 0 && daysLeft < 7; -} - -function isExpired(expiresAt: string | null): boolean { - if (!expiresAt) return false; - return new Date(expiresAt) < new Date(); -} ---- - - -
-

Settings

- - - - {newKeyResult && ( -
-

Key created: {newKeyResult.label}

-

Copy this key now — it won't be shown again.

- {newKeyResult.key} -
- )} - - {error &&

{error}

} - -
-

Create a new key

-
- -
-
- - -
-
- - -
-
- - -
-
- - -
-
- -
-
- -

- Active Keys ({keys.length}) -

- - {keys.length === 0 ? ( -

No API keys yet.

- ) : ( -
- {keys.map((k: any) => ( -
-
-
- {k.label} - {k.keyPrefix && {k.keyPrefix}…} -
-
- {k.scope} - Created {new Date(k.createdAt).toLocaleDateString()} - {k.lastUsedAt && · Last used {new Date(k.lastUsedAt).toLocaleDateString()}} - {k.expiresAt && !isExpired(k.expiresAt) && ( - - · Expires {new Date(k.expiresAt).toLocaleDateString()} - - )} - {isExpired(k.expiresAt) && · Expired} -
-
-
- - - -
-
- ))} -
- )} - - -
-

API Playground

-

Test API calls using your session. Select an endpoint to get started.

-
({ id: c.id, slug: c.slug })))} - >
-
-
- - - diff --git a/src/pages/settings/sessions.astro b/src/pages/settings/sessions.astro deleted file mode 100644 index caffd5c..0000000 --- a/src/pages/settings/sessions.astro +++ /dev/null @@ -1,105 +0,0 @@ ---- -import Base from "@/layouts/Base.astro"; -import { apiBase } from "@/lib/page-utils"; - -const sessionCookie = Astro.cookies.get("session")?.value; - -if (!sessionCookie) { - return Astro.redirect("/login"); -} - -const meRes = await fetch(`${apiBase}/api/accounts/me`, { - headers: { Cookie: `session=${sessionCookie}` }, -}); -if (!meRes.ok) { - return Astro.redirect("/login"); -} - -let success = ""; -let error = ""; - -if (Astro.request.method === "POST") { - const form = await Astro.request.formData(); - const action = form.get("action"); - - if (action === "revoke") { - const sessionId = form.get("sessionId") as string; - const res = await fetch(`${apiBase}/api/accounts/me/sessions/${sessionId}`, { - method: "DELETE", - headers: { Cookie: `session=${sessionCookie}` }, - }); - if (res.ok) { - success = "Session revoked."; - } else { - error = "Failed to revoke session."; - } - } -} - -// Fetch sessions -const sessionsRes = await fetch(`${apiBase}/api/accounts/me/sessions`, { - headers: { Cookie: `session=${sessionCookie}` }, -}); -const sessions = sessionsRes.ok ? await sessionsRes.json() : []; - -function parseUserAgent(ua: string | null): string { - if (!ua) return "Unknown device"; - if (ua.includes("Firefox")) return "Firefox"; - if (ua.includes("Chrome") && !ua.includes("Edg")) return "Chrome"; - if (ua.includes("Edg")) return "Edge"; - if (ua.includes("Safari") && !ua.includes("Chrome")) return "Safari"; - if (ua.includes("curl")) return "curl"; - return "Unknown browser"; -} ---- - - -
-

Settings

- - - - {success &&

{success}

} - {error &&

{error}

} - -

- Active Sessions ({sessions.length}) -

-

- These are the devices currently logged into your account. Revoke any session you don't recognize. -

- - {sessions.length === 0 ? ( -

No active sessions.

- ) : ( -
- {sessions.map((s: any) => ( -
-
-
- {parseUserAgent(s.userAgent)} - {s.current && Current} -
-
- {s.ipAddress && {s.ipAddress}} - Created {new Date(s.createdAt).toLocaleDateString()} - · Expires {new Date(s.expiresAt).toLocaleDateString()} -
-
- {!s.current && ( -
- - - -
- )} -
- ))} -
- )} -
- diff --git a/src/pages/signup.astro b/src/pages/signup.astro deleted file mode 100644 index 5466e11..0000000 --- a/src/pages/signup.astro +++ /dev/null @@ -1,104 +0,0 @@ ---- -import Base from "@/layouts/Base.astro"; -import { apiBase } from "@/lib/page-utils"; - -let error = ""; - -if (Astro.request.method === "POST") { - const form = await Astro.request.formData(); - const email = form.get("email") as string; - const password = form.get("password") as string; - const username = form.get("username") as string; - const displayName = form.get("displayName") as string; - - const res = await fetch(`${apiBase}/api/accounts/signup`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ email, password, username, displayName }), - }); - - if (res.ok) { - const setCookie = res.headers.get("set-cookie"); - if (setCookie) { - const match = setCookie.match(/session=([^;]+)/); - if (match) { - Astro.cookies.set("session", match[1], { - path: "/", - httpOnly: true, - secure: import.meta.env.PROD, - sameSite: "lax", - maxAge: 30 * 24 * 60 * 60, - }); - } - } - return Astro.redirect("/dashboard"); - } else { - const body = await res.json().catch(() => ({})); - error = body.error ?? body.message ?? "Signup failed. Please try again."; - } -} ---- - - -
-

Create an account

- - {error &&

{error}

} - -
-
- - -
-
- - -

Lowercase letters, numbers, and hyphens only.

-
-
- - -
-
- - -
- -
- -

- Already have an account? Log in -

-
- diff --git a/src/routes.ts b/src/routes.ts new file mode 100644 index 0000000..3a2cea5 --- /dev/null +++ b/src/routes.ts @@ -0,0 +1,59 @@ +export interface RouteConfig { + path: string + id: string +} + +export const routes: RouteConfig[] = [ + // Public pages + { path: '/', id: 'home' }, + { path: '/explore', id: 'explore' }, + { path: '/query', id: 'query' }, + { path: '/login', id: 'login' }, + { path: '/signup', id: 'signup' }, + { path: '/logout', id: 'logout' }, + { path: '/forgot-password', id: 'forgot-password' }, + { path: '/reset-password', id: 'reset-password' }, + + // Schemas + { path: '/schemas', id: 'schemas' }, + { path: '/schemas/:id', id: 'schema-detail' }, + + // Blog + { path: '/blog', id: 'blog' }, + { path: '/blog/:slug', id: 'blog-post' }, + + // Docs + { path: '/docs', id: 'docs' }, + { path: '/docs/concepts', id: 'docs-concepts' }, + { path: '/docs/quickstart', id: 'docs-quickstart' }, + { path: '/docs/integration', id: 'docs-integration' }, + { path: '/docs/self-host', id: 'docs-self-host' }, + { path: '/docs/api', id: 'docs-api' }, + { path: '/docs/api/accounts', id: 'docs-api-accounts' }, + { path: '/docs/api/collections', id: 'docs-api-collections' }, + { path: '/docs/api/versions', id: 'docs-api-versions' }, + { path: '/docs/api/files', id: 'docs-api-files' }, + + // Auth-required pages + { path: '/dashboard', id: 'dashboard' }, + { path: '/settings', id: 'settings' }, + { path: '/settings/keys', id: 'settings-keys' }, + { path: '/settings/sessions', id: 'settings-sessions' }, + { path: '/settings/avatar', id: 'settings-avatar' }, + { path: '/invitations/accept', id: 'invitations-accept' }, + + // Admin + { path: '/admin/mirror', id: 'admin-mirror' }, + + // Dynamic owner/collection routes (must come last — catch-all patterns) + { path: '/:owner', id: 'owner' }, + { path: '/:owner/settings', id: 'owner-settings' }, + { path: '/:owner/settings/keys', id: 'owner-settings-keys' }, + { path: '/:owner/settings/members', id: 'owner-settings-members' }, + { path: '/:owner/:collection', id: 'collection' }, + { path: '/:owner/:collection/versions', id: 'collection-versions' }, + { path: '/:owner/:collection/v/:n', id: 'collection-version' }, + { path: '/:owner/:collection/schemas', id: 'collection-schemas' }, + { path: '/:owner/:collection/diff', id: 'collection-diff' }, + { path: '/:owner/:collection/settings', id: 'collection-settings' }, +] diff --git a/src/routes/admin/mirror.tsx b/src/routes/admin/mirror.tsx new file mode 100644 index 0000000..4d73e70 --- /dev/null +++ b/src/routes/admin/mirror.tsx @@ -0,0 +1,40 @@ +import BaseLayout from '~/components/BaseLayout' +import { useSSRData } from '~/lib/ssr-data' +import MirrorAdmin from '~/components/MirrorAdmin' + +interface MirrorConfig { + enabled: boolean + nodeName: string + upstream: string + syncSchedule: string +} + +export default function AdminMirror() { + const me = useSSRData('currentUser') + const mirrorConfig = useSSRData('mirrorConfig') + + if (!mirrorConfig?.enabled) { + if (typeof window !== 'undefined') { + window.location.href = '/' + } + return null + } + + if (!me) return null + + return ( + +
+

Mirror Administration

+

+ Configure and monitor this node's sync with the upstream Underlay server. +

+ +
+
+ ) +} diff --git a/src/routes/blog/index.tsx b/src/routes/blog/index.tsx new file mode 100644 index 0000000..f053d45 --- /dev/null +++ b/src/routes/blog/index.tsx @@ -0,0 +1,61 @@ +import BaseLayout from '~/components/BaseLayout' + +const posts: { title: string; subtitle: string; date: string; url: string }[] = [ + { + title: 'Schema Evolution', + subtitle: 'How Underlay handles schema changes across versions.', + date: '2026-04-30', + url: '/blog/2026-04-30-schema-evolution', + }, + { + title: 'AT Protocol Integration', + subtitle: 'Connecting Underlay to the decentralized social web.', + date: '2026-04-28', + url: '/blog/2026-04-28-atproto-integration', + }, + { + title: 'Institutional Repositories', + subtitle: 'Why universities need better infrastructure for structured data.', + date: '2024-04-27', + url: '/blog/2024-04-27-institutional-repositories', + }, + { + title: 'Underlay, Revived', + subtitle: 'The landscape changed. The project can finally be simple.', + date: '2024-04-27', + url: '/blog/2024-04-27-underlay-revived', + }, +] + +function fmtDate(d: string) { + const date = new Date(d) + return date.toLocaleDateString('en-US', { year: 'numeric', month: 'short', day: 'numeric' }) +} + +function isoDate(d: string) { + return new Date(d).toISOString().slice(0, 10) +} + +export default function Blog() { + return ( + +
+

Blog

+ +
    + {posts.map((post) => ( +
  • + +
    + {post.title} +

    {post.subtitle}

    +
    +
  • + ))} +
+
+
+ ) +} diff --git a/src/routes/blog/post.tsx b/src/routes/blog/post.tsx new file mode 100644 index 0000000..27f160d --- /dev/null +++ b/src/routes/blog/post.tsx @@ -0,0 +1,62 @@ +import { useSSRData } from '~/lib/ssr-data' +import BlogLayout from '~/components/BlogLayout' +import { useParams } from 'react-router' +import { useState, useEffect } from 'react' + +// Blog post metadata +const posts: Record = { + '2024-04-27-underlay-revived': { + title: 'Underlay, Revived', + subtitle: 'The landscape changed. The project can finally be simple.', + date: '2024-04-27', + }, + '2024-04-27-institutional-repositories': { + title: 'Institutional Repositories', + subtitle: 'Why universities need better infrastructure for structured data.', + date: '2024-04-27', + }, + '2026-04-28-atproto-integration': { + title: 'AT Protocol Integration', + subtitle: 'Connecting Underlay to the decentralized social web.', + date: '2026-04-28', + }, + '2026-04-30-schema-evolution': { + title: 'Schema Evolution', + subtitle: 'How Underlay handles schema changes across versions.', + date: '2026-04-30', + }, +} + +export default function BlogPost() { + const { slug } = useParams() + const [content, setContent] = useState(null) + + const meta = slug ? posts[slug] : undefined + + useEffect(() => { + if (!slug) return + // Fetch the rendered markdown from an API endpoint or static file + fetch(`/api/blog/${slug}`) + .then((res) => (res.ok ? res.text() : '')) + .then(setContent) + .catch(() => setContent('')) + }, [slug]) + + if (!meta) { + return ( + +

The requested blog post could not be found.

+
+ ) + } + + return ( + + {content === null ? ( +

Loading...

+ ) : ( +
+ )} + + ) +} diff --git a/src/routes/collection/diff.tsx b/src/routes/collection/diff.tsx new file mode 100644 index 0000000..c553bbd --- /dev/null +++ b/src/routes/collection/diff.tsx @@ -0,0 +1,430 @@ +import { useState, useEffect } from 'react' +import { useParams, useSearchParams } from 'react-router' +import BaseLayout from '~/components/BaseLayout' +import { useSSRData } from '~/lib/ssr-data' +import { CollectionNav } from '.' + +function groupByType(records: any[]) { + const groups: Record = {} + for (const r of records) { + if (!groups[r.type]) groups[r.type] = [] + groups[r.type]!.push(r) + } + return groups +} + +export default function CollectionDiffPage() { + const { owner, collection } = useParams() + const [searchParams, setSearchParams] = useSearchParams() + const currentUser = useSSRData('currentUser') + + const [data, setData] = useState(null) + const [versions, setVersions] = useState([]) + const [isOwner, setIsOwner] = useState(false) + const [diff, setDiff] = useState(null) + const [diffError, setDiffError] = useState(null) + const [loading, setLoading] = useState(true) + const [diffLoading, setDiffLoading] = useState(false) + + // Version selectors + const [fromNum, setFromNum] = useState(0) + const [toNum, setToNum] = useState(0) + + useEffect(() => { + if (!owner || !collection) return + + Promise.all([ + fetch(`/api/collections/${owner}/${collection}`, { credentials: 'include' }).then((r) => + r.ok ? r.json() : null, + ), + fetch(`/api/collections/${owner}/${collection}/versions?limit=100`, { + credentials: 'include', + }).then((r) => (r.ok ? r.json() : [])), + ]).then(([col, vers]) => { + if (!col) { + window.location.href = '/404' + return + } + setData(col) + setVersions(vers) + + if (currentUser) { + setIsOwner( + currentUser.slug === owner || + currentUser.orgs?.some((o: any) => o.slug === owner), + ) + } + + // Determine from/to from URL + const latestNum = vers.length > 0 ? vers[0].number : null + const urlTo = searchParams.get('to') + const urlFrom = searchParams.get('from') + const target = urlTo ? parseInt(urlTo, 10) : latestNum ?? 0 + const base = urlFrom ? parseInt(urlFrom, 10) : target ? target - 1 : 0 + setToNum(target) + setFromNum(base) + + setLoading(false) + }) + }, [owner, collection, currentUser]) + + // Fetch diff when from/to change + useEffect(() => { + if (!toNum || toNum <= 0 || loading) return + setDiffLoading(true) + setDiff(null) + setDiffError(null) + + const fromParam = fromNum >= 0 ? `?from=${fromNum}` : '' + fetch( + `/api/collections/${owner}/${collection}/versions/${toNum}/diff${fromParam}`, + { credentials: 'include' }, + ) + .then(async (r) => { + if (r.ok) { + setDiff(await r.json()) + } else { + const body = await r.json().catch(() => ({})) + setDiffError(body.error ?? 'Failed to load diff') + } + }) + .finally(() => setDiffLoading(false)) + }, [fromNum, toNum, loading, owner, collection]) + + function handleCompare(e: React.FormEvent) { + e.preventDefault() + const form = e.target as HTMLFormElement + const fd = new FormData(form) + const f = parseInt(fd.get('from') as string, 10) + const t = parseInt(fd.get('to') as string, 10) + setFromNum(f) + setToNum(t) + setSearchParams({ from: String(f), to: String(t) }) + } + + if (loading || !data) { + return ( + +
Loading…
+
+ ) + } + + const targetVersion = versions.find((v: any) => v.number === toNum) + const baseVersion = versions.find((v: any) => v.number === fromNum) + + const addedByType = diff ? groupByType(diff.added) : {} + const updatedByType = diff ? groupByType(diff.updated) : {} + const removedCount = diff?.removed?.length ?? 0 + + const totalAdded = diff?.added?.length ?? 0 + const totalUpdated = diff?.updated?.length ?? 0 + const totalRemoved = removedCount + const totalChanges = totalAdded + totalUpdated + totalRemoved + + const meta = diff?.meta ?? {} + const hasMetaChanges = + meta.schemaChanged || + meta.readmeChanged || + meta.filesAdded > 0 || + meta.filesRemoved > 0 + + return ( + +
+ + + {/* Version selector */} +
+
+ + + + + +
+
+ + {diffError && ( +
+ {diffError} +
+ )} + + {diffLoading && ( +

Loading diff…

+ )} + + {diff && ( +
+ {/* Summary bar */} +
+ + {baseVersion ? `v${baseVersion.number}` : '∅'} → v{targetVersion?.number} + + · + + {totalChanges.toLocaleString()} changes + + {totalAdded > 0 && ( + + +{totalAdded.toLocaleString()} added + + )} + {totalUpdated > 0 && ( + + ~{totalUpdated.toLocaleString()} updated + + )} + {totalRemoved > 0 && ( + + -{totalRemoved.toLocaleString()} removed + + )} + {meta.schemaChanged && schema} + {meta.readmeChanged && readme} + {meta.filesAdded > 0 && ( + +{meta.filesAdded} files + )} + {meta.filesRemoved > 0 && ( + -{meta.filesRemoved} files + )} +
+ + {/* Metadata changes */} + {hasMetaChanges && ( +
+

+ + Metadata changes +

+
+ + + {meta.schemaChanged && ( + + + + + )} + {meta.readmeChanged && ( + + + + + )} + {meta.filesAdded > 0 && ( + + + + + )} + {meta.filesRemoved > 0 && ( + + + + + )} + +
SchemaModified
README + {!meta.readmeFrom && meta.readmeTo ? ( + Added + ) : meta.readmeFrom && !meta.readmeTo ? ( + Removed + ) : ( + Modified + )} +
Files added+{meta.filesAdded}
+ Files removed + -{meta.filesRemoved}
+
+
+ )} + + {totalChanges === 0 && !hasMetaChanges && ( +

+ No changes between these versions. +

+ )} + + {/* Added records */} + {totalAdded > 0 && ( +
+

+ + Added ({totalAdded.toLocaleString()}) +

+ {Object.entries(addedByType).map(([type, records]: [string, any[]]) => ( +
+
+ {type} ({records.length}) +
+
+ + + + + + + + + {records.slice(0, 50).map((r: any) => ( + + + + + ))} + {records.length > 50 && ( + + + + )} + +
IDData
+ {r.id.length > 30 ? r.id.slice(0, 30) + '…' : r.id} + +
+                                  {JSON.stringify(r.data, null, 2)}
+                                
+
+ … and {records.length - 50} more +
+
+
+ ))} +
+ )} + + {/* Updated records */} + {totalUpdated > 0 && ( +
+

+ + Updated ({totalUpdated.toLocaleString()}) +

+ {Object.entries(updatedByType).map(([type, records]: [string, any[]]) => ( +
+
+ {type} ({records.length}) +
+
+ + + + + + + + + {records.slice(0, 50).map((r: any) => ( + + + + + ))} + {records.length > 50 && ( + + + + )} + +
IDNew data
+ {r.id.length > 30 ? r.id.slice(0, 30) + '…' : r.id} + +
+                                  {JSON.stringify(r.data, null, 2)}
+                                
+
+ … and {records.length - 50} more +
+
+
+ ))} +
+ )} + + {/* Removed records */} + {totalRemoved > 0 && ( +
+

+ + Removed ({totalRemoved.toLocaleString()}) +

+
+ + + + + + + + {diff.removed.slice(0, 100).map((id: string) => ( + + + + ))} + {diff.removed.length > 100 && ( + + + + )} + +
Record ID
{id}
+ … and {diff.removed.length - 100} more +
+
+
+ )} +
+ )} + + {!diff && !diffError && !diffLoading && ( +

+ Select versions to compare. +

+ )} +
+
+ ) +} diff --git a/src/routes/collection/index.tsx b/src/routes/collection/index.tsx new file mode 100644 index 0000000..52a950c --- /dev/null +++ b/src/routes/collection/index.tsx @@ -0,0 +1,439 @@ +import { useState, useEffect } from 'react' +import { useParams } from 'react-router' +import BaseLayout from '~/components/BaseLayout' +import { useSSRData } from '~/lib/ssr-data' + +function CollectionNav({ + owner, + collection, + isPublic, + isOwner = false, + active, + versionLabel, +}: { + owner: string + collection: string + isPublic?: boolean + isOwner?: boolean + active: 'overview' | 'versions' | 'schemas' | 'settings' + versionLabel?: string +}) { + const linkClass = 'px-3 py-2 text-sm font-medium border-b-2 -mb-px transition-colors' + const activeClass = `${linkClass} border-ink text-ink` + const inactiveClass = `${linkClass} border-transparent text-ink-muted hover:text-ink hover:border-rule` + + return ( + <> +
+ +
+
+ + Overview + + + Versions + + {versionLabel && {versionLabel}} + + Schemas + + {isOwner && ( + + Settings + + )} +
+ + ) +} + +function formatBytes(bytes: number): string { + if (!bytes || bytes < 0) return '0 B' + const k = 1024 + const sizes = ['B', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB'] + const i = Math.min(Math.floor(Math.log(bytes) / Math.log(k)), sizes.length - 1) + return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i] +} + +export default function CollectionPage() { + const { owner, collection } = useParams() + const currentUser = useSSRData('currentUser') + const mirrorConfig = useSSRData('mirrorConfig') + + const [data, setData] = useState(null) + const [totalVersions, setTotalVersions] = useState(0) + const [isOwner, setIsOwner] = useState(false) + const [readmeHtml, setReadmeHtml] = useState(null) + const [loading, setLoading] = useState(true) + + useEffect(() => { + if (!owner || !collection) return + + fetch(`/api/collections/${owner}/${collection}`, { credentials: 'include' }) + .then((r) => (r.ok ? r.json() : null)) + .then((col) => { + if (!col) { + window.location.href = '/404' + return + } + setData(col) + setTotalVersions(col.latestVersion?.number ?? 0) + + // Render readme + const readmeSource = col.latestVersion?.readme || col.latestVersion?.message || null + if (readmeSource) { + import('marked').then(({ marked }) => { + setReadmeHtml(marked.parse(readmeSource) as string) + }) + } + + // Check ownership + if (currentUser) { + setIsOwner( + currentUser.slug === owner || + currentUser.orgs?.some((o: any) => o.slug === owner), + ) + } + + setLoading(false) + }) + }, [owner, collection, currentUser]) + + if (loading || !data) { + return ( + +
Loading…
+
+ ) + } + + const typeCounts: { type: string; count: number }[] = data.latestVersion?.typeCounts ?? [] + const allTypes = typeCounts.sort((a: any, b: any) => a.type.localeCompare(b.type)) + const collectionArkPath: string | null = data.ark ? new URL(data.ark).pathname : null + + return ( + +
+ + + {mirrorConfig?.enabled && ( + + )} + + {/* Two-column layout */} +
+ {/* Main column */} +
+ {/* Latest version bar */} + {data.latestVersion && ( +
+
+ + {data.latestVersion.semver} + + · + + {data.latestVersion.recordCount.toLocaleString()} records + + · + + {data.latestVersion.fileCount.toLocaleString()} files + + · + + {formatBytes(data.latestVersion.totalBytes)} + +
+
+ + {new Date(data.latestVersion.createdAt).toLocaleDateString('en-US', { + month: 'short', + day: 'numeric', + year: 'numeric', + })} + + + + + + {totalVersions} + +
+
+ )} + + {/* Type TOC */} + {allTypes.length > 0 && ( +
+ {allTypes.map((t: any, i: number) => ( + +
+ + + + {t.type} +
+ + {t.count.toLocaleString()} records + +
+ ))} +
+ )} + + {/* README */} + {readmeHtml ? ( +
+
+ README +
+
+
+ ) : ( +
+ No README yet. +
+ )} +
+ + {/* Sidebar */} + +
+
+ + ) +} + +export { CollectionNav, formatBytes } diff --git a/src/routes/collection/schemas.tsx b/src/routes/collection/schemas.tsx new file mode 100644 index 0000000..e6bf319 --- /dev/null +++ b/src/routes/collection/schemas.tsx @@ -0,0 +1,310 @@ +import { useState, useEffect, type FormEvent } from 'react' +import { useParams } from 'react-router' +import BaseLayout from '~/components/BaseLayout' +import { useSSRData } from '~/lib/ssr-data' +import { CollectionNav } from '.' + +export default function CollectionSchemasPage() { + const { owner, collection } = useParams() + const currentUser = useSSRData('currentUser') + + const [data, setData] = useState(null) + const [isOwner, setIsOwner] = useState(false) + const [schemas, setSchemas] = useState([]) + const [schemasData, setSchemasData] = useState({}) + const [arkRecordTypes, setArkRecordTypes] = useState>({}) + const [loading, setLoading] = useState(true) + const [arkSuccess, setArkSuccess] = useState('') + const [arkError, setArkError] = useState('') + + useEffect(() => { + if (!owner || !collection) return + + const ownerFlag = + currentUser && + (currentUser.slug === owner || + currentUser.orgs?.some((o: any) => o.slug === owner)) + setIsOwner(!!ownerFlag) + + Promise.all([ + fetch(`/api/collections/${owner}/${collection}`, { credentials: 'include' }).then((r) => + r.ok ? r.json() : null, + ), + fetch(`/api/collections/${owner}/${collection}/schemas`, { credentials: 'include' }).then( + (r) => (r.ok ? r.json() : { schemas: [], version: null, semver: null }), + ), + ownerFlag + ? fetch(`/api/collections/${owner}/${collection}/ark/record-types`, { + credentials: 'include', + }).then((r) => (r.ok ? r.json() : [])) + : Promise.resolve([]), + ]).then(([col, sd, arkTypes]) => { + if (!col) { + window.location.href = '/404' + return + } + setData(col) + setSchemasData(sd) + setSchemas(sd.schemas ?? []) + + const types: Record = {} + for (const entry of arkTypes) { + types[entry.recordType] = entry.redirectUrlField + } + setArkRecordTypes(types) + + setLoading(false) + }) + }, [owner, collection, currentUser]) + + async function handleUpdateArkType(e: FormEvent, slug: string) { + e.preventDefault() + setArkSuccess('') + setArkError('') + const form = e.target as HTMLFormElement + const formData = new FormData(form) + const redirectUrlField = (formData.get('redirectUrlField') as string) || null + + const res = await fetch(`/api/collections/${owner}/${collection}/ark/record-types`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + credentials: 'include', + body: JSON.stringify({ recordType: slug, redirectUrlField }), + }) + if (res.ok) { + setArkSuccess(`ARK settings updated for ${slug}.`) + setArkRecordTypes((prev) => { + const next = { ...prev } + if (redirectUrlField) { + next[slug] = redirectUrlField + } else { + delete next[slug] + } + return next + }) + } else { + const body = await res.json().catch(() => ({})) + setArkError(body.error ?? 'Failed to update ARK settings.') + } + } + + if (loading || !data) { + return ( + +
Loading…
+
+ ) + } + + return ( + +
+ + + {arkSuccess && ( +

+ {arkSuccess} +

+ )} + {arkError && ( +

+ {arkError} +

+ )} + +
+

+ {schemas.length} type{schemas.length !== 1 ? 's' : ''} + {schemasData.semver && ( + in {schemasData.semver} + )} +

+
+ + {schemas.length === 0 ? ( +

+ No schemas in this version. +

+ ) : ( +
+ {schemas.map((s: any) => { + const properties = (s.schema as any)?.properties ?? {} + const fields = Object.entries(properties) + const isPrivate = (s.schema as any)?.private === true + const labels: string[] = (s.schema as any)?.['x-underlay-labels'] ?? [] + + const urlFields = fields + .filter( + ([, def]: [string, any]) => + def.type === 'string' && + (def.format === 'uri' || def.format === 'url'), + ) + .map(([name]: [string, any]) => name) + const currentField = arkRecordTypes[s.slug] ?? '' + + return ( +
+ {/* Header */} +
+
+ + + + {s.slug} + {isPrivate && ( + + private + + )} +
+
+ {labels.length > 0 && ( +
+ {labels.map((label: string) => ( + + {label} + + ))} +
+ )} + + {s.schemaHash.slice(0, 10)}… + +
+
+ + {/* Fields table */} + + + + + + + + + + {fields.map(([name, def]: [string, any]) => { + const fieldType = def.type ?? 'unknown' + const format = def.format ? ` (${def.format})` : '' + const refType = def['x-ref-type'] + const isFieldPrivate = def.private === true + + return ( + + + + + + ) + })} + +
FieldTypeAnnotations
{name} + {fieldType} + {format} + +
+ {refType && ( + + + + + + → {refType} + + )} + {isFieldPrivate && ( + + private + + )} +
+
+ + {/* ARK section for this type (owner only) */} + {isOwner && urlFields.length > 0 && ( +
+

+ ARK identifiers for this type +

+
handleUpdateArkType(e, s.slug)} + className="flex items-center gap-3" + > + + + +
+
+ )} +
+ ) + })} +
+ )} +
+
+ ) +} diff --git a/src/routes/collection/settings.tsx b/src/routes/collection/settings.tsx new file mode 100644 index 0000000..f96c8d9 --- /dev/null +++ b/src/routes/collection/settings.tsx @@ -0,0 +1,362 @@ +import { useState, useEffect, type FormEvent } from 'react' +import { useParams } from 'react-router' +import BaseLayout from '~/components/BaseLayout' +import { useSSRData } from '~/lib/ssr-data' +import { CollectionNav } from '.' + +export default function CollectionSettingsPage() { + const { owner, collection } = useParams() + const currentUser = useSSRData('currentUser') + + const [data, setData] = useState(null) + const [arkSettings, setArkSettings] = useState({ + enabled: false, + customUrl: null, + arkUrl: null, + }) + const [loading, setLoading] = useState(true) + const [success, setSuccess] = useState('') + const [error, setError] = useState('') + const [submitting, setSubmitting] = useState('') + + // Form state + const [name, setName] = useState('') + const [description, setDescription] = useState('') + const [isPublic, setIsPublic] = useState(false) + + // ARK form + const [arkEnabled, setArkEnabled] = useState(false) + const [arkCustomUrl, setArkCustomUrl] = useState('') + + // Delete form + const [confirmSlug, setConfirmSlug] = useState('') + + useEffect(() => { + if (!owner || !collection || !currentUser) return + + const isOrgMember = currentUser.orgs?.some((o: any) => o.slug === owner) + if (currentUser.slug !== owner && !isOrgMember) { + window.location.href = `/${owner}/${collection}` + return + } + + Promise.all([ + fetch(`/api/collections/${owner}/${collection}`, { credentials: 'include' }).then((r) => + r.ok ? r.json() : null, + ), + fetch(`/api/collections/${owner}/${collection}/ark`, { credentials: 'include' }).then( + (r) => (r.ok ? r.json() : { enabled: false, customUrl: null, arkUrl: null }), + ), + ]).then(([col, ark]) => { + if (!col) { + window.location.href = '/404' + return + } + setData(col) + setName(col.name) + setDescription(col.description ?? '') + setIsPublic(col.public) + + setArkSettings(ark) + setArkEnabled(ark.enabled) + setArkCustomUrl(ark.customUrl ?? '') + + setLoading(false) + }) + }, [owner, collection, currentUser]) + + if (!currentUser) { + window.location.href = '/login' + return null + } + + function clearMessages() { + setSuccess('') + setError('') + } + + async function handleUpdate(e: FormEvent) { + e.preventDefault() + clearMessages() + setSubmitting('update') + try { + const res = await fetch(`/api/collections/${owner}/${collection}`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + credentials: 'include', + body: JSON.stringify({ name, description, public: isPublic }), + }) + if (res.ok) { + setSuccess('Collection updated.') + const refreshed = await fetch(`/api/collections/${owner}/${collection}`, { + credentials: 'include', + }) + if (refreshed.ok) { + const updated = await refreshed.json() + setData(updated) + } + } else { + const body = await res.json().catch(() => ({})) + setError(body.error ?? 'Update failed.') + } + } finally { + setSubmitting('') + } + } + + async function handleUpdateArk(e: FormEvent) { + e.preventDefault() + clearMessages() + setSubmitting('ark') + try { + const res = await fetch(`/api/collections/${owner}/${collection}/ark`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + credentials: 'include', + body: JSON.stringify({ enabled: arkEnabled, customUrl: arkCustomUrl.trim() || null }), + }) + if (res.ok) { + setSuccess('ARK settings updated.') + const refreshed = await fetch(`/api/collections/${owner}/${collection}/ark`, { + credentials: 'include', + }) + if (refreshed.ok) setArkSettings(await refreshed.json()) + } else { + const body = await res.json().catch(() => ({})) + setError(body.error ?? 'Failed to update ARK settings.') + } + } finally { + setSubmitting('') + } + } + + async function handleDelete(e: FormEvent) { + e.preventDefault() + clearMessages() + if (confirmSlug !== collection) { + setError('Collection name does not match. Deletion cancelled.') + return + } + setSubmitting('delete') + try { + const res = await fetch(`/api/collections/${owner}/${collection}`, { + method: 'DELETE', + credentials: 'include', + }) + if (res.ok) { + window.location.href = '/dashboard' + } else { + const body = await res.json().catch(() => ({})) + setError(body.error ?? 'Delete failed.') + } + } finally { + setSubmitting('') + } + } + + if (loading || !data) { + return ( + +
Loading…
+
+ ) + } + + const arkPath: string | null = arkSettings.arkUrl + ? new URL(arkSettings.arkUrl).pathname + : null + + return ( + +
+ + +
+ {success && ( +

+ {success} +

+ )} + {error && ( +

+ {error} +

+ )} + + {/* Update form */} +
+
+ + setName(e.target.value)} + required + className="w-full bg-parchment border border-rule px-3 py-2 text-sm focus:outline-none focus:border-ink" + /> +
+ +
+ +