From 790ec874cfccf1b23f69c140dcaef6c0dd6f8572 Mon Sep 17 00:00:00 2001 From: Travis Rich Date: Fri, 15 May 2026 00:01:19 -0400 Subject: [PATCH 01/10] auth flow functioning with basic tooling --- README.md | 8 +- dev.sh | 12 +- docker-compose.local.yml | 13 +- public/.well-known/ai.txt | 7 +- server.ts | 31 +- src/api/accounts.ts | 410 ++-- src/api/auth.server.ts | 15 +- src/api/collections.ts | 113 +- src/api/kf-auth.ts | 183 ++ src/api/kf-summary.ts | 125 ++ src/components/BaseLayout.tsx | 6 +- src/components/DocsSearch.tsx | 6 +- src/components/UserMenu.tsx | 5 +- .../migrations/0007_kf-auth-integration.sql | 47 + src/db/migrations/0008_sudden_cyclops.sql | 6 + src/db/migrations/0009_salty_colossus.sql | 1 + src/db/migrations/0010_keen_meltdown.sql | 5 + src/db/migrations/0011_messy_molten_man.sql | 1 + src/db/migrations/meta/0007_snapshot.json | 1722 +++++++++++++++++ src/db/migrations/meta/0008_snapshot.json | 1657 ++++++++++++++++ src/db/migrations/meta/0009_snapshot.json | 1663 ++++++++++++++++ src/db/migrations/meta/0010_snapshot.json | 1631 ++++++++++++++++ src/db/migrations/meta/0011_snapshot.json | 1631 ++++++++++++++++ src/db/migrations/meta/_journal.json | 35 + src/db/schema.ts | 24 +- src/db/seed.ts | 5 - src/lib/auth.server.ts | 24 +- src/lib/kf-auth.server.ts | 125 ++ src/lib/kf-orgs.server.ts | 24 + src/lib/kf-profile-cache.server.ts | 51 + src/lib/mirror-sync.ts | 2 - src/loaders.server.ts | 10 +- src/routes/[owner]/[collection]/settings.tsx | 97 +- src/routes/[owner]/settings/index.tsx | 110 +- src/routes/dashboard.tsx | 297 ++- src/routes/docs/api/accounts.tsx | 79 +- src/routes/docs/quickstart.tsx | 49 +- src/routes/forgot-password.tsx | 78 +- src/routes/invitations/accept.tsx | 12 +- src/routes/login.tsx | 108 +- src/routes/logout.tsx | 18 +- src/routes/query.tsx | 4 +- src/routes/reset-password.tsx | 116 +- src/routes/settings/index.tsx | 240 +-- src/routes/signup.tsx | 105 +- tools/seedMirror.ts | 12 +- 46 files changed, 9837 insertions(+), 1086 deletions(-) create mode 100644 src/api/kf-auth.ts create mode 100644 src/api/kf-summary.ts create mode 100644 src/db/migrations/0007_kf-auth-integration.sql create mode 100644 src/db/migrations/0008_sudden_cyclops.sql create mode 100644 src/db/migrations/0009_salty_colossus.sql create mode 100644 src/db/migrations/0010_keen_meltdown.sql create mode 100644 src/db/migrations/0011_messy_molten_man.sql create mode 100644 src/db/migrations/meta/0007_snapshot.json create mode 100644 src/db/migrations/meta/0008_snapshot.json create mode 100644 src/db/migrations/meta/0009_snapshot.json create mode 100644 src/db/migrations/meta/0010_snapshot.json create mode 100644 src/db/migrations/meta/0011_snapshot.json create mode 100644 src/lib/kf-auth.server.ts create mode 100644 src/lib/kf-orgs.server.ts create mode 100644 src/lib/kf-profile-cache.server.ts diff --git a/README.md b/README.md index d0db9d1..6446130 100644 --- a/README.md +++ b/README.md @@ -39,10 +39,8 @@ pnpm dev:app ### Default Seed User -- **Email:** admin@underlay.org -- **Password:** admin - -Also creates a "Knowledge Futures" org with sample collections. +The seed script creates a "Knowledge Futures" org with sample collections. +In production, user accounts are created automatically on first sign-in via [KF Auth](https://auth.knowledgefutures.org) (OIDC SSO). ## Architecture @@ -54,7 +52,7 @@ Also creates a "Knowledge Futures" org with sample collections. | Build | Vite 6 (client + SSR bundles) | | Database | PostgreSQL 16 + Drizzle ORM | | File Storage | Cloudflare R2 (prod) / MinIO (dev) — S3-compatible | -| Auth | Session cookies (web) + API keys (programmatic) | +| Auth | KF Auth SSO (OIDC) for web sessions + API keys (programmatic) | | Deployment | Docker Swarm on Hetzner, Caddy reverse proxy, Cloudflare DNS | | CI/CD | GitHub Actions → GHCR → SSH → `docker stack deploy` | | Secrets | SOPS + age encryption | diff --git a/dev.sh b/dev.sh index 94e9b3f..8180fd5 100755 --- a/dev.sh +++ b/dev.sh @@ -4,11 +4,15 @@ cd "$(dirname "$0")" # Load env vars (prefer .env.local for local dev) set -a -[[ -f .env.local ]] && source .env.local || [[ -f .env ]] && source .env +if [[ -f .env.local ]]; then + source .env.local +elif [[ -f .env ]]; then + source .env +fi set +a -# Find an available port, incrementing from PORT (default 3000) -BASE_PORT="${PORT:-3000}" +# Find an available port, incrementing from PORT (default 4100) +BASE_PORT="${PORT:-4100}" PORT="$BASE_PORT" while lsof -iTCP:"$PORT" -sTCP:LISTEN -t &>/dev/null; do ((PORT++)) @@ -20,4 +24,4 @@ export PORT trap "docker compose -f docker-compose.local.yml down" EXIT -docker compose -f docker-compose.local.yml up --build --attach app +docker compose --env-file .env.local -f docker-compose.local.yml up --build --attach app diff --git a/docker-compose.local.yml b/docker-compose.local.yml index c687052..b96fec0 100644 --- a/docker-compose.local.yml +++ b/docker-compose.local.yml @@ -1,6 +1,6 @@ # Local development compose — source-mounted for fast reload. # Start with: ./dev.sh -# Access at localhost:${PORT:-3000} +# Access at localhost:${PORT:-4100} name: underlay-local @@ -34,13 +34,17 @@ services: dockerfile: Dockerfile target: dev ports: - - '${PORT:-3000}:${PORT:-3000}' + - '${PORT:-4100}:${PORT:-4100}' volumes: - .:/app - - /app/node_modules + - app_node_modules:/app/node_modules + env_file: + - .env.local environment: - PORT: ${PORT:-3000} + PORT: ${PORT:-4100} DATABASE_URL: postgresql://underlay:underlay@db:5432/underlay + extra_hosts: + - 'host.docker.internal:host-gateway' command: sh -c "pnpm db:migrate && pnpm db:seed && pnpm dev:app" depends_on: db: @@ -48,3 +52,4 @@ services: volumes: pgdata-dev: + app_node_modules: diff --git a/public/.well-known/ai.txt b/public/.well-known/ai.txt index 8763292..14fb9fe 100644 --- a/public/.well-known/ai.txt +++ b/public/.well-known/ai.txt @@ -15,12 +15,11 @@ There are two auth methods: 1. API Key (for programmatic access): Header: Authorization: Bearer ul_ Keys have scopes: read, write, admin. - Create keys at https://underlay.org/settings/keys or via POST /api/accounts/keys. + Create keys at https://underlay.org/settings/keys. 2. Session cookie (for browser use): - POST /api/accounts/login with {"email": "...", "password": "..."} sets a session cookie. - POST /api/accounts/signup with {"email", "password", "username", "displayName"} creates an account. - POST /api/accounts/logout clears the session. + Users sign in via KF Auth SSO (OIDC) at https://underlay.org/login. + Accounts are created automatically on first sign-in. GET /api/accounts/me returns the current user (works with either auth method). All GET requests are public — no auth required to read public data. diff --git a/server.ts b/server.ts index 1cc0ecb..eb38d6e 100644 --- a/server.ts +++ b/server.ts @@ -15,6 +15,8 @@ import { authMiddleware, requireAuth, } from '~/api/auth.server' import * as collections from '~/api/collections' import * as files from '~/api/files' import * as health from '~/api/health' +import * as kfAuth from '~/api/kf-auth' +import * as kfSummary from '~/api/kf-summary' import * as query from '~/api/query' import * as schemas from '~/api/schemas' import * as uploads from '~/api/uploads' @@ -42,9 +44,28 @@ app.use('/api/admin/*', async (c, next,) => { // --- ARK resolution middleware --- app.use('/ark\\:*', arkMiddleware,) +// --- KF Auth (OIDC login) --- +app.get('/login', async (c, next,) => { + // Server-side redirect to avoid client-side "Redirecting..." flash. + // Fall through to the React route only when there's an error to display. + const url = new URL(c.req.url,) + if (!url.searchParams.has('error',)) { + const returnTo = url.searchParams.get('return_to',) ?? '' + const target = returnTo ? `/auth/login?return_to=${encodeURIComponent(returnTo,)}` : '/auth/login' + return c.redirect(target,) + } + await next() +},) +app.get('/auth/login', kfAuth.login,) +app.get('/auth/callback', kfAuth.callback,) +app.post('/auth/logout', kfAuth.logout,) + // --- API routes --- app.get('/api/health', health.check,) +// KF internal (service-to-service) +app.get('/api/kf/summary', kfSummary.summary,) + // Admin (mirror) app.get('/api/admin/mirror/status', admin.mirrorStatus,) app.post('/api/admin/mirror/test', admin.mirrorTest,) @@ -82,6 +103,7 @@ app.post('/api/accounts/:owner/collections', requireAuth('write',), collections. app.get('/api/collections/:owner/:slug', collections.get,) app.patch('/api/collections/:owner/:slug', requireAuth('write',), collections.update,) app.delete('/api/collections/:owner/:slug', requireAuth('admin',), collections.remove,) +app.post('/api/collections/:owner/:slug/transfer', requireAuth(), collections.transfer,) app.get('/api/accounts/:owner/collections', collections.listByOwner,) app.get('/api/collections/:owner/:slug/export', collections.exportArchive,) @@ -108,20 +130,13 @@ app.post('/api/collections/:owner/:slug/versions', requireAuth('write',), versio app.get('/api/collections/:owner/:slug/versions/:n/diff', versions.diff,) // Accounts -app.post('/api/accounts/signup', accounts.signup,) -app.post('/api/accounts/login', accounts.login,) -app.post('/api/accounts/logout', accounts.logout,) app.get('/api/accounts/me', requireAuth(), accounts.getMe,) +app.get('/api/accounts/available-kf-orgs', requireAuth(), accounts.availableKfOrgs,) app.get('/api/accounts/:slug', accounts.getBySlug,) app.patch('/api/accounts/me', requireAuth(), accounts.updateMe,) -app.post('/api/accounts/me/email', requireAuth(), accounts.updateEmail,) -app.post('/api/accounts/me/password', requireAuth(), accounts.updatePassword,) -app.post('/api/accounts/me/avatar', requireAuth(), accounts.uploadAvatar,) app.get('/api/accounts/me/sessions', requireAuth(), accounts.listSessions,) app.delete('/api/accounts/me/sessions/:sessionId', requireAuth(), accounts.deleteSession,) app.delete('/api/accounts/me', requireAuth(), accounts.deleteMe,) -app.post('/api/accounts/forgot-password', accounts.forgotPassword,) -app.post('/api/accounts/reset-password', accounts.resetPassword,) app.post('/api/accounts/keys', requireAuth(), accounts.createKey,) app.get('/api/accounts/keys', requireAuth(), accounts.listKeys,) app.delete('/api/accounts/keys/:id', requireAuth(), accounts.deleteKey,) diff --git a/src/api/accounts.ts b/src/api/accounts.ts index eab812e..bbe00ed 100644 --- a/src/api/accounts.ts +++ b/src/api/accounts.ts @@ -6,7 +6,7 @@ import { v4 as uuidv4, } from 'uuid' import { db, schema, } from '../db/client.server.js' import { sendEmail, } from '../lib/email.js' import { deleteS3Objects, listS3Objects, uploadToS3, } from '../lib/s3.js' -import { type AuthEnv, clearSessionCookie, setSessionCookie, } from './auth.server.js' +import { type AuthEnv, clearSessionCookie, } from './auth.server.js' /** Base URL for public assets (avatars, etc.) */ const ASSETS_BASE_URL = process.env.ASSETS_BASE_URL ?? 'https://assets.underlay.org' @@ -34,93 +34,18 @@ const RESERVED_SLUGS = new Set([ '500', ],) -// Signup -export async function signup(c: Context,) { - const { email, password, username, displayName, } = await c.req.json() +const SLUG_RE = /^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/ - if (RESERVED_SLUGS.has(username.toLowerCase(),)) { - return c.json({ error: 'That username is reserved', statusCode: 422, }, 422,) +/** Validate a slug and return an error message or null if valid. */ +function validateSlug(slug: string,): string | null { + if (!slug || typeof slug !== 'string') return 'Slug is required' + if (slug.length < 2) return 'Slug must be at least 2 characters' + if (slug.length > 64) return 'Slug must be at most 64 characters' + if (!SLUG_RE.test(slug,)) { + return 'Slug must be lowercase alphanumeric with hyphens, and cannot start or end with a hyphen' } - - 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 -export async function login(c: Context,) { - 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 -export async function logout(c: Context,) { - 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, },) + if (RESERVED_SLUGS.has(slug,)) return 'That slug is reserved' + return null } // Get current user @@ -131,12 +56,10 @@ export async function getMe(c: Context,) { 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, },) @@ -177,6 +100,7 @@ export async function getBySlug(c: Context,) { location: schema.accounts.location, avatarUrl: schema.accounts.avatarUrl, arkNaan: schema.accounts.arkNaan, + kfOrgId: schema.accounts.kfOrgId, createdAt: schema.accounts.createdAt, },) .from(schema.accounts,) @@ -199,90 +123,38 @@ export async function getBySlug(c: Context,) { // Update own profile export async function updateMe(c: Context,) { - const { displayName, bio, website, location, notificationPrefs, } = await c.req.json() + // Name, email, and avatar are managed by KF Auth — only Underlay-specific fields are writable here. + const { slug, bio, website, location, notificationPrefs, } = await c.req.json() + + const accountId = c.get('accountId',)! + + if (slug !== undefined) { + const slugErr = validateSlug(slug,) + if (slugErr) return c.json({ error: slugErr, statusCode: 422, }, 422,) + + const [existing,] = await db + .select({ id: schema.accounts.id, },) + .from(schema.accounts,) + .where(eq(schema.accounts.slug, slug,),) + .limit(1,) + + if (existing && existing.id !== accountId) { + return c.json({ error: 'That slug is already taken', statusCode: 409, }, 409,) + } + } const updates: Record = {} - if (displayName !== undefined) updates.displayName = displayName + if (slug !== undefined) updates.slug = slug 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) -export async function updateEmail(c: Context,) { - 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(updates,).where(eq(schema.accounts.id, accountId,),) } - 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 -export async function updatePassword(c: Context,) { - 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, },) + return c.json({ ok: true, slug: slug ?? undefined, },) } // Upload avatar @@ -358,7 +230,7 @@ export async function deleteSession(c: Context,) { // Delete own account export async function deleteMe(c: Context,) { - const { password, confirmSlug, } = await c.req.json() + const { confirmSlug, } = await c.req.json() const [account,] = await db .select() @@ -366,19 +238,14 @@ export async function deleteMe(c: Context,) { .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 (!account) { + return c.json({ error: 'Account not found', statusCode: 404, }, 404,) } 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(), },) @@ -408,97 +275,6 @@ export async function deleteMe(c: Context,) { return c.json({ ok: true, },) } -// --- Forgot Password --- -export async function forgotPassword(c: Context,) { - 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 --- -export async function resetPassword(c: Context,) { - 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 export async function createKey(c: Context,) { const { label, scope, collectionId, expiresIn, } = await c.req.json() @@ -705,7 +481,14 @@ export async function deleteOrgKey(c: Context,) { // Create organization export async function createOrg(c: Context,) { - const { slug, displayName, } = await c.req.json() + const { slug, displayName, kfOrgId, } = await c.req.json() + + if (!kfOrgId || typeof kfOrgId !== 'string') { + return c.json( + { error: 'kfOrgId is required — every Underlay org must be linked to a KF org', statusCode: 422, }, + 422, + ) + } if (RESERVED_SLUGS.has(slug.toLowerCase(),)) { return c.json({ error: 'That name is reserved', statusCode: 422, }, 422,) @@ -734,6 +517,7 @@ export async function createOrg(c: Context,) { slug, type: 'org', displayName, + kfOrgId, },) // Add the creating user as owner @@ -743,7 +527,19 @@ export async function createOrg(c: Context,) { role: 'owner', },) - return c.json({ id, slug, displayName, type: 'org', }, 201,) + return c.json({ id, slug, displayName, type: 'org', kfOrgId, }, 201,) +} + +/** + * GET /api/accounts/available-kf-orgs + * Returns all KF orgs the current user belongs to. + */ +export async function availableKfOrgs(c: Context,) { + const accountId = c.get('accountId',)! + + // Fetch user's KF orgs on demand from KF Auth internal API + const { fetchKfOrgs, } = await import('../lib/kf-orgs.server.js') + return c.json(await fetchKfOrgs(accountId,),) } // List org members @@ -900,7 +696,7 @@ export async function removeMember(c: Context,) { // Update org profile export async function updateOrg(c: Context,) { const slug = c.req.param('slug',)! - const { displayName, bio, website, location, } = await c.req.json() + const { slug: newSlug, displayName, bio, website, location, kfOrgId, } = await c.req.json() const [org,] = await db .select() @@ -921,17 +717,52 @@ export async function updateOrg(c: Context,) { return c.json({ error: 'Must be an owner to update the organization', statusCode: 403, }, 403,) } + // Validate slug change if provided + if (newSlug !== undefined) { + const slugErr = validateSlug(newSlug,) + if (slugErr) return c.json({ error: slugErr, statusCode: 422, }, 422,) + + const [existing,] = await db + .select({ id: schema.accounts.id, },) + .from(schema.accounts,) + .where(eq(schema.accounts.slug, newSlug,),) + .limit(1,) + + if (existing && existing.id !== org.id) { + return c.json({ error: 'That slug is already taken', statusCode: 409, }, 409,) + } + } + + // Validate kfOrgId change if provided + if (kfOrgId !== undefined) { + if (!kfOrgId || typeof kfOrgId !== 'string') { + return c.json({ error: 'kfOrgId must be a non-empty string', statusCode: 422, }, 422,) + } + // Check it's not already linked to another UL org + const [alreadyLinked,] = await db + .select({ id: schema.accounts.id, },) + .from(schema.accounts,) + .where(and(eq(schema.accounts.kfOrgId, kfOrgId,), eq(schema.accounts.type, 'org',),),) + .limit(1,) + + if (alreadyLinked && alreadyLinked.id !== org.id) { + return c.json({ error: 'This KF organization is already linked to another Underlay org', statusCode: 409, }, 409,) + } + } + const updates: Record = {} + if (newSlug !== undefined) updates.slug = newSlug 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 (kfOrgId !== undefined) updates.kfOrgId = kfOrgId 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, },) + return c.json({ ok: true, slug: newSlug ?? slug, },) } // Upload org avatar @@ -1009,23 +840,18 @@ export async function createInvitation(c: Context,) { 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 + // Check if there's already a pending invitation for this email + const [existingInvite,] = await db .select() - .from(schema.accounts,) - .where(eq(schema.accounts.email, email,),) + .from(schema.orgInvitations,) + .where(and( + eq(schema.orgInvitations.orgId, org.id,), + eq(schema.orgInvitations.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,) - } + if (existingInvite && !existingInvite.acceptedAt) { + return c.json({ error: 'An invitation is already pending for this email', statusCode: 409, }, 409,) } const token = uuidv4() @@ -1139,14 +965,26 @@ export async function acceptInvitation(c: Context,) { 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,) + // Verify the logged-in user's email matches the invitation. + // Email is fetched from KF Auth since we don't store it locally. + const { getKfProfile, } = await import('../lib/kf-profile-cache.server.js') + const accountId = c.get('accountId',)! + + // Fetch email from KF Auth internal API directly (profile cache doesn't include email) + const KF_AUTH_INTERNAL_URL = process.env.KF_AUTH_INTERNAL_URL ?? process.env.KF_AUTH_URL ?? 'http://localhost:3000' + const KF_INTERNAL_API_KEY = process.env.KF_INTERNAL_API_KEY ?? '' + let userEmail: string | null = null + try { + const res = await fetch(`${KF_AUTH_INTERNAL_URL}/api/internal/users/${accountId}`, { + headers: { Authorization: `Bearer ${KF_INTERNAL_API_KEY}`, }, + },) + if (res.ok) { + const data = (await res.json()) as { email: string } + userEmail = data.email + } + } catch {} - if (!account || account.email !== invitation.email) { + if (!userEmail || userEmail !== invitation.email) { return c.json({ error: 'This invitation was sent to a different email address', statusCode: 403, }, 403,) } diff --git a/src/api/auth.server.ts b/src/api/auth.server.ts index 6de4056..14ccaaf 100644 --- a/src/api/auth.server.ts +++ b/src/api/auth.server.ts @@ -16,26 +16,29 @@ export type AuthEnv = { 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 kfInternalApiKey = process.env.KF_INTERNAL_API_KEY ?? '' const sessionSecret = process.env.SESSION_SECRET ?? 'dev-secret-change-me' export const authMiddleware = createMiddleware(async (c, next,) => { - // Internal service calls + // Internal service calls (legacy header) const internalHeader = c.req.header('x-internal-token',) if (internalHeader === internalToken) { c.set('apiKeyScope', 'read',) return next() } - // API key auth via Bearer token + // KF Auth internal API key (used by /api/kf/* endpoints) const auth = c.req.header('authorization',) + if (kfInternalApiKey && auth === `Bearer ${kfInternalApiKey}`) { + c.set('apiKeyScope', 'admin',) + return next() + } + + // API key auth via Bearer token if (auth?.startsWith('Bearer ',)) { const token = auth.slice(7,) const keys = await db.select().from(schema.apiKeys,) diff --git a/src/api/collections.ts b/src/api/collections.ts index b43f6b6..a0710a1 100644 --- a/src/api/collections.ts +++ b/src/api/collections.ts @@ -253,6 +253,7 @@ export async function update(c: Context,) { const slug = c.req.param('slug',)! const updates = await c.req.json<{ name?: string + slug?: string description?: string public?: boolean }>() @@ -277,12 +278,33 @@ export async function update(c: Context,) { return c.json({ error: 'Not found', statusCode: 404, }, 404,) } + // Validate new slug if provided + if (updates.slug !== undefined) { + const newSlug = updates.slug + if (!newSlug || typeof newSlug !== 'string') { + return c.json({ error: 'Slug is required', statusCode: 422, }, 422,) + } + if (!/^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/.test(newSlug,)) { + return c.json({ error: 'Slug must be lowercase alphanumeric with hyphens', statusCode: 422, }, 422,) + } + // Check uniqueness within same account + const [existing,] = await db + .select({ id: schema.collections.id, },) + .from(schema.collections,) + .where(and(eq(schema.collections.accountId, account.id,), eq(schema.collections.slug, newSlug,),),) + .limit(1,) + + if (existing && existing.id !== collection.id) { + return c.json({ error: 'A collection with that slug already exists', statusCode: 409, }, 409,) + } + } + await db .update(schema.collections,) .set({ ...updates, updatedAt: new Date(), },) .where(eq(schema.collections.id, collection.id,),) - return c.json({ ok: true, },) + return c.json({ ok: true, slug: updates.slug ?? slug, },) } // Delete collection @@ -314,6 +336,95 @@ export async function remove(c: Context,) { return c.json({ ok: true, },) } +// Transfer collection to another account +export async function transfer(c: Context,) { + const owner = c.req.param('owner',)! + const slug = c.req.param('slug',)! + const { targetAccountSlug, } = await c.req.json() + + if (!targetAccountSlug || typeof targetAccountSlug !== 'string') { + return c.json({ error: 'targetAccountSlug is required', statusCode: 422, }, 422,) + } + + const callerId = c.get('accountId',)! + + // Find source account + const [sourceAccount,] = await db + .select() + .from(schema.accounts,) + .where(eq(schema.accounts.slug, owner,),) + .limit(1,) + + if (!sourceAccount) return c.json({ error: 'Source account not found', statusCode: 404, }, 404,) + + // Verify caller has access to source account + const callerIsSource = sourceAccount.id === callerId + let callerHasSourceAccess = callerIsSource + if (!callerIsSource && sourceAccount.type === 'org') { + const [membership,] = await db + .select() + .from(schema.orgMemberships,) + .where(and(eq(schema.orgMemberships.orgId, sourceAccount.id,), eq(schema.orgMemberships.userId, callerId,),),) + .limit(1,) + callerHasSourceAccess = !!membership && (membership.role === 'owner' || membership.role === 'admin') + } + if (!callerHasSourceAccess) { + return c.json({ error: 'You must be an owner or admin of the source account', statusCode: 403, }, 403,) + } + + // Find target account + const [targetAccount,] = await db + .select() + .from(schema.accounts,) + .where(eq(schema.accounts.slug, targetAccountSlug,),) + .limit(1,) + + if (!targetAccount) return c.json({ error: 'Target account not found', statusCode: 404, }, 404,) + + // Verify caller has access to target account + const callerIsTarget = targetAccount.id === callerId + let callerHasTargetAccess = callerIsTarget + if (!callerIsTarget && targetAccount.type === 'org') { + const [membership,] = await db + .select() + .from(schema.orgMemberships,) + .where(and(eq(schema.orgMemberships.orgId, targetAccount.id,), eq(schema.orgMemberships.userId, callerId,),),) + .limit(1,) + callerHasTargetAccess = !!membership && (membership.role === 'owner' || membership.role === 'admin') + } + if (!callerHasTargetAccess) { + return c.json({ error: 'You must be an owner or admin of the target account', statusCode: 403, }, 403,) + } + + // Find collection + const [collection,] = await db + .select() + .from(schema.collections,) + .where(and(eq(schema.collections.accountId, sourceAccount.id,), eq(schema.collections.slug, slug,),),) + .limit(1,) + + if (!collection) return c.json({ error: 'Collection not found', statusCode: 404, }, 404,) + + // Check slug uniqueness in target account + const [existing,] = await db + .select({ id: schema.collections.id, },) + .from(schema.collections,) + .where(and(eq(schema.collections.accountId, targetAccount.id,), eq(schema.collections.slug, slug,),),) + .limit(1,) + + if (existing) { + return c.json({ error: `Target account already has a collection with slug "${slug}"`, statusCode: 409, }, 409,) + } + + // Transfer + await db + .update(schema.collections,) + .set({ accountId: targetAccount.id, updatedAt: new Date(), },) + .where(eq(schema.collections.id, collection.id,),) + + return c.json({ ok: true, newOwner: targetAccountSlug, },) +} + // List collections for an account export async function listByOwner(c: Context,) { const owner = c.req.param('owner',)! diff --git a/src/api/kf-auth.ts b/src/api/kf-auth.ts new file mode 100644 index 0000000..493d6ab --- /dev/null +++ b/src/api/kf-auth.ts @@ -0,0 +1,183 @@ +import { eq, } from 'drizzle-orm' +import type { Context, } from 'hono' +import { deleteCookie, getCookie, setCookie, } from 'hono/cookie' +import crypto from 'node:crypto' +import { v4 as uuidv4, } from 'uuid' +import { db, schema, } from '../db/client.server.js' +import { buildAuthorizeUrl, exchangeCode, fetchUserInfo, type KFOrg, } from '../lib/kf-auth.server.js' +import { type AuthEnv, setSessionCookie, } from './auth.server.js' + +const STATE_COOKIE = 'kf_oauth_state' +const RETURN_COOKIE = 'kf_oauth_return' +const VERIFIER_COOKIE = 'kf_oauth_verifier' + +/** + * GET /auth/login — redirect to KF Auth with CSRF state + PKCE. + * Optional ?return_to= query param preserved for post-login redirect. + */ +export async function login(c: Context,) { + const state = crypto.randomBytes(24,).toString('hex',) + const returnTo = c.req.query('return_to',) ?? '/dashboard' + + const cookieOpts = { + httpOnly: true, + secure: process.env.NODE_ENV === 'production', + sameSite: 'Lax' as const, + path: '/', + maxAge: 600, // 10 minutes + } + + setCookie(c, STATE_COOKIE, state, cookieOpts,) + setCookie(c, RETURN_COOKIE, returnTo, cookieOpts,) + + const { url, codeVerifier, } = buildAuthorizeUrl(state,) + setCookie(c, VERIFIER_COOKIE, codeVerifier, cookieOpts,) + + return c.redirect(url,) +} + +/** + * GET /auth/callback — handle the OIDC callback. + * Exchanges the code for tokens, fetches user info, upserts the local + * account, creates a session, and redirects to the return URL. + */ +export async function callback(c: Context,) { + const code = c.req.query('code',) + const state = c.req.query('state',) + const error = c.req.query('error',) + + if (error) { + console.error('KF Auth returned error:', error, c.req.query('error_description',),) + return c.redirect('/login?error=auth_failed',) + } + + if (!code || !state) { + return c.redirect('/login?error=missing_params',) + } + + // Validate CSRF state + const savedState = getCookie(c, STATE_COOKIE,) + const codeVerifier = getCookie(c, VERIFIER_COOKIE,) + deleteCookie(c, STATE_COOKIE, { path: '/', },) + deleteCookie(c, VERIFIER_COOKIE, { path: '/', },) + + if (!savedState || savedState !== state) { + return c.redirect('/login?error=invalid_state',) + } + + if (!codeVerifier) { + return c.redirect('/login?error=missing_verifier',) + } + + // Exchange code for tokens + let accessToken: string + try { + const tokens = await exchangeCode(code, codeVerifier,) + accessToken = tokens.access_token + } catch (err) { + console.error('Token exchange failed:', err,) + return c.redirect('/login?error=token_exchange',) + } + + // Fetch user info + let userInfo: Awaited> + try { + userInfo = await fetchUserInfo(accessToken,) + } catch (err) { + console.error('UserInfo fetch failed:', err,) + return c.redirect('/login?error=userinfo',) + } + + // Find or create local account. + // User account id IS the KF Auth user id (userInfo.sub). + // No profile data stored locally — fetched from KF Auth on demand. + const kfUserId = userInfo.sub + const kfOrgs: KFOrg[] = userInfo['https://knowledgefutures.org/orgs'] ?? [] + const kfPersonalOrg = kfOrgs.find((o,) => o.type === 'personal') + const accountId = kfUserId + + const [existing,] = await db + .select({ id: schema.accounts.id, },) + .from(schema.accounts,) + .where(eq(schema.accounts.id, kfUserId,),) + .limit(1,) + + if (existing) { + // Update personal org link if it changed + if (kfPersonalOrg) { + await db + .update(schema.accounts,) + .set({ kfOrgId: kfPersonalOrg.id, },) + .where(eq(schema.accounts.id, accountId,),) + } + } else { + // Create new account — generate a slug from email or name + const baseSlug = (userInfo.email?.split('@',)[0] ?? userInfo.name ?? 'user') + .toLowerCase() + .replace(/[^a-z0-9-]/g, '-',) + .replace(/-+/g, '-',) + .slice(0, 30,) + + // Ensure slug is unique + let slug = baseSlug + let attempt = 0 + while (true) { + const [conflict,] = await db + .select({ id: schema.accounts.id, },) + .from(schema.accounts,) + .where(eq(schema.accounts.slug, slug,),) + .limit(1,) + + if (!conflict) break + attempt++ + slug = `${baseSlug}-${attempt}` + } + + await db.insert(schema.accounts,).values({ + id: kfUserId, + slug, + type: 'user', + kfOrgId: kfPersonalOrg?.id ?? null, + },) + } + + // Create session + 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: accountId, + expiresAt, + userAgent: c.req.header('user-agent',) ?? null, + ipAddress: c.req.header('x-forwarded-for',) || 'unknown', + },) + + setSessionCookie(c, sessionId,) + + // Redirect to saved return URL + const returnTo = getCookie(c, RETURN_COOKIE,) ?? '/dashboard' + deleteCookie(c, RETURN_COOKIE, { path: '/', },) + + return c.redirect(returnTo,) +} + +/** + * POST /auth/logout — clear local session, return JSON. + * The client is responsible for redirecting to KF Auth's signout endpoint. + */ +export async function logout(c: Context,) { + const sessionCookie = getCookie(c, 'session',) + if (sessionCookie) { + let sessionId = sessionCookie + const dotIdx = sessionCookie.lastIndexOf('.',) + if (dotIdx > 0) sessionId = sessionCookie.slice(0, dotIdx,) + + await db + .delete(schema.sessions,) + .where(eq(schema.sessions.id, sessionId,),) + .catch(() => {},) + } + + deleteCookie(c, 'session', { path: '/', },) + return c.json({ ok: true, },) +} diff --git a/src/api/kf-summary.ts b/src/api/kf-summary.ts new file mode 100644 index 0000000..f917756 --- /dev/null +++ b/src/api/kf-summary.ts @@ -0,0 +1,125 @@ +import { eq, sql, } from 'drizzle-orm' +import type { Context, } from 'hono' +import { db, schema, } from '../db/client.server.js' + +/** + * GET /api/kf/summary?kf_org_id=xxx + * + * Returns Underlay accounts and their collections linked to a KF org. + * For user-type accounts it also includes UL orgs the user belongs to. + * + * Auth: requires KF_INTERNAL_API_KEY (service-to-service). + */ +export async function summary(c: Context,) { + const kfOrgId = c.req.query('kf_org_id',) + if (!kfOrgId) { + return c.json({ error: 'kf_org_id is required', }, 400,) + } + + // Verify internal API key + const authHeader = c.req.header('Authorization',) + const expectedKey = process.env.KF_INTERNAL_API_KEY + if (!expectedKey || authHeader !== `Bearer ${expectedKey}`) { + return c.json({ error: 'Unauthorized', }, 401,) + } + + const APP_URL = process.env.APP_URL ?? 'http://localhost:4100' + + // Find local accounts linked to this KF org via kf_org_id. + const directAccounts = await db + .select({ + id: schema.accounts.id, + slug: schema.accounts.slug, + type: schema.accounts.type, + displayName: schema.accounts.displayName, + },) + .from(schema.accounts,) + .where(eq(schema.accounts.kfOrgId, kfOrgId,),) + + if (directAccounts.length === 0) { + return c.json({ accounts: [], },) + } + + const allAccountIds = directAccounts.map((a,) => a.id) + + // Get collections for all accounts + const collections = await db + .select({ + id: schema.collections.id, + slug: schema.collections.slug, + name: schema.collections.name, + accountId: schema.collections.accountId, + ownerSlug: schema.accounts.slug, + },) + .from(schema.collections,) + .innerJoin(schema.accounts, eq(schema.collections.accountId, schema.accounts.id,),) + .where( + sql`${schema.collections.accountId} IN (${ + sql.join( + allAccountIds.map((id,) => sql`${id}`), + sql`, `, + ) + })`, + ) + + // Get version stats if we have collections + const statsMap = new Map() + + if (collections.length > 0) { + const versionStats = await db + .select({ + collectionId: schema.versions.collectionId, + versionCount: sql`count(*)::int`, + totalRecords: sql`coalesce(sum(${schema.versions.recordCount}), 0)::int`, + totalFiles: sql`coalesce(sum(${schema.versions.fileCount}), 0)::int`, + totalBytes: sql`coalesce(sum(${schema.versions.totalBytes}), 0)::bigint`, + },) + .from(schema.versions,) + .where( + sql`${schema.versions.collectionId} IN (${ + sql.join( + collections.map((c,) => sql`${c.id}`), + sql`, `, + ) + })`, + ) + .groupBy(schema.versions.collectionId,) + + for (const s of versionStats) { + statsMap.set(s.collectionId, { + versions: s.versionCount, + records: s.totalRecords, + files: s.totalFiles, + bytes: s.totalBytes, + },) + } + } + + // Group collections by account + const collectionsByAccount = new Map() + for (const col of collections) { + const list = collectionsByAccount.get(col.accountId,) ?? [] + list.push(col,) + collectionsByAccount.set(col.accountId, list,) + } + + return c.json({ + accounts: directAccounts.map((acct,) => ({ + id: acct.id, + slug: acct.slug, + type: acct.type, + name: acct.displayName ?? acct.slug, + url: `${APP_URL}/${acct.slug}`, + collections: (collectionsByAccount.get(acct.id,) ?? []).map((col,) => { + const stats = statsMap.get(col.id,) + return { + id: col.id, + name: col.name, + slug: col.slug, + url: `${APP_URL}/${col.ownerSlug}/${col.slug}`, + stats: stats ?? { versions: 0, records: 0, files: 0, bytes: 0, }, + } + },), + })), + },) +} diff --git a/src/components/BaseLayout.tsx b/src/components/BaseLayout.tsx index b7ba334..6150311 100644 --- a/src/components/BaseLayout.tsx +++ b/src/components/BaseLayout.tsx @@ -38,11 +38,11 @@ export default function BaseLayout({ children, }: { children: React.ReactNode }, ? ( currentUser ? Admin - : Log in + : Log in ) : currentUser - ? - : Log in} + ? + : Log in} diff --git a/src/components/DocsSearch.tsx b/src/components/DocsSearch.tsx index cf3f312..9fb893b 100644 --- a/src/components/DocsSearch.tsx +++ b/src/components/DocsSearch.tsx @@ -14,8 +14,7 @@ const docs: DocEntry[] = [ title: 'Quickstart', href: '/docs/quickstart', headings: [ - 'Create an account', - 'Create an API key', + 'Sign in and create an API key', 'Create a collection', 'Push a version', 'Read it back', @@ -61,9 +60,6 @@ const docs: DocEntry[] = [ href: '/docs/api/accounts', headings: [ 'Authentication', - 'POST /api/accounts/signup', - 'POST /api/accounts/login', - 'POST /api/accounts/logout', 'GET /api/accounts/me', 'GET /api/accounts/:slug', 'POST /api/accounts/keys', diff --git a/src/components/UserMenu.tsx b/src/components/UserMenu.tsx index 4fceb70..df7bf19 100644 --- a/src/components/UserMenu.tsx +++ b/src/components/UserMenu.tsx @@ -8,10 +8,11 @@ interface Org { interface UserMenuProps { slug: string + displayName?: string | null orgs?: Org[] } -export default function UserMenu({ slug, orgs = [], }: UserMenuProps,) { +export default function UserMenu({ slug, displayName, orgs = [], }: UserMenuProps,) { const [open, setOpen,] = useState(false,) const rootRef = useRef(null,) const hideTimeout = useRef>(undefined,) @@ -47,7 +48,7 @@ export default function UserMenu({ slug, orgs = [], }: UserMenuProps,) { type='button' onClick={() => setOpen((v,) => !v)} > - {slug} ▾ + {displayName || slug} ▾ {open && (
diff --git a/src/db/migrations/0007_kf-auth-integration.sql b/src/db/migrations/0007_kf-auth-integration.sql new file mode 100644 index 0000000..02ef80f --- /dev/null +++ b/src/db/migrations/0007_kf-auth-integration.sql @@ -0,0 +1,47 @@ +CREATE TABLE IF NOT EXISTS "ark_collections" ( + "collection_id" uuid PRIMARY KEY NOT NULL, + "ark_id" text NOT NULL, + "enabled" boolean DEFAULT true NOT NULL, + "custom_url" text, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + CONSTRAINT "ark_collections_ark_id_unique" UNIQUE("ark_id") +); +--> statement-breakpoint +CREATE TABLE IF NOT EXISTS "ark_record_types" ( + "collection_id" uuid NOT NULL, + "record_type" text NOT NULL, + "redirect_url_field" text NOT NULL, + CONSTRAINT "ark_record_types_collection_id_record_type_pk" PRIMARY KEY("collection_id","record_type") +); +--> statement-breakpoint +CREATE TABLE IF NOT EXISTS "ark_shoulders" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "account_id" uuid NOT NULL, + "shoulder" text NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + CONSTRAINT "ark_shoulders_account_id_unique" UNIQUE("account_id"), + CONSTRAINT "ark_shoulders_shoulder_unique" UNIQUE("shoulder") +); +--> statement-breakpoint +ALTER TABLE "accounts" ADD COLUMN IF NOT EXISTS "ark_naan" text;--> statement-breakpoint +ALTER TABLE "accounts" ADD COLUMN IF NOT EXISTS "kf_user_id" text;--> statement-breakpoint +DO $$ BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'ark_collections_collection_id_collections_id_fk') THEN + ALTER TABLE "ark_collections" ADD CONSTRAINT "ark_collections_collection_id_collections_id_fk" FOREIGN KEY ("collection_id") REFERENCES "public"."collections"("id") ON DELETE cascade ON UPDATE no action; + END IF; +END $$;--> statement-breakpoint +DO $$ BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'ark_record_types_collection_id_collections_id_fk') THEN + ALTER TABLE "ark_record_types" ADD CONSTRAINT "ark_record_types_collection_id_collections_id_fk" FOREIGN KEY ("collection_id") REFERENCES "public"."collections"("id") ON DELETE cascade ON UPDATE no action; + END IF; +END $$;--> statement-breakpoint +DO $$ BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'ark_shoulders_account_id_accounts_id_fk') THEN + ALTER TABLE "ark_shoulders" ADD CONSTRAINT "ark_shoulders_account_id_accounts_id_fk" FOREIGN KEY ("account_id") REFERENCES "public"."accounts"("id") ON DELETE cascade ON UPDATE no action; + END IF; +END $$;--> statement-breakpoint +DO $$ BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'accounts_kf_user_id_unique') THEN + ALTER TABLE "accounts" ADD CONSTRAINT "accounts_kf_user_id_unique" UNIQUE("kf_user_id"); + END IF; +END $$; \ No newline at end of file diff --git a/src/db/migrations/0008_sudden_cyclops.sql b/src/db/migrations/0008_sudden_cyclops.sql new file mode 100644 index 0000000..4cfe033 --- /dev/null +++ b/src/db/migrations/0008_sudden_cyclops.sql @@ -0,0 +1,6 @@ +ALTER TABLE "password_reset_tokens" DISABLE ROW LEVEL SECURITY;--> statement-breakpoint +DROP TABLE "password_reset_tokens" CASCADE;--> statement-breakpoint +ALTER TABLE "accounts" ADD COLUMN "kf_org_id" text;--> statement-breakpoint +ALTER TABLE "accounts" DROP COLUMN "password_hash";--> statement-breakpoint +ALTER TABLE "accounts" DROP COLUMN "email_verified";--> statement-breakpoint +ALTER TABLE "accounts" ADD CONSTRAINT "accounts_kf_org_id_unique" UNIQUE("kf_org_id"); \ No newline at end of file diff --git a/src/db/migrations/0009_salty_colossus.sql b/src/db/migrations/0009_salty_colossus.sql new file mode 100644 index 0000000..970401a --- /dev/null +++ b/src/db/migrations/0009_salty_colossus.sql @@ -0,0 +1 @@ +ALTER TABLE "accounts" ADD COLUMN "kf_orgs" jsonb; \ No newline at end of file diff --git a/src/db/migrations/0010_keen_meltdown.sql b/src/db/migrations/0010_keen_meltdown.sql new file mode 100644 index 0000000..91c3a75 --- /dev/null +++ b/src/db/migrations/0010_keen_meltdown.sql @@ -0,0 +1,5 @@ +ALTER TABLE "accounts" DROP CONSTRAINT "accounts_kf_user_id_unique";--> statement-breakpoint +ALTER TABLE "accounts" DROP CONSTRAINT "accounts_kf_org_id_unique";--> statement-breakpoint +ALTER TABLE "accounts" DROP COLUMN "email";--> statement-breakpoint +ALTER TABLE "accounts" DROP COLUMN "kf_user_id";--> statement-breakpoint +ALTER TABLE "accounts" DROP COLUMN "kf_orgs"; \ No newline at end of file diff --git a/src/db/migrations/0011_messy_molten_man.sql b/src/db/migrations/0011_messy_molten_man.sql new file mode 100644 index 0000000..fd40531 --- /dev/null +++ b/src/db/migrations/0011_messy_molten_man.sql @@ -0,0 +1 @@ +ALTER TABLE "accounts" ALTER COLUMN "display_name" DROP NOT NULL; \ No newline at end of file diff --git a/src/db/migrations/meta/0007_snapshot.json b/src/db/migrations/meta/0007_snapshot.json new file mode 100644 index 0000000..59bbd9f --- /dev/null +++ b/src/db/migrations/meta/0007_snapshot.json @@ -0,0 +1,1722 @@ +{ + "id": "1d7b9127-9ab3-4de0-876b-add5e0cd8833", + "prevId": "ea5ffb7d-a1a1-48d4-b1f6-25b8e70ef73c", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.accounts": { + "name": "accounts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "password_hash": { + "name": "password_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "bio": { + "name": "bio", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "website": { + "name": "website", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "location": { + "name": "location", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "avatar_url": { + "name": "avatar_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "email_verified": { + "name": "email_verified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "notification_prefs": { + "name": "notification_prefs", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "ark_naan": { + "name": "ark_naan", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "kf_user_id": { + "name": "kf_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "accounts_slug_unique": { + "name": "accounts_slug_unique", + "nullsNotDistinct": false, + "columns": [ + "slug" + ] + }, + "accounts_kf_user_id_unique": { + "name": "accounts_kf_user_id_unique", + "nullsNotDistinct": false, + "columns": [ + "kf_user_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.api_keys": { + "name": "api_keys", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "account_id": { + "name": "account_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "collection_id": { + "name": "collection_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "key_hash": { + "name": "key_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "key_prefix": { + "name": "key_prefix", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "label": { + "name": "label", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "last_used_at": { + "name": "last_used_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "api_keys_account_id_accounts_id_fk": { + "name": "api_keys_account_id_accounts_id_fk", + "tableFrom": "api_keys", + "tableTo": "accounts", + "columnsFrom": [ + "account_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "api_keys_collection_id_collections_id_fk": { + "name": "api_keys_collection_id_collections_id_fk", + "tableFrom": "api_keys", + "tableTo": "collections", + "columnsFrom": [ + "collection_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.ark_collections": { + "name": "ark_collections", + "schema": "", + "columns": { + "collection_id": { + "name": "collection_id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "ark_id": { + "name": "ark_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "custom_url": { + "name": "custom_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "ark_collections_collection_id_collections_id_fk": { + "name": "ark_collections_collection_id_collections_id_fk", + "tableFrom": "ark_collections", + "tableTo": "collections", + "columnsFrom": [ + "collection_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "ark_collections_ark_id_unique": { + "name": "ark_collections_ark_id_unique", + "nullsNotDistinct": false, + "columns": [ + "ark_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.ark_record_types": { + "name": "ark_record_types", + "schema": "", + "columns": { + "collection_id": { + "name": "collection_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "record_type": { + "name": "record_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "redirect_url_field": { + "name": "redirect_url_field", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "ark_record_types_collection_id_collections_id_fk": { + "name": "ark_record_types_collection_id_collections_id_fk", + "tableFrom": "ark_record_types", + "tableTo": "collections", + "columnsFrom": [ + "collection_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "ark_record_types_collection_id_record_type_pk": { + "name": "ark_record_types_collection_id_record_type_pk", + "columns": [ + "collection_id", + "record_type" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.ark_shoulders": { + "name": "ark_shoulders", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "account_id": { + "name": "account_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "shoulder": { + "name": "shoulder", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "ark_shoulders_account_id_accounts_id_fk": { + "name": "ark_shoulders_account_id_accounts_id_fk", + "tableFrom": "ark_shoulders", + "tableTo": "accounts", + "columnsFrom": [ + "account_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "ark_shoulders_account_id_unique": { + "name": "ark_shoulders_account_id_unique", + "nullsNotDistinct": false, + "columns": [ + "account_id" + ] + }, + "ark_shoulders_shoulder_unique": { + "name": "ark_shoulders_shoulder_unique", + "nullsNotDistinct": false, + "columns": [ + "shoulder" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.collections": { + "name": "collections", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "account_id": { + "name": "account_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "public": { + "name": "public", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "forked_from": { + "name": "forked_from", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "collections_account_id_idx": { + "name": "collections_account_id_idx", + "columns": [ + { + "expression": "account_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "collections_account_id_accounts_id_fk": { + "name": "collections_account_id_accounts_id_fk", + "tableFrom": "collections", + "tableTo": "accounts", + "columnsFrom": [ + "account_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "collections_forked_from_collections_id_fk": { + "name": "collections_forked_from_collections_id_fk", + "tableFrom": "collections", + "tableTo": "collections", + "columnsFrom": [ + "forked_from" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "collections_account_id_slug_unique": { + "name": "collections_account_id_slug_unique", + "nullsNotDistinct": false, + "columns": [ + "account_id", + "slug" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.files": { + "name": "files", + "schema": "", + "columns": { + "hash": { + "name": "hash", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "size": { + "name": "size", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "mime_type": { + "name": "mime_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "storage_key": { + "name": "storage_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.org_invitations": { + "name": "org_invitations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "org_id": { + "name": "org_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "invited_by": { + "name": "invited_by", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "accepted_at": { + "name": "accepted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "org_invitations_org_id_accounts_id_fk": { + "name": "org_invitations_org_id_accounts_id_fk", + "tableFrom": "org_invitations", + "tableTo": "accounts", + "columnsFrom": [ + "org_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "org_invitations_invited_by_accounts_id_fk": { + "name": "org_invitations_invited_by_accounts_id_fk", + "tableFrom": "org_invitations", + "tableTo": "accounts", + "columnsFrom": [ + "invited_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "org_invitations_token_unique": { + "name": "org_invitations_token_unique", + "nullsNotDistinct": false, + "columns": [ + "token" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.org_memberships": { + "name": "org_memberships", + "schema": "", + "columns": { + "org_id": { + "name": "org_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "org_memberships_org_id_accounts_id_fk": { + "name": "org_memberships_org_id_accounts_id_fk", + "tableFrom": "org_memberships", + "tableTo": "accounts", + "columnsFrom": [ + "org_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "org_memberships_user_id_accounts_id_fk": { + "name": "org_memberships_user_id_accounts_id_fk", + "tableFrom": "org_memberships", + "tableTo": "accounts", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "org_memberships_org_id_user_id_pk": { + "name": "org_memberships_org_id_user_id_pk", + "columns": [ + "org_id", + "user_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.password_reset_tokens": { + "name": "password_reset_tokens", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "token_hash": { + "name": "token_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "used_at": { + "name": "used_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "password_reset_tokens_user_id_accounts_id_fk": { + "name": "password_reset_tokens_user_id_accounts_id_fk", + "tableFrom": "password_reset_tokens", + "tableTo": "accounts", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.records": { + "name": "records", + "schema": "", + "columns": { + "version_id": { + "name": "version_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "record_id": { + "name": "record_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "data": { + "name": "data", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "private": { + "name": "private", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + } + }, + "indexes": { + "records_version_id_type_idx": { + "name": "records_version_id_type_idx", + "columns": [ + { + "expression": "version_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "records_version_id_versions_id_fk": { + "name": "records_version_id_versions_id_fk", + "tableFrom": "records", + "tableTo": "versions", + "columnsFrom": [ + "version_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "records_version_id_record_id_pk": { + "name": "records_version_id_record_id_pk", + "columns": [ + "version_id", + "record_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.schema_labels": { + "name": "schema_labels", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "schema_id": { + "name": "schema_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "label": { + "name": "label", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "schema_labels_label_idx": { + "name": "schema_labels_label_idx", + "columns": [ + { + "expression": "label", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "schema_labels_schema_id_schemas_id_fk": { + "name": "schema_labels_schema_id_schemas_id_fk", + "tableFrom": "schema_labels", + "tableTo": "schemas", + "columnsFrom": [ + "schema_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "schema_labels_schema_id_label_unique": { + "name": "schema_labels_schema_id_label_unique", + "nullsNotDistinct": false, + "columns": [ + "schema_id", + "label" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.schemas": { + "name": "schemas", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "schema": { + "name": "schema", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "schema_hash": { + "name": "schema_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "schemas_schema_hash_unique": { + "name": "schemas_schema_hash_unique", + "nullsNotDistinct": false, + "columns": [ + "schema_hash" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sessions": { + "name": "sessions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "sessions_user_id_accounts_id_fk": { + "name": "sessions_user_id_accounts_id_fk", + "tableFrom": "sessions", + "tableTo": "accounts", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sync_runs": { + "name": "sync_runs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "trigger": { + "name": "trigger", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "started_at": { + "name": "started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "finished_at": { + "name": "finished_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "collections_synced": { + "name": "collections_synced", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "collections_created": { + "name": "collections_created", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "collections_failed": { + "name": "collections_failed", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "versions_pulled": { + "name": "versions_pulled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "files_downloaded": { + "name": "files_downloaded", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "files_skipped": { + "name": "files_skipped", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "errors": { + "name": "errors", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "logs": { + "name": "logs", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.upload_records": { + "name": "upload_records", + "schema": "", + "columns": { + "session_id": { + "name": "session_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "record_id": { + "name": "record_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "data": { + "name": "data", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "private": { + "name": "private", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "operation": { + "name": "operation", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "upload_records_session_id_upload_sessions_id_fk": { + "name": "upload_records_session_id_upload_sessions_id_fk", + "tableFrom": "upload_records", + "tableTo": "upload_sessions", + "columnsFrom": [ + "session_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "upload_records_session_id_record_id_pk": { + "name": "upload_records_session_id_record_id_pk", + "columns": [ + "session_id", + "record_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.upload_sessions": { + "name": "upload_sessions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "collection_id": { + "name": "collection_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "account_id": { + "name": "account_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "base_version": { + "name": "base_version", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "message": { + "name": "message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "readme": { + "name": "readme", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "app_id": { + "name": "app_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "actor_id": { + "name": "actor_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "schemas": { + "name": "schemas", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'open'" + }, + "record_count": { + "name": "record_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "upload_sessions_collection_id_collections_id_fk": { + "name": "upload_sessions_collection_id_collections_id_fk", + "tableFrom": "upload_sessions", + "tableTo": "collections", + "columnsFrom": [ + "collection_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "upload_sessions_account_id_accounts_id_fk": { + "name": "upload_sessions_account_id_accounts_id_fk", + "tableFrom": "upload_sessions", + "tableTo": "accounts", + "columnsFrom": [ + "account_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.version_files": { + "name": "version_files", + "schema": "", + "columns": { + "version_id": { + "name": "version_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "file_hash": { + "name": "file_hash", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "version_files_file_hash_idx": { + "name": "version_files_file_hash_idx", + "columns": [ + { + "expression": "file_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "version_files_version_id_versions_id_fk": { + "name": "version_files_version_id_versions_id_fk", + "tableFrom": "version_files", + "tableTo": "versions", + "columnsFrom": [ + "version_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "version_files_file_hash_files_hash_fk": { + "name": "version_files_file_hash_files_hash_fk", + "tableFrom": "version_files", + "tableTo": "files", + "columnsFrom": [ + "file_hash" + ], + "columnsTo": [ + "hash" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "version_files_version_id_file_hash_pk": { + "name": "version_files_version_id_file_hash_pk", + "columns": [ + "version_id", + "file_hash" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.version_schemas": { + "name": "version_schemas", + "schema": "", + "columns": { + "version_id": { + "name": "version_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "schema_id": { + "name": "schema_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "version_schemas_schema_id_idx": { + "name": "version_schemas_schema_id_idx", + "columns": [ + { + "expression": "schema_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "version_schemas_version_id_versions_id_fk": { + "name": "version_schemas_version_id_versions_id_fk", + "tableFrom": "version_schemas", + "tableTo": "versions", + "columnsFrom": [ + "version_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "version_schemas_schema_id_schemas_id_fk": { + "name": "version_schemas_schema_id_schemas_id_fk", + "tableFrom": "version_schemas", + "tableTo": "schemas", + "columnsFrom": [ + "schema_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "version_schemas_version_id_slug_pk": { + "name": "version_schemas_version_id_slug_pk", + "columns": [ + "version_id", + "slug" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.versions": { + "name": "versions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigserial", + "primaryKey": true, + "notNull": true + }, + "collection_id": { + "name": "collection_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "number": { + "name": "number", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "semver": { + "name": "semver", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "hash": { + "name": "hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "public_hash": { + "name": "public_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "base_number": { + "name": "base_number", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "message": { + "name": "message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "readme": { + "name": "readme", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "pushed_by": { + "name": "pushed_by", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "app_id": { + "name": "app_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "actor_id": { + "name": "actor_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "signature": { + "name": "signature", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "record_count": { + "name": "record_count", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "file_count": { + "name": "file_count", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "total_bytes": { + "name": "total_bytes", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "versions_collection_id_collections_id_fk": { + "name": "versions_collection_id_collections_id_fk", + "tableFrom": "versions", + "tableTo": "collections", + "columnsFrom": [ + "collection_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "versions_pushed_by_accounts_id_fk": { + "name": "versions_pushed_by_accounts_id_fk", + "tableFrom": "versions", + "tableTo": "accounts", + "columnsFrom": [ + "pushed_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "versions_collection_id_number_unique": { + "name": "versions_collection_id_number_unique", + "nullsNotDistinct": false, + "columns": [ + "collection_id", + "number" + ] + }, + "versions_collection_id_hash_unique": { + "name": "versions_collection_id_hash_unique", + "nullsNotDistinct": false, + "columns": [ + "collection_id", + "hash" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} diff --git a/src/db/migrations/meta/0008_snapshot.json b/src/db/migrations/meta/0008_snapshot.json new file mode 100644 index 0000000..fe59a73 --- /dev/null +++ b/src/db/migrations/meta/0008_snapshot.json @@ -0,0 +1,1657 @@ +{ + "id": "33f1ef8c-7579-47cd-89cc-9048f6a29871", + "prevId": "1d7b9127-9ab3-4de0-876b-add5e0cd8833", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.accounts": { + "name": "accounts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "bio": { + "name": "bio", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "website": { + "name": "website", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "location": { + "name": "location", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "avatar_url": { + "name": "avatar_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "notification_prefs": { + "name": "notification_prefs", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "ark_naan": { + "name": "ark_naan", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "kf_user_id": { + "name": "kf_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "kf_org_id": { + "name": "kf_org_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "accounts_slug_unique": { + "name": "accounts_slug_unique", + "nullsNotDistinct": false, + "columns": [ + "slug" + ] + }, + "accounts_kf_user_id_unique": { + "name": "accounts_kf_user_id_unique", + "nullsNotDistinct": false, + "columns": [ + "kf_user_id" + ] + }, + "accounts_kf_org_id_unique": { + "name": "accounts_kf_org_id_unique", + "nullsNotDistinct": false, + "columns": [ + "kf_org_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.api_keys": { + "name": "api_keys", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "account_id": { + "name": "account_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "collection_id": { + "name": "collection_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "key_hash": { + "name": "key_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "key_prefix": { + "name": "key_prefix", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "label": { + "name": "label", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "last_used_at": { + "name": "last_used_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "api_keys_account_id_accounts_id_fk": { + "name": "api_keys_account_id_accounts_id_fk", + "tableFrom": "api_keys", + "tableTo": "accounts", + "columnsFrom": [ + "account_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "api_keys_collection_id_collections_id_fk": { + "name": "api_keys_collection_id_collections_id_fk", + "tableFrom": "api_keys", + "tableTo": "collections", + "columnsFrom": [ + "collection_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.ark_collections": { + "name": "ark_collections", + "schema": "", + "columns": { + "collection_id": { + "name": "collection_id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "ark_id": { + "name": "ark_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "custom_url": { + "name": "custom_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "ark_collections_collection_id_collections_id_fk": { + "name": "ark_collections_collection_id_collections_id_fk", + "tableFrom": "ark_collections", + "tableTo": "collections", + "columnsFrom": [ + "collection_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "ark_collections_ark_id_unique": { + "name": "ark_collections_ark_id_unique", + "nullsNotDistinct": false, + "columns": [ + "ark_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.ark_record_types": { + "name": "ark_record_types", + "schema": "", + "columns": { + "collection_id": { + "name": "collection_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "record_type": { + "name": "record_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "redirect_url_field": { + "name": "redirect_url_field", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "ark_record_types_collection_id_collections_id_fk": { + "name": "ark_record_types_collection_id_collections_id_fk", + "tableFrom": "ark_record_types", + "tableTo": "collections", + "columnsFrom": [ + "collection_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "ark_record_types_collection_id_record_type_pk": { + "name": "ark_record_types_collection_id_record_type_pk", + "columns": [ + "collection_id", + "record_type" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.ark_shoulders": { + "name": "ark_shoulders", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "account_id": { + "name": "account_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "shoulder": { + "name": "shoulder", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "ark_shoulders_account_id_accounts_id_fk": { + "name": "ark_shoulders_account_id_accounts_id_fk", + "tableFrom": "ark_shoulders", + "tableTo": "accounts", + "columnsFrom": [ + "account_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "ark_shoulders_account_id_unique": { + "name": "ark_shoulders_account_id_unique", + "nullsNotDistinct": false, + "columns": [ + "account_id" + ] + }, + "ark_shoulders_shoulder_unique": { + "name": "ark_shoulders_shoulder_unique", + "nullsNotDistinct": false, + "columns": [ + "shoulder" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.collections": { + "name": "collections", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "account_id": { + "name": "account_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "public": { + "name": "public", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "forked_from": { + "name": "forked_from", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "collections_account_id_idx": { + "name": "collections_account_id_idx", + "columns": [ + { + "expression": "account_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "collections_account_id_accounts_id_fk": { + "name": "collections_account_id_accounts_id_fk", + "tableFrom": "collections", + "tableTo": "accounts", + "columnsFrom": [ + "account_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "collections_forked_from_collections_id_fk": { + "name": "collections_forked_from_collections_id_fk", + "tableFrom": "collections", + "tableTo": "collections", + "columnsFrom": [ + "forked_from" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "collections_account_id_slug_unique": { + "name": "collections_account_id_slug_unique", + "nullsNotDistinct": false, + "columns": [ + "account_id", + "slug" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.files": { + "name": "files", + "schema": "", + "columns": { + "hash": { + "name": "hash", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "size": { + "name": "size", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "mime_type": { + "name": "mime_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "storage_key": { + "name": "storage_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.org_invitations": { + "name": "org_invitations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "org_id": { + "name": "org_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "invited_by": { + "name": "invited_by", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "accepted_at": { + "name": "accepted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "org_invitations_org_id_accounts_id_fk": { + "name": "org_invitations_org_id_accounts_id_fk", + "tableFrom": "org_invitations", + "tableTo": "accounts", + "columnsFrom": [ + "org_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "org_invitations_invited_by_accounts_id_fk": { + "name": "org_invitations_invited_by_accounts_id_fk", + "tableFrom": "org_invitations", + "tableTo": "accounts", + "columnsFrom": [ + "invited_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "org_invitations_token_unique": { + "name": "org_invitations_token_unique", + "nullsNotDistinct": false, + "columns": [ + "token" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.org_memberships": { + "name": "org_memberships", + "schema": "", + "columns": { + "org_id": { + "name": "org_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "org_memberships_org_id_accounts_id_fk": { + "name": "org_memberships_org_id_accounts_id_fk", + "tableFrom": "org_memberships", + "tableTo": "accounts", + "columnsFrom": [ + "org_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "org_memberships_user_id_accounts_id_fk": { + "name": "org_memberships_user_id_accounts_id_fk", + "tableFrom": "org_memberships", + "tableTo": "accounts", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "org_memberships_org_id_user_id_pk": { + "name": "org_memberships_org_id_user_id_pk", + "columns": [ + "org_id", + "user_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.records": { + "name": "records", + "schema": "", + "columns": { + "version_id": { + "name": "version_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "record_id": { + "name": "record_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "data": { + "name": "data", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "private": { + "name": "private", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + } + }, + "indexes": { + "records_version_id_type_idx": { + "name": "records_version_id_type_idx", + "columns": [ + { + "expression": "version_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "records_version_id_versions_id_fk": { + "name": "records_version_id_versions_id_fk", + "tableFrom": "records", + "tableTo": "versions", + "columnsFrom": [ + "version_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "records_version_id_record_id_pk": { + "name": "records_version_id_record_id_pk", + "columns": [ + "version_id", + "record_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.schema_labels": { + "name": "schema_labels", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "schema_id": { + "name": "schema_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "label": { + "name": "label", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "schema_labels_label_idx": { + "name": "schema_labels_label_idx", + "columns": [ + { + "expression": "label", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "schema_labels_schema_id_schemas_id_fk": { + "name": "schema_labels_schema_id_schemas_id_fk", + "tableFrom": "schema_labels", + "tableTo": "schemas", + "columnsFrom": [ + "schema_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "schema_labels_schema_id_label_unique": { + "name": "schema_labels_schema_id_label_unique", + "nullsNotDistinct": false, + "columns": [ + "schema_id", + "label" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.schemas": { + "name": "schemas", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "schema": { + "name": "schema", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "schema_hash": { + "name": "schema_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "schemas_schema_hash_unique": { + "name": "schemas_schema_hash_unique", + "nullsNotDistinct": false, + "columns": [ + "schema_hash" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sessions": { + "name": "sessions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "sessions_user_id_accounts_id_fk": { + "name": "sessions_user_id_accounts_id_fk", + "tableFrom": "sessions", + "tableTo": "accounts", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sync_runs": { + "name": "sync_runs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "trigger": { + "name": "trigger", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "started_at": { + "name": "started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "finished_at": { + "name": "finished_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "collections_synced": { + "name": "collections_synced", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "collections_created": { + "name": "collections_created", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "collections_failed": { + "name": "collections_failed", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "versions_pulled": { + "name": "versions_pulled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "files_downloaded": { + "name": "files_downloaded", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "files_skipped": { + "name": "files_skipped", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "errors": { + "name": "errors", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "logs": { + "name": "logs", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.upload_records": { + "name": "upload_records", + "schema": "", + "columns": { + "session_id": { + "name": "session_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "record_id": { + "name": "record_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "data": { + "name": "data", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "private": { + "name": "private", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "operation": { + "name": "operation", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "upload_records_session_id_upload_sessions_id_fk": { + "name": "upload_records_session_id_upload_sessions_id_fk", + "tableFrom": "upload_records", + "tableTo": "upload_sessions", + "columnsFrom": [ + "session_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "upload_records_session_id_record_id_pk": { + "name": "upload_records_session_id_record_id_pk", + "columns": [ + "session_id", + "record_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.upload_sessions": { + "name": "upload_sessions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "collection_id": { + "name": "collection_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "account_id": { + "name": "account_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "base_version": { + "name": "base_version", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "message": { + "name": "message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "readme": { + "name": "readme", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "app_id": { + "name": "app_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "actor_id": { + "name": "actor_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "schemas": { + "name": "schemas", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'open'" + }, + "record_count": { + "name": "record_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "upload_sessions_collection_id_collections_id_fk": { + "name": "upload_sessions_collection_id_collections_id_fk", + "tableFrom": "upload_sessions", + "tableTo": "collections", + "columnsFrom": [ + "collection_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "upload_sessions_account_id_accounts_id_fk": { + "name": "upload_sessions_account_id_accounts_id_fk", + "tableFrom": "upload_sessions", + "tableTo": "accounts", + "columnsFrom": [ + "account_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.version_files": { + "name": "version_files", + "schema": "", + "columns": { + "version_id": { + "name": "version_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "file_hash": { + "name": "file_hash", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "version_files_file_hash_idx": { + "name": "version_files_file_hash_idx", + "columns": [ + { + "expression": "file_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "version_files_version_id_versions_id_fk": { + "name": "version_files_version_id_versions_id_fk", + "tableFrom": "version_files", + "tableTo": "versions", + "columnsFrom": [ + "version_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "version_files_file_hash_files_hash_fk": { + "name": "version_files_file_hash_files_hash_fk", + "tableFrom": "version_files", + "tableTo": "files", + "columnsFrom": [ + "file_hash" + ], + "columnsTo": [ + "hash" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "version_files_version_id_file_hash_pk": { + "name": "version_files_version_id_file_hash_pk", + "columns": [ + "version_id", + "file_hash" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.version_schemas": { + "name": "version_schemas", + "schema": "", + "columns": { + "version_id": { + "name": "version_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "schema_id": { + "name": "schema_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "version_schemas_schema_id_idx": { + "name": "version_schemas_schema_id_idx", + "columns": [ + { + "expression": "schema_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "version_schemas_version_id_versions_id_fk": { + "name": "version_schemas_version_id_versions_id_fk", + "tableFrom": "version_schemas", + "tableTo": "versions", + "columnsFrom": [ + "version_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "version_schemas_schema_id_schemas_id_fk": { + "name": "version_schemas_schema_id_schemas_id_fk", + "tableFrom": "version_schemas", + "tableTo": "schemas", + "columnsFrom": [ + "schema_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "version_schemas_version_id_slug_pk": { + "name": "version_schemas_version_id_slug_pk", + "columns": [ + "version_id", + "slug" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.versions": { + "name": "versions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigserial", + "primaryKey": true, + "notNull": true + }, + "collection_id": { + "name": "collection_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "number": { + "name": "number", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "semver": { + "name": "semver", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "hash": { + "name": "hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "public_hash": { + "name": "public_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "base_number": { + "name": "base_number", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "message": { + "name": "message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "readme": { + "name": "readme", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "pushed_by": { + "name": "pushed_by", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "app_id": { + "name": "app_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "actor_id": { + "name": "actor_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "signature": { + "name": "signature", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "record_count": { + "name": "record_count", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "file_count": { + "name": "file_count", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "total_bytes": { + "name": "total_bytes", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "versions_collection_id_collections_id_fk": { + "name": "versions_collection_id_collections_id_fk", + "tableFrom": "versions", + "tableTo": "collections", + "columnsFrom": [ + "collection_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "versions_pushed_by_accounts_id_fk": { + "name": "versions_pushed_by_accounts_id_fk", + "tableFrom": "versions", + "tableTo": "accounts", + "columnsFrom": [ + "pushed_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "versions_collection_id_number_unique": { + "name": "versions_collection_id_number_unique", + "nullsNotDistinct": false, + "columns": [ + "collection_id", + "number" + ] + }, + "versions_collection_id_hash_unique": { + "name": "versions_collection_id_hash_unique", + "nullsNotDistinct": false, + "columns": [ + "collection_id", + "hash" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} diff --git a/src/db/migrations/meta/0009_snapshot.json b/src/db/migrations/meta/0009_snapshot.json new file mode 100644 index 0000000..ec59e47 --- /dev/null +++ b/src/db/migrations/meta/0009_snapshot.json @@ -0,0 +1,1663 @@ +{ + "id": "58f002f7-8fdd-4c06-8cb5-25bf3c2a62e2", + "prevId": "33f1ef8c-7579-47cd-89cc-9048f6a29871", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.accounts": { + "name": "accounts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "bio": { + "name": "bio", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "website": { + "name": "website", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "location": { + "name": "location", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "avatar_url": { + "name": "avatar_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "notification_prefs": { + "name": "notification_prefs", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "ark_naan": { + "name": "ark_naan", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "kf_user_id": { + "name": "kf_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "kf_org_id": { + "name": "kf_org_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "kf_orgs": { + "name": "kf_orgs", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "accounts_slug_unique": { + "name": "accounts_slug_unique", + "nullsNotDistinct": false, + "columns": [ + "slug" + ] + }, + "accounts_kf_user_id_unique": { + "name": "accounts_kf_user_id_unique", + "nullsNotDistinct": false, + "columns": [ + "kf_user_id" + ] + }, + "accounts_kf_org_id_unique": { + "name": "accounts_kf_org_id_unique", + "nullsNotDistinct": false, + "columns": [ + "kf_org_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.api_keys": { + "name": "api_keys", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "account_id": { + "name": "account_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "collection_id": { + "name": "collection_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "key_hash": { + "name": "key_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "key_prefix": { + "name": "key_prefix", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "label": { + "name": "label", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "last_used_at": { + "name": "last_used_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "api_keys_account_id_accounts_id_fk": { + "name": "api_keys_account_id_accounts_id_fk", + "tableFrom": "api_keys", + "tableTo": "accounts", + "columnsFrom": [ + "account_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "api_keys_collection_id_collections_id_fk": { + "name": "api_keys_collection_id_collections_id_fk", + "tableFrom": "api_keys", + "tableTo": "collections", + "columnsFrom": [ + "collection_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.ark_collections": { + "name": "ark_collections", + "schema": "", + "columns": { + "collection_id": { + "name": "collection_id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "ark_id": { + "name": "ark_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "custom_url": { + "name": "custom_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "ark_collections_collection_id_collections_id_fk": { + "name": "ark_collections_collection_id_collections_id_fk", + "tableFrom": "ark_collections", + "tableTo": "collections", + "columnsFrom": [ + "collection_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "ark_collections_ark_id_unique": { + "name": "ark_collections_ark_id_unique", + "nullsNotDistinct": false, + "columns": [ + "ark_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.ark_record_types": { + "name": "ark_record_types", + "schema": "", + "columns": { + "collection_id": { + "name": "collection_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "record_type": { + "name": "record_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "redirect_url_field": { + "name": "redirect_url_field", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "ark_record_types_collection_id_collections_id_fk": { + "name": "ark_record_types_collection_id_collections_id_fk", + "tableFrom": "ark_record_types", + "tableTo": "collections", + "columnsFrom": [ + "collection_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "ark_record_types_collection_id_record_type_pk": { + "name": "ark_record_types_collection_id_record_type_pk", + "columns": [ + "collection_id", + "record_type" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.ark_shoulders": { + "name": "ark_shoulders", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "account_id": { + "name": "account_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "shoulder": { + "name": "shoulder", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "ark_shoulders_account_id_accounts_id_fk": { + "name": "ark_shoulders_account_id_accounts_id_fk", + "tableFrom": "ark_shoulders", + "tableTo": "accounts", + "columnsFrom": [ + "account_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "ark_shoulders_account_id_unique": { + "name": "ark_shoulders_account_id_unique", + "nullsNotDistinct": false, + "columns": [ + "account_id" + ] + }, + "ark_shoulders_shoulder_unique": { + "name": "ark_shoulders_shoulder_unique", + "nullsNotDistinct": false, + "columns": [ + "shoulder" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.collections": { + "name": "collections", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "account_id": { + "name": "account_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "public": { + "name": "public", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "forked_from": { + "name": "forked_from", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "collections_account_id_idx": { + "name": "collections_account_id_idx", + "columns": [ + { + "expression": "account_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "collections_account_id_accounts_id_fk": { + "name": "collections_account_id_accounts_id_fk", + "tableFrom": "collections", + "tableTo": "accounts", + "columnsFrom": [ + "account_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "collections_forked_from_collections_id_fk": { + "name": "collections_forked_from_collections_id_fk", + "tableFrom": "collections", + "tableTo": "collections", + "columnsFrom": [ + "forked_from" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "collections_account_id_slug_unique": { + "name": "collections_account_id_slug_unique", + "nullsNotDistinct": false, + "columns": [ + "account_id", + "slug" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.files": { + "name": "files", + "schema": "", + "columns": { + "hash": { + "name": "hash", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "size": { + "name": "size", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "mime_type": { + "name": "mime_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "storage_key": { + "name": "storage_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.org_invitations": { + "name": "org_invitations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "org_id": { + "name": "org_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "invited_by": { + "name": "invited_by", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "accepted_at": { + "name": "accepted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "org_invitations_org_id_accounts_id_fk": { + "name": "org_invitations_org_id_accounts_id_fk", + "tableFrom": "org_invitations", + "tableTo": "accounts", + "columnsFrom": [ + "org_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "org_invitations_invited_by_accounts_id_fk": { + "name": "org_invitations_invited_by_accounts_id_fk", + "tableFrom": "org_invitations", + "tableTo": "accounts", + "columnsFrom": [ + "invited_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "org_invitations_token_unique": { + "name": "org_invitations_token_unique", + "nullsNotDistinct": false, + "columns": [ + "token" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.org_memberships": { + "name": "org_memberships", + "schema": "", + "columns": { + "org_id": { + "name": "org_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "org_memberships_org_id_accounts_id_fk": { + "name": "org_memberships_org_id_accounts_id_fk", + "tableFrom": "org_memberships", + "tableTo": "accounts", + "columnsFrom": [ + "org_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "org_memberships_user_id_accounts_id_fk": { + "name": "org_memberships_user_id_accounts_id_fk", + "tableFrom": "org_memberships", + "tableTo": "accounts", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "org_memberships_org_id_user_id_pk": { + "name": "org_memberships_org_id_user_id_pk", + "columns": [ + "org_id", + "user_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.records": { + "name": "records", + "schema": "", + "columns": { + "version_id": { + "name": "version_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "record_id": { + "name": "record_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "data": { + "name": "data", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "private": { + "name": "private", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + } + }, + "indexes": { + "records_version_id_type_idx": { + "name": "records_version_id_type_idx", + "columns": [ + { + "expression": "version_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "records_version_id_versions_id_fk": { + "name": "records_version_id_versions_id_fk", + "tableFrom": "records", + "tableTo": "versions", + "columnsFrom": [ + "version_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "records_version_id_record_id_pk": { + "name": "records_version_id_record_id_pk", + "columns": [ + "version_id", + "record_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.schema_labels": { + "name": "schema_labels", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "schema_id": { + "name": "schema_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "label": { + "name": "label", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "schema_labels_label_idx": { + "name": "schema_labels_label_idx", + "columns": [ + { + "expression": "label", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "schema_labels_schema_id_schemas_id_fk": { + "name": "schema_labels_schema_id_schemas_id_fk", + "tableFrom": "schema_labels", + "tableTo": "schemas", + "columnsFrom": [ + "schema_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "schema_labels_schema_id_label_unique": { + "name": "schema_labels_schema_id_label_unique", + "nullsNotDistinct": false, + "columns": [ + "schema_id", + "label" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.schemas": { + "name": "schemas", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "schema": { + "name": "schema", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "schema_hash": { + "name": "schema_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "schemas_schema_hash_unique": { + "name": "schemas_schema_hash_unique", + "nullsNotDistinct": false, + "columns": [ + "schema_hash" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sessions": { + "name": "sessions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "sessions_user_id_accounts_id_fk": { + "name": "sessions_user_id_accounts_id_fk", + "tableFrom": "sessions", + "tableTo": "accounts", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sync_runs": { + "name": "sync_runs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "trigger": { + "name": "trigger", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "started_at": { + "name": "started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "finished_at": { + "name": "finished_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "collections_synced": { + "name": "collections_synced", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "collections_created": { + "name": "collections_created", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "collections_failed": { + "name": "collections_failed", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "versions_pulled": { + "name": "versions_pulled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "files_downloaded": { + "name": "files_downloaded", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "files_skipped": { + "name": "files_skipped", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "errors": { + "name": "errors", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "logs": { + "name": "logs", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.upload_records": { + "name": "upload_records", + "schema": "", + "columns": { + "session_id": { + "name": "session_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "record_id": { + "name": "record_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "data": { + "name": "data", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "private": { + "name": "private", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "operation": { + "name": "operation", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "upload_records_session_id_upload_sessions_id_fk": { + "name": "upload_records_session_id_upload_sessions_id_fk", + "tableFrom": "upload_records", + "tableTo": "upload_sessions", + "columnsFrom": [ + "session_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "upload_records_session_id_record_id_pk": { + "name": "upload_records_session_id_record_id_pk", + "columns": [ + "session_id", + "record_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.upload_sessions": { + "name": "upload_sessions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "collection_id": { + "name": "collection_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "account_id": { + "name": "account_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "base_version": { + "name": "base_version", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "message": { + "name": "message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "readme": { + "name": "readme", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "app_id": { + "name": "app_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "actor_id": { + "name": "actor_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "schemas": { + "name": "schemas", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'open'" + }, + "record_count": { + "name": "record_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "upload_sessions_collection_id_collections_id_fk": { + "name": "upload_sessions_collection_id_collections_id_fk", + "tableFrom": "upload_sessions", + "tableTo": "collections", + "columnsFrom": [ + "collection_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "upload_sessions_account_id_accounts_id_fk": { + "name": "upload_sessions_account_id_accounts_id_fk", + "tableFrom": "upload_sessions", + "tableTo": "accounts", + "columnsFrom": [ + "account_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.version_files": { + "name": "version_files", + "schema": "", + "columns": { + "version_id": { + "name": "version_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "file_hash": { + "name": "file_hash", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "version_files_file_hash_idx": { + "name": "version_files_file_hash_idx", + "columns": [ + { + "expression": "file_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "version_files_version_id_versions_id_fk": { + "name": "version_files_version_id_versions_id_fk", + "tableFrom": "version_files", + "tableTo": "versions", + "columnsFrom": [ + "version_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "version_files_file_hash_files_hash_fk": { + "name": "version_files_file_hash_files_hash_fk", + "tableFrom": "version_files", + "tableTo": "files", + "columnsFrom": [ + "file_hash" + ], + "columnsTo": [ + "hash" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "version_files_version_id_file_hash_pk": { + "name": "version_files_version_id_file_hash_pk", + "columns": [ + "version_id", + "file_hash" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.version_schemas": { + "name": "version_schemas", + "schema": "", + "columns": { + "version_id": { + "name": "version_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "schema_id": { + "name": "schema_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "version_schemas_schema_id_idx": { + "name": "version_schemas_schema_id_idx", + "columns": [ + { + "expression": "schema_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "version_schemas_version_id_versions_id_fk": { + "name": "version_schemas_version_id_versions_id_fk", + "tableFrom": "version_schemas", + "tableTo": "versions", + "columnsFrom": [ + "version_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "version_schemas_schema_id_schemas_id_fk": { + "name": "version_schemas_schema_id_schemas_id_fk", + "tableFrom": "version_schemas", + "tableTo": "schemas", + "columnsFrom": [ + "schema_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "version_schemas_version_id_slug_pk": { + "name": "version_schemas_version_id_slug_pk", + "columns": [ + "version_id", + "slug" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.versions": { + "name": "versions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigserial", + "primaryKey": true, + "notNull": true + }, + "collection_id": { + "name": "collection_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "number": { + "name": "number", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "semver": { + "name": "semver", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "hash": { + "name": "hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "public_hash": { + "name": "public_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "base_number": { + "name": "base_number", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "message": { + "name": "message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "readme": { + "name": "readme", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "pushed_by": { + "name": "pushed_by", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "app_id": { + "name": "app_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "actor_id": { + "name": "actor_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "signature": { + "name": "signature", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "record_count": { + "name": "record_count", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "file_count": { + "name": "file_count", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "total_bytes": { + "name": "total_bytes", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "versions_collection_id_collections_id_fk": { + "name": "versions_collection_id_collections_id_fk", + "tableFrom": "versions", + "tableTo": "collections", + "columnsFrom": [ + "collection_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "versions_pushed_by_accounts_id_fk": { + "name": "versions_pushed_by_accounts_id_fk", + "tableFrom": "versions", + "tableTo": "accounts", + "columnsFrom": [ + "pushed_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "versions_collection_id_number_unique": { + "name": "versions_collection_id_number_unique", + "nullsNotDistinct": false, + "columns": [ + "collection_id", + "number" + ] + }, + "versions_collection_id_hash_unique": { + "name": "versions_collection_id_hash_unique", + "nullsNotDistinct": false, + "columns": [ + "collection_id", + "hash" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} diff --git a/src/db/migrations/meta/0010_snapshot.json b/src/db/migrations/meta/0010_snapshot.json new file mode 100644 index 0000000..3fccb43 --- /dev/null +++ b/src/db/migrations/meta/0010_snapshot.json @@ -0,0 +1,1631 @@ +{ + "id": "30fdfcfb-99e9-48b7-841b-0a91525bc555", + "prevId": "58f002f7-8fdd-4c06-8cb5-25bf3c2a62e2", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.accounts": { + "name": "accounts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "bio": { + "name": "bio", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "website": { + "name": "website", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "location": { + "name": "location", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "avatar_url": { + "name": "avatar_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "notification_prefs": { + "name": "notification_prefs", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "ark_naan": { + "name": "ark_naan", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "kf_org_id": { + "name": "kf_org_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "accounts_slug_unique": { + "name": "accounts_slug_unique", + "nullsNotDistinct": false, + "columns": [ + "slug" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.api_keys": { + "name": "api_keys", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "account_id": { + "name": "account_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "collection_id": { + "name": "collection_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "key_hash": { + "name": "key_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "key_prefix": { + "name": "key_prefix", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "label": { + "name": "label", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "last_used_at": { + "name": "last_used_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "api_keys_account_id_accounts_id_fk": { + "name": "api_keys_account_id_accounts_id_fk", + "tableFrom": "api_keys", + "tableTo": "accounts", + "columnsFrom": [ + "account_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "api_keys_collection_id_collections_id_fk": { + "name": "api_keys_collection_id_collections_id_fk", + "tableFrom": "api_keys", + "tableTo": "collections", + "columnsFrom": [ + "collection_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.ark_collections": { + "name": "ark_collections", + "schema": "", + "columns": { + "collection_id": { + "name": "collection_id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "ark_id": { + "name": "ark_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "custom_url": { + "name": "custom_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "ark_collections_collection_id_collections_id_fk": { + "name": "ark_collections_collection_id_collections_id_fk", + "tableFrom": "ark_collections", + "tableTo": "collections", + "columnsFrom": [ + "collection_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "ark_collections_ark_id_unique": { + "name": "ark_collections_ark_id_unique", + "nullsNotDistinct": false, + "columns": [ + "ark_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.ark_record_types": { + "name": "ark_record_types", + "schema": "", + "columns": { + "collection_id": { + "name": "collection_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "record_type": { + "name": "record_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "redirect_url_field": { + "name": "redirect_url_field", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "ark_record_types_collection_id_collections_id_fk": { + "name": "ark_record_types_collection_id_collections_id_fk", + "tableFrom": "ark_record_types", + "tableTo": "collections", + "columnsFrom": [ + "collection_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "ark_record_types_collection_id_record_type_pk": { + "name": "ark_record_types_collection_id_record_type_pk", + "columns": [ + "collection_id", + "record_type" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.ark_shoulders": { + "name": "ark_shoulders", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "account_id": { + "name": "account_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "shoulder": { + "name": "shoulder", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "ark_shoulders_account_id_accounts_id_fk": { + "name": "ark_shoulders_account_id_accounts_id_fk", + "tableFrom": "ark_shoulders", + "tableTo": "accounts", + "columnsFrom": [ + "account_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "ark_shoulders_account_id_unique": { + "name": "ark_shoulders_account_id_unique", + "nullsNotDistinct": false, + "columns": [ + "account_id" + ] + }, + "ark_shoulders_shoulder_unique": { + "name": "ark_shoulders_shoulder_unique", + "nullsNotDistinct": false, + "columns": [ + "shoulder" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.collections": { + "name": "collections", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "account_id": { + "name": "account_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "public": { + "name": "public", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "forked_from": { + "name": "forked_from", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "collections_account_id_idx": { + "name": "collections_account_id_idx", + "columns": [ + { + "expression": "account_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "collections_account_id_accounts_id_fk": { + "name": "collections_account_id_accounts_id_fk", + "tableFrom": "collections", + "tableTo": "accounts", + "columnsFrom": [ + "account_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "collections_forked_from_collections_id_fk": { + "name": "collections_forked_from_collections_id_fk", + "tableFrom": "collections", + "tableTo": "collections", + "columnsFrom": [ + "forked_from" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "collections_account_id_slug_unique": { + "name": "collections_account_id_slug_unique", + "nullsNotDistinct": false, + "columns": [ + "account_id", + "slug" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.files": { + "name": "files", + "schema": "", + "columns": { + "hash": { + "name": "hash", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "size": { + "name": "size", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "mime_type": { + "name": "mime_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "storage_key": { + "name": "storage_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.org_invitations": { + "name": "org_invitations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "org_id": { + "name": "org_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "invited_by": { + "name": "invited_by", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "accepted_at": { + "name": "accepted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "org_invitations_org_id_accounts_id_fk": { + "name": "org_invitations_org_id_accounts_id_fk", + "tableFrom": "org_invitations", + "tableTo": "accounts", + "columnsFrom": [ + "org_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "org_invitations_invited_by_accounts_id_fk": { + "name": "org_invitations_invited_by_accounts_id_fk", + "tableFrom": "org_invitations", + "tableTo": "accounts", + "columnsFrom": [ + "invited_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "org_invitations_token_unique": { + "name": "org_invitations_token_unique", + "nullsNotDistinct": false, + "columns": [ + "token" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.org_memberships": { + "name": "org_memberships", + "schema": "", + "columns": { + "org_id": { + "name": "org_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "org_memberships_org_id_accounts_id_fk": { + "name": "org_memberships_org_id_accounts_id_fk", + "tableFrom": "org_memberships", + "tableTo": "accounts", + "columnsFrom": [ + "org_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "org_memberships_user_id_accounts_id_fk": { + "name": "org_memberships_user_id_accounts_id_fk", + "tableFrom": "org_memberships", + "tableTo": "accounts", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "org_memberships_org_id_user_id_pk": { + "name": "org_memberships_org_id_user_id_pk", + "columns": [ + "org_id", + "user_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.records": { + "name": "records", + "schema": "", + "columns": { + "version_id": { + "name": "version_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "record_id": { + "name": "record_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "data": { + "name": "data", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "private": { + "name": "private", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + } + }, + "indexes": { + "records_version_id_type_idx": { + "name": "records_version_id_type_idx", + "columns": [ + { + "expression": "version_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "records_version_id_versions_id_fk": { + "name": "records_version_id_versions_id_fk", + "tableFrom": "records", + "tableTo": "versions", + "columnsFrom": [ + "version_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "records_version_id_record_id_pk": { + "name": "records_version_id_record_id_pk", + "columns": [ + "version_id", + "record_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.schema_labels": { + "name": "schema_labels", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "schema_id": { + "name": "schema_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "label": { + "name": "label", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "schema_labels_label_idx": { + "name": "schema_labels_label_idx", + "columns": [ + { + "expression": "label", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "schema_labels_schema_id_schemas_id_fk": { + "name": "schema_labels_schema_id_schemas_id_fk", + "tableFrom": "schema_labels", + "tableTo": "schemas", + "columnsFrom": [ + "schema_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "schema_labels_schema_id_label_unique": { + "name": "schema_labels_schema_id_label_unique", + "nullsNotDistinct": false, + "columns": [ + "schema_id", + "label" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.schemas": { + "name": "schemas", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "schema": { + "name": "schema", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "schema_hash": { + "name": "schema_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "schemas_schema_hash_unique": { + "name": "schemas_schema_hash_unique", + "nullsNotDistinct": false, + "columns": [ + "schema_hash" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sessions": { + "name": "sessions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "sessions_user_id_accounts_id_fk": { + "name": "sessions_user_id_accounts_id_fk", + "tableFrom": "sessions", + "tableTo": "accounts", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sync_runs": { + "name": "sync_runs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "trigger": { + "name": "trigger", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "started_at": { + "name": "started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "finished_at": { + "name": "finished_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "collections_synced": { + "name": "collections_synced", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "collections_created": { + "name": "collections_created", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "collections_failed": { + "name": "collections_failed", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "versions_pulled": { + "name": "versions_pulled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "files_downloaded": { + "name": "files_downloaded", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "files_skipped": { + "name": "files_skipped", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "errors": { + "name": "errors", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "logs": { + "name": "logs", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.upload_records": { + "name": "upload_records", + "schema": "", + "columns": { + "session_id": { + "name": "session_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "record_id": { + "name": "record_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "data": { + "name": "data", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "private": { + "name": "private", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "operation": { + "name": "operation", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "upload_records_session_id_upload_sessions_id_fk": { + "name": "upload_records_session_id_upload_sessions_id_fk", + "tableFrom": "upload_records", + "tableTo": "upload_sessions", + "columnsFrom": [ + "session_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "upload_records_session_id_record_id_pk": { + "name": "upload_records_session_id_record_id_pk", + "columns": [ + "session_id", + "record_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.upload_sessions": { + "name": "upload_sessions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "collection_id": { + "name": "collection_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "account_id": { + "name": "account_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "base_version": { + "name": "base_version", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "message": { + "name": "message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "readme": { + "name": "readme", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "app_id": { + "name": "app_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "actor_id": { + "name": "actor_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "schemas": { + "name": "schemas", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'open'" + }, + "record_count": { + "name": "record_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "upload_sessions_collection_id_collections_id_fk": { + "name": "upload_sessions_collection_id_collections_id_fk", + "tableFrom": "upload_sessions", + "tableTo": "collections", + "columnsFrom": [ + "collection_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "upload_sessions_account_id_accounts_id_fk": { + "name": "upload_sessions_account_id_accounts_id_fk", + "tableFrom": "upload_sessions", + "tableTo": "accounts", + "columnsFrom": [ + "account_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.version_files": { + "name": "version_files", + "schema": "", + "columns": { + "version_id": { + "name": "version_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "file_hash": { + "name": "file_hash", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "version_files_file_hash_idx": { + "name": "version_files_file_hash_idx", + "columns": [ + { + "expression": "file_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "version_files_version_id_versions_id_fk": { + "name": "version_files_version_id_versions_id_fk", + "tableFrom": "version_files", + "tableTo": "versions", + "columnsFrom": [ + "version_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "version_files_file_hash_files_hash_fk": { + "name": "version_files_file_hash_files_hash_fk", + "tableFrom": "version_files", + "tableTo": "files", + "columnsFrom": [ + "file_hash" + ], + "columnsTo": [ + "hash" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "version_files_version_id_file_hash_pk": { + "name": "version_files_version_id_file_hash_pk", + "columns": [ + "version_id", + "file_hash" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.version_schemas": { + "name": "version_schemas", + "schema": "", + "columns": { + "version_id": { + "name": "version_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "schema_id": { + "name": "schema_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "version_schemas_schema_id_idx": { + "name": "version_schemas_schema_id_idx", + "columns": [ + { + "expression": "schema_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "version_schemas_version_id_versions_id_fk": { + "name": "version_schemas_version_id_versions_id_fk", + "tableFrom": "version_schemas", + "tableTo": "versions", + "columnsFrom": [ + "version_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "version_schemas_schema_id_schemas_id_fk": { + "name": "version_schemas_schema_id_schemas_id_fk", + "tableFrom": "version_schemas", + "tableTo": "schemas", + "columnsFrom": [ + "schema_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "version_schemas_version_id_slug_pk": { + "name": "version_schemas_version_id_slug_pk", + "columns": [ + "version_id", + "slug" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.versions": { + "name": "versions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigserial", + "primaryKey": true, + "notNull": true + }, + "collection_id": { + "name": "collection_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "number": { + "name": "number", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "semver": { + "name": "semver", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "hash": { + "name": "hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "public_hash": { + "name": "public_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "base_number": { + "name": "base_number", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "message": { + "name": "message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "readme": { + "name": "readme", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "pushed_by": { + "name": "pushed_by", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "app_id": { + "name": "app_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "actor_id": { + "name": "actor_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "signature": { + "name": "signature", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "record_count": { + "name": "record_count", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "file_count": { + "name": "file_count", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "total_bytes": { + "name": "total_bytes", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "versions_collection_id_collections_id_fk": { + "name": "versions_collection_id_collections_id_fk", + "tableFrom": "versions", + "tableTo": "collections", + "columnsFrom": [ + "collection_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "versions_pushed_by_accounts_id_fk": { + "name": "versions_pushed_by_accounts_id_fk", + "tableFrom": "versions", + "tableTo": "accounts", + "columnsFrom": [ + "pushed_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "versions_collection_id_number_unique": { + "name": "versions_collection_id_number_unique", + "nullsNotDistinct": false, + "columns": [ + "collection_id", + "number" + ] + }, + "versions_collection_id_hash_unique": { + "name": "versions_collection_id_hash_unique", + "nullsNotDistinct": false, + "columns": [ + "collection_id", + "hash" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} diff --git a/src/db/migrations/meta/0011_snapshot.json b/src/db/migrations/meta/0011_snapshot.json new file mode 100644 index 0000000..9f7d2a0 --- /dev/null +++ b/src/db/migrations/meta/0011_snapshot.json @@ -0,0 +1,1631 @@ +{ + "id": "9898328b-fc86-4214-a39d-4a17f262e207", + "prevId": "30fdfcfb-99e9-48b7-841b-0a91525bc555", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.accounts": { + "name": "accounts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "bio": { + "name": "bio", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "website": { + "name": "website", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "location": { + "name": "location", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "avatar_url": { + "name": "avatar_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "notification_prefs": { + "name": "notification_prefs", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "ark_naan": { + "name": "ark_naan", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "kf_org_id": { + "name": "kf_org_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "accounts_slug_unique": { + "name": "accounts_slug_unique", + "nullsNotDistinct": false, + "columns": [ + "slug" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.api_keys": { + "name": "api_keys", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "account_id": { + "name": "account_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "collection_id": { + "name": "collection_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "key_hash": { + "name": "key_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "key_prefix": { + "name": "key_prefix", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "label": { + "name": "label", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "last_used_at": { + "name": "last_used_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "api_keys_account_id_accounts_id_fk": { + "name": "api_keys_account_id_accounts_id_fk", + "tableFrom": "api_keys", + "tableTo": "accounts", + "columnsFrom": [ + "account_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "api_keys_collection_id_collections_id_fk": { + "name": "api_keys_collection_id_collections_id_fk", + "tableFrom": "api_keys", + "tableTo": "collections", + "columnsFrom": [ + "collection_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.ark_collections": { + "name": "ark_collections", + "schema": "", + "columns": { + "collection_id": { + "name": "collection_id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "ark_id": { + "name": "ark_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "custom_url": { + "name": "custom_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "ark_collections_collection_id_collections_id_fk": { + "name": "ark_collections_collection_id_collections_id_fk", + "tableFrom": "ark_collections", + "tableTo": "collections", + "columnsFrom": [ + "collection_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "ark_collections_ark_id_unique": { + "name": "ark_collections_ark_id_unique", + "nullsNotDistinct": false, + "columns": [ + "ark_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.ark_record_types": { + "name": "ark_record_types", + "schema": "", + "columns": { + "collection_id": { + "name": "collection_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "record_type": { + "name": "record_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "redirect_url_field": { + "name": "redirect_url_field", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "ark_record_types_collection_id_collections_id_fk": { + "name": "ark_record_types_collection_id_collections_id_fk", + "tableFrom": "ark_record_types", + "tableTo": "collections", + "columnsFrom": [ + "collection_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "ark_record_types_collection_id_record_type_pk": { + "name": "ark_record_types_collection_id_record_type_pk", + "columns": [ + "collection_id", + "record_type" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.ark_shoulders": { + "name": "ark_shoulders", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "account_id": { + "name": "account_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "shoulder": { + "name": "shoulder", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "ark_shoulders_account_id_accounts_id_fk": { + "name": "ark_shoulders_account_id_accounts_id_fk", + "tableFrom": "ark_shoulders", + "tableTo": "accounts", + "columnsFrom": [ + "account_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "ark_shoulders_account_id_unique": { + "name": "ark_shoulders_account_id_unique", + "nullsNotDistinct": false, + "columns": [ + "account_id" + ] + }, + "ark_shoulders_shoulder_unique": { + "name": "ark_shoulders_shoulder_unique", + "nullsNotDistinct": false, + "columns": [ + "shoulder" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.collections": { + "name": "collections", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "account_id": { + "name": "account_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "public": { + "name": "public", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "forked_from": { + "name": "forked_from", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "collections_account_id_idx": { + "name": "collections_account_id_idx", + "columns": [ + { + "expression": "account_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "collections_account_id_accounts_id_fk": { + "name": "collections_account_id_accounts_id_fk", + "tableFrom": "collections", + "tableTo": "accounts", + "columnsFrom": [ + "account_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "collections_forked_from_collections_id_fk": { + "name": "collections_forked_from_collections_id_fk", + "tableFrom": "collections", + "tableTo": "collections", + "columnsFrom": [ + "forked_from" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "collections_account_id_slug_unique": { + "name": "collections_account_id_slug_unique", + "nullsNotDistinct": false, + "columns": [ + "account_id", + "slug" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.files": { + "name": "files", + "schema": "", + "columns": { + "hash": { + "name": "hash", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "size": { + "name": "size", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "mime_type": { + "name": "mime_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "storage_key": { + "name": "storage_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.org_invitations": { + "name": "org_invitations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "org_id": { + "name": "org_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "invited_by": { + "name": "invited_by", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "accepted_at": { + "name": "accepted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "org_invitations_org_id_accounts_id_fk": { + "name": "org_invitations_org_id_accounts_id_fk", + "tableFrom": "org_invitations", + "tableTo": "accounts", + "columnsFrom": [ + "org_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "org_invitations_invited_by_accounts_id_fk": { + "name": "org_invitations_invited_by_accounts_id_fk", + "tableFrom": "org_invitations", + "tableTo": "accounts", + "columnsFrom": [ + "invited_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "org_invitations_token_unique": { + "name": "org_invitations_token_unique", + "nullsNotDistinct": false, + "columns": [ + "token" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.org_memberships": { + "name": "org_memberships", + "schema": "", + "columns": { + "org_id": { + "name": "org_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "org_memberships_org_id_accounts_id_fk": { + "name": "org_memberships_org_id_accounts_id_fk", + "tableFrom": "org_memberships", + "tableTo": "accounts", + "columnsFrom": [ + "org_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "org_memberships_user_id_accounts_id_fk": { + "name": "org_memberships_user_id_accounts_id_fk", + "tableFrom": "org_memberships", + "tableTo": "accounts", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "org_memberships_org_id_user_id_pk": { + "name": "org_memberships_org_id_user_id_pk", + "columns": [ + "org_id", + "user_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.records": { + "name": "records", + "schema": "", + "columns": { + "version_id": { + "name": "version_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "record_id": { + "name": "record_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "data": { + "name": "data", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "private": { + "name": "private", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + } + }, + "indexes": { + "records_version_id_type_idx": { + "name": "records_version_id_type_idx", + "columns": [ + { + "expression": "version_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "records_version_id_versions_id_fk": { + "name": "records_version_id_versions_id_fk", + "tableFrom": "records", + "tableTo": "versions", + "columnsFrom": [ + "version_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "records_version_id_record_id_pk": { + "name": "records_version_id_record_id_pk", + "columns": [ + "version_id", + "record_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.schema_labels": { + "name": "schema_labels", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "schema_id": { + "name": "schema_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "label": { + "name": "label", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "schema_labels_label_idx": { + "name": "schema_labels_label_idx", + "columns": [ + { + "expression": "label", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "schema_labels_schema_id_schemas_id_fk": { + "name": "schema_labels_schema_id_schemas_id_fk", + "tableFrom": "schema_labels", + "tableTo": "schemas", + "columnsFrom": [ + "schema_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "schema_labels_schema_id_label_unique": { + "name": "schema_labels_schema_id_label_unique", + "nullsNotDistinct": false, + "columns": [ + "schema_id", + "label" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.schemas": { + "name": "schemas", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "schema": { + "name": "schema", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "schema_hash": { + "name": "schema_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "schemas_schema_hash_unique": { + "name": "schemas_schema_hash_unique", + "nullsNotDistinct": false, + "columns": [ + "schema_hash" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sessions": { + "name": "sessions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "sessions_user_id_accounts_id_fk": { + "name": "sessions_user_id_accounts_id_fk", + "tableFrom": "sessions", + "tableTo": "accounts", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sync_runs": { + "name": "sync_runs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "trigger": { + "name": "trigger", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "started_at": { + "name": "started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "finished_at": { + "name": "finished_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "collections_synced": { + "name": "collections_synced", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "collections_created": { + "name": "collections_created", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "collections_failed": { + "name": "collections_failed", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "versions_pulled": { + "name": "versions_pulled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "files_downloaded": { + "name": "files_downloaded", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "files_skipped": { + "name": "files_skipped", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "errors": { + "name": "errors", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "logs": { + "name": "logs", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.upload_records": { + "name": "upload_records", + "schema": "", + "columns": { + "session_id": { + "name": "session_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "record_id": { + "name": "record_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "data": { + "name": "data", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "private": { + "name": "private", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "operation": { + "name": "operation", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "upload_records_session_id_upload_sessions_id_fk": { + "name": "upload_records_session_id_upload_sessions_id_fk", + "tableFrom": "upload_records", + "tableTo": "upload_sessions", + "columnsFrom": [ + "session_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "upload_records_session_id_record_id_pk": { + "name": "upload_records_session_id_record_id_pk", + "columns": [ + "session_id", + "record_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.upload_sessions": { + "name": "upload_sessions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "collection_id": { + "name": "collection_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "account_id": { + "name": "account_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "base_version": { + "name": "base_version", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "message": { + "name": "message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "readme": { + "name": "readme", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "app_id": { + "name": "app_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "actor_id": { + "name": "actor_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "schemas": { + "name": "schemas", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'open'" + }, + "record_count": { + "name": "record_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "upload_sessions_collection_id_collections_id_fk": { + "name": "upload_sessions_collection_id_collections_id_fk", + "tableFrom": "upload_sessions", + "tableTo": "collections", + "columnsFrom": [ + "collection_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "upload_sessions_account_id_accounts_id_fk": { + "name": "upload_sessions_account_id_accounts_id_fk", + "tableFrom": "upload_sessions", + "tableTo": "accounts", + "columnsFrom": [ + "account_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.version_files": { + "name": "version_files", + "schema": "", + "columns": { + "version_id": { + "name": "version_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "file_hash": { + "name": "file_hash", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "version_files_file_hash_idx": { + "name": "version_files_file_hash_idx", + "columns": [ + { + "expression": "file_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "version_files_version_id_versions_id_fk": { + "name": "version_files_version_id_versions_id_fk", + "tableFrom": "version_files", + "tableTo": "versions", + "columnsFrom": [ + "version_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "version_files_file_hash_files_hash_fk": { + "name": "version_files_file_hash_files_hash_fk", + "tableFrom": "version_files", + "tableTo": "files", + "columnsFrom": [ + "file_hash" + ], + "columnsTo": [ + "hash" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "version_files_version_id_file_hash_pk": { + "name": "version_files_version_id_file_hash_pk", + "columns": [ + "version_id", + "file_hash" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.version_schemas": { + "name": "version_schemas", + "schema": "", + "columns": { + "version_id": { + "name": "version_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "schema_id": { + "name": "schema_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "version_schemas_schema_id_idx": { + "name": "version_schemas_schema_id_idx", + "columns": [ + { + "expression": "schema_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "version_schemas_version_id_versions_id_fk": { + "name": "version_schemas_version_id_versions_id_fk", + "tableFrom": "version_schemas", + "tableTo": "versions", + "columnsFrom": [ + "version_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "version_schemas_schema_id_schemas_id_fk": { + "name": "version_schemas_schema_id_schemas_id_fk", + "tableFrom": "version_schemas", + "tableTo": "schemas", + "columnsFrom": [ + "schema_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "version_schemas_version_id_slug_pk": { + "name": "version_schemas_version_id_slug_pk", + "columns": [ + "version_id", + "slug" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.versions": { + "name": "versions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigserial", + "primaryKey": true, + "notNull": true + }, + "collection_id": { + "name": "collection_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "number": { + "name": "number", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "semver": { + "name": "semver", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "hash": { + "name": "hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "public_hash": { + "name": "public_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "base_number": { + "name": "base_number", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "message": { + "name": "message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "readme": { + "name": "readme", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "pushed_by": { + "name": "pushed_by", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "app_id": { + "name": "app_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "actor_id": { + "name": "actor_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "signature": { + "name": "signature", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "record_count": { + "name": "record_count", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "file_count": { + "name": "file_count", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "total_bytes": { + "name": "total_bytes", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "versions_collection_id_collections_id_fk": { + "name": "versions_collection_id_collections_id_fk", + "tableFrom": "versions", + "tableTo": "collections", + "columnsFrom": [ + "collection_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "versions_pushed_by_accounts_id_fk": { + "name": "versions_pushed_by_accounts_id_fk", + "tableFrom": "versions", + "tableTo": "accounts", + "columnsFrom": [ + "pushed_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "versions_collection_id_number_unique": { + "name": "versions_collection_id_number_unique", + "nullsNotDistinct": false, + "columns": [ + "collection_id", + "number" + ] + }, + "versions_collection_id_hash_unique": { + "name": "versions_collection_id_hash_unique", + "nullsNotDistinct": false, + "columns": [ + "collection_id", + "hash" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} diff --git a/src/db/migrations/meta/_journal.json b/src/db/migrations/meta/_journal.json index c985778..7263189 100644 --- a/src/db/migrations/meta/_journal.json +++ b/src/db/migrations/meta/_journal.json @@ -50,6 +50,41 @@ "when": 1746388800000, "tag": "0006_ark_identifiers", "breakpoints": true + }, + { + "idx": 7, + "version": "7", + "when": 1778791722837, + "tag": "0007_kf-auth-integration", + "breakpoints": true + }, + { + "idx": 8, + "version": "7", + "when": 1778806667338, + "tag": "0008_sudden_cyclops", + "breakpoints": true + }, + { + "idx": 9, + "version": "7", + "when": 1778809182465, + "tag": "0009_salty_colossus", + "breakpoints": true + }, + { + "idx": 10, + "version": "7", + "when": 1778810604741, + "tag": "0010_keen_meltdown", + "breakpoints": true + }, + { + "idx": 11, + "version": "7", + "when": 1778812081690, + "tag": "0011_messy_molten_man", + "breakpoints": true } ] } diff --git a/src/db/schema.ts b/src/db/schema.ts index 0b85bb6..726d9ef 100644 --- a/src/db/schema.ts +++ b/src/db/schema.ts @@ -16,19 +16,22 @@ import { // --- Accounts --- export const accounts = pgTable('accounts', { + // For user accounts: id = KF Auth user.id (set on OIDC callback). + // For org accounts: id = auto-generated UUID. id: uuid('id',).defaultRandom().primaryKey(), slug: text('slug',).unique().notNull(), type: text('type', { enum: ['user', 'org',], },).notNull(), - displayName: text('display_name',).notNull(), - email: text('email',), - passwordHash: text('password_hash',), + // displayName/avatarUrl: stored for org accounts only. + // For user accounts, name + avatar are fetched from KF Auth on demand. + displayName: text('display_name',), bio: text('bio',), website: text('website',), location: text('location',), avatarUrl: text('avatar_url',), - emailVerified: boolean('email_verified',).default(false,).notNull(), notificationPrefs: jsonb('notification_prefs',), arkNaan: text('ark_naan',), + // Links this account to a KF Organization. NOT unique — multiple UL orgs can belong to the same KF org. + kfOrgId: text('kf_org_id',), createdAt: timestamp('created_at', { withTimezone: true, },).defaultNow().notNull(), },) @@ -290,19 +293,6 @@ export const syncRuns = pgTable('sync_runs', { logs: jsonb('logs',).$type().default([],).notNull(), },) -// --- Password Reset Tokens --- - -export const passwordResetTokens = pgTable('password_reset_tokens', { - id: uuid('id',).defaultRandom().primaryKey(), - userId: uuid('user_id',) - .notNull() - .references(() => accounts.id, { onDelete: 'cascade', },), - tokenHash: text('token_hash',).notNull(), - expiresAt: timestamp('expires_at', { withTimezone: true, },).notNull(), - usedAt: timestamp('used_at', { withTimezone: true, },), - createdAt: timestamp('created_at', { withTimezone: true, },).defaultNow().notNull(), -},) - // --- ARK Identifiers --- export const arkShoulders = pgTable('ark_shoulders', { diff --git a/src/db/seed.ts b/src/db/seed.ts index a503fea..e6e8956 100644 --- a/src/db/seed.ts +++ b/src/db/seed.ts @@ -1,4 +1,3 @@ -import bcrypt from 'bcrypt' import { eq, } from 'drizzle-orm' import { createHash, } from 'node:crypto' import { v4 as uuidv4, } from 'uuid' @@ -82,16 +81,12 @@ async function seed() { await db.delete(schema.accounts,) } - const passwordHash = await bcrypt.hash('admin', 10,) const adminId = uuidv4() await db.insert(schema.accounts,).values({ id: adminId, slug: 'admin', type: 'user', - displayName: 'Admin', - email: 'admin@underlay.org', - passwordHash, },) const kfId = uuidv4() diff --git a/src/lib/auth.server.ts b/src/lib/auth.server.ts index dc0fc2a..2e44967 100644 --- a/src/lib/auth.server.ts +++ b/src/lib/auth.server.ts @@ -1,11 +1,11 @@ import { eq, } from 'drizzle-orm' import { db, schema, } from '../db/client.server.js' +import { getKfProfile, } from './kf-profile-cache.server.js' export interface SessionUser { id: string slug: string displayName: string | null - email: string | null type: string bio: string | null avatarUrl: string | null @@ -15,6 +15,10 @@ export interface SessionUser { /** * Extract the session cookie from a Request object and look up the user. * Returns the user data or null if not authenticated. + * + * For user accounts, displayName and avatarUrl are fetched from KF Auth + * (via in-memory cache with 5-min TTL). For org accounts, they come from + * the local DB. */ export async function getSessionUser(request: Request,): Promise { const cookieHeader = request.headers.get('cookie',) @@ -57,6 +61,19 @@ export async function getSessionUser(request: Request,): Promise ({ slug: m.orgSlug, displayName: m.orgDisplayName, diff --git a/src/lib/kf-auth.server.ts b/src/lib/kf-auth.server.ts new file mode 100644 index 0000000..9c30a80 --- /dev/null +++ b/src/lib/kf-auth.server.ts @@ -0,0 +1,125 @@ +/** + * Lightweight OIDC client for KF Auth. + * + * Two base URLs: + * KF_AUTH_INTERNAL_URL — server-to-server (e.g. host.docker.internal:3000 in Docker) + * KF_AUTH_URL — browser-facing (e.g. localhost:3000) + */ + +import crypto from 'node:crypto' + +/** Used for browser redirects (authorize). */ +const KF_AUTH_URL = process.env.KF_AUTH_URL ?? 'http://localhost:3000' +/** Used for server-side calls (token, userinfo). Falls back to KF_AUTH_URL. */ +const KF_AUTH_INTERNAL_URL = process.env.KF_AUTH_INTERNAL_URL ?? KF_AUTH_URL +const KF_AUTH_CLIENT_ID = process.env.KF_AUTH_CLIENT_ID ?? 'kf_underlay' +const KF_AUTH_CLIENT_SECRET = process.env.KF_AUTH_CLIENT_SECRET ?? '' +const APP_URL = process.env.APP_URL ?? 'http://localhost:4100' +const REDIRECT_URI = `${APP_URL}/auth/callback` + +// BetterAuth OIDC endpoints (well-known paths) +const AUTHORIZE_PATH = '/api/auth/oauth2/authorize' +const TOKEN_PATH = '/api/auth/oauth2/token' +const USERINFO_PATH = '/api/auth/oauth2/userinfo' + +// --- PKCE helpers --- + +/** Generate a random code_verifier (43–128 chars, URL-safe). */ +export function generateCodeVerifier(): string { + return crypto.randomBytes(32,).toString('base64url',) +} + +/** Derive the S256 code_challenge from a code_verifier. */ +export function generateCodeChallenge(verifier: string,): string { + return crypto.createHash('sha256',).update(verifier,).digest('base64url',) +} + +/** + * Build the URL to redirect the user to for authentication. + * Uses KF_AUTH_URL (browser-facing). + * Returns the URL and the PKCE code_verifier (must be stored server-side). + */ +export function buildAuthorizeUrl(state: string,): { url: string; codeVerifier: string } { + const codeVerifier = generateCodeVerifier() + const codeChallenge = generateCodeChallenge(codeVerifier,) + const params = new URLSearchParams({ + client_id: KF_AUTH_CLIENT_ID, + redirect_uri: REDIRECT_URI, + response_type: 'code', + scope: 'openid profile email', + state, + code_challenge: codeChallenge, + code_challenge_method: 'S256', + },) + return { url: `${KF_AUTH_URL}${AUTHORIZE_PATH}?${params}`, codeVerifier, } +} + +interface TokenResponse { + access_token: string + token_type: string + expires_in: number + id_token?: string + refresh_token?: string +} + +/** + * Exchange an authorization code for tokens. + * Uses KF_AUTH_INTERNAL_URL (server-to-server). + */ +export async function exchangeCode(code: string, codeVerifier: string,): Promise { + const body = new URLSearchParams({ + grant_type: 'authorization_code', + code, + redirect_uri: REDIRECT_URI, + client_id: KF_AUTH_CLIENT_ID, + client_secret: KF_AUTH_CLIENT_SECRET, + code_verifier: codeVerifier, + },) + + const res = await fetch(`${KF_AUTH_INTERNAL_URL}${TOKEN_PATH}`, { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded', }, + body, + },) + + if (!res.ok) { + const text = await res.text() + throw new Error(`Token exchange failed: ${res.status} ${text}`,) + } + + return res.json() as Promise +} + +export interface KFOrg { + id: string + name: string + slug: string + type: 'personal' | 'shared' + role: string +} + +export interface KFUserInfo { + sub: string + name?: string + email?: string + picture?: string + 'https://knowledgefutures.org/orgs'?: KFOrg[] +} + +/** + * Fetch user info from KF Auth using an access token. + * Uses KF_AUTH_INTERNAL_URL (server-to-server). + */ +export async function fetchUserInfo(accessToken: string,): Promise { + const res = await fetch(`${KF_AUTH_INTERNAL_URL}${USERINFO_PATH}`, { + headers: { Authorization: `Bearer ${accessToken}`, }, + },) + + if (!res.ok) { + throw new Error(`UserInfo failed: ${res.status}`,) + } + + return res.json() as Promise +} + +export { KF_AUTH_CLIENT_ID, KF_AUTH_URL, REDIRECT_URI, } diff --git a/src/lib/kf-orgs.server.ts b/src/lib/kf-orgs.server.ts new file mode 100644 index 0000000..526d1f1 --- /dev/null +++ b/src/lib/kf-orgs.server.ts @@ -0,0 +1,24 @@ +/** + * Fetch a user's KF org memberships on demand from KF Auth's internal API. + * Used for the org-creation dropdown (availableKfOrgs). + */ + +import type { KFOrg, } from './kf-auth.server.js' + +const KF_AUTH_INTERNAL_URL = process.env.KF_AUTH_INTERNAL_URL ?? process.env.KF_AUTH_URL ?? 'http://localhost:3000' +const KF_INTERNAL_API_KEY = process.env.KF_INTERNAL_API_KEY ?? '' + +/** + * Fetch all KF orgs the given user belongs to. + * Calls KF Auth internal API with API key auth. + */ +export async function fetchKfOrgs(kfUserId: string,): Promise { + const res = await fetch(`${KF_AUTH_INTERNAL_URL}/api/internal/users/${kfUserId}/orgs`, { + headers: { Authorization: `Bearer ${KF_INTERNAL_API_KEY}`, }, + },) + if (!res.ok) { + console.error(`Failed to fetch KF orgs for ${kfUserId}: ${res.status}`,) + return [] + } + return res.json() as Promise +} diff --git a/src/lib/kf-profile-cache.server.ts b/src/lib/kf-profile-cache.server.ts new file mode 100644 index 0000000..324a2e6 --- /dev/null +++ b/src/lib/kf-profile-cache.server.ts @@ -0,0 +1,51 @@ +/** + * In-memory cache for KF Auth user profile data. + * Fetches name + image from KF Auth's internal API with a 5-minute TTL. + */ + +const KF_AUTH_INTERNAL_URL = process.env.KF_AUTH_INTERNAL_URL ?? process.env.KF_AUTH_URL ?? 'http://localhost:3000' +const KF_INTERNAL_API_KEY = process.env.KF_INTERNAL_API_KEY ?? '' + +const TTL_MS = 5 * 60 * 1000 // 5 minutes + +interface CachedProfile { + name: string + image: string | null + fetchedAt: number +} + +const cache = new Map() + +export interface KFProfile { + name: string + image: string | null +} + +/** + * Get a KF Auth user's profile (name, image). + * Uses an in-memory cache with a 5-minute TTL. + * On miss/expiry, calls KF Auth internal API. + */ +export async function getKfProfile(userId: string,): Promise { + const cached = cache.get(userId,) + if (cached && Date.now() - cached.fetchedAt < TTL_MS) { + return { name: cached.name, image: cached.image, } + } + + try { + const res = await fetch(`${KF_AUTH_INTERNAL_URL}/api/internal/users/${userId}`, { + headers: { Authorization: `Bearer ${KF_INTERNAL_API_KEY}`, }, + },) + if (!res.ok) return null + + const data = (await res.json()) as { id: string; name: string; email: string; image: string | null } + const entry: CachedProfile = { name: data.name, image: data.image, fetchedAt: Date.now(), } + cache.set(userId, entry,) + return { name: entry.name, image: entry.image, } + } catch (e) { + console.error(`Failed to fetch KF profile for ${userId}:`, e,) + // Return stale cache on error if available + if (cached) return { name: cached.name, image: cached.image, } + return null + } +} diff --git a/src/lib/mirror-sync.ts b/src/lib/mirror-sync.ts index e51aefc..35756a2 100644 --- a/src/lib/mirror-sync.ts +++ b/src/lib/mirror-sync.ts @@ -109,8 +109,6 @@ async function ensureMirrorAccount(ownerSlug: string,): Promise { slug: ownerSlug, type: 'org', displayName: ownerSlug, - email: null, - passwordHash: null, },) .returning({ id: schema.accounts.id, },) diff --git a/src/loaders.server.ts b/src/loaders.server.ts index 28710f6..64e8be4 100644 --- a/src/loaders.server.ts +++ b/src/loaders.server.ts @@ -91,7 +91,7 @@ const loaders: Record = { }, '/logout': async () => { - return { data: {}, redirect: '/login', } + return { data: { kfAuthUrl: process.env.KF_AUTH_URL ?? 'http://localhost:3000', }, } }, '/forgot-password': async () => { @@ -386,6 +386,8 @@ const loaders: Record = { }, } +const kfAccountUrl = process.env.KF_ACCOUNT_URL ?? 'http://localhost:3001' + export async function runLoaders( matchedRoutes: { path: string; params: Record }[], request: Request, @@ -393,10 +395,12 @@ export async function runLoaders( for (const { path, params, } of matchedRoutes) { const loader = loaders[path] if (loader) { - return loader({ params, request, },) + const result = await loader({ params, request, },) + result.data.kfAccountUrl = kfAccountUrl + return result } } // No loader found — return empty data - return { data: {}, } + return { data: { kfAccountUrl, }, } } diff --git a/src/routes/[owner]/[collection]/settings.tsx b/src/routes/[owner]/[collection]/settings.tsx index 8ba4e13..cb0a06c 100644 --- a/src/routes/[owner]/[collection]/settings.tsx +++ b/src/routes/[owner]/[collection]/settings.tsx @@ -22,6 +22,7 @@ export default function CollectionSettingsPage() { // Form state const [name, setName,] = useState('',) + const [slugValue, setSlugValue,] = useState('',) const [description, setDescription,] = useState('',) const [isPublic, setIsPublic,] = useState(false,) @@ -32,6 +33,9 @@ export default function CollectionSettingsPage() { // Delete form const [confirmSlug, setConfirmSlug,] = useState('',) + // Transfer form + const [transferTarget, setTransferTarget,] = useState('',) + useEffect(() => { if (!owner || !collection || !currentUser) return @@ -55,6 +59,7 @@ export default function CollectionSettingsPage() { } setData(col,) setName(col.name,) + setSlugValue(col.slug,) setDescription(col.description ?? '',) setIsPublic(col.public,) @@ -80,14 +85,23 @@ export default function CollectionSettingsPage() { e.preventDefault() clearMessages() setSubmitting('update',) + const slugChanged = slugValue.trim() !== '' && slugValue.trim() !== collection try { + const payload: Record = { name, description, public: isPublic, } + if (slugChanged) payload.slug = slugValue.trim() + const res = await fetch(`/api/collections/${owner}/${collection}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json', }, credentials: 'include', - body: JSON.stringify({ name, description, public: isPublic, },), + body: JSON.stringify(payload,), },) if (res.ok) { + if (slugChanged) { + const body = await res.json().catch(() => ({})) + window.location.href = `/${owner}/${body.slug ?? slugValue.trim()}/settings` + return + } setSuccess('Collection updated.',) const refreshed = await fetch(`/api/collections/${owner}/${collection}`, { credentials: 'include', @@ -207,6 +221,23 @@ export default function CollectionSettingsPage() { />
+
+ + setSlugValue(e.target.value.toLowerCase().replace(/[^a-z0-9-]/g, '',),)} + pattern='[a-z0-9][a-z0-9-]*[a-z0-9]' + className='w-full bg-parchment border border-rule px-3 py-2 text-sm font-mono focus:outline-none focus:border-ink' + /> + {slugValue !== collection && ( +

Changing the slug will update this collection's URL.

+ )} +
+
+ {/* Transfer */} +
+

+ Transfer Collection +

+

+ Move this collection to another account you have access to. +

+
{ + e.preventDefault() + clearMessages() + if (!transferTarget) return + setSubmitting('transfer',) + try { + const res = await fetch(`/api/collections/${owner}/${collection}/transfer`, { + method: 'POST', + headers: { 'Content-Type': 'application/json', }, + credentials: 'include', + body: JSON.stringify({ targetAccountSlug: transferTarget, },), + },) + if (res.ok) { + window.location.href = `/${transferTarget}/${data?.slug ?? collection}/settings` + } else { + const body = await res.json().catch(() => ({})) + setError(body.error ?? 'Transfer failed.',) + } + } finally { + setSubmitting('',) + } + }} + className='space-y-3' + > +
+ + +
+ +
+
+ {/* Danger zone */}

Danger Zone

diff --git a/src/routes/[owner]/settings/index.tsx b/src/routes/[owner]/settings/index.tsx index cb5bf13..53e2a5d 100644 --- a/src/routes/[owner]/settings/index.tsx +++ b/src/routes/[owner]/settings/index.tsx @@ -1,5 +1,5 @@ import { type FormEvent, useEffect, useState, } from 'react' -import { Link, useParams, } from 'react-router' +import { Link, useNavigate, useParams, } from 'react-router' import BaseLayout from '~/components/BaseLayout' import { NotFoundError, } from '~/components/NotFound' import { useSSRData, } from '~/lib/ssr-data' @@ -18,10 +18,16 @@ export default function OwnerSettings() { // Profile form const [displayName, setDisplayName,] = useState('',) + const [slugValue, setSlugValue,] = useState('',) const [bio, setBio,] = useState('',) const [website, setWebsite,] = useState('',) const [location, setLocation,] = useState('',) + // KF org link + const [kfOrgId, setKfOrgId,] = useState('',) + const [kfOrgs, setKfOrgs,] = useState<{ id: string; name: string }[]>([],) + const [kfOrgsLoading, setKfOrgsLoading,] = useState(false,) + // ARK form const [arkNaan, setArkNaan,] = useState('',) @@ -51,12 +57,24 @@ export default function OwnerSettings() { } setOrgData(data,) setDisplayName(data.displayName ?? '',) + setSlugValue(data.slug ?? owner,) setBio(data.bio ?? '',) setWebsite(data.website ?? '',) setLocation(data.location ?? '',) + setKfOrgId(data.kfOrgId ?? '',) setArkNaan(data.arkNaan ?? '',) setLoading(false,) },) + + // Fetch available KF orgs for the transfer UI + setKfOrgsLoading(true,) + fetch('/api/accounts/available-kf-orgs', { credentials: 'include', },) + .then((r,) => (r.ok ? r.json() : [])) + .then((orgs,) => { + setKfOrgs(Array.isArray(orgs) ? orgs : [],) + setKfOrgsLoading(false,) + },) + .catch(() => setKfOrgsLoading(false,)) }, [owner, currentUser,],) if (!currentUser) { @@ -73,14 +91,23 @@ export default function OwnerSettings() { e.preventDefault() clearMessages() setSubmitting('profile',) + const slugChanged = slugValue.trim() !== '' && slugValue.trim() !== owner try { + const payload: Record = { displayName, bio, website, location, } + if (slugChanged) payload.slug = slugValue.trim() + const res = await fetch(`/api/accounts/${owner}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json', }, credentials: 'include', - body: JSON.stringify({ displayName, bio, website, location, },), + body: JSON.stringify(payload,), },) if (res.ok) { + if (slugChanged) { + const body = await res.json().catch(() => ({})) + window.location.href = `/${body.slug ?? slugValue.trim()}/settings` + return + } setSuccess('Organization updated.',) } else { const body = await res.json().catch(() => ({})) @@ -263,9 +290,18 @@ export default function OwnerSettings() {
- -

{owner}

-

Slugs cannot be changed.

+ + setSlugValue(e.target.value.toLowerCase().replace(/[^a-z0-9-]/g, '',),)} + pattern='[a-z0-9][a-z0-9-]*[a-z0-9]' + className='w-full bg-parchment border border-rule px-3 py-2 text-sm font-mono focus:outline-none focus:border-ink' + /> + {slugValue !== owner && ( +

Changing the slug will update all URLs for this account.

+ )}
+ + )} + + )} + {/* Danger zone */} {isOwner && (
diff --git a/src/routes/dashboard.tsx b/src/routes/dashboard.tsx index f91f164..cc8ff20 100644 --- a/src/routes/dashboard.tsx +++ b/src/routes/dashboard.tsx @@ -18,19 +18,44 @@ interface Org { collections: Collection[] } +interface KfOrg { + id: string + name: string + slug: string + type: string + role: string +} + export default function Dashboard() { const me = useSSRData('currentUser',) const [collections, setCollections,] = useState([],) const [orgs, setOrgs,] = useState([],) const [filter, setFilter,] = useState('',) + + // Org creation state const [orgSlug, setOrgSlug,] = useState('',) const [orgDisplayName, setOrgDisplayName,] = useState('',) + const [orgKfOrgId, setOrgKfOrgId,] = useState('',) const [orgError, setOrgError,] = useState('',) const [submitting, setSubmitting,] = useState(false,) + const [availableKfOrgs, setAvailableKfOrgs,] = useState([],) + + // Collection creation state + const [showCreateCollection, setShowCreateCollection,] = useState(false,) + const [colSlug, setColSlug,] = useState('',) + const [colName, setColName,] = useState('',) + const [colDescription, setColDescription,] = useState('',) + const [colPublic, setColPublic,] = useState(false,) + const [colOwner, setColOwner,] = useState('',) + const [colError, setColError,] = useState('',) + const [colSubmitting, setColSubmitting,] = useState(false,) useEffect(() => { if (!me) return + // Set default collection owner + setColOwner(me.slug,) + fetch(`/api/accounts/${me.slug}/collections`, { credentials: 'include', },) .then((r,) => (r.ok ? r.json() : [])) .then(setCollections,) @@ -48,6 +73,17 @@ export default function Dashboard() { },), ).then(setOrgs,) } + + // Fetch available KF orgs for the org creation dropdown + fetch('/api/accounts/available-kf-orgs', { credentials: 'include', },) + .then((r,) => (r.ok ? r.json() : [])) + .then((orgs: KfOrg[],) => { + setAvailableKfOrgs(orgs,) + // Auto-select if only one KF org available + if (orgs.length === 1) { + setOrgKfOrgId(orgs[0]!.id,) + } + },) }, [me,],) async function handleCreateOrg(e: FormEvent,) { @@ -59,7 +95,7 @@ export default function Dashboard() { method: 'POST', headers: { 'Content-Type': 'application/json', }, credentials: 'include', - body: JSON.stringify({ slug: orgSlug, displayName: orgDisplayName, },), + body: JSON.stringify({ slug: orgSlug, displayName: orgDisplayName, kfOrgId: orgKfOrgId, },), },) if (res.ok) { window.location.reload() @@ -72,6 +108,39 @@ export default function Dashboard() { } } + async function handleCreateCollection(e: FormEvent,) { + e.preventDefault() + setColError('',) + setColSubmitting(true,) + try { + const res = await fetch(`/api/accounts/${colOwner}/collections`, { + method: 'POST', + headers: { 'Content-Type': 'application/json', }, + credentials: 'include', + body: JSON.stringify({ + slug: colSlug, + name: colName, + description: colDescription || undefined, + public: colPublic, + },), + },) + if (res.ok) { + window.location.reload() + } else { + const err = await res.json() + setColError(err.error ?? 'Failed to create collection',) + } + } finally { + setColSubmitting(false,) + } + } + + // All accounts the user can create collections under + const ownerOptions = [ + { slug: me?.slug, label: me?.displayName ?? me?.slug, }, + ...(orgs ?? []).map((o,) => ({ slug: o.slug, label: o.displayName, })), + ] + function matchesFilter(text: string,) { return text.toLowerCase().includes(filter.toLowerCase(),) } @@ -182,67 +251,181 @@ export default function Dashboard() { {/* Right sidebar */}
- {/* Quick reference */} + {/* Create collection */}
-

Quick Reference

-
-
-

Create collection

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

Push version

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

New Collection

+ {!showCreateCollection && ( + + )}
-
- {/* Create org */} -
-

New Organization

- {orgError &&

{orgError}

} -
-
- - setOrgSlug(e.target.value,)} - className='w-full border border-rule px-2 py-1 text-xs bg-parchment focus:outline-none focus:border-ink' - /> -
-
- - setOrgDisplayName(e.target.value,)} - className='w-full border border-rule px-2 py-1 text-xs bg-parchment focus:outline-none focus:border-ink' - /> -
- -
+ {showCreateCollection + ? ( +
+ {colError &&

{colError}

} + + {/* Owner picker — only show if user has orgs */} + {ownerOptions.length > 1 && ( +
+ + +
+ )} + +
+ + setColName(e.target.value,)} + className='w-full border border-rule px-2 py-1 text-xs bg-parchment focus:outline-none focus:border-ink' + /> +
+
+ + setColSlug(e.target.value,)} + className='w-full border border-rule px-2 py-1 text-xs bg-parchment focus:outline-none focus:border-ink' + /> +
+
+ + setColDescription(e.target.value,)} + className='w-full border border-rule px-2 py-1 text-xs bg-parchment focus:outline-none focus:border-ink' + /> +
+ +
+ + +
+
+ ) + : ( +
+
+

Via API

+ + POST /api/accounts/{me.slug}/collections + +
+ + Quickstart guide → + +
+ )}
+ {/* Create org — requires at least one available KF org */} + {availableKfOrgs.length > 0 && ( +
+

New Organization

+ {orgError &&

{orgError}

} +
+ {availableKfOrgs.length > 1 + ? ( +
+ + +
+ ) + : ( +

+ Linked to: {availableKfOrgs[0]?.name} +

+ )} +
+ + setOrgSlug(e.target.value,)} + className='w-full border border-rule px-2 py-1 text-xs bg-parchment focus:outline-none focus:border-ink' + /> +
+
+ + setOrgDisplayName(e.target.value,)} + className='w-full border border-rule px-2 py-1 text-xs bg-parchment focus:outline-none focus:border-ink' + /> +
+ +
+
+ )} + {/* Links */}
diff --git a/src/routes/docs/api/accounts.tsx b/src/routes/docs/api/accounts.tsx index d209aa2..ca9419b 100644 --- a/src/routes/docs/api/accounts.tsx +++ b/src/routes/docs/api/accounts.tsx @@ -1,27 +1,5 @@ import DocsLayout from '~/components/DocsLayout' -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", @@ -53,23 +31,29 @@ const createKeyRes = `{ "collectionId": null }` -const deleteKeyRes = logoutRes +const deleteKeyRes = `{"ok": true}` export default function DocsApiAccounts() { return ( -

Create accounts, authenticate, and manage API keys.

+

Manage accounts and API keys.

Authentication

There are two authentication methods:

  • - Session cookies — set by login, used by the web UI + Session cookies — set via SSO login through{' '} + KF Auth, used by the web + UI
  • API keysAuthorization: Bearer ul_..., used by apps and scripts
+

+ User accounts are created automatically on first sign-in via KF Auth (OIDC SSO). There are no local signup or + login endpoints. +

API keys have three scopes: read, write,{' '} admin. A key can optionally be scoped to a single collection. @@ -77,51 +61,6 @@ export default function DocsApiAccounts() {


-
-

POST /api/accounts/signup

-

No auth required

-

Create a new user account.

-

Request

-
{signupReq}
-

- Response 201 -

-
{signupRes}
-

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

-
- -
- -
-

POST /api/accounts/login

-

No auth required

-

Request

-
{loginReq}
-

- Response 200 -

-
{loginRes}
-

- Sets a session cookie. -

-
- -
- -
-

POST /api/accounts/logout

-

No auth required

-

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

-

- Response 200 -

-
{logoutRes}
-
- -
-

GET /api/accounts/me

Auth: session or API key (any scope)

diff --git a/src/routes/docs/quickstart.tsx b/src/routes/docs/quickstart.tsx index fa90e89..0f1d803 100644 --- a/src/routes/docs/quickstart.tsx +++ b/src/routes/docs/quickstart.tsx @@ -1,27 +1,9 @@ import { Link, } from 'react-router' import DocsLayout from '~/components/DocsLayout' -const signupCode = `curl -X POST https://underlay.org/api/accounts/signup \\ - -H "Content-Type: application/json" \\ - -d '{ - "email": "you@example.com", - "password": "your-password", - "username": "yourname", - "displayName": "Your Name" - }'` - -const loginCode = `# Login (saves session cookie) -curl -X POST https://underlay.org/api/accounts/login \\ - -H "Content-Type: application/json" \\ - -c cookies.txt \\ - -d '{"email": "you@example.com", "password": "your-password"}' - -# Create API key -curl -X POST https://underlay.org/api/accounts/keys \\ - -H "Content-Type: application/json" \\ - -b cookies.txt \\ - -d '{"label": "my-app", "scope": "write"}' -# → {"id":"...","key":"ul_abc123...","label":"my-app","scope":"write"}` +const loginNote = `# Sign in via KF Auth SSO at https://underlay.org/login +# Your account is created automatically on first sign-in. +# Then create an API key at https://underlay.org/settings/keys` const createCollectionCode = `export KEY="ul_abc123..." @@ -112,29 +94,32 @@ export default function DocsQuickstart() { Push your first version in 5 minutes. All you need is curl and a running Underlay instance.

-

1. Create an account

-
{signupCode}
- -

2. Create an API key

-

Log in and create a write-scoped key:

-
{loginCode}
+

1. Sign in and create an API key

+

+ Sign in at underlay.org/login + {' '} + via KF Auth SSO. Your account is created automatically on first sign-in. Then go to{' '} + Settings → API Keys{' '} + and create a write-scoped key. +

+
{loginNote}

Save the key value — it's shown only once.

-

3. Create a collection

+

2. Create a collection

{createCollectionCode}
-

4. Push a version

+

3. Push a version

{pushCode}
-

5. Read it back

+

4. Read it back

{readCode}
-

6. Push an update

+

5. Push an update

{updateCode}
-

7. Diff versions

+

6. Diff versions

{diffCode}

Working with files

diff --git a/src/routes/forgot-password.tsx b/src/routes/forgot-password.tsx index 43f7df4..cf2105f 100644 --- a/src/routes/forgot-password.tsx +++ b/src/routes/forgot-password.tsx @@ -1,80 +1,16 @@ -import { useState, } from 'react' -import { Link, } from 'react-router' +import { useEffect, } from 'react' import BaseLayout from '~/components/BaseLayout' export default function ForgotPasswordPage() { - const [sent, setSent,] = useState(false,) - const [error, setError,] = useState('',) - const [submitting, setSubmitting,] = useState(false,) - - async function handleSubmit(e: React.FormEvent,) { - e.preventDefault() - setSubmitting(true,) - setError('',) - - const form = new FormData(e.currentTarget,) - const email = form.get('email',) as string - - try { - await fetch('/api/accounts/forgot-password', { - method: 'POST', - headers: { 'Content-Type': 'application/json', }, - body: JSON.stringify({ email, },), - },) - // Always show success to avoid email enumeration - setSent(true,) - } catch { - setSent(true,) - } finally { - setSubmitting(false,) - } - } + useEffect(() => { + // Password management now happens via KF Auth + window.location.href = '/auth/login' + }, [],) return ( -
-

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 -

+
+

Redirecting to sign in...

) diff --git a/src/routes/invitations/accept.tsx b/src/routes/invitations/accept.tsx index 063af7c..d0a67dc 100644 --- a/src/routes/invitations/accept.tsx +++ b/src/routes/invitations/accept.tsx @@ -60,12 +60,12 @@ export default function InvitationsAccept() { {!me && token && (

You may need to{' '} - log in - {' '} + {' '} or

- Log in - + ,) { - e.preventDefault() - setSubmitting(true,) - setLoginError('',) - - const form = new FormData(e.currentTarget,) - const email = form.get('email',) as string - const password = form.get('password',) as string - - try { - const res = await fetch('/api/accounts/login', { - method: 'POST', - headers: { 'Content-Type': 'application/json', }, - body: JSON.stringify({ email, password, },), - credentials: 'same-origin', - },) + useEffect(() => { + // If no error, redirect to KF Auth immediately + if (!error) { + window.location.href = '/auth/login' + } + }, [error,],) - if (res.ok) { - window.location.href = '/dashboard' - return - } + if (!error) { + return ( + +
+

Redirecting to sign in...

+
+
+ ) + } - const err = await res.json().catch(() => null) - setLoginError(err?.error ?? 'Invalid email or password.',) - } catch { - setLoginError('Network error. Please try again.',) - } finally { - setSubmitting(false,) - } + const messages: Record = { + auth_failed: 'Authentication was cancelled or failed.', + missing_params: 'Invalid response from the auth server.', + invalid_state: 'Session expired. Please try again.', + token_exchange: 'Could not complete sign in. Please try again.', + userinfo: 'Could not retrieve your profile. Please try again.', } return (
-

Log in

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

Sign in

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

- Don't have an account? Sign up -

- Forgot password? +
+ {messages[error] ?? 'Something went wrong. Please try again.'}
+ + + Try again +
) diff --git a/src/routes/logout.tsx b/src/routes/logout.tsx index 67b8dc7..b9b8539 100644 --- a/src/routes/logout.tsx +++ b/src/routes/logout.tsx @@ -1,17 +1,27 @@ import { useEffect, } from 'react' import BaseLayout from '~/components/BaseLayout' +import { useSSRData, } from '~/lib/ssr-data' export default function LogoutPage() { + const kfAuthUrl = useSSRData('kfAuthUrl',) + useEffect(() => { - // Loader handles session cleanup and redirects to /login - // This is a fallback in case the redirect doesn't happen server-side - window.location.href = '/login' + // Clear the local Underlay session, then redirect to KF Auth signout + // so the IdP session is also cleared (prevents auto-re-login) + fetch('/auth/logout', { method: 'POST', credentials: 'include', },) + .finally(() => { + const appHomeUrl = window.location.origin + const signoutUrl = kfAuthUrl + ? `${kfAuthUrl}/auth/signout?redirect_uri=${encodeURIComponent(appHomeUrl,)}` + : '/' + window.location.href = signoutUrl + },) }, [],) return (
-

Logging out…

+

Signing out…

) diff --git a/src/routes/query.tsx b/src/routes/query.tsx index fbe1416..3942507 100644 --- a/src/routes/query.tsx +++ b/src/routes/query.tsx @@ -22,8 +22,8 @@ export default function QueryPage() { Docs Blog {currentUser - ? - : Log in} + ? + : Log in}
diff --git a/src/routes/reset-password.tsx b/src/routes/reset-password.tsx index ed82c87..6a89ffc 100644 --- a/src/routes/reset-password.tsx +++ b/src/routes/reset-password.tsx @@ -1,119 +1,15 @@ -import { useState, } from 'react' -import { Link, } from 'react-router' +import { useEffect, } from 'react' import BaseLayout from '~/components/BaseLayout' export default function ResetPasswordPage() { - const params = new URLSearchParams(typeof window !== 'undefined' ? window.location.search : '',) - const token = params.get('token',) ?? '' - const email = params.get('email',) ?? '' - - const [success, setSuccess,] = useState(false,) - const [error, setError,] = useState(!token || !email ? 'Invalid or missing reset token.' : '',) - const [submitting, setSubmitting,] = useState(false,) - - async function handleSubmit(e: React.FormEvent,) { - e.preventDefault() - setSubmitting(true,) - setError('',) - - const form = new FormData(e.currentTarget,) - const password = form.get('password',) as string - const confirm = form.get('confirm',) as string - - if (password !== confirm) { - setError('Passwords do not match.',) - setSubmitting(false,) - return - } - - if (password.length < 8) { - setError('Password must be at least 8 characters.',) - setSubmitting(false,) - return - } - - try { - const res = await fetch('/api/accounts/reset-password', { - method: 'POST', - headers: { 'Content-Type': 'application/json', }, - body: JSON.stringify({ email, token, newPassword: password, },), - },) - - if (res.ok) { - setSuccess(true,) - } else { - const data = await res.json().catch(() => null) - setError(data?.error ?? 'Reset link is invalid or expired.',) - } - } catch { - setError('Network error. Please try again.',) - } finally { - setSubmitting(false,) - } - } + useEffect(() => { + window.location.href = '/auth/login' + }, [],) return ( -
-

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 → -

- )} - - )} +
+

Redirecting to sign in...

) diff --git a/src/routes/settings/index.tsx b/src/routes/settings/index.tsx index 024db6b..4617ccc 100644 --- a/src/routes/settings/index.tsx +++ b/src/routes/settings/index.tsx @@ -5,25 +5,17 @@ import { useSSRData, } from '~/lib/ssr-data' export default function Settings() { const me = useSSRData('currentUser',) + const kfAccountUrl = useSSRData('kfAccountUrl',) const [success, setSuccess,] = useState('',) const [error, setError,] = useState('',) - // Profile form - const [displayName, setDisplayName,] = useState(me?.displayName ?? '',) + // Profile form (Underlay-specific fields only — name/email/avatar managed by KF Auth) + const [slugValue, setSlugValue,] = useState(me?.slug ?? '',) const [bio, setBio,] = useState(me?.bio ?? '',) const [website, setWebsite,] = useState(me?.website ?? '',) const [location, setLocation,] = useState(me?.location ?? '',) - // Email form - const [newEmail, setNewEmail,] = useState('',) - const [emailPassword, setEmailPassword,] = useState('',) - - // Password form - const [currentPassword, setCurrentPassword,] = useState('',) - const [newPassword, setNewPassword,] = useState('',) - const [confirmPassword, setConfirmPassword,] = useState('',) - // Notifications const notifPrefs = (me?.notificationPrefs as Record) ?? {} const [collectionActivity, setCollectionActivity,] = useState(notifPrefs.collectionActivity ?? true,) @@ -32,7 +24,6 @@ export default function Settings() { // Delete account const [confirmSlug, setConfirmSlug,] = useState('',) - const [deletePassword, setDeletePassword,] = useState('',) const [submitting, setSubmitting,] = useState('',) @@ -45,14 +36,22 @@ export default function Settings() { e.preventDefault() clearMessages() setSubmitting('profile',) + const slugChanged = slugValue.trim() !== '' && slugValue.trim() !== me?.slug try { + const payload: Record = { bio, website, location, } + if (slugChanged) payload.slug = slugValue.trim() + const res = await fetch('/api/accounts/me', { method: 'PATCH', headers: { 'Content-Type': 'application/json', }, credentials: 'include', - body: JSON.stringify({ displayName, bio, website, location, },), + body: JSON.stringify(payload,), },) if (res.ok) { + if (slugChanged) { + window.location.href = '/settings' + return + } setSuccess('Profile updated.',) } else { const body = await res.json().catch(() => ({})) @@ -63,57 +62,6 @@ export default function Settings() { } } - async function handleChangeEmail(e: FormEvent,) { - e.preventDefault() - clearMessages() - setSubmitting('email',) - try { - const res = await fetch('/api/accounts/me/email', { - method: 'POST', - headers: { 'Content-Type': 'application/json', }, - credentials: 'include', - body: JSON.stringify({ newEmail, password: emailPassword, },), - },) - if (res.ok) { - setSuccess('Email updated.',) - } else { - const body = await res.json().catch(() => ({})) - setError(body.error ?? 'Failed to update email.',) - } - } finally { - setSubmitting('',) - } - } - - async function handleChangePassword(e: FormEvent,) { - e.preventDefault() - clearMessages() - if (newPassword !== confirmPassword) { - setError('New passwords do not match.',) - return - } - setSubmitting('password',) - try { - const res = await fetch('/api/accounts/me/password', { - method: 'POST', - headers: { 'Content-Type': 'application/json', }, - credentials: 'include', - body: JSON.stringify({ currentPassword, newPassword, },), - },) - if (res.ok) { - setSuccess('Password changed.',) - setCurrentPassword('',) - setNewPassword('',) - setConfirmPassword('',) - } else { - const body = await res.json().catch(() => ({})) - setError(body.error ?? 'Failed to change password.',) - } - } finally { - setSubmitting('',) - } - } - async function handleUpdateNotifications(e: FormEvent,) { e.preventDefault() clearMessages() @@ -144,7 +92,7 @@ export default function Settings() { method: 'DELETE', headers: { 'Content-Type': 'application/json', }, credentials: 'include', - body: JSON.stringify({ confirmSlug, password: deletePassword, },), + body: JSON.stringify({ confirmSlug, },), },) if (res.ok) { window.location.href = '/' @@ -157,14 +105,6 @@ export default function Settings() { } } - async function handleLogout() { - await fetch('/api/accounts/logout', { - method: 'POST', - credentials: 'include', - },) - window.location.href = '/' - } - if (!me) return null return ( @@ -204,22 +144,26 @@ export default function Settings() {

{me.displayName}

@{me.slug}

- - Change avatar - + + Edit name or avatar at KF Account → +
- - setDisplayName(e.target.value,)} - required - className='w-full bg-parchment border border-rule px-3 py-2 text-sm focus:outline-none focus:border-ink' - /> + +

{me.displayName}

+

+ Managed by your{' '} + + KF Account + . +

@@ -257,9 +201,18 @@ export default function Settings() {
- -

{me.slug}

-

Usernames cannot be changed.

+ + setSlugValue(e.target.value.toLowerCase().replace(/[^a-z0-9-]/g, '',),)} + pattern='[a-z0-9][a-z0-9-]*[a-z0-9]' + className='w-full bg-parchment border border-rule px-3 py-2 text-sm font-mono focus:outline-none focus:border-ink' + /> + {slugValue !== me?.slug && ( +

Changing your username will update all your URLs.

+ )}
- - -
- - {/* Change Password */} -
-

Password

-
- Change password -
-
- - setCurrentPassword(e.target.value,)} - required - className='w-full bg-parchment border border-rule px-3 py-2 text-sm focus:outline-none focus:border-ink' - /> -
-
- - setNewPassword(e.target.value,)} - required - minLength={8} - className='w-full bg-parchment border border-rule px-3 py-2 text-sm focus:outline-none focus:border-ink' - /> -
-
- - setConfirmPassword(e.target.value,)} - required - minLength={8} - className='w-full bg-parchment border border-rule px-3 py-2 text-sm focus:outline-none focus:border-ink' - /> -
- -
-
+ + Manage account → +
{/* Notifications */} @@ -429,17 +304,6 @@ export default function Settings() { className='w-full bg-parchment border border-red-200 px-3 py-2 text-sm focus:outline-none focus:border-red-400' />
-
- - setDeletePassword(e.target.value,)} - required - className='w-full bg-parchment border border-red-200 px-3 py-2 text-sm focus:outline-none focus:border-red-400' - /> -
- - -

- Already have an account? Log in -

+
+

Redirecting to sign up...

) diff --git a/tools/seedMirror.ts b/tools/seedMirror.ts index 3d03101..49e3d01 100644 --- a/tools/seedMirror.ts +++ b/tools/seedMirror.ts @@ -5,7 +5,6 @@ * Usage: npm run tool:seed-mirror */ -import bcrypt from 'bcrypt' import { v4 as uuidv4, } from 'uuid' import { db, schema, } from '../src/db/client.server.js' @@ -18,23 +17,16 @@ async function seedMirror() { process.exit(0,) } - const password = process.env.MIRROR_ADMIN_PASSWORD ?? 'admin' - const email = process.env.MIRROR_ADMIN_EMAIL ?? 'admin@mirror.underlay.org' - - const passwordHash = await bcrypt.hash(password, 10,) const adminId = uuidv4() await db.insert(schema.accounts,).values({ id: adminId, slug: 'admin', type: 'user', - displayName: 'Mirror Admin', - email, - passwordHash, },) - console.log(`[seed-mirror] Created admin user (${email} / ${password})`,) - console.log('[seed-mirror] Done. You can now log in to /admin/mirror.',) + console.log('[seed-mirror] Created admin user',) + console.log('[seed-mirror] Done. Log in via KF Auth.',) process.exit(0,) } From 1f52d92c73fba1324a46d4474f1fe33d34ff2c73 Mon Sep 17 00:00:00 2001 From: Travis Rich Date: Sat, 16 May 2026 01:14:41 -0400 Subject: [PATCH 02/10] small array change --- src/lib/kf-orgs.server.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/lib/kf-orgs.server.ts b/src/lib/kf-orgs.server.ts index 526d1f1..caafdb3 100644 --- a/src/lib/kf-orgs.server.ts +++ b/src/lib/kf-orgs.server.ts @@ -20,5 +20,8 @@ export async function fetchKfOrgs(kfUserId: string,): Promise { console.error(`Failed to fetch KF orgs for ${kfUserId}: ${res.status}`,) return [] } - return res.json() as Promise + const data = await res.json() as { orgs?: KFOrg[] } | KFOrg[] + // Handle both { orgs: [...] } and bare array shapes + if (Array.isArray(data,)) return data + return data.orgs ?? [] } From 21f28d7562ad69dffb6493ba202d137dcd56e5b4 Mon Sep 17 00:00:00 2001 From: Travis Rich Date: Sat, 16 May 2026 12:08:02 -0400 Subject: [PATCH 03/10] Switch dprint for oxfmt --- .github/workflows/ci.yml | 3 + .github/workflows/deploy-mirror.yml | 2 +- .oxfmtrc.json | 7 + README.md | 92 +- .../2024-04-27-institutional-repositories.md | 4 +- content/blog/2024-04-27-underlay-revived.md | 8 +- .../blog/2026-04-28-atproto-integration.md | 4 +- content/blog/2026-04-30-schema-evolution.md | 4 +- docker-compose.yml | 16 +- dprint.json | 25 - drizzle.config.ts | 2 +- package.json | 12 +- pnpm-lock.yaml | 338 +++-- pnpm-workspace.yaml | 1 - server.ts | 343 ++--- src/App.tsx | 28 +- src/api/accounts.ts | 1048 ++++++++------- src/api/admin.ts | 74 +- src/api/ark-middleware.server.ts | 59 +- src/api/ark.ts | 415 +++--- src/api/auth.server.ts | 104 +- src/api/collections.ts | 580 +++++---- src/api/files.ts | 226 ++-- src/api/health.ts | 6 +- src/api/kf-auth.ts | 151 +-- src/api/kf-summary.ts | 84 +- src/api/query.ts | 338 ++--- src/api/schemas.ts | 390 +++--- src/api/uploads.ts | 718 ++++++----- src/api/versions.ts | 953 +++++++------- src/components/ApiPlayground.tsx | 224 ++-- src/components/BaseLayout.tsx | 99 +- src/components/BlogLayout.tsx | 27 +- src/components/CollectionExplorer.tsx | 86 +- src/components/DocsLayout.tsx | 57 +- src/components/DocsSearch.tsx | 81 +- src/components/MirrorAdmin.tsx | 395 +++--- src/components/NotFound.tsx | 59 +- src/components/QueryExplorer.tsx | 1127 +++++++++-------- src/components/SchemaBrowser.tsx | 206 +-- src/components/SchemaLabelManager.tsx | 104 +- src/components/UserMenu.tsx | 71 +- src/db/client.server.ts | 12 +- src/db/migrate.ts | 35 +- src/db/migrations/meta/0000_snapshot.json | 168 +-- src/db/migrations/meta/0001_snapshot.json | 196 +-- src/db/migrations/meta/0002_snapshot.json | 225 +--- src/db/migrations/meta/0003_snapshot.json | 225 +--- src/db/migrations/meta/0004_snapshot.json | 225 +--- src/db/migrations/meta/0005_snapshot.json | 225 +--- src/db/migrations/meta/0007_snapshot.json | 270 +--- src/db/migrations/meta/0008_snapshot.json | 266 +--- src/db/migrations/meta/0009_snapshot.json | 266 +--- src/db/migrations/meta/0010_snapshot.json | 258 +--- src/db/migrations/meta/0011_snapshot.json | 258 +--- src/db/schema.ts | 363 +++--- src/db/seed.ts | 466 ++++--- src/entry-client.tsx | 10 +- src/entry-server.tsx | 76 +- src/global.css | 15 +- src/lib/ark.test.ts | 161 +-- src/lib/ark.ts | 109 +- src/lib/auth.server.ts | 53 +- src/lib/email.ts | 26 +- src/lib/kf-auth.server.ts | 34 +- src/lib/kf-orgs.server.ts | 17 +- src/lib/kf-profile-cache.server.ts | 30 +- src/lib/mirror-sync.ts | 383 +++--- src/lib/s3.ts | 43 +- src/lib/sqlite-gen.ts | 64 +- src/lib/ssr-data.tsx | 16 +- src/lib/version-helpers.server.ts | 54 +- src/loaders.server.ts | 254 ++-- src/route-gen.test.ts | 55 +- src/route-gen.ts | 36 +- src/routes/[owner]/[collection]/diff.tsx | 359 +++--- src/routes/[owner]/[collection]/index.tsx | 325 +++-- src/routes/[owner]/[collection]/schemas.tsx | 455 ++++--- src/routes/[owner]/[collection]/settings.tsx | 355 +++--- src/routes/[owner]/[collection]/v/[n].tsx | 900 +++++++------ src/routes/[owner]/[collection]/versions.tsx | 177 +-- src/routes/[owner]/index.tsx | 309 +++-- src/routes/[owner]/settings/index.tsx | 536 ++++---- src/routes/[owner]/settings/keys.tsx | 331 ++--- src/routes/[owner]/settings/members.tsx | 335 ++--- src/routes/admin/mirror.tsx | 12 +- src/routes/blog/[slug].tsx | 31 +- src/routes/blog/index.tsx | 36 +- src/routes/dashboard.tsx | 578 +++++---- src/routes/docs/api/accounts.tsx | 77 +- src/routes/docs/api/collections.tsx | 94 +- src/routes/docs/api/files.tsx | 68 +- src/routes/docs/api/index.tsx | 103 +- src/routes/docs/api/versions.tsx | 211 +-- src/routes/docs/concepts.tsx | 86 +- src/routes/docs/index.tsx | 80 +- src/routes/docs/integration.tsx | 107 +- src/routes/docs/quickstart.tsx | 52 +- src/routes/docs/self-host.tsx | 40 +- src/routes/explore.tsx | 8 +- src/routes/forgot-password.tsx | 9 +- src/routes/index.tsx | 205 +-- src/routes/invitations/accept.tsx | 187 +-- src/routes/login.tsx | 25 +- src/routes/logout.tsx | 28 +- src/routes/query.tsx | 79 +- src/routes/reset-password.tsx | 9 +- src/routes/schemas/[id].tsx | 188 +-- src/routes/schemas/index.tsx | 9 +- src/routes/settings/avatar.tsx | 131 +- src/routes/settings/index.tsx | 326 ++--- src/routes/settings/keys.tsx | 295 +++-- src/routes/settings/sessions.tsx | 146 ++- src/routes/signup.tsx | 9 +- tools/backupDb.ts | 38 +- tools/cron.ts | 44 +- tools/seedMirror.ts | 31 +- vite.config.ts | 27 +- vitest.config.ts | 9 +- 119 files changed, 10283 insertions(+), 10642 deletions(-) create mode 100644 .oxfmtrc.json delete mode 100644 dprint.json diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8d66c85..fef46aa 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -41,6 +41,9 @@ jobs: - name: Install dependencies run: pnpm install --frozen-lockfile + - name: Format check + run: pnpm fmt:check + - name: Lint run: pnpm lint diff --git a/.github/workflows/deploy-mirror.yml b/.github/workflows/deploy-mirror.yml index 9b11e1b..51b5550 100644 --- a/.github/workflows/deploy-mirror.yml +++ b/.github/workflows/deploy-mirror.yml @@ -1,6 +1,6 @@ name: Deploy Mirror -run-name: "Deploy mirror: ${{ github.sha }}" +run-name: 'Deploy mirror: ${{ github.sha }}' concurrency: group: deploy-mirror diff --git a/.oxfmtrc.json b/.oxfmtrc.json new file mode 100644 index 0000000..242abd1 --- /dev/null +++ b/.oxfmtrc.json @@ -0,0 +1,7 @@ +{ + "$schema": "./node_modules/oxfmt/configuration_schema.json", + "semi": false, + "singleQuote": true, + "sortImports": true, + "sortTailwindcss": true +} diff --git a/README.md b/README.md index 6446130..a6c721a 100644 --- a/README.md +++ b/README.md @@ -20,6 +20,7 @@ cd underlay ``` This starts: + - **PostgreSQL 16** on port 5433 (host) → 5432 (container) - **MinIO** (S3-compatible storage) on ports 9000/9001 - **Underlay** on port 3000 @@ -44,18 +45,18 @@ In production, user accounts are created automatically on first sign-in via [KF ## Architecture -| Layer | Technology | -|-------|-----------| -| Server | Hono 4 + @hono/node-server | -| Frontend | React 19 + React Router v7 (SSR + client hydration) | -| Styling | Tailwind CSS 4 (@tailwindcss/vite) | -| Build | Vite 6 (client + SSR bundles) | -| Database | PostgreSQL 16 + Drizzle ORM | -| File Storage | Cloudflare R2 (prod) / MinIO (dev) — S3-compatible | -| Auth | KF Auth SSO (OIDC) for web sessions + API keys (programmatic) | -| Deployment | Docker Swarm on Hetzner, Caddy reverse proxy, Cloudflare DNS | -| CI/CD | GitHub Actions → GHCR → SSH → `docker stack deploy` | -| Secrets | SOPS + age encryption | +| Layer | Technology | +| ------------ | ------------------------------------------------------------- | +| Server | Hono 4 + @hono/node-server | +| Frontend | React 19 + React Router v7 (SSR + client hydration) | +| Styling | Tailwind CSS 4 (@tailwindcss/vite) | +| Build | Vite 6 (client + SSR bundles) | +| Database | PostgreSQL 16 + Drizzle ORM | +| File Storage | Cloudflare R2 (prod) / MinIO (dev) — S3-compatible | +| Auth | KF Auth SSO (OIDC) for web sessions + API keys (programmatic) | +| Deployment | Docker Swarm on Hetzner, Caddy reverse proxy, Cloudflare DNS | +| CI/CD | GitHub Actions → GHCR → SSH → `docker stack deploy` | +| Secrets | SOPS + age encryption | The app runs as a single Hono server on one port (default 3000). In dev, Vite runs in middleware mode for HMR. In production, Vite builds client and SSR bundles that Hono serves directly. @@ -133,10 +134,10 @@ tools/ Two Docker Swarm stacks run on the same box: -| Stack | Domain | Host Port | Purpose | -|-------|--------|-----------|---------| -| `underlay-prod` | www.underlay.org | 3001 | Production | -| `underlay-dev` | dev.underlay.org | 3000 | Staging | +| Stack | Domain | Host Port | Purpose | +| --------------- | ---------------- | --------- | ---------- | +| `underlay-prod` | www.underlay.org | 3001 | Production | +| `underlay-dev` | dev.underlay.org | 3000 | Staging | Container-internal port is always 3000. Host port is configured via `PORT` in .env files. @@ -152,23 +153,23 @@ Required GitHub secrets: `SSH_PRIVATE_KEY`, `SSH_USER`, `GHCR_USER`, `GHCR_TOKEN ### Docker Compose Files -| File | Purpose | -|------|---------| -| `docker-compose.yml` | Deployed stacks (prod & dev via Swarm) | +| File | Purpose | +| -------------------------- | ----------------------------------------------------- | +| `docker-compose.yml` | Deployed stacks (prod & dev via Swarm) | | `docker-compose.local.yml` | Local development (source-mounted, MinIO, hot reload) | ## Environment Variables -| Variable | Description | -|----------|-------------| -| `DATABASE_URL` | PostgreSQL connection string | +| Variable | Description | +| ---------------- | ---------------------------------- | +| `DATABASE_URL` | PostgreSQL connection string | | `SESSION_SECRET` | Secret for signing session cookies | -| `PORT` | Server port (default: 3000) | -| `S3_BUCKET` | S3 bucket name | -| `S3_REGION` | S3 region (`auto` for R2) | -| `S3_ENDPOINT` | S3 endpoint URL | -| `S3_ACCESS_KEY` | S3 access key | -| `S3_SECRET_KEY` | S3 secret key | +| `PORT` | Server port (default: 3000) | +| `S3_BUCKET` | S3 bucket name | +| `S3_REGION` | S3 region (`auto` for R2) | +| `S3_ENDPOINT` | S3 endpoint URL | +| `S3_ACCESS_KEY` | S3 access key | +| `S3_SECRET_KEY` | S3 secret key | `NODE_ENV` is set in `docker-compose.yml` `environment:` block (not in .env files). @@ -184,7 +185,7 @@ pnpm start # Start production server # Code quality pnpm typecheck # TypeScript type checking pnpm lint # Lint with oxlint -pnpm fmt # Format with dprint +pnpm fmt # Format with oxfmt pnpm fmt:check # Check formatting # Database @@ -242,11 +243,11 @@ Schemas can be labeled post-hoc with human-readable names or URIs (e.g. `schema. ### Schema discovery API -| Endpoint | Purpose | -|----------|--------| -| `GET /api/schemas` | Global search (filter by `q`, `slug`, `label`, `schema_hash`) | -| `GET /api/schemas/:id` | Single schema with labels + usage info | -| `GET /api/collections/:owner/:slug/schemas` | Collection's schemas (with label enrichment) | +| Endpoint | Purpose | +| ------------------------------------------- | ------------------------------------------------------------- | +| `GET /api/schemas` | Global search (filter by `q`, `slug`, `label`, `schema_hash`) | +| `GET /api/schemas/:id` | Single schema with labels + usage info | +| `GET /api/collections/:owner/:slug/schemas` | Collection's schemas (with label enrichment) | ### Versioning semantics @@ -258,21 +259,22 @@ Schemas can be labeled post-hoc with human-readable names or URIs (e.g. `schema. When adding or changing features, update these locations: -| What | Where | Purpose | -|------|-------|---------| -| API documentation | `public/.well-known/ai.txt` | Machine-readable docs for LLMs and bots | -| Concepts | `src/routes/docs/concepts.tsx` | Core concepts explanation | -| API reference | `src/routes/docs/api/*.tsx` | Endpoint-level docs with examples | -| Integration guide | `src/routes/docs/integration.tsx` | Developer onboarding guide | -| Quick start | `src/routes/docs/quickstart.tsx` | Getting started tutorial | -| Self-hosting | `src/routes/docs/self-host.tsx` | Deployment instructions | -| DB schema | `src/db/schema.ts` → `pnpm db:generate` | Schema changes need a migration | -| Schema discovery | `src/api/schemas.ts` | Schema search, labeling, cross-referencing | -| Encrypted secrets | `.env.enc` / `.env.dev.enc` | Re-encrypt after changing .env files | +| What | Where | Purpose | +| ----------------- | --------------------------------------- | ------------------------------------------ | +| API documentation | `public/.well-known/ai.txt` | Machine-readable docs for LLMs and bots | +| Concepts | `src/routes/docs/concepts.tsx` | Core concepts explanation | +| API reference | `src/routes/docs/api/*.tsx` | Endpoint-level docs with examples | +| Integration guide | `src/routes/docs/integration.tsx` | Developer onboarding guide | +| Quick start | `src/routes/docs/quickstart.tsx` | Getting started tutorial | +| Self-hosting | `src/routes/docs/self-host.tsx` | Deployment instructions | +| DB schema | `src/db/schema.ts` → `pnpm db:generate` | Schema changes need a migration | +| Schema discovery | `src/api/schemas.ts` | Schema search, labeling, cross-referencing | +| Encrypted secrets | `.env.enc` / `.env.dev.enc` | Re-encrypt after changing .env files | ### Privacy features The system supports three levels of privacy (type-level, field-level, record-level) via `"private": true` annotations in per-type schemas. When changing how privacy works, update: + - `src/api/versions.ts` — filtering logic (reads from `version_schemas` JOIN `schemas`) - `src/api/files.ts` — file access checks - `src/api/schemas.ts` — public schema filtering diff --git a/content/blog/2024-04-27-institutional-repositories.md b/content/blog/2024-04-27-institutional-repositories.md index 5bf50de..78f88ef 100644 --- a/content/blog/2024-04-27-institutional-repositories.md +++ b/content/blog/2024-04-27-institutional-repositories.md @@ -1,5 +1,5 @@ --- -title: "The IR of the Future Is a Reading List" +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 --- @@ -62,4 +62,4 @@ The institutional repository we are describing is a curated reading list backed Institutional repositories were always meant to make a university's knowledge accessible. They ended up becoming another silo that knowledge had to be manually deposited into. The better path is to make the knowledge accessible first, structured and versioned and public, and let the institution put its frame around it. -*Underlay is a public registry for structured knowledge. An institutional repository is one way to read it.* +_Underlay is a public registry for structured knowledge. An institutional repository is one way to read it._ diff --git a/content/blog/2024-04-27-underlay-revived.md b/content/blog/2024-04-27-underlay-revived.md index 027f15e..235dc48 100644 --- a/content/blog/2024-04-27-underlay-revived.md +++ b/content/blog/2024-04-27-underlay-revived.md @@ -1,6 +1,6 @@ --- -title: "Underlay, Revived" -subtitle: "The landscape changed. The project can finally be simple." +title: 'Underlay, Revived' +subtitle: 'The landscape changed. The project can finally be simple.' date: 2024-04-27 --- @@ -20,7 +20,7 @@ Three things, each removing a class of complexity the original design had to abs That is no longer true. With modern tooling and AI-assisted development, a bespoke publishing application with custom workflows and a tailored editorial process can be built in a fraction of the time and cost. Better for the user: they get exactly the tool they need. -But if the application is bespoke, and perhaps even disposable, the *data* needs to live somewhere durable. The application is the interface; the archive is the thing that lasts. +But if the application is bespoke, and perhaps even disposable, the _data_ needs to live somewhere durable. The application is the interface; the archive is the thing that lasts. **LLMs changed what "interoperability" requires.** The original Underlay spent enormous effort on alignment: reconciling schemas, mapping ontologies, resolving entity references across datasets. This was genuinely hard when the consumer was a rigid program that needed exact field names and precise types. @@ -69,4 +69,4 @@ The Underlay was always about one thing: public knowledge should be a public res The hard problems we originally tried to solve in the protocol (alignment, mapping, transformation, discovery) are now better solved by tools and models that sit on top of structured data. The Underlay does not need to be smart. It needs to be reliable, durable, and clear. Get the structure right, make it public, and the rest follows. -*Underlay is a public registry for structured knowledge. The structure is the infrastructure.* +_Underlay is a public registry for structured knowledge. The structure is the infrastructure._ diff --git a/content/blog/2026-04-28-atproto-integration.md b/content/blog/2026-04-28-atproto-integration.md index e7b7ee5..5e6e889 100644 --- a/content/blog/2026-04-28-atproto-integration.md +++ b/content/blog/2026-04-28-atproto-integration.md @@ -1,6 +1,6 @@ --- -title: "Underlay Meets AT Protocol" -subtitle: "Every collection is a feed. Every push is an event." +title: 'Underlay Meets AT Protocol' +subtitle: 'Every collection is a feed. Every push is an event.' date: 2026-04-28 --- diff --git a/content/blog/2026-04-30-schema-evolution.md b/content/blog/2026-04-30-schema-evolution.md index 037ecb5..8d44ed3 100644 --- a/content/blog/2026-04-30-schema-evolution.md +++ b/content/blog/2026-04-30-schema-evolution.md @@ -1,6 +1,6 @@ --- -title: "From Monolithic Schemas to Content-Addressed Types" -subtitle: "How we made interoperability automatic by treating schemas like files." +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/docker-compose.yml b/docker-compose.yml index 50472de..3851255 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -38,7 +38,11 @@ services: tmpfs: - /tmp:size=64m healthcheck: - test: ['CMD-SHELL', 'node -e "Promise.all([fetch(\"http://127.0.0.1:${PORT:-3000}/api/health\"),fetch(\"http://127.0.0.1:${PORT:-3000}/\")]).then(rs=>{for(const r of rs)if(!r.ok)throw r.status}).catch(()=>process.exit(1))"'] + test: + [ + 'CMD-SHELL', + 'node -e "Promise.all([fetch(\"http://127.0.0.1:${PORT:-3000}/api/health\"),fetch(\"http://127.0.0.1:${PORT:-3000}/\")]).then(rs=>{for(const r of rs)if(!r.ok)throw r.status}).catch(()=>process.exit(1))"', + ] interval: 30s timeout: 10s retries: 3 @@ -114,24 +118,24 @@ services: resources: limits: memory: 4g - cpus: "2.0" + cpus: '2.0' reservations: memory: 1g - cpus: "0.5" + cpus: '0.5' placement: constraints: [node.role == manager] restart_policy: condition: any healthcheck: - test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-underlay}"] + test: ['CMD-SHELL', 'pg_isready -U ${POSTGRES_USER:-underlay}'] interval: 10s timeout: 5s retries: 5 logging: driver: json-file options: - max-size: "10m" - max-file: "3" + max-size: '10m' + max-file: '3' networks: appnet: diff --git a/dprint.json b/dprint.json deleted file mode 100644 index bd92cbf..0000000 --- a/dprint.json +++ /dev/null @@ -1,25 +0,0 @@ -{ - "typescript": { - "indentWidth": 2, - "semiColons": "asi", - "quoteStyle": "preferSingle", - "trailingCommas": "always" - }, - "json": { - "indentWidth": 2 - }, - "plugins": [ - "https://plugins.dprint.dev/typescript-0.93.3.wasm", - "https://plugins.dprint.dev/json-0.19.4.wasm", - "https://plugins.dprint.dev/g-plane/malva-v0.11.0.wasm" - ], - "includes": [ - "src/**/*.{ts,tsx,css}", - "*.{ts,json}", - "server.ts", - "vite.config.ts", - "vitest.config.ts", - "drizzle.config.ts" - ], - "excludes": ["node_modules", "dist"] -} diff --git a/drizzle.config.ts b/drizzle.config.ts index 36f59a5..d81dc9d 100644 --- a/drizzle.config.ts +++ b/drizzle.config.ts @@ -1,4 +1,4 @@ -import type { Config, } from 'drizzle-kit' +import type { Config } from 'drizzle-kit' export default { schema: './src/db/schema.ts', diff --git a/package.json b/package.json index a08edc8..09e97bd 100644 --- a/package.json +++ b/package.json @@ -3,7 +3,6 @@ "version": "0.1.0", "private": true, "type": "module", - "packageManager": "pnpm@10.33.3", "scripts": { "dev": "./dev.sh", "dev:app": "tsx --env-file=.env.local server.ts", @@ -11,8 +10,8 @@ "start": "NODE_ENV=production node --import tsx/esm server.ts", "typecheck": "tsc --noEmit", "lint": "oxlint .", - "fmt": "dprint fmt", - "fmt:check": "dprint check", + "fmt": "oxfmt", + "fmt:check": "oxfmt --check", "db:generate": "drizzle-kit generate", "db:migrate": "tsx src/db/migrate.ts", "db:seed": "tsx src/db/seed.ts", @@ -69,10 +68,10 @@ "@types/react-dom": "^19.1.0", "@types/tar-stream": "^3.1.4", "@vitejs/plugin-react": "^4", - "dprint": "latest", "drizzle-kit": "^0.31.0", "happy-dom": "^20.9.0", "lint-staged": "^17.0.4", + "oxfmt": "latest", "oxlint": "latest", "simple-git-hooks": "^2.13.1", "tailwindcss": "^4.1.0", @@ -84,6 +83,7 @@ "pre-commit": "pnpm lint-staged" }, "lint-staged": { - "*.{ts,tsx,css,json}": "dprint fmt --" - } + "*.{ts,tsx,css,json}": "oxfmt --" + }, + "packageManager": "pnpm@10.33.3" } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2057bc2..cea68fe 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -126,9 +126,6 @@ importers: '@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)(yaml@2.9.0)) - dprint: - specifier: latest - version: 0.54.0 drizzle-kit: specifier: ^0.31.0 version: 0.31.10 @@ -138,6 +135,9 @@ importers: lint-staged: specifier: ^17.0.4 version: 17.0.4 + oxfmt: + specifier: latest + version: 0.50.0 oxlint: specifier: latest version: 1.63.0 @@ -429,68 +429,6 @@ packages: '@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==} @@ -983,6 +921,128 @@ packages: '@nodable/entities@2.1.0': resolution: {integrity: sha512-nyT7T3nbMyBI/lvr6L5TyWbFJAI9FTgVRakNoBqCD+PmID8DzFrrNdLLtHMwMszOtqZa8PAOV24ZqDnQrhQINA==} + '@oxfmt/binding-android-arm-eabi@0.50.0': + resolution: {integrity: sha512-ICXQVKrDvsWUtfx6EiVJxfWrajKTwTfRV8vz2XiMkxZeuCKJLgD4YAj6dE3BWvpqDlkVkie4VSTAtMUWO9LDXg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [android] + + '@oxfmt/binding-android-arm64@0.50.0': + resolution: {integrity: sha512-quwjLQFkuW6OwLHeDeIXsTzOmipQFQbqsYN9HLk2B5I01IlAQZHP1UiLIg0O7pP+dUgPD2AD7SCYA3gs6NH5/g==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [android] + + '@oxfmt/binding-darwin-arm64@0.50.0': + resolution: {integrity: sha512-ikU5umElcMi78/TNI334wtjr5WZ5F4nWa1aIDseAKKGL0W3ygxeYKkrIJ0fggWa8MOon66BmG3xCqmX1m9YAOw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [darwin] + + '@oxfmt/binding-darwin-x64@0.50.0': + resolution: {integrity: sha512-WT4MOYG4mv9IXrH0m60vHsJh+rRMPSOKTQmwDpwmgQ+DuW/i5dU4pqc0HDO5uclO5vjz5IFX5z/taW86LSVe/g==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [darwin] + + '@oxfmt/binding-freebsd-x64@0.50.0': + resolution: {integrity: sha512-gH0rycVXqV4juWkvLs2uPMtTyppDc7qEUVzXAxnQ7FpcSZNXqKowUgtjH8q67ngj416r8+4NnAlyR/D35zwwhQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [freebsd] + + '@oxfmt/binding-linux-arm-gnueabihf@0.50.0': + resolution: {integrity: sha512-wL/k+o0hiTeRvi/gPzeC1L/yTHTXIeHDKWU09s2zTBmv7ma59wTm+fADNSGYxhJQDxyavQbwTf1QpW3Zj924tQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + + '@oxfmt/binding-linux-arm-musleabihf@0.50.0': + resolution: {integrity: sha512-Y59FKqoUM3Gf00E395b4ixfWyJGwO2GzaZawF5MZoVWcb3f6CkWUXyao0jyOvoIxDMzMybcVRuXyG7ih/Nxweg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + + '@oxfmt/binding-linux-arm64-gnu@0.50.0': + resolution: {integrity: sha512-OvXbfTjMignXWyJXg/NOFsiy996vFe8wb9tkxJaUq8ylq0XrzJg3ttavC5Tcmm6F8/GUs2r3XFJWWu9q/27uYw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@oxfmt/binding-linux-arm64-musl@0.50.0': + resolution: {integrity: sha512-rqmvHZm7vMa3NLYa0khwkhReCmp9tqKnF23TFZ7S5cYJLvIE4b0k8famWE7kO897/DXznJe675n5SohFBggbxA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@oxfmt/binding-linux-ppc64-gnu@0.50.0': + resolution: {integrity: sha512-49bAdYbMSde42tzPDtuHnBWzOgmoS0PT9THCjvMnDVYMQYiHzPc2Mv5rkpBHVQOXM+PHfafJlxgK0anXSWBVvw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@oxfmt/binding-linux-riscv64-gnu@0.50.0': + resolution: {integrity: sha512-VFT25/6kckkIM62KeWB2bi+xCEmC/zC+DcMaIpEfaio8ulkGDLSiTz11TyK0eqgTl3x5OklYEGDWohvAgOr8Bw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@oxfmt/binding-linux-riscv64-musl@0.50.0': + resolution: {integrity: sha512-BBJMuNy6jjkXjUUINF5UTQqb/nvjmtJad43Gp7bab0AAURAdthhJvduR7rHpWInpWYiaMzYsdrmURNcrmpxdZA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [riscv64] + os: [linux] + libc: [musl] + + '@oxfmt/binding-linux-s390x-gnu@0.50.0': + resolution: {integrity: sha512-Xd4y+yjAYHKmryXhyUUwbyRD01iKfcvI74iE01L6p4F8SwjhZQXDshK+T8PcrPZLiFqH263P5xqJk94amjkjzQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@oxfmt/binding-linux-x64-gnu@0.50.0': + resolution: {integrity: sha512-Qp96rYJru7l++7mk4R+eh8qq9GFfFAMdmoN6VGoRHI8AA1XMnUIzH4u+zOcKZZwY+irHdsaBldDearwB4nOH7A==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@oxfmt/binding-linux-x64-musl@0.50.0': + resolution: {integrity: sha512-5XLGp+yd5w2Key5LMqJO+X3XVsJKgeeUKljy32+MBF/J/JZ5m8WHl6dI5eOQOr3ixopxPiXIyDAxn3slI3UXiQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [musl] + + '@oxfmt/binding-openharmony-arm64@0.50.0': + resolution: {integrity: sha512-QAxwzh7+GHugCD7WuERolVs8TKQwXNIAZXAHHTecbKVc9oWBkWzOiLauQuezXS57tVcof5zhi1IjZ8tOV0htTg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [openharmony] + + '@oxfmt/binding-win32-arm64-msvc@0.50.0': + resolution: {integrity: sha512-3nKN/kqClm9iCFWTwtJ9UpR5SGyExp5l3nw6uIiBt+3XitQtszin+vjHrL7JHfDksZ7Svigdaow2zqz/IKCfqw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [win32] + + '@oxfmt/binding-win32-ia32-msvc@0.50.0': + resolution: {integrity: sha512-3r6XZ8+X6qlLbXaPW2NygfiAWSpKbkE36pAVzS83mY+cYY+pSMalJ+qnCgkr92tr+Iqv988XKQ1CpARTg9ITbQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ia32] + os: [win32] + + '@oxfmt/binding-win32-x64-msvc@0.50.0': + resolution: {integrity: sha512-BSE8D8KsvquMG9vU+Qt4qGuoOcZ36rxU5S6ZkHNguj+MlWkXWCBETnno3yJ9CfWvfCrbmieaN9LK6hdcdHNZ/w==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [win32] + '@oxlint/binding-android-arm-eabi@1.63.0': resolution: {integrity: sha512-A9xLtQt7i0OA1PoB/meog6kikXI9CdwEp7ZwQqmgnpKn3G3b1orvTDy8CQ6T7w1HvDrgWGB78PkFKcWgibcTCg==} engines: {node: ^20.19.0 || >=22.12.0} @@ -1850,10 +1910,6 @@ packages: dom-accessibility-api@0.5.16: resolution: {integrity: sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==} - dprint@0.54.0: - resolution: {integrity: sha512-sIy25poR2gRP/tWPTgP0MPeJoJcpv0xzYDcsboapvthbEt1Qw3Al252CA0xFyIh2cYEGGKyBJtKokryv4ERlJw==} - hasBin: true - drizzle-kit@0.31.10: resolution: {integrity: sha512-7OZcmQUrdGI+DUNNsKBn1aW8qSoKuTH7d0mYgSP8bAzdFzKoovxEFnoGQp2dVs82EOJeYycqRtciopszwUf8bw==} hasBin: true @@ -2269,6 +2325,16 @@ packages: resolution: {integrity: sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ==} engines: {node: '>=18'} + oxfmt@0.50.0: + resolution: {integrity: sha512-owwjTnhfM5aCOJhYeqDvk7iM504OeYFZpdRU7cxx7xtZMo4uVpjlryTUon+Cf76CugsvnqA32e6rC73pr1hXaw==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + peerDependencies: + svelte: ^5.0.0 + peerDependenciesMeta: + svelte: + optional: true + oxlint@1.63.0: resolution: {integrity: sha512-9TGXetdjgIHOJ9OiReomP7nnrMkV9HxC1xM2ramJSLQpzxjsAJtQwa4wqkJN2f/uCrqZuJseFuSlWDdvcruveg==} engines: {node: ^20.19.0 || >=22.12.0} @@ -2496,6 +2562,10 @@ packages: resolution: {integrity: sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==} engines: {node: '>=12.0.0'} + tinypool@2.1.0: + resolution: {integrity: sha512-Pugqs6M0m7Lv1I7FtxN4aoyToKg1C4tu+/381vH35y8oENM/Ai7f7C4StcoK4/+BSw9ebcS8jRiVrORFKCALLw==} + engines: {node: ^20.0.0 || >=22.0.0} + tinyrainbow@3.1.0: resolution: {integrity: sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==} engines: {node: '>=14.0.0'} @@ -3281,39 +3351,6 @@ snapshots: 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': @@ -3585,6 +3622,63 @@ snapshots: '@nodable/entities@2.1.0': {} + '@oxfmt/binding-android-arm-eabi@0.50.0': + optional: true + + '@oxfmt/binding-android-arm64@0.50.0': + optional: true + + '@oxfmt/binding-darwin-arm64@0.50.0': + optional: true + + '@oxfmt/binding-darwin-x64@0.50.0': + optional: true + + '@oxfmt/binding-freebsd-x64@0.50.0': + optional: true + + '@oxfmt/binding-linux-arm-gnueabihf@0.50.0': + optional: true + + '@oxfmt/binding-linux-arm-musleabihf@0.50.0': + optional: true + + '@oxfmt/binding-linux-arm64-gnu@0.50.0': + optional: true + + '@oxfmt/binding-linux-arm64-musl@0.50.0': + optional: true + + '@oxfmt/binding-linux-ppc64-gnu@0.50.0': + optional: true + + '@oxfmt/binding-linux-riscv64-gnu@0.50.0': + optional: true + + '@oxfmt/binding-linux-riscv64-musl@0.50.0': + optional: true + + '@oxfmt/binding-linux-s390x-gnu@0.50.0': + optional: true + + '@oxfmt/binding-linux-x64-gnu@0.50.0': + optional: true + + '@oxfmt/binding-linux-x64-musl@0.50.0': + optional: true + + '@oxfmt/binding-openharmony-arm64@0.50.0': + optional: true + + '@oxfmt/binding-win32-arm64-msvc@0.50.0': + optional: true + + '@oxfmt/binding-win32-ia32-msvc@0.50.0': + optional: true + + '@oxfmt/binding-win32-x64-msvc@0.50.0': + optional: true + '@oxlint/binding-android-arm-eabi@1.63.0': optional: true @@ -4417,20 +4511,6 @@ snapshots: dom-accessibility-api@0.5.16: {} - 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 @@ -4768,6 +4848,30 @@ snapshots: dependencies: mimic-function: 5.0.1 + oxfmt@0.50.0: + dependencies: + tinypool: 2.1.0 + optionalDependencies: + '@oxfmt/binding-android-arm-eabi': 0.50.0 + '@oxfmt/binding-android-arm64': 0.50.0 + '@oxfmt/binding-darwin-arm64': 0.50.0 + '@oxfmt/binding-darwin-x64': 0.50.0 + '@oxfmt/binding-freebsd-x64': 0.50.0 + '@oxfmt/binding-linux-arm-gnueabihf': 0.50.0 + '@oxfmt/binding-linux-arm-musleabihf': 0.50.0 + '@oxfmt/binding-linux-arm64-gnu': 0.50.0 + '@oxfmt/binding-linux-arm64-musl': 0.50.0 + '@oxfmt/binding-linux-ppc64-gnu': 0.50.0 + '@oxfmt/binding-linux-riscv64-gnu': 0.50.0 + '@oxfmt/binding-linux-riscv64-musl': 0.50.0 + '@oxfmt/binding-linux-s390x-gnu': 0.50.0 + '@oxfmt/binding-linux-x64-gnu': 0.50.0 + '@oxfmt/binding-linux-x64-musl': 0.50.0 + '@oxfmt/binding-openharmony-arm64': 0.50.0 + '@oxfmt/binding-win32-arm64-msvc': 0.50.0 + '@oxfmt/binding-win32-ia32-msvc': 0.50.0 + '@oxfmt/binding-win32-x64-msvc': 0.50.0 + oxlint@1.63.0: optionalDependencies: '@oxlint/binding-android-arm-eabi': 1.63.0 @@ -5043,6 +5147,8 @@ snapshots: fdir: 6.5.0(picomatch@4.0.4) picomatch: 4.0.4 + tinypool@2.1.0: {} + tinyrainbow@3.1.0: {} tslib@2.8.1: {} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 6d6aebc..aa9dd4d 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -1,6 +1,5 @@ allowBuilds: bcrypt: true better-sqlite3: true - dprint: true esbuild: true simple-git-hooks: false diff --git a/server.ts b/server.ts index eb38d6e..f575334 100644 --- a/server.ts +++ b/server.ts @@ -1,17 +1,18 @@ -import { serve, } from '@hono/node-server' -import { serveStatic, } from '@hono/node-server/serve-static' -import { Hono, } from 'hono' -import { cors, } from 'hono/cors' -import { marked, } from 'marked' -import { existsSync, readFileSync, } from 'node:fs' -import { resolve, } from 'node:path' +import { existsSync, readFileSync } from 'node:fs' +import { resolve } from 'node:path' + +import { serve } from '@hono/node-server' +import { serveStatic } from '@hono/node-server/serve-static' +import { Hono } from 'hono' +import { cors } from 'hono/cors' +import { marked } from 'marked' import * as accounts from '~/api/accounts' import * as admin from '~/api/admin' import * as ark from '~/api/ark' -import { arkMiddleware, } from '~/api/ark-middleware.server' -import type { AuthEnv, } from '~/api/auth.server' -import { authMiddleware, requireAuth, } from '~/api/auth.server' +import { arkMiddleware } from '~/api/ark-middleware.server' +import type { AuthEnv } from '~/api/auth.server' +import { authMiddleware, requireAuth } from '~/api/auth.server' import * as collections from '~/api/collections' import * as files from '~/api/files' import * as health from '~/api/health' @@ -21,257 +22,287 @@ import * as query from '~/api/query' import * as schemas from '~/api/schemas' import * as uploads from '~/api/uploads' import * as versions from '~/api/versions' -import { getMirrorConfig, } from '~/lib/mirror-config' +import { getMirrorConfig } from '~/lib/mirror-config' const isProd = process.env.NODE_ENV === 'production' const app = new Hono() // --- CORS --- -app.use('/api/*', cors({ origin: '*', credentials: true, },),) +app.use('/api/*', cors({ origin: '*', credentials: true })) // --- Auth middleware for API routes --- -app.use('/api/*', authMiddleware,) +app.use('/api/*', authMiddleware) // --- Mirror mode guard for admin routes --- -app.use('/api/admin/*', async (c, next,) => { +app.use('/api/admin/*', async (c, next) => { const config = getMirrorConfig() if (!config.enabled) { - return c.json({ error: 'Not found', statusCode: 404, }, 404,) + return c.json({ error: 'Not found', statusCode: 404 }, 404) } await next() -},) +}) // --- ARK resolution middleware --- -app.use('/ark\\:*', arkMiddleware,) +app.use('/ark\\:*', arkMiddleware) // --- KF Auth (OIDC login) --- -app.get('/login', async (c, next,) => { +app.get('/login', async (c, next) => { // Server-side redirect to avoid client-side "Redirecting..." flash. // Fall through to the React route only when there's an error to display. - const url = new URL(c.req.url,) - if (!url.searchParams.has('error',)) { - const returnTo = url.searchParams.get('return_to',) ?? '' - const target = returnTo ? `/auth/login?return_to=${encodeURIComponent(returnTo,)}` : '/auth/login' - return c.redirect(target,) + const url = new URL(c.req.url) + if (!url.searchParams.has('error')) { + const returnTo = url.searchParams.get('return_to') ?? '' + const target = returnTo + ? `/auth/login?return_to=${encodeURIComponent(returnTo)}` + : '/auth/login' + return c.redirect(target) } await next() -},) -app.get('/auth/login', kfAuth.login,) -app.get('/auth/callback', kfAuth.callback,) -app.post('/auth/logout', kfAuth.logout,) +}) +app.get('/auth/login', kfAuth.login) +app.get('/auth/callback', kfAuth.callback) +app.post('/auth/logout', kfAuth.logout) // --- API routes --- -app.get('/api/health', health.check,) +app.get('/api/health', health.check) // KF internal (service-to-service) -app.get('/api/kf/summary', kfSummary.summary,) +app.get('/api/kf/summary', kfSummary.summary) // Admin (mirror) -app.get('/api/admin/mirror/status', admin.mirrorStatus,) -app.post('/api/admin/mirror/test', admin.mirrorTest,) -app.post('/api/admin/mirror/sync', admin.mirrorSync,) -app.post('/api/admin/mirror/sync/stop', admin.mirrorSyncStop,) -app.get('/api/admin/mirror/sync/progress', admin.mirrorSyncProgress,) -app.get('/api/admin/mirror/sync/active', admin.mirrorSyncActive,) -app.get('/api/admin/mirror/history', admin.mirrorHistory,) +app.get('/api/admin/mirror/status', admin.mirrorStatus) +app.post('/api/admin/mirror/test', admin.mirrorTest) +app.post('/api/admin/mirror/sync', admin.mirrorSync) +app.post('/api/admin/mirror/sync/stop', admin.mirrorSyncStop) +app.get('/api/admin/mirror/sync/progress', admin.mirrorSyncProgress) +app.get('/api/admin/mirror/sync/active', admin.mirrorSyncActive) +app.get('/api/admin/mirror/history', admin.mirrorHistory) // Query -app.get('/api/query/sqlite/:owner/:slug/:version', query.sqlite,) -app.get('/api/query/ddl/:owner/:slug/:version', query.ddl,) -app.post('/api/query/generate-sql', query.generateSql,) -app.get('/api/query/collections/search', query.searchCollections,) -app.get('/api/query/collections/:owner/:slug/versions', query.collectionVersions,) +app.get('/api/query/sqlite/:owner/:slug/:version', query.sqlite) +app.get('/api/query/ddl/:owner/:slug/:version', query.ddl) +app.post('/api/query/generate-sql', query.generateSql) +app.get('/api/query/collections/search', query.searchCollections) +app.get('/api/query/collections/:owner/:slug/versions', query.collectionVersions) // Schemas -app.get('/api/schemas', schemas.listSchemas,) -app.get('/api/schemas/:id', schemas.getSchema,) -app.get('/api/collections/:owner/:slug/schemas', schemas.collectionSchemas,) -app.post('/api/schemas/:id/labels', requireAuth('write',), schemas.addLabel,) -app.delete('/api/schemas/:id/labels/:label', requireAuth('admin',), schemas.removeLabel,) +app.get('/api/schemas', schemas.listSchemas) +app.get('/api/schemas/:id', schemas.getSchema) +app.get('/api/collections/:owner/:slug/schemas', schemas.collectionSchemas) +app.post('/api/schemas/:id/labels', requireAuth('write'), schemas.addLabel) +app.delete('/api/schemas/:id/labels/:label', requireAuth('admin'), schemas.removeLabel) // ARK -app.get('/api/ark/resolve', ark.resolve,) -app.get('/api/collections/:owner/:slug/ark', requireAuth('read',), ark.getArk,) -app.patch('/api/collections/:owner/:slug/ark', requireAuth('write',), ark.updateArk,) -app.get('/api/collections/:owner/:slug/ark/record-types', requireAuth('read',), ark.getArkRecordTypes,) -app.patch('/api/collections/:owner/:slug/ark/record-types', requireAuth('write',), ark.updateArkRecordTypes,) -app.patch('/api/accounts/:slug/ark', requireAuth('admin',), ark.updateAccountArk,) +app.get('/api/ark/resolve', ark.resolve) +app.get('/api/collections/:owner/:slug/ark', requireAuth('read'), ark.getArk) +app.patch('/api/collections/:owner/:slug/ark', requireAuth('write'), ark.updateArk) +app.get( + '/api/collections/:owner/:slug/ark/record-types', + requireAuth('read'), + ark.getArkRecordTypes, +) +app.patch( + '/api/collections/:owner/:slug/ark/record-types', + requireAuth('write'), + ark.updateArkRecordTypes, +) +app.patch('/api/accounts/:slug/ark', requireAuth('admin'), ark.updateAccountArk) // Collections -app.get('/api/collections', collections.list,) -app.post('/api/accounts/:owner/collections', requireAuth('write',), collections.create,) -app.get('/api/collections/:owner/:slug', collections.get,) -app.patch('/api/collections/:owner/:slug', requireAuth('write',), collections.update,) -app.delete('/api/collections/:owner/:slug', requireAuth('admin',), collections.remove,) -app.post('/api/collections/:owner/:slug/transfer', requireAuth(), collections.transfer,) -app.get('/api/accounts/:owner/collections', collections.listByOwner,) -app.get('/api/collections/:owner/:slug/export', collections.exportArchive,) +app.get('/api/collections', collections.list) +app.post('/api/accounts/:owner/collections', requireAuth('write'), collections.create) +app.get('/api/collections/:owner/:slug', collections.get) +app.patch('/api/collections/:owner/:slug', requireAuth('write'), collections.update) +app.delete('/api/collections/:owner/:slug', requireAuth('admin'), collections.remove) +app.post('/api/collections/:owner/:slug/transfer', requireAuth(), collections.transfer) +app.get('/api/accounts/:owner/collections', collections.listByOwner) +app.get('/api/collections/:owner/:slug/export', collections.exportArchive) // Files -app.on('HEAD', '/api/collections/:owner/:slug/files/:hash', files.headFile,) -app.get('/api/collections/:owner/:slug/files/:hash', files.getFile,) -app.put('/api/collections/:owner/:slug/files/:hash', requireAuth('write',), files.putFile,) +app.on('HEAD', '/api/collections/:owner/:slug/files/:hash', files.headFile) +app.get('/api/collections/:owner/:slug/files/:hash', files.getFile) +app.put('/api/collections/:owner/:slug/files/:hash', requireAuth('write'), files.putFile) // Uploads -app.post('/api/collections/:owner/:slug/versions/upload', requireAuth('write',), uploads.startSession,) -app.put('/api/collections/:owner/:slug/versions/upload/:sessionId', requireAuth('write',), uploads.appendBatch,) -app.get('/api/collections/:owner/:slug/versions/upload/:sessionId', requireAuth('read',), uploads.getSession,) -app.post('/api/collections/:owner/:slug/versions/upload/:sessionId/finalize', requireAuth('write',), uploads.finalize,) -app.delete('/api/collections/:owner/:slug/versions/upload/:sessionId', requireAuth('write',), uploads.cancelSession,) +app.post( + '/api/collections/:owner/:slug/versions/upload', + requireAuth('write'), + uploads.startSession, +) +app.put( + '/api/collections/:owner/:slug/versions/upload/:sessionId', + requireAuth('write'), + uploads.appendBatch, +) +app.get( + '/api/collections/:owner/:slug/versions/upload/:sessionId', + requireAuth('read'), + uploads.getSession, +) +app.post( + '/api/collections/:owner/:slug/versions/upload/:sessionId/finalize', + requireAuth('write'), + uploads.finalize, +) +app.delete( + '/api/collections/:owner/:slug/versions/upload/:sessionId', + requireAuth('write'), + uploads.cancelSession, +) // Versions -app.get('/api/collections/:owner/:slug/versions', versions.list,) -app.get('/api/collections/:owner/:slug/versions/latest', versions.latest,) -app.get('/api/collections/:owner/:slug/versions/:n', versions.getByNumber,) -app.get('/api/collections/:owner/:slug/versions/:n/records', versions.records,) -app.get('/api/collections/:owner/:slug/versions/:n/files', versions.files,) -app.get('/api/collections/:owner/:slug/versions/:n/manifest', versions.manifest,) -app.post('/api/collections/:owner/:slug/versions', requireAuth('write',), versions.push,) -app.get('/api/collections/:owner/:slug/versions/:n/diff', versions.diff,) +app.get('/api/collections/:owner/:slug/versions', versions.list) +app.get('/api/collections/:owner/:slug/versions/latest', versions.latest) +app.get('/api/collections/:owner/:slug/versions/:n', versions.getByNumber) +app.get('/api/collections/:owner/:slug/versions/:n/records', versions.records) +app.get('/api/collections/:owner/:slug/versions/:n/files', versions.files) +app.get('/api/collections/:owner/:slug/versions/:n/manifest', versions.manifest) +app.post('/api/collections/:owner/:slug/versions', requireAuth('write'), versions.push) +app.get('/api/collections/:owner/:slug/versions/:n/diff', versions.diff) // Accounts -app.get('/api/accounts/me', requireAuth(), accounts.getMe,) -app.get('/api/accounts/available-kf-orgs', requireAuth(), accounts.availableKfOrgs,) -app.get('/api/accounts/:slug', accounts.getBySlug,) -app.patch('/api/accounts/me', requireAuth(), accounts.updateMe,) -app.get('/api/accounts/me/sessions', requireAuth(), accounts.listSessions,) -app.delete('/api/accounts/me/sessions/:sessionId', requireAuth(), accounts.deleteSession,) -app.delete('/api/accounts/me', requireAuth(), accounts.deleteMe,) -app.post('/api/accounts/keys', requireAuth(), accounts.createKey,) -app.get('/api/accounts/keys', requireAuth(), accounts.listKeys,) -app.delete('/api/accounts/keys/:id', requireAuth(), accounts.deleteKey,) -app.post('/api/accounts/:slug/keys', requireAuth(), accounts.createOrgKey,) -app.get('/api/accounts/:slug/keys', requireAuth(), accounts.listOrgKeys,) -app.delete('/api/accounts/:slug/keys/:id', requireAuth(), accounts.deleteOrgKey,) -app.post('/api/accounts/orgs', requireAuth(), accounts.createOrg,) -app.get('/api/accounts/:slug/members', requireAuth(), accounts.listMembers,) -app.post('/api/accounts/:slug/members', requireAuth(), accounts.addMember,) -app.patch('/api/accounts/:slug/members/:userId', requireAuth(), accounts.updateMember,) -app.delete('/api/accounts/:slug/members/:userId', requireAuth(), accounts.removeMember,) -app.patch('/api/accounts/:slug', requireAuth(), accounts.updateOrg,) -app.post('/api/accounts/:slug/avatar', requireAuth(), accounts.uploadOrgAvatar,) -app.post('/api/accounts/:slug/invitations', requireAuth(), accounts.createInvitation,) -app.get('/api/accounts/:slug/invitations', requireAuth(), accounts.listInvitations,) -app.delete('/api/accounts/:slug/invitations/:id', requireAuth(), accounts.deleteInvitation,) -app.post('/api/accounts/invitations/accept', requireAuth(), accounts.acceptInvitation,) -app.delete('/api/accounts/:slug', requireAuth(), accounts.deleteOrg,) +app.get('/api/accounts/me', requireAuth(), accounts.getMe) +app.get('/api/accounts/available-kf-orgs', requireAuth(), accounts.availableKfOrgs) +app.get('/api/accounts/:slug', accounts.getBySlug) +app.patch('/api/accounts/me', requireAuth(), accounts.updateMe) +app.get('/api/accounts/me/sessions', requireAuth(), accounts.listSessions) +app.delete('/api/accounts/me/sessions/:sessionId', requireAuth(), accounts.deleteSession) +app.delete('/api/accounts/me', requireAuth(), accounts.deleteMe) +app.post('/api/accounts/keys', requireAuth(), accounts.createKey) +app.get('/api/accounts/keys', requireAuth(), accounts.listKeys) +app.delete('/api/accounts/keys/:id', requireAuth(), accounts.deleteKey) +app.post('/api/accounts/:slug/keys', requireAuth(), accounts.createOrgKey) +app.get('/api/accounts/:slug/keys', requireAuth(), accounts.listOrgKeys) +app.delete('/api/accounts/:slug/keys/:id', requireAuth(), accounts.deleteOrgKey) +app.post('/api/accounts/orgs', requireAuth(), accounts.createOrg) +app.get('/api/accounts/:slug/members', requireAuth(), accounts.listMembers) +app.post('/api/accounts/:slug/members', requireAuth(), accounts.addMember) +app.patch('/api/accounts/:slug/members/:userId', requireAuth(), accounts.updateMember) +app.delete('/api/accounts/:slug/members/:userId', requireAuth(), accounts.removeMember) +app.patch('/api/accounts/:slug', requireAuth(), accounts.updateOrg) +app.post('/api/accounts/:slug/avatar', requireAuth(), accounts.uploadOrgAvatar) +app.post('/api/accounts/:slug/invitations', requireAuth(), accounts.createInvitation) +app.get('/api/accounts/:slug/invitations', requireAuth(), accounts.listInvitations) +app.delete('/api/accounts/:slug/invitations/:id', requireAuth(), accounts.deleteInvitation) +app.post('/api/accounts/invitations/accept', requireAuth(), accounts.acceptInvitation) +app.delete('/api/accounts/:slug', requireAuth(), accounts.deleteOrg) // --- 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,) +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',) + 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 : '',) -},) + 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 : '') +}) // API 404 catch-all -app.all('/api/*', (c,) => { - return c.json({ error: 'API route not found', statusCode: 404, }, 404,) -},) +app.all('/api/*', (c) => { + return c.json({ error: 'API route not found', statusCode: 404 }, 404) +}) // --- SSR --- if (isProd) { // Verify SSR build artifacts exist at startup (fail fast, don't wait for first request) - const clientHtml = resolve('dist/client/index.html',) - const ssrBundle = resolve('dist/server/entry-server.js',) - if (!existsSync(clientHtml,)) throw new Error(`Missing ${clientHtml} — did 'pnpm build' run?`,) - if (!existsSync(ssrBundle,)) throw new Error(`Missing ${ssrBundle} — did 'pnpm build' run?`,) + const clientHtml = resolve('dist/client/index.html') + const ssrBundle = resolve('dist/server/entry-server.js') + if (!existsSync(clientHtml)) throw new Error(`Missing ${clientHtml} — did 'pnpm build' run?`) + if (!existsSync(ssrBundle)) throw new Error(`Missing ${ssrBundle} — did 'pnpm build' run?`) // Serve Vite build assets (hashed JS/CSS bundles) - app.use('/assets/*', serveStatic({ root: './dist/client', },),) + app.use('/assets/*', serveStatic({ root: './dist/client' })) // Serve public/ folder files (favicon, wasm, .well-known, etc.) - app.use('/*', serveStatic({ root: './public', },),) + app.use('/*', serveStatic({ root: './public' })) // Run migrations on startup - const { runMigrations, } = await import('~/db/migrate') + const { runMigrations } = await import('~/db/migrate') await runMigrations() - const template = readFileSync(clientHtml, 'utf-8',) - const { render, } = await import(ssrBundle as string) + const template = readFileSync(clientHtml, 'utf-8') + const { render } = await import(ssrBundle as string) - app.get('*', async (c,) => { - const { html, ssrData, redirect, statusCode, title, description, } = await render(c.req.raw,) + app.get('*', async (c) => { + const { html, ssrData, redirect, statusCode, title, description } = await render(c.req.raw) if (redirect) { - return c.redirect(redirect, 302,) + return c.redirect(redirect, 302) } let page = template - .replace('', html,) + .replace('', html) .replace( '', - ``, + ``, ) if (title) { - page = page.replace('Underlay', `${title}`,) + page = page.replace('Underlay', `${title}`) } if (description) { page = page.replace( '', - `\n`, + `\n`, ) } - return c.html(page, statusCode ?? 200,) - },) + return c.html(page, statusCode ?? 200) + }) } else { - const { createServer: createViteServer, } = await import('vite') + const { createServer: createViteServer } = await import('vite') const vite = await createViteServer({ - server: { middlewareMode: true, }, + server: { middlewareMode: true }, appType: 'custom', - },) + }) // Vite's Connect middleware for HMR and asset transforms - app.use('*', async (c, next,) => { + 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(),),) - },) - },) + return new Promise((resolve) => { + vite.middlewares(nodeReq, nodeRes, () => resolve(next())) + }) + }) - app.get('*', async (c,) => { + app.get('*', async (c) => { const url = c.req.url - let template = readFileSync(resolve('index.html',), 'utf-8',) - template = await vite.transformIndexHtml(url, template,) + 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,) + 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,) + return c.redirect(redirect, 302) } let page = template - .replace('', html,) + .replace('', html) .replace( '', - ``, + ``, ) if (title) { - page = page.replace('Underlay', `${title}`,) + page = page.replace('Underlay', `${title}`) } if (description) { page = page.replace( '', - `\n`, + `\n`, ) } - return c.html(page, statusCode ?? 200,) - },) + 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, },) +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 index 4923fc0..db229fd 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,29 +1,25 @@ -import { lazy, Suspense, } from 'react' -import { Route, Routes, } from 'react-router' -import { AppErrorBoundary, } from '~/components/NotFound' -import { buildRoutes, } from '~/route-gen' +import { lazy, Suspense } from 'react' +import { Route, Routes } from 'react-router' -const modules = import.meta.glob<{ default: React.ComponentType }>('./routes/**/[!_]*.tsx',) -const routes = buildRoutes(modules,) +import { AppErrorBoundary } from '~/components/NotFound' +import { buildRoutes } from '~/route-gen' -const componentMap = new Map( - routes.map((r,) => [ - r.path, - lazy(modules[r.filePath]!,), - ]), -) +const modules = import.meta.glob<{ default: React.ComponentType }>('./routes/**/[!_]*.tsx') +const routes = buildRoutes(modules) -export { routes, } +const componentMap = new Map(routes.map((r) => [r.path, lazy(modules[r.filePath]!)])) + +export { routes } export default function App() { return ( - {routes.map((r,) => { - const Page = componentMap.get(r.path,) + {routes.map((r) => { + const Page = componentMap.get(r.path) return Page ? } /> : null - },)} + })} diff --git a/src/api/accounts.ts b/src/api/accounts.ts index bbe00ed..a192dea 100644 --- a/src/api/accounts.ts +++ b/src/api/accounts.ts @@ -1,12 +1,13 @@ import bcrypt from 'bcrypt' -import { and, count, eq, } from 'drizzle-orm' -import type { Context, } from 'hono' -import { getCookie, } from 'hono/cookie' -import { v4 as uuidv4, } from 'uuid' -import { db, schema, } from '../db/client.server.js' -import { sendEmail, } from '../lib/email.js' -import { deleteS3Objects, listS3Objects, uploadToS3, } from '../lib/s3.js' -import { type AuthEnv, clearSessionCookie, } from './auth.server.js' +import { and, count, eq } from 'drizzle-orm' +import type { Context } from 'hono' +import { getCookie } from 'hono/cookie' +import { v4 as uuidv4 } from 'uuid' + +import { db, schema } from '../db/client.server.js' +import { sendEmail } from '../lib/email.js' +import { deleteS3Objects, listS3Objects, uploadToS3 } from '../lib/s3.js' +import { type AuthEnv, clearSessionCookie } from './auth.server.js' /** Base URL for public assets (avatars, etc.) */ const ASSETS_BASE_URL = process.env.ASSETS_BASE_URL ?? 'https://assets.underlay.org' @@ -32,25 +33,25 @@ const RESERVED_SLUGS = new Set([ 'delete', '404', '500', -],) +]) const SLUG_RE = /^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/ /** Validate a slug and return an error message or null if valid. */ -function validateSlug(slug: string,): string | null { +function validateSlug(slug: string): string | null { if (!slug || typeof slug !== 'string') return 'Slug is required' if (slug.length < 2) return 'Slug must be at least 2 characters' if (slug.length > 64) return 'Slug must be at most 64 characters' - if (!SLUG_RE.test(slug,)) { + if (!SLUG_RE.test(slug)) { return 'Slug must be lowercase alphanumeric with hyphens, and cannot start or end with a hyphen' } - if (RESERVED_SLUGS.has(slug,)) return 'That slug is reserved' + if (RESERVED_SLUGS.has(slug)) return 'That slug is reserved' return null } // Get current user -export async function getMe(c: Context,) { - const [account,] = await db +export async function getMe(c: Context) { + const [account] = await db .select({ id: schema.accounts.id, slug: schema.accounts.slug, @@ -62,13 +63,13 @@ export async function getMe(c: Context,) { avatarUrl: schema.accounts.avatarUrl, notificationPrefs: schema.accounts.notificationPrefs, createdAt: schema.accounts.createdAt, - },) - .from(schema.accounts,) - .where(eq(schema.accounts.id, c.get('accountId',)!,),) - .limit(1,) + }) + .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,) + return c.json({ error: 'Account not found', statusCode: 404 }, 404) } // Fetch org memberships @@ -78,18 +79,18 @@ export async function getMe(c: Context,) { 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,),) + }) + .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, },) + return c.json({ ...account, orgs: memberships }) } // Get account by slug (public) -export async function getBySlug(c: Context,) { - const slug = c.req.param('slug',)! - const [account,] = await db +export async function getBySlug(c: Context) { + const slug = c.req.param('slug')! + const [account] = await db .select({ id: schema.accounts.id, slug: schema.accounts.slug, @@ -102,44 +103,44 @@ export async function getBySlug(c: Context,) { arkNaan: schema.accounts.arkNaan, kfOrgId: schema.accounts.kfOrgId, createdAt: schema.accounts.createdAt, - },) - .from(schema.accounts,) - .where(eq(schema.accounts.slug, slug,),) - .limit(1,) + }) + .from(schema.accounts) + .where(eq(schema.accounts.slug, slug)) + .limit(1) if (!account) { - return c.json({ error: 'Account not found', statusCode: 404, }, 404,) + 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,) + 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, },) + return c.json({ ...account, arkShoulder: shoulderRow?.shoulder ?? null }) } // Update own profile -export async function updateMe(c: Context,) { +export async function updateMe(c: Context) { // Name, email, and avatar are managed by KF Auth — only Underlay-specific fields are writable here. - const { slug, bio, website, location, notificationPrefs, } = await c.req.json() + const { slug, bio, website, location, notificationPrefs } = await c.req.json() - const accountId = c.get('accountId',)! + const accountId = c.get('accountId')! if (slug !== undefined) { - const slugErr = validateSlug(slug,) - if (slugErr) return c.json({ error: slugErr, statusCode: 422, }, 422,) + const slugErr = validateSlug(slug) + if (slugErr) return c.json({ error: slugErr, statusCode: 422 }, 422) - const [existing,] = await db - .select({ id: schema.accounts.id, },) - .from(schema.accounts,) - .where(eq(schema.accounts.slug, slug,),) - .limit(1,) + const [existing] = await db + .select({ id: schema.accounts.id }) + .from(schema.accounts) + .where(eq(schema.accounts.slug, slug)) + .limit(1) if (existing && existing.id !== accountId) { - return c.json({ error: 'That slug is already taken', statusCode: 409, }, 409,) + return c.json({ error: 'That slug is already taken', statusCode: 409 }, 409) } } @@ -150,47 +151,50 @@ export async function updateMe(c: Context,) { 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, accountId,),) + if (Object.keys(updates).length > 0) { + await db.update(schema.accounts).set(updates).where(eq(schema.accounts.id, accountId)) } - return c.json({ ok: true, slug: slug ?? undefined, },) + return c.json({ ok: true, slug: slug ?? undefined }) } // Upload avatar -export async function uploadAvatar(c: Context,) { +export async function uploadAvatar(c: Context) { const body = await c.req.parseBody() - const file = Object.values(body,).find((v,): v is File => v instanceof File) + 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,) + 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 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(),) + 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,) + 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 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 uploadToS3(key, buffer, file.type) await db - .update(schema.accounts,) - .set({ avatarUrl: `${ASSETS_BASE_URL}/${key}`, },) - .where(eq(schema.accounts.id, accountId,),) + .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}`, },) + return c.json({ ok: true, avatarUrl: `${ASSETS_BASE_URL}/${key}` }) } // List sessions -export async function listSessions(c: Context,) { +export async function listSessions(c: Context) { const sessions = await db .select({ id: schema.sessions.id, @@ -198,121 +202,127 @@ export async function listSessions(c: Context,) { ipAddress: schema.sessions.ipAddress, createdAt: schema.sessions.createdAt, expiresAt: schema.sessions.expiresAt, - },) - .from(schema.sessions,) - .where(eq(schema.sessions.userId, c.get('accountId',)!,),) + }) + .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, - })),) + const currentSessionId = getCookie(c, 'session') + return c.json( + sessions.map((s) => ({ + ...s, + current: s.id === currentSessionId, + })), + ) } // Revoke a session -export async function deleteSession(c: Context,) { - const sessionId = c.req.param('sessionId',)! +export async function deleteSession(c: Context) { + const sessionId = c.req.param('sessionId')! - const [session,] = await db + const [session] = await db .select() - .from(schema.sessions,) - .where(and(eq(schema.sessions.id, sessionId,), eq(schema.sessions.userId, c.get('accountId',)!,),),) - .limit(1,) + .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,) + 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, },) + await db.delete(schema.sessions).where(eq(schema.sessions.id, sessionId)) + return c.json({ ok: true }) } // Delete own account -export async function deleteMe(c: Context,) { - const { confirmSlug, } = await c.req.json() +export async function deleteMe(c: Context) { + const { confirmSlug } = await c.req.json() - const [account,] = await db + const [account] = await db .select() - .from(schema.accounts,) - .where(eq(schema.accounts.id, c.get('accountId',)!,),) - .limit(1,) + .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,) + return c.json({ error: 'Account not found', statusCode: 404 }, 404) } if (confirmSlug !== account.slug) { - return c.json({ error: 'Username confirmation does not match', statusCode: 422, }, 422,) + return c.json({ error: 'Username confirmation does not match', statusCode: 422 }, 422) } // Check for owned collections - const [collCount,] = await db - .select({ count: count(), },) - .from(schema.collections,) - .where(eq(schema.collections.accountId, account.id,),) + 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,) + 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}/`,) + const avatarKeys = await listS3Objects(`avatars/${account.id}/`) if (avatarKeys.length > 0) { - await deleteS3Objects(avatarKeys,) + 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, },) + await db.delete(schema.accounts).where(eq(schema.accounts.id, account.id)) + clearSessionCookie(c) + return c.json({ ok: true }) } // Create API key -export async function createKey(c: Context,) { - const { label, scope, collectionId, expiresIn, } = await c.req.json() +export async function createKey(c: Context) { + 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 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 expiresAt = expiresIn ? new Date(Date.now() + expiresIn * 24 * 60 * 60 * 1000) : null - const [key,] = await db - .insert(schema.apiKeys,) + const [key] = await db + .insert(schema.apiKeys) .values({ - accountId: c.get('accountId',)!, + 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,) + return c.json( + { + id: key!.id, + key: rawKey, // shown once + label, + scope, + keyPrefix, + collectionId: collectionId ?? null, + expiresAt, + }, + 201, + ) } // List API keys -export async function listKeys(c: Context,) { +export async function listKeys(c: Context) { const keys = await db .select({ id: schema.apiKeys.id, @@ -323,65 +333,67 @@ export async function listKeys(c: Context,) { 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,) + }) + .from(schema.apiKeys) + .where(eq(schema.apiKeys.accountId, c.get('accountId')!)) + return c.json(keys) } // Delete API key -export async function deleteKey(c: Context,) { - const id = c.req.param('id',)! - const [key,] = await db - .select() - .from(schema.apiKeys,) - .where(eq(schema.apiKeys.id, id,),) - .limit(1,) +export async function deleteKey(c: Context) { + 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,) + 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, },) + 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 -export async function createOrgKey(c: Context,) { - const slug = c.req.param('slug',)! - const { label, scope, collectionId, expiresIn, } = await c.req.json() +export async function createOrgKey(c: Context) { + const slug = c.req.param('slug')! + const { label, scope, collectionId, expiresIn } = await c.req.json() - const [org,] = await db + const [org] = await db .select() - .from(schema.accounts,) - .where(and(eq(schema.accounts.slug, slug,), eq(schema.accounts.type, 'org',),),) - .limit(1,) + .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,) + if (!org) return c.json({ error: 'Organization not found', statusCode: 404 }, 404) // Must be owner or admin - const [membership,] = await db + 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,) + .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,) + 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 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 expiresAt = expiresIn ? new Date(Date.now() + expiresIn * 24 * 60 * 60 * 1000) : null - const [key,] = await db - .insert(schema.apiKeys,) + const [key] = await db + .insert(schema.apiKeys) .values({ accountId: org.id, scope, @@ -390,40 +402,48 @@ export async function createOrgKey(c: Context,) { label, collectionId: collectionId ?? null, expiresAt, - },) + }) .returning() - return c.json({ - id: key!.id, - key: rawKey, - label, - scope, - keyPrefix, - collectionId: collectionId ?? null, - expiresAt, - }, 201,) + return c.json( + { + id: key!.id, + key: rawKey, + label, + scope, + keyPrefix, + collectionId: collectionId ?? null, + expiresAt, + }, + 201, + ) } // List org API keys -export async function listOrgKeys(c: Context,) { - const slug = c.req.param('slug',)! +export async function listOrgKeys(c: Context) { + const slug = c.req.param('slug')! - const [org,] = await db + const [org] = await db .select() - .from(schema.accounts,) - .where(and(eq(schema.accounts.slug, slug,), eq(schema.accounts.type, 'org',),),) - .limit(1,) + .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,) + if (!org) return c.json({ error: 'Organization not found', statusCode: 404 }, 404) // Must be a member - const [membership,] = await db + 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,) + .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,) + if (!membership) return c.json({ error: 'Forbidden', statusCode: 403 }, 403) const keys = await db .select({ @@ -435,133 +455,152 @@ export async function listOrgKeys(c: Context,) { expiresAt: schema.apiKeys.expiresAt, createdAt: schema.apiKeys.createdAt, lastUsedAt: schema.apiKeys.lastUsedAt, - },) - .from(schema.apiKeys,) - .where(eq(schema.apiKeys.accountId, org.id,),) + }) + .from(schema.apiKeys) + .where(eq(schema.apiKeys.accountId, org.id)) - return c.json(keys,) + return c.json(keys) } // Delete org API key -export async function deleteOrgKey(c: Context,) { - const slug = c.req.param('slug',)! - const id = c.req.param('id',)! +export async function deleteOrgKey(c: Context) { + const slug = c.req.param('slug')! + const id = c.req.param('id')! - const [org,] = await db + const [org] = await db .select() - .from(schema.accounts,) - .where(and(eq(schema.accounts.slug, slug,), eq(schema.accounts.type, 'org',),),) - .limit(1,) + .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,) + if (!org) return c.json({ error: 'Organization not found', statusCode: 404 }, 404) - const [membership,] = await db + 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,) + .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,) + return c.json( + { error: 'Must be an owner or admin to manage org API keys', statusCode: 403 }, + 403, + ) } - const [key,] = await db + const [key] = await db .select() - .from(schema.apiKeys,) - .where(and(eq(schema.apiKeys.id, id,), eq(schema.apiKeys.accountId, org.id,),),) - .limit(1,) + .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,) + 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, },) + await db.delete(schema.apiKeys).where(eq(schema.apiKeys.id, id)) + return c.json({ ok: true }) } // --- Org Management --- // Create organization -export async function createOrg(c: Context,) { - const { slug, displayName, kfOrgId, } = await c.req.json() +export async function createOrg(c: Context) { + const { slug, displayName, kfOrgId } = await c.req.json() if (!kfOrgId || typeof kfOrgId !== 'string') { return c.json( - { error: 'kfOrgId is required — every Underlay org must be linked to a KF org', statusCode: 422, }, + { + error: 'kfOrgId is required — every Underlay org must be linked to a KF org', + statusCode: 422, + }, 422, ) } - if (RESERVED_SLUGS.has(slug.toLowerCase(),)) { - return c.json({ error: 'That name is reserved', statusCode: 422, }, 422,) + 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,) + 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,) + .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,) + return c.json({ error: 'Name already taken', statusCode: 409 }, 409) } const id = uuidv4() - await db.insert(schema.accounts,).values({ + await db.insert(schema.accounts).values({ id, slug, type: 'org', displayName, kfOrgId, - },) + }) // Add the creating user as owner - await db.insert(schema.orgMemberships,).values({ + await db.insert(schema.orgMemberships).values({ orgId: id, - userId: c.get('accountId',)!, + userId: c.get('accountId')!, role: 'owner', - },) + }) - return c.json({ id, slug, displayName, type: 'org', kfOrgId, }, 201,) + return c.json({ id, slug, displayName, type: 'org', kfOrgId }, 201) } /** * GET /api/accounts/available-kf-orgs * Returns all KF orgs the current user belongs to. */ -export async function availableKfOrgs(c: Context,) { - const accountId = c.get('accountId',)! +export async function availableKfOrgs(c: Context) { + const accountId = c.get('accountId')! // Fetch user's KF orgs on demand from KF Auth internal API - const { fetchKfOrgs, } = await import('../lib/kf-orgs.server.js') - return c.json(await fetchKfOrgs(accountId,),) + const { fetchKfOrgs } = await import('../lib/kf-orgs.server.js') + return c.json(await fetchKfOrgs(accountId)) } // List org members -export async function listMembers(c: Context,) { - const slug = c.req.param('slug',)! +export async function listMembers(c: Context) { + const slug = c.req.param('slug')! - const [org,] = await db + const [org] = await db .select() - .from(schema.accounts,) - .where(and(eq(schema.accounts.slug, slug,), eq(schema.accounts.type, 'org',),),) - .limit(1,) + .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,) + if (!org) return c.json({ error: 'Organization not found', statusCode: 404 }, 404) // Must be a member to view - const [membership,] = await db + 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,) + .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,) + if (!membership) return c.json({ error: 'Forbidden', statusCode: 403 }, 403) const members = await db .select({ @@ -569,184 +608,210 @@ export async function listMembers(c: Context,) { 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,),) + }) + .from(schema.orgMemberships) + .innerJoin(schema.accounts, eq(schema.orgMemberships.userId, schema.accounts.id)) + .where(eq(schema.orgMemberships.orgId, org.id)) - return c.json(members,) + return c.json(members) } // Add org member -export async function addMember(c: Context,) { - const slug = c.req.param('slug',)! - const { username, role, } = await c.req.json() +export async function addMember(c: Context) { + const slug = c.req.param('slug')! + const { username, role } = await c.req.json() - const [org,] = await db + const [org] = await db .select() - .from(schema.accounts,) - .where(and(eq(schema.accounts.slug, slug,), eq(schema.accounts.type, 'org',),),) - .limit(1,) + .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,) + if (!org) return c.json({ error: 'Organization not found', statusCode: 404 }, 404) // Must be owner or admin - const [callerMembership,] = await db + 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,) + .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,) + return c.json({ error: 'Must be an owner or admin to add members', statusCode: 403 }, 403) } // Find user to add - const [user,] = await db + const [user] = await db .select() - .from(schema.accounts,) - .where(and(eq(schema.accounts.slug, username,), eq(schema.accounts.type, 'user',),),) - .limit(1,) + .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,) + if (!user) return c.json({ error: 'User not found', statusCode: 404 }, 404) // Check not already a member - const [existing,] = await db + const [existing] = await db .select() - .from(schema.orgMemberships,) - .where(and(eq(schema.orgMemberships.orgId, org.id,), eq(schema.orgMemberships.userId, user.id,),),) - .limit(1,) + .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,) + if (existing) return c.json({ error: 'Already a member', statusCode: 409 }, 409) - await db.insert(schema.orgMemberships,).values({ + await db.insert(schema.orgMemberships).values({ orgId: org.id, userId: user.id, role: role ?? 'member', - },) + }) - return c.json({ ok: true, username, role, }, 201,) + return c.json({ ok: true, username, role }, 201) } // Update member role -export async function updateMember(c: Context,) { - const slug = c.req.param('slug',)! - const userId = c.req.param('userId',)! - const { role, } = await c.req.json() +export async function updateMember(c: Context) { + const slug = c.req.param('slug')! + const userId = c.req.param('userId')! + const { role } = await c.req.json() - const [org,] = await db + const [org] = await db .select() - .from(schema.accounts,) - .where(and(eq(schema.accounts.slug, slug,), eq(schema.accounts.type, 'org',),),) - .limit(1,) + .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,) + if (!org) return c.json({ error: 'Organization not found', statusCode: 404 }, 404) // Must be owner - const [callerMembership,] = await db + 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,) + .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,) + 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,),),) + .update(schema.orgMemberships) + .set({ role }) + .where(and(eq(schema.orgMemberships.orgId, org.id), eq(schema.orgMemberships.userId, userId))) - return c.json({ ok: true, },) + return c.json({ ok: true }) } // Remove member -export async function removeMember(c: Context,) { - const slug = c.req.param('slug',)! - const userId = c.req.param('userId',)! +export async function removeMember(c: Context) { + const slug = c.req.param('slug')! + const userId = c.req.param('userId')! - const [org,] = await db + const [org] = await db .select() - .from(schema.accounts,) - .where(and(eq(schema.accounts.slug, slug,), eq(schema.accounts.type, 'org',),),) - .limit(1,) + .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,) + if (!org) return c.json({ error: 'Organization not found', statusCode: 404 }, 404) // Must be owner or admin (or removing yourself) - const [callerMembership,] = await db + 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,) + .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 + const isSelf = c.get('accountId') === userId if (!callerMembership || (callerMembership.role === 'member' && !isSelf)) { - return c.json({ error: 'Forbidden', statusCode: 403, }, 403,) + 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,),),) + .delete(schema.orgMemberships) + .where(and(eq(schema.orgMemberships.orgId, org.id), eq(schema.orgMemberships.userId, userId))) - return c.json({ ok: true, },) + return c.json({ ok: true }) } // Update org profile -export async function updateOrg(c: Context,) { - const slug = c.req.param('slug',)! - const { slug: newSlug, displayName, bio, website, location, kfOrgId, } = await c.req.json() +export async function updateOrg(c: Context) { + const slug = c.req.param('slug')! + const { slug: newSlug, displayName, bio, website, location, kfOrgId } = await c.req.json() - const [org,] = await db + const [org] = await db .select() - .from(schema.accounts,) - .where(and(eq(schema.accounts.slug, slug,), eq(schema.accounts.type, 'org',),),) - .limit(1,) + .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,) + if (!org) return c.json({ error: 'Organization not found', statusCode: 404 }, 404) // Must be owner - const [callerMembership,] = await db + 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,) + .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,) + return c.json({ error: 'Must be an owner to update the organization', statusCode: 403 }, 403) } // Validate slug change if provided if (newSlug !== undefined) { - const slugErr = validateSlug(newSlug,) - if (slugErr) return c.json({ error: slugErr, statusCode: 422, }, 422,) + const slugErr = validateSlug(newSlug) + if (slugErr) return c.json({ error: slugErr, statusCode: 422 }, 422) - const [existing,] = await db - .select({ id: schema.accounts.id, },) - .from(schema.accounts,) - .where(eq(schema.accounts.slug, newSlug,),) - .limit(1,) + const [existing] = await db + .select({ id: schema.accounts.id }) + .from(schema.accounts) + .where(eq(schema.accounts.slug, newSlug)) + .limit(1) if (existing && existing.id !== org.id) { - return c.json({ error: 'That slug is already taken', statusCode: 409, }, 409,) + return c.json({ error: 'That slug is already taken', statusCode: 409 }, 409) } } // Validate kfOrgId change if provided if (kfOrgId !== undefined) { if (!kfOrgId || typeof kfOrgId !== 'string') { - return c.json({ error: 'kfOrgId must be a non-empty string', statusCode: 422, }, 422,) + return c.json({ error: 'kfOrgId must be a non-empty string', statusCode: 422 }, 422) } // Check it's not already linked to another UL org - const [alreadyLinked,] = await db - .select({ id: schema.accounts.id, },) - .from(schema.accounts,) - .where(and(eq(schema.accounts.kfOrgId, kfOrgId,), eq(schema.accounts.type, 'org',),),) - .limit(1,) + const [alreadyLinked] = await db + .select({ id: schema.accounts.id }) + .from(schema.accounts) + .where(and(eq(schema.accounts.kfOrgId, kfOrgId), eq(schema.accounts.type, 'org'))) + .limit(1) if (alreadyLinked && alreadyLinked.id !== org.id) { - return c.json({ error: 'This KF organization is already linked to another Underlay org', statusCode: 409, }, 409,) + return c.json( + { + error: 'This KF organization is already linked to another Underlay org', + statusCode: 409, + }, + 409, + ) } } @@ -758,148 +823,168 @@ export async function updateOrg(c: Context,) { if (location !== undefined) updates.location = location if (kfOrgId !== undefined) updates.kfOrgId = kfOrgId - if (Object.keys(updates,).length > 0) { - await db.update(schema.accounts,).set(updates,).where(eq(schema.accounts.id, org.id,),) + 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, slug: newSlug ?? slug, },) + return c.json({ ok: true, slug: newSlug ?? slug }) } // Upload org avatar -export async function uploadOrgAvatar(c: Context,) { - const slug = c.req.param('slug',)! +export async function uploadOrgAvatar(c: Context) { + const slug = c.req.param('slug')! - const [org,] = await db + const [org] = await db .select() - .from(schema.accounts,) - .where(and(eq(schema.accounts.slug, slug,), eq(schema.accounts.type, 'org',),),) - .limit(1,) + .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,) + if (!org) return c.json({ error: 'Organization not found', statusCode: 404 }, 404) - const [membership,] = await db + 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,) + .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,) + 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) + 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,) + 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 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(),) + 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,) + 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 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 uploadToS3(key, buffer, file.type) - await db.update(schema.accounts,).set({ avatarUrl: `${ASSETS_BASE_URL}/${key}`, },).where( - eq(schema.accounts.id, org.id,), - ) + 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}`, },) + return c.json({ ok: true, avatarUrl: `${ASSETS_BASE_URL}/${key}` }) } // --- Org Invitations --- // Invite user to org -export async function createInvitation(c: Context,) { - const slug = c.req.param('slug',)! - const { email, role, } = await c.req.json() +export async function createInvitation(c: Context) { + const slug = c.req.param('slug')! + const { email, role } = await c.req.json() - const [org,] = await db + const [org] = await db .select() - .from(schema.accounts,) - .where(and(eq(schema.accounts.slug, slug,), eq(schema.accounts.type, 'org',),),) - .limit(1,) + .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,) + if (!org) return c.json({ error: 'Organization not found', statusCode: 404 }, 404) - const [callerMembership,] = await db + 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,) + .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,) + return c.json({ error: 'Must be an owner or admin to invite members', statusCode: 403 }, 403) } // Check if there's already a pending invitation for this email - const [existingInvite,] = await db + const [existingInvite] = await db .select() - .from(schema.orgInvitations,) - .where(and( - eq(schema.orgInvitations.orgId, org.id,), - eq(schema.orgInvitations.email, email,), - ),) - .limit(1,) + .from(schema.orgInvitations) + .where(and(eq(schema.orgInvitations.orgId, org.id), eq(schema.orgInvitations.email, email))) + .limit(1) if (existingInvite && !existingInvite.acceptedAt) { - return c.json({ error: 'An invitation is already pending for this email', statusCode: 409, }, 409,) + return c.json( + { error: 'An invitation is already pending for this email', statusCode: 409 }, + 409, + ) } const token = uuidv4() - const expiresAt = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000,) // 7 days + const expiresAt = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000) // 7 days - await db.insert(schema.orgInvitations,).values({ + await db.insert(schema.orgInvitations).values({ orgId: org.id, email, role, - invitedBy: c.get('accountId',)!, + invitedBy: c.get('accountId')!, token, expiresAt, - },) + }) // Send invitation email - const origin = new URL(c.req.url,).origin + 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.

`, - },) + 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,) + return c.json({ ok: true }, 201) } // List pending invitations for an org -export async function listInvitations(c: Context,) { - const slug = c.req.param('slug',)! +export async function listInvitations(c: Context) { + const slug = c.req.param('slug')! - const [org,] = await db + const [org] = await db .select() - .from(schema.accounts,) - .where(and(eq(schema.accounts.slug, slug,), eq(schema.accounts.type, 'org',),),) - .limit(1,) + .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,) + if (!org) return c.json({ error: 'Organization not found', statusCode: 404 }, 404) - const [membership,] = await db + 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,) + .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,) + if (!membership) return c.json({ error: 'Forbidden', statusCode: 403 }, 403) const invitations = await db .select({ @@ -909,75 +994,84 @@ export async function listInvitations(c: Context,) { expiresAt: schema.orgInvitations.expiresAt, acceptedAt: schema.orgInvitations.acceptedAt, createdAt: schema.orgInvitations.createdAt, - },) - .from(schema.orgInvitations,) - .where(eq(schema.orgInvitations.orgId, org.id,),) + }) + .from(schema.orgInvitations) + .where(eq(schema.orgInvitations.orgId, org.id)) - return c.json(invitations,) + return c.json(invitations) } // Cancel an invitation -export async function deleteInvitation(c: Context,) { - const slug = c.req.param('slug',)! - const id = c.req.param('id',)! +export async function deleteInvitation(c: Context) { + const slug = c.req.param('slug')! + const id = c.req.param('id')! - const [org,] = await db + const [org] = await db .select() - .from(schema.accounts,) - .where(and(eq(schema.accounts.slug, slug,), eq(schema.accounts.type, 'org',),),) - .limit(1,) + .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,) + if (!org) return c.json({ error: 'Organization not found', statusCode: 404 }, 404) - const [membership,] = await db + 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,) + .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,) + 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, },) + await db.delete(schema.orgInvitations).where(eq(schema.orgInvitations.id, id)) + return c.json({ ok: true }) } // Accept an invitation (public, token-based) -export async function acceptInvitation(c: Context,) { - const { token, } = await c.req.json() +export async function acceptInvitation(c: Context) { + const { token } = await c.req.json() - const [invitation,] = await db + const [invitation] = await db .select() - .from(schema.orgInvitations,) - .where(eq(schema.orgInvitations.token, token,),) - .limit(1,) + .from(schema.orgInvitations) + .where(eq(schema.orgInvitations.token, token)) + .limit(1) if (!invitation) { - return c.json({ error: 'Invitation not found', statusCode: 404, }, 404,) + return c.json({ error: 'Invitation not found', statusCode: 404 }, 404) } if (invitation.acceptedAt) { - return c.json({ error: 'Invitation already accepted', statusCode: 409, }, 409,) + 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,) + 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. // Email is fetched from KF Auth since we don't store it locally. - const { getKfProfile, } = await import('../lib/kf-profile-cache.server.js') - const accountId = c.get('accountId',)! + const { getKfProfile } = await import('../lib/kf-profile-cache.server.js') + const accountId = c.get('accountId')! // Fetch email from KF Auth internal API directly (profile cache doesn't include email) - const KF_AUTH_INTERNAL_URL = process.env.KF_AUTH_INTERNAL_URL ?? process.env.KF_AUTH_URL ?? 'http://localhost:3000' + const KF_AUTH_INTERNAL_URL = + process.env.KF_AUTH_INTERNAL_URL ?? process.env.KF_AUTH_URL ?? 'http://localhost:3000' const KF_INTERNAL_API_KEY = process.env.KF_INTERNAL_API_KEY ?? '' let userEmail: string | null = null try { const res = await fetch(`${KF_AUTH_INTERNAL_URL}/api/internal/users/${accountId}`, { - headers: { Authorization: `Bearer ${KF_INTERNAL_API_KEY}`, }, - },) + headers: { Authorization: `Bearer ${KF_INTERNAL_API_KEY}` }, + }) if (res.ok) { const data = (await res.json()) as { email: string } userEmail = data.email @@ -985,56 +1079,64 @@ export async function acceptInvitation(c: Context,) { } catch {} if (!userEmail || userEmail !== invitation.email) { - return c.json({ error: 'This invitation was sent to a different email address', statusCode: 403, }, 403,) + 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({ + await db.insert(schema.orgMemberships).values({ orgId: invitation.orgId, - userId: c.get('accountId',)!, + 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,),) + .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,) + 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 ?? '', },) + return c.json({ ok: true, orgSlug: org?.slug ?? '' }) } // Delete org -export async function deleteOrg(c: Context,) { - const slug = c.req.param('slug',)! +export async function deleteOrg(c: Context) { + const slug = c.req.param('slug')! - const [org,] = await db + const [org] = await db .select() - .from(schema.accounts,) - .where(and(eq(schema.accounts.slug, slug,), eq(schema.accounts.type, 'org',),),) - .limit(1,) + .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,) + if (!org) return c.json({ error: 'Organization not found', statusCode: 404 }, 404) // Must be owner - const [callerMembership,] = await db + 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,) + .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,) + 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, },) + await db.delete(schema.accounts).where(eq(schema.accounts.id, org.id)) + return c.json({ ok: true }) } diff --git a/src/api/admin.ts b/src/api/admin.ts index beefc32..0060012 100644 --- a/src/api/admin.ts +++ b/src/api/admin.ts @@ -1,6 +1,7 @@ -import type { Context, } from 'hono' -import { streamSSE, } from 'hono/streaming' -import { getMirrorConfig, } from '../lib/mirror-config.js' +import type { Context } from 'hono' +import { streamSSE } from 'hono/streaming' + +import { getMirrorConfig } from '../lib/mirror-config.js' import { cleanupStaleRuns, getActiveRunId, @@ -14,47 +15,47 @@ import { type SyncProgressEvent, testUpstreamConnection, } from '../lib/mirror-sync.js' -import { type AuthEnv, } from './auth.server.js' +import { type AuthEnv } from './auth.server.js' // Get mirror status -export async function mirrorStatus(c: Context,) { +export async function mirrorStatus(c: Context) { const status = await getMirrorStatus() - return c.json(status,) + return c.json(status) } // Test upstream connection -export async function mirrorTest(c: Context,) { +export async function mirrorTest(c: Context) { const config = getMirrorConfig() - const result = await testUpstreamConnection(config.upstream,) - return c.json(result,) + const result = await testUpstreamConnection(config.upstream) + return c.json(result) } // Trigger a sync manually (fire-and-forget, client uses SSE for progress) -export async function mirrorSync(c: Context,) { +export async function mirrorSync(c: Context) { if (isSyncRunning()) { - return c.json({ started: false, error: 'A sync is already running', },) + 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, },) + 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) -export async function mirrorSyncStop(c: Context,) { +export async function mirrorSyncStop(c: Context) { 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: false, cleaned }) } - return c.json({ stopped: true, },) + return c.json({ stopped: true }) } // SSE endpoint for live sync progress (replays buffered logs on connect) -export async function mirrorSyncProgress(c: Context,) { - return streamSSE(c, async (stream,) => { +export async function mirrorSyncProgress(c: Context) { + return streamSSE(c, async (stream) => { // Replay buffered logs so reconnects/refreshes don't lose history const buffered = getActiveRunLogs() if (buffered.length > 0) { @@ -71,7 +72,7 @@ export async function mirrorSyncProgress(c: Context,) { errors: 0, }, } - await stream.writeSSE({ data: JSON.stringify(replayEvent,), },) + await stream.writeSSE({ data: JSON.stringify(replayEvent) }) } } @@ -80,40 +81,37 @@ export async function mirrorSyncProgress(c: Context,) { return } - const onProgress = async (event: SyncProgressEvent,) => { - await stream.writeSSE({ data: JSON.stringify(event,), },) + const onProgress = async (event: SyncProgressEvent) => { + await stream.writeSSE({ data: JSON.stringify(event) }) if (event.type === 'done') { - setTimeout(() => stream.close(), 100,) + setTimeout(() => stream.close(), 100) } } - syncEvents.on('progress', onProgress,) + syncEvents.on('progress', onProgress) stream.onAbort(() => { - syncEvents.off('progress', onProgress,) - },) + syncEvents.off('progress', onProgress) + }) // Keep the stream open until aborted or done - await new Promise((resolve,) => { + await new Promise((resolve) => { stream.onAbort(() => resolve()) - },) - },) + }) + }) } // Get current sync running state (for page refresh reconnection) -export async function mirrorSyncActive(c: Context,) { +export async function mirrorSyncActive(c: Context) { return c.json({ running: isSyncRunning(), runId: getActiveRunId(), logs: getActiveRunLogs(), - },) + }) } // Sync history -export async function mirrorHistory(c: Context,) { - const limit = Math.min( - Number(c.req.query('limit',),) || 20, - 100, - ) - return c.json(await getSyncHistory(limit,),) +export async function mirrorHistory(c: Context) { + const limit = Math.min(Number(c.req.query('limit')) || 20, 100) + return c.json(await getSyncHistory(limit)) } diff --git a/src/api/ark-middleware.server.ts b/src/api/ark-middleware.server.ts index cd9da47..ade9923 100644 --- a/src/api/ark-middleware.server.ts +++ b/src/api/ark-middleware.server.ts @@ -1,27 +1,28 @@ -import type { MiddlewareHandler, } from 'hono' -import { buildErc, DEFAULT_NAAN, } from '../lib/ark.js' +import type { MiddlewareHandler } from 'hono' + +import { buildErc, DEFAULT_NAAN } 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,) +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:',)) { + if (!pathname.startsWith('/ark:')) { return _next() } - const fullPath = pathname.slice(1,) // strip leading / + 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,) + 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( @@ -37,37 +38,37 @@ export const arkMiddleware: MiddlewareHandler = async (c, _next,) => { '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', }, }, + ].join('\n'), + { headers: { 'Content-Type': 'text/plain; charset=utf-8' } }, ) } // Resolve the ARK via internal API - const port = Number(process.env.PORT,) || 3000 + const port = Number(process.env.PORT) || 3000 const apiBase = `http://localhost:${port}` - const params = new URLSearchParams({ path: fullPath, },) + const params = new URLSearchParams({ path: fullPath }) let resolveRes: Response try { - resolveRes = await fetch(`${apiBase}/api/ark/resolve?${params}`,) + resolveRes = await fetch(`${apiBase}/api/ark/resolve?${params}`) } catch { - return new Response('ARK resolver unavailable', { status: 503, },) + 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 not found', { status: 404 }) } - return new Response('ARK resolution error', { status: 502, },) + 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, },) + return new Response('ARK not found', { status: 404 }) } - const { metadata, } = data + const { metadata } = data const resolvedNaan = metadata?.naan ?? DEFAULT_NAAN // Handle inflections @@ -79,23 +80,21 @@ export const arkMiddleware: MiddlewareHandler = async (c, _next,) => { when: metadata.when ?? '(:unkn)', where: metadata.where ?? metadata.arkUrl ?? '(:unkn)', naan: resolvedNaan, - },) + }) return new Response(erc, { - headers: { 'Content-Type': 'text/plain; charset=utf-8', }, - },) + headers: { 'Content-Type': 'text/plain; charset=utf-8' }, + }) } if (search === '?json') { - return new Response(JSON.stringify(metadata, null, 2,), { - headers: { 'Content-Type': 'application/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 + const redirectTarget = targetUrl.startsWith('/') ? `${url.origin}${targetUrl}` : targetUrl - return c.redirect(redirectTarget, 302,) + return c.redirect(redirectTarget, 302) } diff --git a/src/api/ark.ts b/src/api/ark.ts index aefa1e5..424d0e7 100644 --- a/src/api/ark.ts +++ b/src/api/ark.ts @@ -1,6 +1,7 @@ -import { and, desc, eq, } from 'drizzle-orm' -import type { Context, } from 'hono' -import { db, schema, } from '../db/client.server.js' +import { and, desc, eq } from 'drizzle-orm' +import type { Context } from 'hono' + +import { db, schema } from '../db/client.server.js' import { buildArkUrl, buildErc, @@ -10,43 +11,43 @@ import { getOrMintShoulder, parseArkPath, } from '../lib/ark.js' -import { type AuthEnv, } from './auth.server.js' +import { type AuthEnv } from './auth.server.js' // --- Resolution --- -export async function resolve(c: Context,) { - const path = c.req.query('path',) - if (!path) return c.json({ error: 'Missing path', }, 400,) +export async function resolve(c: Context) { + 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 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 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,) + 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,) + if (!pathAfterNaan) return c.json({ type: 'not_found' }, 404) - const components = parseArkPath(pathAfterNaan,) - if (!components) 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 + 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,) + 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 + const [collRow] = await db .select({ collectionId: schema.arkCollections.collectionId, enabled: schema.arkCollections.enabled, @@ -57,22 +58,22 @@ export async function resolve(c: Context,) { 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,) + }) + .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,) + 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,) + return c.json({ type: 'not_found' }, 404) } const resolvedNaan = collRow.ownerNaan ?? naan - const { collectionId, collectionSlug, collectionName, ownerSlug, ownerName, } = collRow + const { collectionId, collectionSlug, collectionName, ownerSlug, ownerName } = collRow // --- Resolve version --- let versionRow: { @@ -88,7 +89,7 @@ export async function resolve(c: Context,) { } | null = null if (version !== undefined) { - const [row,] = await db + const [row] = await db .select({ id: schema.versions.id, number: schema.versions.number, @@ -99,14 +100,16 @@ export async function resolve(c: Context,) { 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,) + }) + .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 + const [row] = await db .select({ id: schema.versions.id, number: schema.versions.number, @@ -117,65 +120,65 @@ export async function resolve(c: Context,) { 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,) + }) + .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,) + 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,) + const [artRow] = await db + .select({ redirectUrlField: schema.arkRecordTypes.redirectUrlField }) + .from(schema.arkRecordTypes) .where( and( - eq(schema.arkRecordTypes.collectionId, collectionId,), - eq(schema.arkRecordTypes.recordType, recordType,), + eq(schema.arkRecordTypes.collectionId, collectionId), + eq(schema.arkRecordTypes.recordType, recordType), ), ) - .limit(1,) + .limit(1) - if (!artRow) return c.json({ type: 'not_found', }, 404,) + if (!artRow) return c.json({ type: 'not_found' }, 404) - if (!versionRow) 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,) + 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,), + eq(schema.records.versionId, versionRow.id), + eq(schema.records.recordId, recordId), + eq(schema.records.type, recordType), ), ) - .limit(1,) + .limit(1) - if (!recordRow) return c.json({ type: 'not_found', }, 404,) + 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,) + 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,),) + 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,), + eq(schema.versionSchemas.versionId, versionRow.id), + eq(schema.versionSchemas.slug, recordType), ), ) - .limit(1,) + .limit(1) return c.json({ type: 'redirect' as const, @@ -184,7 +187,7 @@ export async function resolve(c: Context,) { type: 'record', who: ownerName, what: `${recordType} ${recordId} in ${collectionName}`, - when: formatErcDate(versionRow.createdAt,), + when: formatErcDate(versionRow.createdAt), where: arkUrl, naan: resolvedNaan, collectionName, @@ -198,17 +201,13 @@ export async function resolve(c: Context,) { createdAt: versionRow.createdAt, arkUrl, }, - },) + }) } // --- Collection / version resolution --- if (collRow.customUrl) { - const what = versionRow - ? `${collectionName} ${versionRow.semver}` - : collectionName - const when = versionRow - ? formatErcDate(versionRow.createdAt,) - : '(:unkn)' + const what = versionRow ? `${collectionName} ${versionRow.semver}` : collectionName + const when = versionRow ? formatErcDate(versionRow.createdAt) : '(:unkn)' return c.json({ type: 'redirect' as const, url: collRow.customUrl, @@ -230,7 +229,7 @@ export async function resolve(c: Context,) { createdAt: versionRow?.createdAt, arkUrl, }, - },) + }) } if (version !== undefined && versionRow) { @@ -242,7 +241,7 @@ export async function resolve(c: Context,) { type: 'version', who: ownerName, what: `${collectionName} ${versionRow.semver}`, - when: formatErcDate(versionRow.createdAt,), + when: formatErcDate(versionRow.createdAt), where: arkUrl, naan: resolvedNaan, collectionName, @@ -256,7 +255,7 @@ export async function resolve(c: Context,) { createdAt: versionRow.createdAt, arkUrl, }, - },) + }) } // Default: redirect to collection overview @@ -268,7 +267,7 @@ export async function resolve(c: Context,) { type: 'collection', who: ownerName, what: collectionName, - when: versionRow ? formatErcDate(versionRow.createdAt,) : '(:unkn)', + when: versionRow ? formatErcDate(versionRow.createdAt) : '(:unkn)', where: arkUrl, naan: resolvedNaan, collectionName, @@ -278,238 +277,238 @@ export async function resolve(c: Context,) { createdAt: versionRow?.createdAt, arkUrl, }, - },) + }) } // --- Collection ARK settings --- -export async function getArk(c: Context,) { - const owner = c.req.param('owner',)! - const slug = c.req.param('slug',)! +export async function getArk(c: Context) { + const owner = c.req.param('owner')! + const slug = c.req.param('slug')! - const [coll,] = await db + 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,) + }) + .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 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 + 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,) + }) + .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, },) + return c.json({ enabled: false, customUrl: null, arkUrl: null, shoulder: null, arkId: null }) } - const arkUrl = buildArkUrl(naan, arkRow.shoulder, arkRow.arkId,) + const arkUrl = buildArkUrl(naan, arkRow.shoulder, arkRow.arkId) return c.json({ enabled: arkRow.enabled, customUrl: arkRow.customUrl, arkUrl, shoulder: arkRow.shoulder, arkId: arkRow.arkId, - },) + }) } -export async function updateArk(c: Context,) { - const owner = c.req.param('owner',)! - const slug = c.req.param('slug',)! - const { enabled, customUrl, } = await c.req.json() +export async function updateArk(c: Context) { + 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 [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 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,) + 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({ + 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) { + if (Object.keys(updates).length > 0) { await db - .update(schema.arkCollections,) - .set(updates,) - .where(eq(schema.arkCollections.collectionId, coll.id,),) + .update(schema.arkCollections) + .set(updates) + .where(eq(schema.arkCollections.collectionId, coll.id)) } } - return c.json({ ok: true, },) + return c.json({ ok: true }) } // --- Record type ARK settings --- -export async function getArkRecordTypes(c: Context,) { - const owner = c.req.param('owner',)! - const slug = c.req.param('slug',)! +export async function getArkRecordTypes(c: Context) { + 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 [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 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,),) + }) + .from(schema.arkRecordTypes) + .where(eq(schema.arkRecordTypes.collectionId, coll.id)) - return c.json(rows,) + return c.json(rows) } -export async function updateArkRecordTypes(c: Context,) { - const owner = c.req.param('owner',)! - const slug = c.req.param('slug',)! - const { recordType, redirectUrlField, } = await c.req.json() +export async function updateArkRecordTypes(c: Context) { + 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,) + 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 [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 hasAccess = await checkCollectionAccess(coll.accountId, c.get('accountId')!) + if (!hasAccess) return c.json({ error: 'Forbidden' }, 403) if (redirectUrlField === null) { await db - .delete(schema.arkRecordTypes,) + .delete(schema.arkRecordTypes) .where( and( - eq(schema.arkRecordTypes.collectionId, coll.id,), - eq(schema.arkRecordTypes.recordType, recordType,), + eq(schema.arkRecordTypes.collectionId, coll.id), + eq(schema.arkRecordTypes.recordType, recordType), ), ) } else { await db - .insert(schema.arkRecordTypes,) - .values({ collectionId: coll.id, recordType, redirectUrlField, },) + .insert(schema.arkRecordTypes) + .values({ collectionId: coll.id, recordType, redirectUrlField }) .onConflictDoUpdate({ - target: [schema.arkRecordTypes.collectionId, schema.arkRecordTypes.recordType,], - set: { redirectUrlField, }, - },) + target: [schema.arkRecordTypes.collectionId, schema.arkRecordTypes.recordType], + set: { redirectUrlField }, + }) } - return c.json({ ok: true, },) + return c.json({ ok: true }) } // --- Org ARK NAAN --- -export async function updateAccountArk(c: Context,) { - const slug = c.req.param('slug',)! - const { naan, } = await c.req.json() +export async function updateAccountArk(c: Context) { + 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,) + 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,) + 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,) + 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',)!,), + eq(schema.orgMemberships.orgId, account.id), + eq(schema.orgMemberships.userId, c.get('accountId')!), ), ) - .limit(1,) + .limit(1) if (!membership || (membership.role !== 'owner' && membership.role !== 'admin')) { - return c.json({ error: 'Forbidden', }, 403,) + return c.json({ error: 'Forbidden' }, 403) } - } else if (account.id !== c.get('accountId',)) { - 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, },) + 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,) +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,) + 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,), + eq(schema.orgMemberships.orgId, account.id), + eq(schema.orgMemberships.userId, requestAccountId), ), ) - .limit(1,) + .limit(1) return !!membership } return false diff --git a/src/api/auth.server.ts b/src/api/auth.server.ts index 14ccaaf..2fa3a38 100644 --- a/src/api/auth.server.ts +++ b/src/api/auth.server.ts @@ -1,9 +1,10 @@ import bcrypt from 'bcrypt' -import { eq, } from 'drizzle-orm' -import type { Context, MiddlewareHandler, } from 'hono' -import { deleteCookie, getCookie, setCookie, } from 'hono/cookie' -import { createMiddleware, } from 'hono/factory' -import { db, schema, } from '../db/client.server.js' +import { eq } from 'drizzle-orm' +import type { Context, MiddlewareHandler } from 'hono' +import { deleteCookie, getCookie, setCookie } from 'hono/cookie' +import { createMiddleware } from 'hono/factory' + +import { db, schema } from '../db/client.server.js' export type AuthEnv = { Variables: { @@ -14,75 +15,72 @@ export type AuthEnv = { } } -const publicPaths = new Set([ - '/api/health', - '/api/query/generate-sql', -],) +const publicPaths = new Set(['/api/health', '/api/query/generate-sql']) const internalToken = process.env.INTERNAL_API_TOKEN ?? 'internal-dev-token' const kfInternalApiKey = process.env.KF_INTERNAL_API_KEY ?? '' const sessionSecret = process.env.SESSION_SECRET ?? 'dev-secret-change-me' -export const authMiddleware = createMiddleware(async (c, next,) => { +export const authMiddleware = createMiddleware(async (c, next) => { // Internal service calls (legacy header) - const internalHeader = c.req.header('x-internal-token',) + const internalHeader = c.req.header('x-internal-token') if (internalHeader === internalToken) { - c.set('apiKeyScope', 'read',) + c.set('apiKeyScope', 'read') return next() } // KF Auth internal API key (used by /api/kf/* endpoints) - const auth = c.req.header('authorization',) + const auth = c.req.header('authorization') if (kfInternalApiKey && auth === `Bearer ${kfInternalApiKey}`) { - c.set('apiKeyScope', 'admin',) + c.set('apiKeyScope', 'admin') return next() } // API key auth via Bearer token - if (auth?.startsWith('Bearer ',)) { - const token = auth.slice(7,) - const keys = await db.select().from(schema.apiKeys,) + 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,) + 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,) + 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,),) + .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 c.json({ error: 'Invalid API key', statusCode: 401 }, 401) } return next() } // Session cookie auth - const sessionCookie = getCookie(c, 'session',) + const sessionCookie = getCookie(c, 'session') if (sessionCookie) { try { // Try to parse as signed cookie (value.signature format) let sessionId = sessionCookie - const dotIdx = sessionCookie.lastIndexOf('.',) + const dotIdx = sessionCookie.lastIndexOf('.') if (dotIdx > 0) { - sessionId = sessionCookie.slice(0, dotIdx,) + sessionId = sessionCookie.slice(0, dotIdx) } if (sessionId) { - const [session,] = await db + 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',) + .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 { @@ -94,41 +92,41 @@ export const authMiddleware = createMiddleware(async (c, next,) => { 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,) + 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,) +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 === '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,) + 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,) { +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: '/', },) +export function clearSessionCookie(c: Context) { + deleteCookie(c, 'session', { path: '/' }) } diff --git a/src/api/collections.ts b/src/api/collections.ts index a0710a1..80b6a2e 100644 --- a/src/api/collections.ts +++ b/src/api/collections.ts @@ -1,28 +1,30 @@ -import { and, eq, ilike, or, sql, } from 'drizzle-orm' -import type { Context, } from 'hono' -import { stream, } from 'hono/streaming' -import { createGzip, } from 'node:zlib' -import { pack as tarPack, } from 'tar-stream' -import { v4 as uuidv4, } from 'uuid' -import { db, schema, } from '../db/client.server.js' -import { buildArkUrl, collectionToArkId, DEFAULT_NAAN, getOrMintShoulder, } from '../lib/ark.js' -import { downloadFromS3, } from '../lib/s3.js' -import { type AuthEnv, } from './auth.server.js' +import { createGzip } from 'node:zlib' + +import { and, eq, ilike, or, sql } from 'drizzle-orm' +import type { Context } from 'hono' +import { stream } from 'hono/streaming' +import { pack as tarPack } from 'tar-stream' +import { v4 as uuidv4 } from 'uuid' + +import { db, schema } from '../db/client.server.js' +import { buildArkUrl, collectionToArkId, DEFAULT_NAAN, getOrMintShoulder } from '../lib/ark.js' +import { downloadFromS3 } from '../lib/s3.js' +import { type AuthEnv } from './auth.server.js' // Browse public collections -export async function list(c: Context,) { - 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,),] +export async function list(c: Context) { + 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}%`,), + ilike(schema.collections.name, `%${q}%`), + ilike(schema.collections.description, `%${q}%`), )!, ) } @@ -37,21 +39,26 @@ export async function list(c: Context,) { 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,) + }) + .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 -export async function create(c: Context,) { - const owner = c.req.param('owner',)! - const { slug, name, description, public: isPublic, } = await c.req.json<{ +export async function create(c: Context) { + const owner = c.req.param('owner')! + const { + slug, + name, + description, + public: isPublic, + } = await c.req.json<{ slug: string name: string description?: string @@ -59,82 +66,77 @@ export async function create(c: Context,) { }>() // Resolve owner account - const [account,] = await db + const [account] = await db .select() - .from(schema.accounts,) - .where(eq(schema.accounts.slug, owner,),) - .limit(1,) + .from(schema.accounts) + .where(eq(schema.accounts.slug, owner)) + .limit(1) if (!account) { - return c.json({ error: 'Account not found', statusCode: 404, }, 404,) + 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 === 'user' && account.id !== c.get('accountId')) { + return c.json({ error: 'Forbidden', statusCode: 403 }, 403) } if (account.type === 'org') { - const [membership,] = await db + const [membership] = await db .select() - .from(schema.orgMemberships,) + .from(schema.orgMemberships) .where( and( - eq(schema.orgMemberships.orgId, account.id,), - eq(schema.orgMemberships.userId, c.get('accountId',)!,), + eq(schema.orgMemberships.orgId, account.id), + eq(schema.orgMemberships.userId, c.get('accountId')!), ), ) - .limit(1,) + .limit(1) if (!membership) { - return c.json({ error: 'Forbidden', statusCode: 403, }, 403,) + 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,) + 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,) + return c.json({ error: 'Collection already exists', statusCode: 409 }, 409) } const id = uuidv4() - await db.insert(schema.collections,).values({ + 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 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,) + 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,) + return c.json({ id, owner, slug, name }, 201) } } // Get collection -export async function get(c: Context,) { - const owner = c.req.param('owner',)! - const slug = c.req.param('slug',)! +export async function get(c: Context) { + const owner = c.req.param('owner')! + const slug = c.req.param('slug')! - const [result,] = await db + const [result] = await db .select({ id: schema.collections.id, slug: schema.collections.slug, @@ -146,50 +148,50 @@ export async function get(c: Context,) { 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,) + }) + .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,) + return c.json({ error: 'Collection not found', statusCode: 404 }, 404) } - if (!result.public && c.get('accountId',) !== result.id) { + if (!result.public && c.get('accountId') !== result.id) { // Check if user owns or is member of the owning account - const [account,] = await db + const [account] = await db .select() - .from(schema.accounts,) - .where(eq(schema.accounts.slug, owner,),) - .limit(1,) + .from(schema.accounts) + .where(eq(schema.accounts.slug, owner)) + .limit(1) if (!account) { - return c.json({ error: 'Collection not found', statusCode: 404, }, 404,) + return c.json({ error: 'Collection not found', statusCode: 404 }, 404) } - let hasAccess = account.id === c.get('accountId',) + let hasAccess = account.id === c.get('accountId') if (!hasAccess && account.type === 'org') { - const [membership,] = await db + const [membership] = await db .select() - .from(schema.orgMemberships,) + .from(schema.orgMemberships) .where( and( - eq(schema.orgMemberships.orgId, account.id,), - eq(schema.orgMemberships.userId, c.get('accountId',)!,), + eq(schema.orgMemberships.orgId, account.id), + eq(schema.orgMemberships.userId, c.get('accountId')!), ), ) - .limit(1,) + .limit(1) hasAccess = !!membership } if (!hasAccess) { - return c.json({ error: 'Collection not found', statusCode: 404, }, 404,) + return c.json({ error: 'Collection not found', statusCode: 404 }, 404) } } // Get latest version info - const [latestVersion,] = await db + const [latestVersion] = await db .select({ id: schema.versions.id, number: schema.versions.number, @@ -200,11 +202,11 @@ export async function get(c: Context,) { 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,) + }) + .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 }[] = [] @@ -213,44 +215,48 @@ export async function get(c: Context,) { .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, })) + }) + .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 + 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,) + }) + .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,) + 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, },) + const { id: _vid, ...latestVersionData } = latestVersion ?? { id: undefined } + return c.json({ + ...result, + ark, + latestVersion: latestVersion ? { ...latestVersionData, typeCounts } : null, + }) } // Update collection -export async function update(c: Context,) { - const owner = c.req.param('owner',)! - const slug = c.req.param('slug',)! +export async function update(c: Context) { + const owner = c.req.param('owner')! + const slug = c.req.param('slug')! const updates = await c.req.json<{ name?: string slug?: string @@ -258,204 +264,234 @@ export async function update(c: Context,) { public?: boolean }>() - const [account,] = await db + const [account] = await db .select() - .from(schema.accounts,) - .where(eq(schema.accounts.slug, owner,),) - .limit(1,) + .from(schema.accounts) + .where(eq(schema.accounts.slug, owner)) + .limit(1) if (!account) { - return c.json({ error: 'Not found', statusCode: 404, }, 404,) + return c.json({ error: 'Not found', statusCode: 404 }, 404) } - const [collection,] = await db + const [collection] = await db .select() - .from(schema.collections,) - .where(and(eq(schema.collections.accountId, account.id,), eq(schema.collections.slug, slug,),),) - .limit(1,) + .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,) + return c.json({ error: 'Not found', statusCode: 404 }, 404) } // Validate new slug if provided if (updates.slug !== undefined) { const newSlug = updates.slug if (!newSlug || typeof newSlug !== 'string') { - return c.json({ error: 'Slug is required', statusCode: 422, }, 422,) + return c.json({ error: 'Slug is required', statusCode: 422 }, 422) } - if (!/^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/.test(newSlug,)) { - return c.json({ error: 'Slug must be lowercase alphanumeric with hyphens', statusCode: 422, }, 422,) + if (!/^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/.test(newSlug)) { + return c.json( + { error: 'Slug must be lowercase alphanumeric with hyphens', statusCode: 422 }, + 422, + ) } // Check uniqueness within same account - const [existing,] = await db - .select({ id: schema.collections.id, },) - .from(schema.collections,) - .where(and(eq(schema.collections.accountId, account.id,), eq(schema.collections.slug, newSlug,),),) - .limit(1,) + const [existing] = await db + .select({ id: schema.collections.id }) + .from(schema.collections) + .where( + and(eq(schema.collections.accountId, account.id), eq(schema.collections.slug, newSlug)), + ) + .limit(1) if (existing && existing.id !== collection.id) { - return c.json({ error: 'A collection with that slug already exists', statusCode: 409, }, 409,) + return c.json({ error: 'A collection with that slug already exists', statusCode: 409 }, 409) } } await db - .update(schema.collections,) - .set({ ...updates, updatedAt: new Date(), },) - .where(eq(schema.collections.id, collection.id,),) + .update(schema.collections) + .set({ ...updates, updatedAt: new Date() }) + .where(eq(schema.collections.id, collection.id)) - return c.json({ ok: true, slug: updates.slug ?? slug, },) + return c.json({ ok: true, slug: updates.slug ?? slug }) } // Delete collection -export async function remove(c: Context,) { - const owner = c.req.param('owner',)! - const slug = c.req.param('slug',)! +export async function remove(c: Context) { + const owner = c.req.param('owner')! + const slug = c.req.param('slug')! - const [account,] = await db + const [account] = await db .select() - .from(schema.accounts,) - .where(eq(schema.accounts.slug, owner,),) - .limit(1,) + .from(schema.accounts) + .where(eq(schema.accounts.slug, owner)) + .limit(1) if (!account) { - return c.json({ error: 'Not found', statusCode: 404, }, 404,) + return c.json({ error: 'Not found', statusCode: 404 }, 404) } - const [collection,] = await db + const [collection] = await db .select() - .from(schema.collections,) - .where(and(eq(schema.collections.accountId, account.id,), eq(schema.collections.slug, slug,),),) - .limit(1,) + .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,) + 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, },) + await db.delete(schema.collections).where(eq(schema.collections.id, collection.id)) + return c.json({ ok: true }) } // Transfer collection to another account -export async function transfer(c: Context,) { - const owner = c.req.param('owner',)! - const slug = c.req.param('slug',)! - const { targetAccountSlug, } = await c.req.json() +export async function transfer(c: Context) { + const owner = c.req.param('owner')! + const slug = c.req.param('slug')! + const { targetAccountSlug } = await c.req.json() if (!targetAccountSlug || typeof targetAccountSlug !== 'string') { - return c.json({ error: 'targetAccountSlug is required', statusCode: 422, }, 422,) + return c.json({ error: 'targetAccountSlug is required', statusCode: 422 }, 422) } - const callerId = c.get('accountId',)! + const callerId = c.get('accountId')! // Find source account - const [sourceAccount,] = await db + const [sourceAccount] = await db .select() - .from(schema.accounts,) - .where(eq(schema.accounts.slug, owner,),) - .limit(1,) + .from(schema.accounts) + .where(eq(schema.accounts.slug, owner)) + .limit(1) - if (!sourceAccount) return c.json({ error: 'Source account not found', statusCode: 404, }, 404,) + if (!sourceAccount) return c.json({ error: 'Source account not found', statusCode: 404 }, 404) // Verify caller has access to source account const callerIsSource = sourceAccount.id === callerId let callerHasSourceAccess = callerIsSource if (!callerIsSource && sourceAccount.type === 'org') { - const [membership,] = await db + const [membership] = await db .select() - .from(schema.orgMemberships,) - .where(and(eq(schema.orgMemberships.orgId, sourceAccount.id,), eq(schema.orgMemberships.userId, callerId,),),) - .limit(1,) - callerHasSourceAccess = !!membership && (membership.role === 'owner' || membership.role === 'admin') + .from(schema.orgMemberships) + .where( + and( + eq(schema.orgMemberships.orgId, sourceAccount.id), + eq(schema.orgMemberships.userId, callerId), + ), + ) + .limit(1) + callerHasSourceAccess = + !!membership && (membership.role === 'owner' || membership.role === 'admin') } if (!callerHasSourceAccess) { - return c.json({ error: 'You must be an owner or admin of the source account', statusCode: 403, }, 403,) + return c.json( + { error: 'You must be an owner or admin of the source account', statusCode: 403 }, + 403, + ) } // Find target account - const [targetAccount,] = await db + const [targetAccount] = await db .select() - .from(schema.accounts,) - .where(eq(schema.accounts.slug, targetAccountSlug,),) - .limit(1,) + .from(schema.accounts) + .where(eq(schema.accounts.slug, targetAccountSlug)) + .limit(1) - if (!targetAccount) return c.json({ error: 'Target account not found', statusCode: 404, }, 404,) + if (!targetAccount) return c.json({ error: 'Target account not found', statusCode: 404 }, 404) // Verify caller has access to target account const callerIsTarget = targetAccount.id === callerId let callerHasTargetAccess = callerIsTarget if (!callerIsTarget && targetAccount.type === 'org') { - const [membership,] = await db + const [membership] = await db .select() - .from(schema.orgMemberships,) - .where(and(eq(schema.orgMemberships.orgId, targetAccount.id,), eq(schema.orgMemberships.userId, callerId,),),) - .limit(1,) - callerHasTargetAccess = !!membership && (membership.role === 'owner' || membership.role === 'admin') + .from(schema.orgMemberships) + .where( + and( + eq(schema.orgMemberships.orgId, targetAccount.id), + eq(schema.orgMemberships.userId, callerId), + ), + ) + .limit(1) + callerHasTargetAccess = + !!membership && (membership.role === 'owner' || membership.role === 'admin') } if (!callerHasTargetAccess) { - return c.json({ error: 'You must be an owner or admin of the target account', statusCode: 403, }, 403,) + return c.json( + { error: 'You must be an owner or admin of the target account', statusCode: 403 }, + 403, + ) } // Find collection - const [collection,] = await db + const [collection] = await db .select() - .from(schema.collections,) - .where(and(eq(schema.collections.accountId, sourceAccount.id,), eq(schema.collections.slug, slug,),),) - .limit(1,) + .from(schema.collections) + .where( + and(eq(schema.collections.accountId, sourceAccount.id), eq(schema.collections.slug, slug)), + ) + .limit(1) - if (!collection) return c.json({ error: 'Collection not found', statusCode: 404, }, 404,) + if (!collection) return c.json({ error: 'Collection not found', statusCode: 404 }, 404) // Check slug uniqueness in target account - const [existing,] = await db - .select({ id: schema.collections.id, },) - .from(schema.collections,) - .where(and(eq(schema.collections.accountId, targetAccount.id,), eq(schema.collections.slug, slug,),),) - .limit(1,) + const [existing] = await db + .select({ id: schema.collections.id }) + .from(schema.collections) + .where( + and(eq(schema.collections.accountId, targetAccount.id), eq(schema.collections.slug, slug)), + ) + .limit(1) if (existing) { - return c.json({ error: `Target account already has a collection with slug "${slug}"`, statusCode: 409, }, 409,) + return c.json( + { error: `Target account already has a collection with slug "${slug}"`, statusCode: 409 }, + 409, + ) } // Transfer await db - .update(schema.collections,) - .set({ accountId: targetAccount.id, updatedAt: new Date(), },) - .where(eq(schema.collections.id, collection.id,),) + .update(schema.collections) + .set({ accountId: targetAccount.id, updatedAt: new Date() }) + .where(eq(schema.collections.id, collection.id)) - return c.json({ ok: true, newOwner: targetAccountSlug, },) + return c.json({ ok: true, newOwner: targetAccountSlug }) } // List collections for an account -export async function listByOwner(c: Context,) { - const owner = c.req.param('owner',)! +export async function listByOwner(c: Context) { + const owner = c.req.param('owner')! - const [account,] = await db + const [account] = await db .select() - .from(schema.accounts,) - .where(eq(schema.accounts.slug, owner,),) - .limit(1,) + .from(schema.accounts) + .where(eq(schema.accounts.slug, owner)) + .limit(1) - if (!account) return c.json([],) + 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 + let hasFullAccess = c.get('accountId') === account.id + if (!hasFullAccess && account.type === 'org' && c.get('accountId')) { + const [membership] = await db .select() - .from(schema.orgMemberships,) + .from(schema.orgMemberships) .where( and( - eq(schema.orgMemberships.orgId, account.id,), - eq(schema.orgMemberships.userId, c.get('accountId',)!,), + eq(schema.orgMemberships.orgId, account.id), + eq(schema.orgMemberships.userId, c.get('accountId')!), ), ) - .limit(1,) + .limit(1) hasFullAccess = !!membership } - const conditions = [eq(schema.collections.accountId, account.id,),] + const conditions = [eq(schema.collections.accountId, account.id)] if (!hasFullAccess) { - conditions.push(eq(schema.collections.public, true,),) + conditions.push(eq(schema.collections.public, true)) } const results = await db @@ -467,22 +503,22 @@ export async function listByOwner(c: Context,) { public: schema.collections.public, createdAt: schema.collections.createdAt, updatedAt: schema.collections.updatedAt, - },) - .from(schema.collections,) - .where(and(...conditions,),) - .orderBy(schema.collections.updatedAt,) + }) + .from(schema.collections) + .where(and(...conditions)) + .orderBy(schema.collections.updatedAt) - return c.json(results,) + return c.json(results) } // Export collection as .tar.gz archive -export async function exportArchive(c: Context,) { - const owner = c.req.param('owner',)! - const slug = c.req.param('slug',)! - const versionParam = c.req.query('version',) +export async function exportArchive(c: Context) { + const owner = c.req.param('owner')! + const slug = c.req.param('slug')! + const versionParam = c.req.query('version') // Resolve collection - const [collection,] = await db + const [collection] = await db .select({ id: schema.collections.id, slug: schema.collections.slug, @@ -490,35 +526,35 @@ export async function exportArchive(c: Context,) { 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,) + }) + .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,) + 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,) + 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,),] + const versionConditions = [eq(schema.versions.collectionId, collection.id)] if (versionParam) { - versionConditions.push(eq(schema.versions.number, parseInt(versionParam, 10,),),) + versionConditions.push(eq(schema.versions.number, parseInt(versionParam, 10))) } - const [version,] = await db + const [version] = await db .select() - .from(schema.versions,) - .where(and(...versionConditions,),) - .orderBy(sql`${schema.versions.number} desc`,) - .limit(1,) + .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,) + return c.json({ error: 'No versions found', statusCode: 404 }, 404) } // Fetch records and files for this version @@ -527,9 +563,9 @@ export async function exportArchive(c: Context,) { recordId: schema.records.recordId, type: schema.records.type, data: schema.records.data, - },) - .from(schema.records,) - .where(eq(schema.records.versionId, version.id,),) + }) + .from(schema.records) + .where(eq(schema.records.versionId, version.id)) const versionFiles = await db .select({ @@ -537,28 +573,26 @@ export async function exportArchive(c: Context,) { 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,),) + }) + .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,),) + }) + .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,]), - ) + 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, }, + collection: { owner, slug, name: collection.name, description: collection.description }, version: { number: version.number, semver: version.semver, @@ -579,28 +613,30 @@ export async function exportArchive(c: Context,) { 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,) + 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,) + 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,) + 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,) + 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) } @@ -609,23 +645,23 @@ export async function exportArchive(c: Context,) { pack.finalize() // Pipe tar → gzip and collect into a ReadableStream - const outputStream = pack.pipe(gzip,) + const outputStream = pack.pipe(gzip) const readableStream = new ReadableStream({ - start(controller,) { - outputStream.on('data', (chunk: Buffer,) => { - controller.enqueue(new Uint8Array(chunk,),) - },) + start(controller) { + outputStream.on('data', (chunk: Buffer) => { + controller.enqueue(new Uint8Array(chunk)) + }) outputStream.on('end', () => { controller.close() - },) - outputStream.on('error', (err,) => { - controller.error(err,) - },) + }) + outputStream.on('error', (err) => { + controller.error(err) + }) }, - },) + }) return c.body(readableStream, 200, { 'Content-Type': 'application/gzip', 'Content-Disposition': `attachment; filename="${filename}"`, - },) + }) } diff --git a/src/api/files.ts b/src/api/files.ts index cae4f49..ea3aac6 100644 --- a/src/api/files.ts +++ b/src/api/files.ts @@ -1,9 +1,11 @@ -import { and, eq, sql, } from 'drizzle-orm' -import type { Context, } from 'hono' -import { createHash, } from 'node:crypto' -import { db, schema, } from '../db/client.server.js' -import { getS3ObjectMeta, uploadToS3, } from '../lib/s3.js' -import { type AuthEnv, } from './auth.server.js' +import { createHash } from 'node:crypto' + +import { and, eq, sql } from 'drizzle-orm' +import type { Context } from 'hono' + +import { db, schema } from '../db/client.server.js' +import { getS3ObjectMeta, uploadToS3 } from '../lib/s3.js' +import { type AuthEnv } from './auth.server.js' /** * Check if a file hash is referenced by any public (non-private) record @@ -16,15 +18,15 @@ async function isFilePubliclyAccessible( accountId: string | undefined, ): Promise { // Resolve collection - const [collection,] = await db + 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,) + }) + .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 @@ -34,23 +36,23 @@ async function isFilePubliclyAccessible( } // 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,) + 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,) + 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,),), + and(eq(schema.versionFiles.versionId, latest.id), eq(schema.versionFiles.fileHash, fileHash)), ) - .limit(1,) + .limit(1) if (!vf) return false @@ -59,56 +61,56 @@ async function isFilePubliclyAccessible( .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,),) + }) + .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,) + 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,) + .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,), + eq(schema.records.versionId, latest.id), + eq(schema.records.private, false), sql`${schema.records.data}::text LIKE ${'%' + fileHash + '%'}`, ), ) - .limit(10,) + .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 + if (privateTypes.has(rec.type)) continue // Get private fields for this type - const typeSchema = typeSchemaMap.get(rec.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,) + 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 + 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}` + 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 } @@ -119,136 +121,142 @@ async function isFilePubliclyAccessible( } // Check if file exists -export async function headFile(c: Context,) { - const owner = c.req.param('owner',)! - const slug = c.req.param('slug',)! - const hash = c.req.param('hash',)! - const cleanHash = hash.replace('sha256:', '',) +export async function headFile(c: Context) { + 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 + const [file] = await db .select() - .from(schema.files,) - .where(eq(schema.files.hash, cleanHash,),) - .limit(1,) + .from(schema.files) + .where(eq(schema.files.hash, cleanHash)) + .limit(1) if (!file) { - return c.body(null, 404,) + return c.body(null, 404) } // Check visibility - const accessible = await isFilePubliclyAccessible(owner, slug, cleanHash, c.get('accountId',),) + const accessible = await isFilePubliclyAccessible(owner, slug, cleanHash, c.get('accountId')) if (!accessible) { - return c.body(null, 404,) + return c.body(null, 404) } - c.header('Content-Length', String(file.size,),) - c.header('Content-Type', file.mimeType,) - return c.body(null, 200,) + c.header('Content-Length', String(file.size)) + c.header('Content-Type', file.mimeType) + return c.body(null, 200) } // Download file -export async function getFile(c: Context,) { - const owner = c.req.param('owner',)! - const slug = c.req.param('slug',)! - const hash = c.req.param('hash',)! - const cleanHash = hash.replace('sha256:', '',) +export async function getFile(c: Context) { + 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 + const [file] = await db .select() - .from(schema.files,) - .where(eq(schema.files.hash, cleanHash,),) - .limit(1,) + .from(schema.files) + .where(eq(schema.files.hash, cleanHash)) + .limit(1) if (!file) { - return c.json({ error: 'File not found', statusCode: 404, }, 404,) + return c.json({ error: 'File not found', statusCode: 404 }, 404) } // Check visibility - const accessible = await isFilePubliclyAccessible(owner, slug, cleanHash, c.get('accountId',),) + const accessible = await isFilePubliclyAccessible(owner, slug, cleanHash, c.get('accountId')) if (!accessible) { - return c.json({ error: 'File not found', statusCode: 404, }, 404,) + 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,) + const cdnUrl = `https://assets.underlay.org/files/${cleanHash.slice(0, 2)}/${cleanHash.slice(2, 4)}/${cleanHash}` + return c.redirect(cdnUrl) } // Upload file -export async function putFile(c: Context,) { - const owner = c.req.param('owner',)! - const slug = c.req.param('slug',)! - const hash = c.req.param('hash',)! - const cleanHash = hash.replace('sha256:', '',) +export async function putFile(c: Context) { + 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 + const [existing] = await db .select() - .from(schema.files,) - .where(eq(schema.files.hash, cleanHash,),) - .limit(1,) + .from(schema.files) + .where(eq(schema.files.hash, cleanHash)) + .limit(1) if (existing) { - return c.json({ hash: cleanHash, status: 'exists', }, 200,) + 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,) + 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,) + 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' + const contentType = c.req.header('content-type') ?? 'application/octet-stream' let buffer: Buffer let mimeType: string - if (contentType.startsWith('multipart/',)) { + 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,) + buffer = Buffer.from(ab) mimeType = file.type || 'application/octet-stream' } else { - return c.json({ error: 'No file in multipart body', statusCode: 400, }, 400,) + 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,) + buffer = Buffer.from(ab) mimeType = contentType } // Verify hash - const computedHash = createHash('sha256',).update(buffer,).digest('hex',) + const computedHash = createHash('sha256').update(buffer).digest('hex') if (computedHash !== cleanHash) { - return c.json({ - error: 'Hash mismatch', - expected: cleanHash, - computed: computedHash, - statusCode: 400, - }, 400,) + 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}` + const storageKey = `files/${cleanHash.slice(0, 2)}/${cleanHash.slice(2, 4)}/${cleanHash}` - await uploadToS3(storageKey, buffer, mimeType,) + await uploadToS3(storageKey, buffer, mimeType) - await db.insert(schema.files,).values({ + await db.insert(schema.files).values({ hash: cleanHash, size: buffer.length, mimeType, storageKey, - },) + }) - return c.json({ hash: cleanHash, size: buffer.length, }, 201,) + return c.json({ hash: cleanHash, size: buffer.length }, 201) } diff --git a/src/api/health.ts b/src/api/health.ts index 24bd286..648041b 100644 --- a/src/api/health.ts +++ b/src/api/health.ts @@ -1,5 +1,5 @@ -import type { Context, } from 'hono' +import type { Context } from 'hono' -export async function check(c: Context,) { - return c.json({ status: 'ok', timestamp: new Date().toISOString(), },) +export async function check(c: Context) { + return c.json({ status: 'ok', timestamp: new Date().toISOString() }) } diff --git a/src/api/kf-auth.ts b/src/api/kf-auth.ts index 493d6ab..a6d367a 100644 --- a/src/api/kf-auth.ts +++ b/src/api/kf-auth.ts @@ -1,11 +1,18 @@ -import { eq, } from 'drizzle-orm' -import type { Context, } from 'hono' -import { deleteCookie, getCookie, setCookie, } from 'hono/cookie' import crypto from 'node:crypto' -import { v4 as uuidv4, } from 'uuid' -import { db, schema, } from '../db/client.server.js' -import { buildAuthorizeUrl, exchangeCode, fetchUserInfo, type KFOrg, } from '../lib/kf-auth.server.js' -import { type AuthEnv, setSessionCookie, } from './auth.server.js' + +import { eq } from 'drizzle-orm' +import type { Context } from 'hono' +import { deleteCookie, getCookie, setCookie } from 'hono/cookie' +import { v4 as uuidv4 } from 'uuid' + +import { db, schema } from '../db/client.server.js' +import { + buildAuthorizeUrl, + exchangeCode, + fetchUserInfo, + type KFOrg, +} from '../lib/kf-auth.server.js' +import { type AuthEnv, setSessionCookie } from './auth.server.js' const STATE_COOKIE = 'kf_oauth_state' const RETURN_COOKIE = 'kf_oauth_return' @@ -15,9 +22,9 @@ const VERIFIER_COOKIE = 'kf_oauth_verifier' * GET /auth/login — redirect to KF Auth with CSRF state + PKCE. * Optional ?return_to= query param preserved for post-login redirect. */ -export async function login(c: Context,) { - const state = crypto.randomBytes(24,).toString('hex',) - const returnTo = c.req.query('return_to',) ?? '/dashboard' +export async function login(c: Context) { + const state = crypto.randomBytes(24).toString('hex') + const returnTo = c.req.query('return_to') ?? '/dashboard' const cookieOpts = { httpOnly: true, @@ -27,13 +34,13 @@ export async function login(c: Context,) { maxAge: 600, // 10 minutes } - setCookie(c, STATE_COOKIE, state, cookieOpts,) - setCookie(c, RETURN_COOKIE, returnTo, cookieOpts,) + setCookie(c, STATE_COOKIE, state, cookieOpts) + setCookie(c, RETURN_COOKIE, returnTo, cookieOpts) - const { url, codeVerifier, } = buildAuthorizeUrl(state,) - setCookie(c, VERIFIER_COOKIE, codeVerifier, cookieOpts,) + const { url, codeVerifier } = buildAuthorizeUrl(state) + setCookie(c, VERIFIER_COOKIE, codeVerifier, cookieOpts) - return c.redirect(url,) + return c.redirect(url) } /** @@ -41,51 +48,51 @@ export async function login(c: Context,) { * Exchanges the code for tokens, fetches user info, upserts the local * account, creates a session, and redirects to the return URL. */ -export async function callback(c: Context,) { - const code = c.req.query('code',) - const state = c.req.query('state',) - const error = c.req.query('error',) +export async function callback(c: Context) { + const code = c.req.query('code') + const state = c.req.query('state') + const error = c.req.query('error') if (error) { - console.error('KF Auth returned error:', error, c.req.query('error_description',),) - return c.redirect('/login?error=auth_failed',) + console.error('KF Auth returned error:', error, c.req.query('error_description')) + return c.redirect('/login?error=auth_failed') } if (!code || !state) { - return c.redirect('/login?error=missing_params',) + return c.redirect('/login?error=missing_params') } // Validate CSRF state - const savedState = getCookie(c, STATE_COOKIE,) - const codeVerifier = getCookie(c, VERIFIER_COOKIE,) - deleteCookie(c, STATE_COOKIE, { path: '/', },) - deleteCookie(c, VERIFIER_COOKIE, { path: '/', },) + const savedState = getCookie(c, STATE_COOKIE) + const codeVerifier = getCookie(c, VERIFIER_COOKIE) + deleteCookie(c, STATE_COOKIE, { path: '/' }) + deleteCookie(c, VERIFIER_COOKIE, { path: '/' }) if (!savedState || savedState !== state) { - return c.redirect('/login?error=invalid_state',) + return c.redirect('/login?error=invalid_state') } if (!codeVerifier) { - return c.redirect('/login?error=missing_verifier',) + return c.redirect('/login?error=missing_verifier') } // Exchange code for tokens let accessToken: string try { - const tokens = await exchangeCode(code, codeVerifier,) + const tokens = await exchangeCode(code, codeVerifier) accessToken = tokens.access_token } catch (err) { - console.error('Token exchange failed:', err,) - return c.redirect('/login?error=token_exchange',) + console.error('Token exchange failed:', err) + return c.redirect('/login?error=token_exchange') } // Fetch user info let userInfo: Awaited> try { - userInfo = await fetchUserInfo(accessToken,) + userInfo = await fetchUserInfo(accessToken) } catch (err) { - console.error('UserInfo fetch failed:', err,) - return c.redirect('/login?error=userinfo',) + console.error('UserInfo fetch failed:', err) + return c.redirect('/login?error=userinfo') } // Find or create local account. @@ -93,91 +100,91 @@ export async function callback(c: Context,) { // No profile data stored locally — fetched from KF Auth on demand. const kfUserId = userInfo.sub const kfOrgs: KFOrg[] = userInfo['https://knowledgefutures.org/orgs'] ?? [] - const kfPersonalOrg = kfOrgs.find((o,) => o.type === 'personal') + const kfPersonalOrg = kfOrgs.find((o) => o.type === 'personal') const accountId = kfUserId - const [existing,] = await db - .select({ id: schema.accounts.id, },) - .from(schema.accounts,) - .where(eq(schema.accounts.id, kfUserId,),) - .limit(1,) + const [existing] = await db + .select({ id: schema.accounts.id }) + .from(schema.accounts) + .where(eq(schema.accounts.id, kfUserId)) + .limit(1) if (existing) { // Update personal org link if it changed if (kfPersonalOrg) { await db - .update(schema.accounts,) - .set({ kfOrgId: kfPersonalOrg.id, },) - .where(eq(schema.accounts.id, accountId,),) + .update(schema.accounts) + .set({ kfOrgId: kfPersonalOrg.id }) + .where(eq(schema.accounts.id, accountId)) } } else { // Create new account — generate a slug from email or name - const baseSlug = (userInfo.email?.split('@',)[0] ?? userInfo.name ?? 'user') + const baseSlug = (userInfo.email?.split('@')[0] ?? userInfo.name ?? 'user') .toLowerCase() - .replace(/[^a-z0-9-]/g, '-',) - .replace(/-+/g, '-',) - .slice(0, 30,) + .replace(/[^a-z0-9-]/g, '-') + .replace(/-+/g, '-') + .slice(0, 30) // Ensure slug is unique let slug = baseSlug let attempt = 0 while (true) { - const [conflict,] = await db - .select({ id: schema.accounts.id, },) - .from(schema.accounts,) - .where(eq(schema.accounts.slug, slug,),) - .limit(1,) + const [conflict] = await db + .select({ id: schema.accounts.id }) + .from(schema.accounts) + .where(eq(schema.accounts.slug, slug)) + .limit(1) if (!conflict) break attempt++ slug = `${baseSlug}-${attempt}` } - await db.insert(schema.accounts,).values({ + await db.insert(schema.accounts).values({ id: kfUserId, slug, type: 'user', kfOrgId: kfPersonalOrg?.id ?? null, - },) + }) } // Create session const sessionId = uuidv4() - const expiresAt = new Date(Date.now() + 30 * 24 * 60 * 60 * 1000,) // 30 days - await db.insert(schema.sessions,).values({ + const expiresAt = new Date(Date.now() + 30 * 24 * 60 * 60 * 1000) // 30 days + await db.insert(schema.sessions).values({ id: sessionId, userId: accountId, expiresAt, - userAgent: c.req.header('user-agent',) ?? null, - ipAddress: c.req.header('x-forwarded-for',) || 'unknown', - },) + userAgent: c.req.header('user-agent') ?? null, + ipAddress: c.req.header('x-forwarded-for') || 'unknown', + }) - setSessionCookie(c, sessionId,) + setSessionCookie(c, sessionId) // Redirect to saved return URL - const returnTo = getCookie(c, RETURN_COOKIE,) ?? '/dashboard' - deleteCookie(c, RETURN_COOKIE, { path: '/', },) + const returnTo = getCookie(c, RETURN_COOKIE) ?? '/dashboard' + deleteCookie(c, RETURN_COOKIE, { path: '/' }) - return c.redirect(returnTo,) + return c.redirect(returnTo) } /** * POST /auth/logout — clear local session, return JSON. * The client is responsible for redirecting to KF Auth's signout endpoint. */ -export async function logout(c: Context,) { - const sessionCookie = getCookie(c, 'session',) +export async function logout(c: Context) { + const sessionCookie = getCookie(c, 'session') if (sessionCookie) { let sessionId = sessionCookie - const dotIdx = sessionCookie.lastIndexOf('.',) - if (dotIdx > 0) sessionId = sessionCookie.slice(0, dotIdx,) + const dotIdx = sessionCookie.lastIndexOf('.') + if (dotIdx > 0) sessionId = sessionCookie.slice(0, dotIdx) await db - .delete(schema.sessions,) - .where(eq(schema.sessions.id, sessionId,),) - .catch(() => {},) + .delete(schema.sessions) + .where(eq(schema.sessions.id, sessionId)) + .catch(() => {}) } - deleteCookie(c, 'session', { path: '/', },) - return c.json({ ok: true, },) + deleteCookie(c, 'session', { path: '/' }) + return c.json({ ok: true }) } diff --git a/src/api/kf-summary.ts b/src/api/kf-summary.ts index f917756..b028ddf 100644 --- a/src/api/kf-summary.ts +++ b/src/api/kf-summary.ts @@ -1,6 +1,7 @@ -import { eq, sql, } from 'drizzle-orm' -import type { Context, } from 'hono' -import { db, schema, } from '../db/client.server.js' +import { eq, sql } from 'drizzle-orm' +import type { Context } from 'hono' + +import { db, schema } from '../db/client.server.js' /** * GET /api/kf/summary?kf_org_id=xxx @@ -10,17 +11,17 @@ import { db, schema, } from '../db/client.server.js' * * Auth: requires KF_INTERNAL_API_KEY (service-to-service). */ -export async function summary(c: Context,) { - const kfOrgId = c.req.query('kf_org_id',) +export async function summary(c: Context) { + const kfOrgId = c.req.query('kf_org_id') if (!kfOrgId) { - return c.json({ error: 'kf_org_id is required', }, 400,) + return c.json({ error: 'kf_org_id is required' }, 400) } // Verify internal API key - const authHeader = c.req.header('Authorization',) + const authHeader = c.req.header('Authorization') const expectedKey = process.env.KF_INTERNAL_API_KEY if (!expectedKey || authHeader !== `Bearer ${expectedKey}`) { - return c.json({ error: 'Unauthorized', }, 401,) + return c.json({ error: 'Unauthorized' }, 401) } const APP_URL = process.env.APP_URL ?? 'http://localhost:4100' @@ -32,15 +33,15 @@ export async function summary(c: Context,) { slug: schema.accounts.slug, type: schema.accounts.type, displayName: schema.accounts.displayName, - },) - .from(schema.accounts,) - .where(eq(schema.accounts.kfOrgId, kfOrgId,),) + }) + .from(schema.accounts) + .where(eq(schema.accounts.kfOrgId, kfOrgId)) if (directAccounts.length === 0) { - return c.json({ accounts: [], },) + return c.json({ accounts: [] }) } - const allAccountIds = directAccounts.map((a,) => a.id) + const allAccountIds = directAccounts.map((a) => a.id) // Get collections for all accounts const collections = await db @@ -50,20 +51,21 @@ export async function summary(c: Context,) { name: schema.collections.name, accountId: schema.collections.accountId, ownerSlug: schema.accounts.slug, - },) - .from(schema.collections,) - .innerJoin(schema.accounts, eq(schema.collections.accountId, schema.accounts.id,),) + }) + .from(schema.collections) + .innerJoin(schema.accounts, eq(schema.collections.accountId, schema.accounts.id)) .where( - sql`${schema.collections.accountId} IN (${ - sql.join( - allAccountIds.map((id,) => sql`${id}`), - sql`, `, - ) - })`, + sql`${schema.collections.accountId} IN (${sql.join( + allAccountIds.map((id) => sql`${id}`), + sql`, `, + )})`, ) // Get version stats if we have collections - const statsMap = new Map() + const statsMap = new Map< + string, + { versions: number; records: number; files: number; bytes: number } + >() if (collections.length > 0) { const versionStats = await db @@ -73,17 +75,15 @@ export async function summary(c: Context,) { totalRecords: sql`coalesce(sum(${schema.versions.recordCount}), 0)::int`, totalFiles: sql`coalesce(sum(${schema.versions.fileCount}), 0)::int`, totalBytes: sql`coalesce(sum(${schema.versions.totalBytes}), 0)::bigint`, - },) - .from(schema.versions,) + }) + .from(schema.versions) .where( - sql`${schema.versions.collectionId} IN (${ - sql.join( - collections.map((c,) => sql`${c.id}`), - sql`, `, - ) - })`, + sql`${schema.versions.collectionId} IN (${sql.join( + collections.map((c) => sql`${c.id}`), + sql`, `, + )})`, ) - .groupBy(schema.versions.collectionId,) + .groupBy(schema.versions.collectionId) for (const s of versionStats) { statsMap.set(s.collectionId, { @@ -91,35 +91,35 @@ export async function summary(c: Context,) { records: s.totalRecords, files: s.totalFiles, bytes: s.totalBytes, - },) + }) } } // Group collections by account const collectionsByAccount = new Map() for (const col of collections) { - const list = collectionsByAccount.get(col.accountId,) ?? [] - list.push(col,) - collectionsByAccount.set(col.accountId, list,) + const list = collectionsByAccount.get(col.accountId) ?? [] + list.push(col) + collectionsByAccount.set(col.accountId, list) } return c.json({ - accounts: directAccounts.map((acct,) => ({ + accounts: directAccounts.map((acct) => ({ id: acct.id, slug: acct.slug, type: acct.type, name: acct.displayName ?? acct.slug, url: `${APP_URL}/${acct.slug}`, - collections: (collectionsByAccount.get(acct.id,) ?? []).map((col,) => { - const stats = statsMap.get(col.id,) + collections: (collectionsByAccount.get(acct.id) ?? []).map((col) => { + const stats = statsMap.get(col.id) return { id: col.id, name: col.name, slug: col.slug, url: `${APP_URL}/${col.ownerSlug}/${col.slug}`, - stats: stats ?? { versions: 0, records: 0, files: 0, bytes: 0, }, + stats: stats ?? { versions: 0, records: 0, files: 0, bytes: 0 }, } - },), + }), })), - },) + }) } diff --git a/src/api/query.ts b/src/api/query.ts index ddc2c16..5ecb407 100644 --- a/src/api/query.ts +++ b/src/api/query.ts @@ -1,8 +1,9 @@ -import { and, desc, eq, ilike, inArray, or, } from 'drizzle-orm' -import type { Context, } from 'hono' -import { db, schema, } from '../db/client.server.js' -import { buildSqliteBuffer, generateAllDDL, generateDDL, } from '../lib/sqlite-gen.js' -import { type AuthEnv, } from './auth.server.js' +import { and, desc, eq, ilike, inArray, or } from 'drizzle-orm' +import type { Context } from 'hono' + +import { db, schema } from '../db/client.server.js' +import { buildSqliteBuffer, generateAllDDL, generateDDL } from '../lib/sqlite-gen.js' +import { type AuthEnv } from './auth.server.js' // In-memory LRU cache: key = `${collectionId}:${versionNumber}`, value = { buffer, expiresAt } const sqliteCache = new Map< @@ -20,8 +21,8 @@ const CACHE_MAX_ENTRIES = 10 function cleanExpired() { const now = Date.now() - for (const [key, entry,] of sqliteCache) { - if (entry.expiresAt < now) sqliteCache.delete(key,) + for (const [key, entry] of sqliteCache) { + if (entry.expiresAt < now) sqliteCache.delete(key) } } @@ -29,51 +30,60 @@ 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,) + if (firstKey) sqliteCache.delete(firstKey) else break } } // Run cleanup every 5 minutes -setInterval(cleanExpired, 5 * 60 * 1000,) +setInterval(cleanExpired, 5 * 60 * 1000) -async function getOrBuildSqlite(owner: string, slug: string, versionNumber: number,) { +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,) + 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,) + 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,) + const cached = sqliteCache.get(cacheKey) if (cached && cached.expiresAt > Date.now()) { - sqliteCache.delete(cacheKey,) + sqliteCache.delete(cacheKey) cached.expiresAt = Date.now() + CACHE_TTL_MS - sqliteCache.set(cacheKey, cached,) + 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,),) + .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) { @@ -82,93 +92,100 @@ async function getOrBuildSqlite(owner: string, slug: string, versionNumber: numb // 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,),) + .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,) + 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) + 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 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 + `\n-- Example row: ${JSON.stringify(sample)}` } return tableDdl - },) - .join('\n\n',) + }) + .join('\n\n') - const entry = { buffer, ddl, ddlWithSamples, sampleRows, expiresAt: Date.now() + CACHE_TTL_MS, } + const entry = { buffer, ddl, ddlWithSamples, sampleRows, expiresAt: Date.now() + CACHE_TTL_MS } evictIfNeeded() - sqliteCache.set(cacheKey, entry,) + sqliteCache.set(cacheKey, entry) return entry } // GET /query/sqlite/:owner/:slug/:version — Download SQLite file for a version -export async function sqlite(c: Context,) { - 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,) +export async function sqlite(c: Context) { + 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,) + 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,), { + 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 -export async function ddl(c: Context,) { - 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,) +export async function ddl(c: Context) { + 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,) + 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, },) + return c.json({ ddl: result.ddl }) } // POST /query/generate-sql — LLM-powered SQL generation from natural language -export async function generateSql(c: Context,) { - const { collections: collectionRefs, question, } = await c.req.json() +export async function generateSql(c: Context) { + const { collections: collectionRefs, question } = await c.req.json() if (!collectionRefs?.length || !question) { - return c.json({ error: 'collections and question are required', }, 400,) + 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,) + 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 @@ -177,29 +194,33 @@ export async function generateSql(c: Context,) { 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,) + 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, '_',) + 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,) + .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',) + 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. + 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): @@ -211,24 +232,24 @@ REASONING: Important rules: - Examine the "Example row" comments in the schema — they show the ACTUAL data format stored in each column.${ - isMultiCollection - ? ` + 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.' : '' - } + 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}`,) + console.info(`[generate-sql] User prompt:\n${userPrompt}`) try { const response = await fetch( @@ -241,84 +262,97 @@ Important rules: }, body: JSON.stringify({ messages: [ - { role: 'system', content: systemPrompt, }, - { role: 'user', content: userPrompt, }, + { 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,) + 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,) + 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:',) + 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() + 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() + 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,) + 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, },) + return c.json({ sql, reasoning }) } catch (err: any) { - console.error(`LLM generation error: ${err.message}`,) - return c.json({ error: 'Failed to generate SQL', }, 500,) + 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) -export async function searchCollections(c: Context,) { - const q = c.req.query('q',) - if (!q || q.trim().length < 2) return c.json([],) +export async function searchCollections(c: Context) { + const q = c.req.query('q') + if (!q || q.trim().length < 2) return c.json([]) const term = `%${q.trim()}%` - const userId = c.get('accountId',) + 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),] + .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,), + ilike(schema.accounts.slug, term), + ilike(schema.collections.slug, term), + ilike(schema.collections.name, term), ) let whereCondition @@ -326,12 +360,12 @@ export async function searchCollections(c: Context,) { whereCondition = and( searchCondition, or( - eq(schema.collections.public, true,), - inArray(schema.collections.accountId, accessibleAccountIds,), + eq(schema.collections.public, true), + inArray(schema.collections.accountId, accessibleAccountIds), ), ) } else { - whereCondition = and(searchCondition, eq(schema.collections.public, true,),) + whereCondition = and(searchCondition, eq(schema.collections.public, true)) } const collections = await db @@ -341,27 +375,27 @@ export async function searchCollections(c: Context,) { 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,) + }) + .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 + 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,) + }) + .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, @@ -372,16 +406,16 @@ export async function searchCollections(c: Context,) { latestVersion: latestVersion?.number ?? null, latestSemver: latestVersion?.semver ?? null, recordCount: latestVersion?.recordCount ?? 0, - },) + }) } - return c.json(result,) + return c.json(result) } // GET /query/collections/:owner/:slug/versions — List versions for a collection -export async function collectionVersions(c: Context,) { - const owner = c.req.param('owner',)! - const slug = c.req.param('slug',)! +export async function collectionVersions(c: Context) { + const owner = c.req.param('owner')! + const slug = c.req.param('slug')! const versions = await db .select({ @@ -390,22 +424,22 @@ export async function collectionVersions(c: Context,) { 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,),) + }) + .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,), + eq(schema.accounts.slug, owner), + eq(schema.collections.slug, slug), + eq(schema.collections.public, true), ), ) - .orderBy(desc(schema.versions.number,),) + .orderBy(desc(schema.versions.number)) if (versions.length === 0) { - return c.json({ error: 'Collection not found or not public', }, 404,) + return c.json({ error: 'Collection not found or not public' }, 404) } - return c.json(versions,) + return c.json(versions) } diff --git a/src/api/schemas.ts b/src/api/schemas.ts index 47af20b..246b4bd 100644 --- a/src/api/schemas.ts +++ b/src/api/schemas.ts @@ -1,78 +1,81 @@ -import { and, eq, ilike, inArray, sql, } from 'drizzle-orm' -import type { Context, } from 'hono' -import { db, schema, } from '../db/client.server.js' -import { type AuthEnv, } from './auth.server.js' +import { and, eq, ilike, inArray, sql } from 'drizzle-orm' +import type { Context } from 'hono' + +import { db, schema } from '../db/client.server.js' +import { type AuthEnv } from './auth.server.js' // --- Global schema search --- // GET /schemas?q=...&slug=...&label=...&schema_hash=...&limit=...&offset=... -export async function listSchemas(c: Context,) { - 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',) +export async function listSchemas(c: Context) { + 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,) + 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 + const [row] = await db .select() - .from(schema.schemas,) - .where(eq(schema.schemas.schemaHash, schema_hash,),) - .limit(1,) + .from(schema.schemas) + .where(eq(schema.schemas.schemaHash, schema_hash)) + .limit(1) - if (!row) return c.json({ error: 'Schema not found', statusCode: 404, }, 404,) + 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,),) + .select({ label: schema.schemaLabels.label }) + .from(schema.schemaLabels) + .where(eq(schema.schemaLabels.schemaId, row.id)) - const usageCount = await getUsageCount(row.id,) + const usageCount = await getUsageCount(row.id) return c.json({ ...row, - labels: labels.map((l,) => l.label), + 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,) + .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([],) + if (vsRows.length === 0) return c.json([]) - const schemaIds = vsRows.map((r,) => r.schemaId) + const schemaIds = vsRows.map((r) => r.schemaId) const schemaRows = await db .select() - .from(schema.schemas,) - .where(inArray(schema.schemas.id, schemaIds,),) + .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,),) + .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,) + 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,) ?? [], - })),) + return c.json( + schemaRows.map((s) => ({ + ...s, + labels: labelsMap.get(s.id) ?? [], + })), + ) } // Search by label @@ -81,112 +84,116 @@ export async function listSchemas(c: Context,) { .select({ schemaId: schema.schemaLabels.schemaId, label: schema.schemaLabels.label, - },) - .from(schema.schemaLabels,) - .where(ilike(schema.schemaLabels.label, `%${label}%`,),) - .limit(pageLimit,) - .offset(pageOffset,) + }) + .from(schema.schemaLabels) + .where(ilike(schema.schemaLabels.label, `%${label}%`)) + .limit(pageLimit) + .offset(pageOffset) - if (labelRows.length === 0) return c.json([],) + if (labelRows.length === 0) return c.json([]) - const schemaIds = [...new Set(labelRows.map((r,) => r.schemaId),),] + const schemaIds = [...new Set(labelRows.map((r) => r.schemaId))] const schemaRows = await db .select() - .from(schema.schemas,) - .where(inArray(schema.schemas.id, schemaIds,),) + .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,),) + .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,) + 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,) ?? [], - })),) + 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,),) - : [] + .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,) + 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,) ?? [], - })),) + 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,),) - : [] + .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,) + 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,) ?? [], - })),) + return c.json( + rows.map((s) => ({ + ...s, + labels: labelsMap.get(s.id) ?? [], + })), + ) } // --- Single schema by ID --- // GET /schemas/:id -export async function getSchema(c: Context,) { - const id = c.req.param('id',)! +export async function getSchema(c: Context) { + const id = c.req.param('id')! - const [row,] = await db - .select() - .from(schema.schemas,) - .where(eq(schema.schemas.id, id,),) - .limit(1,) + 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,) + 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,),) + .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 @@ -197,68 +204,72 @@ export async function getSchema(c: Context,) { 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,) + }) + .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,) => ({ + 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 -export async function collectionSchemas(c: Context,) { - const owner = c.req.param('owner',)! - const slug = c.req.param('slug',)! - const versionParam = c.req.query('version',) - const raw = c.req.query('raw',) +export async function collectionSchemas(c: Context) { + 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 + 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,) + }) + .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) 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,) + 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,),] + const versionConditions = [eq(schema.versions.collectionId, collection.id)] if (versionParam) { - versionConditions.push(eq(schema.versions.number, parseInt(versionParam, 10,),),) + 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,) + 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,) + if (!version) return c.json({ error: 'No versions found', statusCode: 404 }, 404) // Load schemas for this version const entries = await db @@ -267,36 +278,37 @@ export async function collectionSchemas(c: Context,) { 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,),) + }) + .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 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,),) + .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,) + 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 + 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, @@ -304,75 +316,75 @@ export async function collectionSchemas(c: Context,) { schemaHash: e.schemaHash, schema: body, } - },), - },) + }), + }) } // --- Label management --- // Add a label to a schema // POST /schemas/:id/labels { label: "schema.org/Person" } -export async function addLabel(c: Context,) { - const id = c.req.param('id',)! - const { label, } = await c.req.json() +export async function addLabel(c: Context) { + 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,) + 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,) + 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,) + 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(), },) + 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: 'exists', schemaId: id, label: label.trim() }) } - return c.json({ status: 'created', schemaId: id, label: label.trim(), }, 201,) + 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,) + return c.json({ error: 'Failed to add label', statusCode: 500 }, 500) } } // Remove a label from a schema // DELETE /schemas/:id/labels/:label -export async function removeLabel(c: Context,) { - const id = c.req.param('id',)! - const label = c.req.param('label',)! +export async function removeLabel(c: Context) { + 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,),),) + .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({ error: 'Label not found', statusCode: 404 }, 404) } - return c.json({ status: 'deleted', schemaId: id, label, },) + 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,),) +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/uploads.ts b/src/api/uploads.ts index 9f50200..8fef07f 100644 --- a/src/api/uploads.ts +++ b/src/api/uploads.ts @@ -1,8 +1,10 @@ -import { and, eq, inArray, sql, } from 'drizzle-orm' -import type { Context, } from 'hono' -import { createHash, } from 'node:crypto' -import { db, schema, } from '../db/client.server.js' -import { getS3ObjectMeta, } from '../lib/s3.js' +import { createHash } from 'node:crypto' + +import { and, eq, inArray, sql } from 'drizzle-orm' +import type { Context } from 'hono' + +import { db, schema } from '../db/client.server.js' +import { getS3ObjectMeta } from '../lib/s3.js' import { ajv, deriveSemver, @@ -14,7 +16,7 @@ import { loadVersionSchemas, type SchemaEntry, } from '../lib/version-helpers.server.js' -import { type AuthEnv, } from './auth.server.js' +import { type AuthEnv } from './auth.server.js' /** Session expiry: 1 hour from creation */ const SESSION_TTL_MS = 60 * 60 * 1000 @@ -22,24 +24,24 @@ const SESSION_TTL_MS = 60 * 60 * 1000 /** Max records per batch request */ const MAX_BATCH_SIZE = 10_000 -async function resolveCollection(owner: string, slug: string,) { - const [result,] = await db +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,) + }) + .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 } // --- Start a chunked upload session --- -export async function startSession(c: Context,) { - const owner = c.req.param('owner',)! - const slug = c.req.param('slug',)! +export async function startSession(c: Context) { + const owner = c.req.param('owner')! + const slug = c.req.param('slug')! const body = await c.req.json<{ base_version: number | null message?: string @@ -49,38 +51,41 @@ export async function startSession(c: Context,) { schemas?: Record }>() - const collection = await resolveCollection(owner, slug,) - if (!collection) return c.json({ error: 'Collection not found', statusCode: 404, }, 404,) + 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,) + 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 [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,) + return c.json( + { + error: 'Version conflict', + currentVersion: currentNumber, + statusCode: 409, + }, + 409, + ) } - const expiresAt = new Date(Date.now() + SESSION_TTL_MS,) + const expiresAt = new Date(Date.now() + SESSION_TTL_MS) - const [session,] = await db - .insert(schema.uploadSessions,) + const [session] = await db + .insert(schema.uploadSessions) .values({ collectionId: collection.id, - accountId: c.get('accountId',)!, + accountId: c.get('accountId')!, baseVersion: body.base_version ?? null, message: body.message ?? null, readme: body.readme ?? null, @@ -90,20 +95,23 @@ export async function startSession(c: Context,) { status: 'open', recordCount: 0, expiresAt, - },) - .returning({ id: schema.uploadSessions.id, },) - - return c.json({ - sessionId: session!.id, - expiresAt: expiresAt.toISOString(), - }, 201,) + }) + .returning({ id: schema.uploadSessions.id }) + + return c.json( + { + sessionId: session!.id, + expiresAt: expiresAt.toISOString(), + }, + 201, + ) } // --- Append a batch of changes to a session --- -export async function appendBatch(c: Context,) { - const owner = c.req.param('owner',)! - const slug = c.req.param('slug',)! - const sessionId = c.req.param('sessionId',)! +export async function appendBatch(c: Context) { + 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 }[] @@ -113,37 +121,40 @@ export async function appendBatch(c: Context,) { }>() // Validate session exists and belongs to caller - const [session,] = await db + const [session] = await db .select() - .from(schema.uploadSessions,) - .where(eq(schema.uploadSessions.id, sessionId,),) - .limit(1,) + .from(schema.uploadSessions) + .where(eq(schema.uploadSessions.id, sessionId)) + .limit(1) if (!session) { - return c.json({ error: 'Upload session not found', statusCode: 404, }, 404,) + 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.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,) + return c.json( + { + error: 'Session is not open', + status: session.status, + statusCode: 409, + }, + 409, + ) } - if (new Date(session.expiresAt,) < new Date()) { + 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,) + .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,) + const collection = await resolveCollection(owner, slug) if (!collection || collection.id !== session.collectionId) { - return c.json({ error: 'Collection mismatch', statusCode: 404, }, 404,) + return c.json({ error: 'Collection mismatch', statusCode: 404 }, 404) } // Count total records in this batch @@ -153,13 +164,16 @@ export async function appendBatch(c: Context,) { const batchSize = addedCount + updatedCount + removedCount if (batchSize === 0) { - return c.json({ error: 'Empty batch', statusCode: 400, }, 400,) + 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,) + 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) @@ -180,7 +194,7 @@ export async function appendBatch(c: Context,) { data: rec.data, private: rec.private ?? false, operation: 'add', - },) + }) } for (const rec of body.changes.updated ?? []) { rows.push({ @@ -190,7 +204,7 @@ export async function appendBatch(c: Context,) { data: rec.data, private: rec.private ?? false, operation: 'update', - },) + }) } for (const id of body.changes.removed ?? []) { rows.push({ @@ -200,59 +214,59 @@ export async function appendBatch(c: Context,) { 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,) + const batch = rows.slice(i, i + BATCH) await db - .insert(schema.uploadRecords,) - .values(batch,) + .insert(schema.uploadRecords) + .values(batch) .onConflictDoUpdate({ - target: [schema.uploadRecords.sessionId, schema.uploadRecords.recordId,], + 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,),) + 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,),) + .update(schema.uploadSessions) + .set({ recordCount: countResult?.count ?? 0 }) + .where(eq(schema.uploadSessions.id, sessionId)) return c.json({ - received: { added: addedCount, updated: updatedCount, removed: removedCount, }, + received: { added: addedCount, updated: updatedCount, removed: removedCount }, totalStaged: countResult?.count ?? 0, - },) + }) } // --- Get session status --- -export async function getSession(c: Context,) { - const sessionId = c.req.param('sessionId',)! +export async function getSession(c: Context) { + const sessionId = c.req.param('sessionId')! - const [session,] = await db + const [session] = await db .select() - .from(schema.uploadSessions,) - .where(eq(schema.uploadSessions.id, sessionId,),) - .limit(1,) + .from(schema.uploadSessions) + .where(eq(schema.uploadSessions.id, sessionId)) + .limit(1) if (!session) { - return c.json({ error: 'Upload session not found', statusCode: 404, }, 404,) + 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.accountId !== c.get('accountId')) { + return c.json({ error: 'Not authorized for this session', statusCode: 403 }, 403) } return c.json({ @@ -262,128 +276,147 @@ export async function getSession(c: Context,) { baseVersion: session.baseVersion, expiresAt: session.expiresAt, createdAt: session.createdAt, - },) + }) } // --- Finalize: build the version from staged records --- -export async function finalize(c: Context,) { - const owner = c.req.param('owner',)! - const slug = c.req.param('slug',)! - const sessionId = c.req.param('sessionId',)! +export async function finalize(c: Context) { + 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 + const [session] = await db .select() - .from(schema.uploadSessions,) - .where(eq(schema.uploadSessions.id, sessionId,),) - .limit(1,) + .from(schema.uploadSessions) + .where(eq(schema.uploadSessions.id, sessionId)) + .limit(1) if (!session) { - return c.json({ error: 'Upload session not found', statusCode: 404, }, 404,) + 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.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,) + return c.json( + { + error: `Session cannot be finalized (status: ${session.status})`, + statusCode: 409, + }, + 409, + ) } - if (new Date(session.expiresAt,) < new Date()) { + 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,) + .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,) + const collection = await resolveCollection(owner, slug) if (!collection || collection.id !== session.collectionId) { - return c.json({ error: 'Collection mismatch', statusCode: 404, }, 404,) + 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,),) + .update(schema.uploadSessions) + .set({ status: 'finalizing' }) + .where(eq(schema.uploadSessions.id, sessionId)) try { // Re-check optimistic lock - const [latest,] = await db + const [latest] = await db .select() - .from(schema.versions,) - .where(eq(schema.versions.collectionId, collection.id,),) - .orderBy(sql`${schema.versions.number} desc`,) - .limit(1,) + .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,) + .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,) + prevSchemaEntries = await loadVersionSchemas(latest.id) } let schemasInput: Record - if (session.schemas && Object.keys(session.schemas as object,).length > 0) { + 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,]),) + 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,) + .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,) + 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, },) + 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, },) + 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,]),) + 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) { + for (const [s, hash] of newSchemaMap) { + if (prevSchemaMap.get(s) !== hash) { schemaChanged = true break } @@ -393,17 +426,17 @@ export async function finalize(c: Context,) { // Build validators const validators = new Map>() for (const entry of newSchemaSet) { - validators.set(entry.slug, ajv.compile(entry.schema as object,),) + 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) + .select({ hash: schema.versionFiles.fileHash }) + .from(schema.versionFiles) + .where(eq(schema.versionFiles.versionId, latest.id)) + existingFileHashes = vf.map((f) => f.hash) } // --- Streaming finalize --- @@ -422,7 +455,7 @@ export async function finalize(c: Context,) { data jsonb NOT NULL, private boolean NOT NULL DEFAULT false ) ON COMMIT DROP - `,) + `) // Insert existing records from base version (if any) if (latest) { @@ -431,7 +464,7 @@ export async function finalize(c: Context,) { SELECT record_id, type, data, private FROM records WHERE version_id = ${latest.id} - `,) + `) } // Apply staged changes (upserts and deletes) @@ -445,7 +478,7 @@ export async function finalize(c: Context,) { type = EXCLUDED.type, data = EXCLUDED.data, private = EXCLUDED.private - `,) + `) // Remove deleted records await db.execute(sql` @@ -454,64 +487,68 @@ export async function finalize(c: Context,) { 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,) + 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`,) + 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)) + 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 + .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, ) - 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[],) + const privateTypes = getPrivateTypes(newSchemaSet as SchemaEntry[]) // Streaming hash state - const privateHasher = createHash('sha256',) - const publicHasher = createHash('sha256',) + 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,)) + .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,)) + .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,]),), + Object.fromEntries(schemaSetForHash.map((s) => [s.slug, s.schemaHash])), ) const publicSchemasCanonical = JSON.stringify( - Object.fromEntries(publicSchemaSet.map((s,) => [s.slug, s.schemaHash,]),), + Object.fromEntries(publicSchemaSet.map((s) => [s.slug, s.schemaHash])), ) // Start building canonical: {"schemas":...,"records":[ - privateHasher.update(`{"schemas":${schemasCanonical},"records":[`,) - publicHasher.update(`{"schemas":${publicSchemasCanonical},"records":[`,) + privateHasher.update(`{"schemas":${schemasCanonical},"records":[`) + publicHasher.update(`{"schemas":${publicSchemasCanonical},"records":[`) - const referencedHashes = new Set(existingFileHashes,) + const referencedHashes = new Set(existingFileHashes) const validationErrors: { recordId: string; type: string; errors: string[] }[] = [] let totalBytes = 0 let recordCount = 0 @@ -521,16 +558,16 @@ export async function finalize(c: Context,) { 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,),) + 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 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) @@ -539,15 +576,15 @@ export async function finalize(c: Context,) { // Phase 2: insert records (re-stream from temp table) while (hasMore) { - const batch = await db.execute(sql` + 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[] + `)) as any[] - const rows = Array.isArray(batch,) ? batch : [] + const rows = Array.isArray(batch) ? batch : [] if (rows.length === 0) { hasMore = false break @@ -555,56 +592,56 @@ export async function finalize(c: Context,) { for (const rec of rows) { // Validate - const validate = validators.get(rec.type,) + 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,)) { + 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'}`, + (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,) + 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,) + 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,) + 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',) + 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,)) { + for (const val of Object.values(data)) { if ( - typeof val === 'object' - && val !== null - && '$file' in val - && typeof (val as { $file: string }).$file === 'string' + 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,) + const fileHash = (val as { $file: string }).$file.replace('sha256:', '') + referencedHashes.add(fileHash) } } } @@ -615,107 +652,119 @@ export async function finalize(c: Context,) { // Bail on validation errors if (validationErrors.length > 0) { - await db.update(schema.uploadSessions,).set({ status: 'failed', },).where( - eq(schema.uploadSessions.id, sessionId,), + 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, ) - 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,) + 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,)) + .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,) + 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() + await db + .insert(schema.files) + .values({ + hash: h, + size: meta.size, + mimeType: meta.contentType, + storageKey: key, + }) + .onConflictDoNothing() } else { - stillNeeded.push(h,) + 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 + .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, ) - 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,) + const filesCanonical = JSON.stringify(sortedFileHashes) + const readmeCanonical = JSON.stringify(readmeValue ?? null) - privateHasher.update(`],"files":${filesCanonical},"readme":${readmeCanonical}}`,) - publicHasher.update(`],"files":${filesCanonical},"readme":${readmeCanonical}}`,) + privateHasher.update(`],"files":${filesCanonical},"readme":${readmeCanonical}}`) + publicHasher.update(`],"files":${filesCanonical},"readme":${readmeCanonical}}`) - const versionHash = 'private:' + privateHasher.digest('hex',) - const publicHash = 'public:' + publicHasher.digest('hex',) + 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,) + 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,), - ), + and(eq(schema.versions.collectionId, collection.id), eq(schema.versions.hash, versionHash)), ) - .limit(1,) + .limit(1) if (existingHash) { - await db.update(schema.uploadSessions,).set({ status: 'failed', },).where( - eq(schema.uploadSessions.id, sessionId,), + 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, ) - 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,) + 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,) + const [version] = await db + .insert(schema.versions) .values({ collectionId: collection.id, number: newNumber, @@ -725,13 +774,13 @@ export async function finalize(c: Context,) { baseNumber: session.baseVersion, message: session.message ?? null, readme: readmeValue, - pushedBy: c.get('accountId',) ?? null, + 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) @@ -739,15 +788,15 @@ export async function finalize(c: Context,) { 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`,) + 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,) => ({ + await db.insert(schema.versionFiles).values( + allFileHashes.map((hash) => ({ versionId: version!.id, fileHash: hash, })), @@ -755,8 +804,8 @@ export async function finalize(c: Context,) { } // Insert version_schemas - await db.insert(schema.versionSchemas,).values( - newSchemaSet.map((entry,) => ({ + await db.insert(schema.versionSchemas).values( + newSchemaSet.map((entry) => ({ versionId: version!.id, slug: entry.slug, schemaId: entry.schemaId, @@ -765,60 +814,55 @@ export async function finalize(c: Context,) { // Update collection timestamp await db - .update(schema.collections,) - .set({ updatedAt: new Date(), },) - .where(eq(schema.collections.id, collection.id,),) + .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,) + 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.execute(sql`DROP TABLE IF EXISTS _finalize_records`) await db - .update(schema.uploadSessions,) - .set({ status: 'failed', },) - .where(eq(schema.uploadSessions.id, sessionId,),) + .update(schema.uploadSessions) + .set({ status: 'failed' }) + .where(eq(schema.uploadSessions.id, sessionId)) throw err } } // --- Abort/cancel a session --- -export async function cancelSession(c: Context,) { - const sessionId = c.req.param('sessionId',)! +export async function cancelSession(c: Context) { + const sessionId = c.req.param('sessionId')! - const [session,] = await db + const [session] = await db .select() - .from(schema.uploadSessions,) - .where(eq(schema.uploadSessions.id, sessionId,),) - .limit(1,) + .from(schema.uploadSessions) + .where(eq(schema.uploadSessions.id, sessionId)) + .limit(1) if (!session) { - return c.json({ error: 'Upload session not found', statusCode: 404, }, 404,) + 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.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,),) + 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,) + return c.body(null, 204) } diff --git a/src/api/versions.ts b/src/api/versions.ts index a89470a..337115d 100644 --- a/src/api/versions.ts +++ b/src/api/versions.ts @@ -1,8 +1,10 @@ -import { and, eq, inArray, sql, } from 'drizzle-orm' -import type { Context, } from 'hono' -import { createHash, } from 'node:crypto' -import { db, schema, } from '../db/client.server.js' -import { buildArkUrl, DEFAULT_NAAN, } from '../lib/ark.js' +import { createHash } from 'node:crypto' + +import { and, eq, inArray, sql } from 'drizzle-orm' +import type { Context } from 'hono' + +import { db, schema } from '../db/client.server.js' +import { buildArkUrl, DEFAULT_NAAN } from '../lib/ark.js' import { ajv, deriveSemver, @@ -14,20 +16,20 @@ import { loadVersionSchemas, type SchemaEntry, } from '../lib/version-helpers.server.js' -import { type AuthEnv, } from './auth.server.js' +import { type AuthEnv } from './auth.server.js' /** Build a public-facing schemas map (excluding private types, stripping private fields) */ -function filterSchemasForPublic(schemaEntries: SchemaEntry[],): Record { +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,) + 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 { +function isOwner(accountId: string | undefined, collectionAccountId: string): boolean { return accountId != null && accountId === collectionAccountId } @@ -39,15 +41,15 @@ function computeVersionHash( ): string { const canonical = JSON.stringify({ schemas: Object.fromEntries( - schemaSet.sort((a, b,) => a.slug.localeCompare(b.slug,)).map((s,) => [s.slug, s.schemaHash,]), + 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, })), + .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',) + }) + return 'private:' + createHash('sha256').update(canonical).digest('hex') } /** Compute a public hash that only covers non-private content */ @@ -57,85 +59,91 @@ function computePublicHash( fileHashes: string[], readme: string | null, ): string { - const privateTypes = getPrivateTypes(schemaEntries,) + 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,), },) + 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,)) + .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,]), + 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',) + }) + return 'public:' + createHash('sha256').update(canonical).digest('hex') } // 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 },) { +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) { + 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,),) + .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',) + 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,) + 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,),) + await db.update(schema.versions).set({ totalBytes }).where(eq(schema.versions.id, version.id)) return totalBytes } // List versions -export async function list(c: Context,) { - const owner = c.req.param('owner',)! - const slug = c.req.param('slug',)! - const limit = c.req.query('limit',) - const offset = c.req.query('offset',) +export async function list(c: Context) { + 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 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 accountId = c.get('accountId') + const ownerAccess = isOwner(accountId, collection.accountId) + const arkInfo = await getCollectionArkInfo(collection.id).catch(() => null) const rows = await db .select({ @@ -150,206 +158,226 @@ export async function list(c: Context,) { 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, - })),) + }) + .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 -export async function latest(c: Context,) { - 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,) +export async function latest(c: Context) { + 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 + const [version] = await db .select() - .from(schema.versions,) - .where(eq(schema.versions.collectionId, collection.id,),) - .orderBy(sql`${schema.versions.number} desc`,) - .limit(1,) + .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,) + 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 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,) + ? 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, - },) + ark: arkInfo + ? buildArkUrl(arkInfo.naan, arkInfo.shoulder, arkInfo.arkId, version.number) + : null, + }) } // Get version by number -export async function getByNumber(c: Context,) { - 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 +export async function getByNumber(c: Context) { + 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,) + .from(schema.versions) .where( - and(eq(schema.versions.collectionId, collection.id,), eq(schema.versions.number, parseInt(n, 10,),),), + and( + eq(schema.versions.collectionId, collection.id), + eq(schema.versions.number, parseInt(n, 10)), + ), ) - .limit(1,) + .limit(1) - if (!version) return c.json({ error: 'Version not found', statusCode: 404, }, 404,) - version.totalBytes = await backfillTotalBytes(version,) + 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 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,) + ? 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, - },) + ark: arkInfo + ? buildArkUrl(arkInfo.naan, arkInfo.shoulder, arkInfo.arkId, version.number) + : null, + }) } // Get records for a version -export async function records(c: Context,) { - 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 +export async function records(c: Context) { + 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,) + .from(schema.versions) .where( - and(eq(schema.versions.collectionId, collection.id,), eq(schema.versions.number, parseInt(n, 10,),),), + and( + eq(schema.versions.collectionId, collection.id), + eq(schema.versions.number, parseInt(n, 10)), + ), ) - .limit(1,) + .limit(1) - if (!version) return c.json({ error: 'Version not found', statusCode: 404, }, 404,) + 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,),) + 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}`,) + conditions.push(sql`${schema.records.recordId} > ${after}`) } // Determine visibility - const accountId = c.get('accountId',) - const ownerAccess = isOwner(accountId, collection.accountId,) + 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,) + 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 + 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}`,) + conditions.push(sql`${schema.records.type} != ${pt}`) } } // Exclude record-level private records - conditions.push(eq(schema.records.private, false,),) + conditions.push(eq(schema.records.private, false)) } - const pageLimit = Math.min(parseInt(limit ?? '100', 10,), 1000,) + 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,),) + }) + .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 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(),) + 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,)! + const privateFields = fieldCache.get(rec.type)! return privateFields.size > 0 - ? { ...rec, data: filterRecordData(rec.data, privateFields,), } + ? { ...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) + 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,) + }) + .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 - },) + 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, @@ -359,26 +387,29 @@ export async function records(c: Context,) { nextCursor, total: version.recordCount, }, - },) + }) } // List files for a version -export async function files(c: Context,) { - 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 +export async function files(c: Context) { + 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,) + .from(schema.versions) .where( - and(eq(schema.versions.collectionId, collection.id,), eq(schema.versions.number, parseInt(n, 10,),),), + and( + eq(schema.versions.collectionId, collection.id), + eq(schema.versions.number, parseInt(n, 10)), + ), ) - .limit(1,) + .limit(1) - if (!version) return c.json({ error: 'Version not found', statusCode: 404, }, 404,) + if (!version) return c.json({ error: 'Version not found', statusCode: 404 }, 404) const fileRows = await db .select({ @@ -386,80 +417,89 @@ export async function files(c: Context,) { 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,),) + }) + .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,),) + .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,)) { + 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, },) + 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,) ?? [], - })),) + return c.json( + fileRows.map((f) => ({ + ...f, + references: fileRefs.get(f.hash) ?? [], + })), + ) } // Get manifest for a version -export async function manifest(c: Context,) { - 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 +export async function manifest(c: Context) { + 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,) + .from(schema.versions) .where( - and(eq(schema.versions.collectionId, collection.id,), eq(schema.versions.number, parseInt(n, 10,),),), + and( + eq(schema.versions.collectionId, collection.id), + eq(schema.versions.number, parseInt(n, 10)), + ), ) - .limit(1,) + .limit(1) - if (!version) return c.json({ error: 'Version not found', statusCode: 404, }, 404,) + 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,),) + .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,),) + .select({ hash: schema.versionFiles.fileHash }) + .from(schema.versionFiles) + .where(eq(schema.versionFiles.versionId, version.id)) - const schemaEntries = await loadVersionSchemas(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,]),), + schemas: Object.fromEntries(schemaEntries.map((e) => [e.slug, e.schemaHash])), records: recordIds, - files: fileHashes.map((f,) => f.hash), - },) + files: fileHashes.map((f) => f.hash), + }) } // Push a new version -export async function push(c: Context,) { - const owner = c.req.param('owner',)! - const slug = c.req.param('slug',)! - const body = await c.req.json() as { +export async function push(c: Context) { + 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 @@ -475,26 +515,29 @@ export async function push(c: Context,) { } } - const collection = await resolveCollection(owner, slug,) - if (!collection) return c.json({ error: 'Collection not found', statusCode: 404, }, 404,) + 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 + const [latest] = await db .select() - .from(schema.versions,) - .where(eq(schema.versions.collectionId, collection.id,),) - .orderBy(sql`${schema.versions.number} desc`,) - .limit(1,) + .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,) + return c.json( + { + error: 'Version conflict', + currentVersion: currentNumber, + statusCode: 409, + }, + 409, + ) } // Build the full record set for this version @@ -506,180 +549,213 @@ export async function push(c: Context,) { type: schema.records.type, data: schema.records.data, private: schema.records.private, - },) - .from(schema.records,) - .where(eq(schema.records.versionId, latest.id,),) + }) + .from(schema.records) + .where(eq(schema.records.versionId, latest.id)) } // Apply changes - const recordMap = new Map(existingRecords.map((r,) => [r.recordId, r,]),) + 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, },) + 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, },) + 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,) + recordMap.delete(id) } - const newRecords = Array.from(recordMap.values(),) + const newRecords = Array.from(recordMap.values()) // --- Resolve schemas --- let prevSchemaEntries: SchemaEntry[] = [] if (latest) { - prevSchemaEntries = await loadVersionSchemas(latest.id,) + prevSchemaEntries = await loadVersionSchemas(latest.id) } // Determine the schema set for this version let schemasInput: Record - if (body.schemas && Object.keys(body.schemas,).length > 0) { + 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,]),) + 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,) + 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)) + 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,) + 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,) + 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, },) + 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, },) + 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,),) + validators.set(entry.slug, ajv.compile(entry.schema as object)) } for (const rec of newRecords) { - const validate = validators.get(rec.type,) + 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}"`,], - },) + errors: [`No schema defined for record type "${rec.type}"`], + }) continue } - if (!validate(rec.data,)) { + if (!validate(rec.data)) { validationErrors.push({ recordId: rec.recordId, type: rec.type, errors: (validate.errors ?? []).map( - (e,) => `${e.instancePath || '/'} ${e.message ?? 'validation failed'}`, + (e) => `${e.instancePath || '/'} ${e.message ?? 'validation failed'}`, ), - },) + }) } } if (validationErrors.length > 0) { - return c.json({ - error: 'Schema validation failed', - validationErrors, - statusCode: 422, - }, 422,) + 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,]),) + 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) { + 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 + 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) + .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,) + const referencedHashes = new Set(existingFileHashes) for (const rec of newRecords) { const data = rec.data as Record - for (const val of Object.values(data,)) { + for (const val of Object.values(data)) { if ( - typeof val === 'object' - && val !== null - && '$file' in val - && typeof (val as { $file: string }).$file === 'string' + 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,) + const hash = (val as { $file: string }).$file.replace('sha256:', '') + referencedHashes.add(hash) } } } // Check all referenced files exist - const allFileHashes = Array.from(referencedHashes,) + 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,)) + .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,) + return c.json( + { + error: 'Missing files', + filesNeeded: filesNeeded.map((h) => `sha256:${h}`), + statusCode: 422, + }, + 422, + ) } } @@ -687,55 +763,60 @@ export async function push(c: Context,) { 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 schemaSetForHash = newSchemaSet.map((e) => ({ slug: e.slug, schemaHash: e.schemaHash })) + const versionHash = computeVersionHash(schemaSetForHash, newRecords, allFileHashes, readmeValue) - const schemaEntriesForPublicHash: SchemaEntry[] = newSchemaSet.map((e,) => ({ + 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 publicHash = computePublicHash( + schemaEntriesForPublicHash, + newRecords, + allFileHashes, + readmeValue, + ) - const semver = deriveSemver(latest?.semver ?? null, schemaChanged, recordsChanged,) + 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,) + 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,), - ), + and(eq(schema.versions.collectionId, collection.id), eq(schema.versions.hash, versionHash)), ) - .limit(1,) + .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,) + 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',) + 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,) + 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,) + const [version] = await db + .insert(schema.versions) .values({ collectionId: collection.id, number: newNumber, @@ -745,22 +826,22 @@ export async function push(c: Context,) { baseNumber: body.base_version, message: body.message ?? null, readme: readmeValue, - pushedBy: c.get('accountId',) ?? null, + 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,) => ({ + 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, @@ -773,8 +854,8 @@ export async function push(c: Context,) { // Insert version_files if (allFileHashes.length > 0) { - await db.insert(schema.versionFiles,).values( - allFileHashes.map((hash,) => ({ + await db.insert(schema.versionFiles).values( + allFileHashes.map((hash) => ({ versionId: version!.id, fileHash: hash, })), @@ -782,8 +863,8 @@ export async function push(c: Context,) { } // Insert version_schemas - await db.insert(schema.versionSchemas,).values( - newSchemaSet.map((entry,) => ({ + await db.insert(schema.versionSchemas).values( + newSchemaSet.map((entry) => ({ versionId: version!.id, slug: entry.slug, schemaId: entry.schemaId, @@ -791,88 +872,95 @@ export async function push(c: Context,) { ) // Update collection timestamp + optional name/description - const collectionUpdates: Record = { updatedAt: new Date(), } + 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,),) + .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,) + return c.json( + { + version: newNumber, + semver, + hash: versionHash, + recordCount: newRecords.length, + fileCount: allFileHashes.length, + }, + 201, + ) } // Diff between versions -export async function diff(c: Context,) { - const owner = c.req.param('owner',)! - const slug = c.req.param('slug',)! - const n = c.req.param('n',)! - const from = c.req.query('from',) +export async function diff(c: Context) { + 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 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 targetNum = parseInt(n, 10) + const fromNum = from ? parseInt(from, 10) : targetNum - 1 - const [targetVersion,] = await db + const [targetVersion] = await db .select() - .from(schema.versions,) - .where(and(eq(schema.versions.collectionId, collection.id,), eq(schema.versions.number, targetNum,),),) - .limit(1,) + .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,) + 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,),) + .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 + const [fv] = await db .select() - .from(schema.versions,) - .where(and(eq(schema.versions.collectionId, collection.id,), eq(schema.versions.number, fromNum,),),) - .limit(1,) + .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,),) + .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 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,) - },) + 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,]),) + 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) { + for (const [s, hash] of targetSchemaMap) { + if (fromSchemaMap.get(s) !== hash) { schemaChanged = true break } @@ -883,66 +971,71 @@ export async function diff(c: Context,) { // Compare file sets const targetFiles = await db - .select({ hash: schema.versionFiles.fileHash, },) - .from(schema.versionFiles,) - .where(eq(schema.versionFiles.versionId, targetVersion.id,),) + .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,),) + .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) + 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), + 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, + 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 +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,) + }) + .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 + 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,) + }) + .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, } + return { shoulder: row.shoulder, arkId: row.arkId, naan: row.naan ?? DEFAULT_NAAN } } diff --git a/src/components/ApiPlayground.tsx b/src/components/ApiPlayground.tsx index d072206..5eb59cf 100644 --- a/src/components/ApiPlayground.tsx +++ b/src/components/ApiPlayground.tsx @@ -1,4 +1,4 @@ -import { useCallback, useState, } from 'react' +import { useCallback, useState } from 'react' interface Collection { id: string @@ -25,7 +25,7 @@ interface Endpoint { description: string } -function getEndpoints(slug: string, collectionSlug: string,): Endpoint[] { +function getEndpoints(slug: string, collectionSlug: string): Endpoint[] { return [ { label: 'List collections', @@ -43,58 +43,58 @@ function getEndpoints(slug: string, collectionSlug: string,): Endpoint[] { }, ...(collectionSlug ? [ - { - label: 'Get collection', - method: 'GET', - path: `/api/collections/${slug}/${collectionSlug}`, - body: '', - description: 'Returns collection metadata and latest version info.', - }, - { - label: 'List versions', - method: 'GET', - path: `/api/collections/${slug}/${collectionSlug}/versions`, - body: '', - description: 'Returns all versions for this collection.', - }, - { - label: 'Get latest version', - method: 'GET', - path: `/api/collections/${slug}/${collectionSlug}/versions/latest`, - body: '', - description: 'Returns the latest version with records and files.', - }, - { - label: 'List files', - method: 'GET', - path: `/api/collections/${slug}/${collectionSlug}/files`, - body: '', - description: 'Returns all files in the latest version.', - }, - ] + { + label: 'Get collection', + method: 'GET', + path: `/api/collections/${slug}/${collectionSlug}`, + body: '', + description: 'Returns collection metadata and latest version info.', + }, + { + label: 'List versions', + method: 'GET', + path: `/api/collections/${slug}/${collectionSlug}/versions`, + body: '', + description: 'Returns all versions for this collection.', + }, + { + label: 'Get latest version', + method: 'GET', + path: `/api/collections/${slug}/${collectionSlug}/versions/latest`, + body: '', + description: 'Returns the latest version with records and files.', + }, + { + label: 'List files', + method: 'GET', + path: `/api/collections/${slug}/${collectionSlug}/files`, + body: '', + description: 'Returns all files in the latest version.', + }, + ] : []), ] } -export function ApiPlayground({ slug, collections, }: ApiPlaygroundProps,) { - const [selectedCollection, setSelectedCollection,] = useState(collections[0]?.slug ?? '',) - const [selectedEndpoint, setSelectedEndpoint,] = useState(0,) - const [response, setResponse,] = useState(null,) - const [loading, setLoading,] = useState(false,) - const [copied, setCopied,] = useState(false,) - const [token, setToken,] = useState('',) +export function ApiPlayground({ slug, collections }: ApiPlaygroundProps) { + const [selectedCollection, setSelectedCollection] = useState(collections[0]?.slug ?? '') + const [selectedEndpoint, setSelectedEndpoint] = useState(0) + const [response, setResponse] = useState(null) + const [loading, setLoading] = useState(false) + const [copied, setCopied] = useState(false) + const [token, setToken] = useState('') - const endpoints = getEndpoints(slug, selectedCollection,) + const endpoints = getEndpoints(slug, selectedCollection) const current = endpoints[selectedEndpoint] ?? endpoints[0] const sendRequest = useCallback(async () => { if (!current) return - setLoading(true,) - setResponse(null,) + setLoading(true) + setResponse(null) const start = performance.now() try { - const headers: Record = { 'Content-Type': 'application/json', } + const headers: Record = { 'Content-Type': 'application/json' } if (token.trim()) { headers['Authorization'] = `Bearer ${token.trim()}` } @@ -106,23 +106,23 @@ export function ApiPlayground({ slug, collections, }: ApiPlaygroundProps,) { if (current.body && current.method !== 'GET') { opts.body = current.body } - const res = await fetch(current.path, opts,) - const elapsed = Math.round(performance.now() - start,) + const res = await fetch(current.path, opts) + const elapsed = Math.round(performance.now() - start) let body: string - const contentType = res.headers.get('content-type',) ?? '' - if (contentType.includes('json',)) { + const contentType = res.headers.get('content-type') ?? '' + if (contentType.includes('json')) { const json = await res.json() - body = JSON.stringify(json, null, 2,) + body = JSON.stringify(json, null, 2) } else { body = await res.text() } - setResponse({ status: res.status, statusText: res.statusText, time: elapsed, body, },) + setResponse({ status: res.status, statusText: res.statusText, time: elapsed, body }) } catch (err: any) { - setResponse({ status: 0, statusText: 'Network Error', time: 0, body: err.message, },) + setResponse({ status: 0, statusText: 'Network Error', time: 0, body: err.message }) } finally { - setLoading(false,) + setLoading(false) } - }, [current, token,],) + }, [current, token]) const copyAsCurl = useCallback(() => { if (!current) return @@ -133,68 +133,74 @@ export function ApiPlayground({ slug, collections, }: ApiPlaygroundProps,) { cmd += ` \\\n -H 'Content-Type: application/json'` cmd += ` \\\n -d '${current.body}'` } - navigator.clipboard.writeText(cmd,) - setCopied(true,) - setTimeout(() => setCopied(false,), 2000,) - }, [current, token,],) + navigator.clipboard.writeText(cmd) + setCopied(true) + setTimeout(() => setCopied(false), 2000) + }, [current, token]) return ( -
+
{/* Controls bar */} -
+
{collections.length > 0 && ( -
- +
+
)} -
- +
+ setToken(e.target.value,)} - placeholder='Paste key to test it (optional)' - className='text-xs bg-parchment border border-rule px-2 py-1 w-52 font-mono focus:outline-none focus:border-ink' + onChange={(e) => setToken(e.target.value)} + placeholder="Paste key to test it (optional)" + className="bg-parchment border-rule focus:border-ink w-52 border px-2 py-1 font-mono text-xs focus:outline-none" />
-
+
{/* Left column: endpoint list */} -
-

Endpoints

-
- {endpoints.map((ep, i,) => ( +
+

Endpoints

+
+ {endpoints.map((ep, i) => (
{collections.length === 0 && ( -

+

No collections yet. Create one to see collection endpoints.

)}
{/* Right column: request + response */} -
+
{current && ( <> {/* Request display */} -
-
+
+
{current.method} - {current.path} + {current.path}
-

{current.description}

+

{current.description}

{/* Action bar */} -
+
- + {token.trim() ? 'Using API key' : 'Using your session'}
@@ -263,25 +269,27 @@ export function ApiPlayground({ slug, collections, }: ApiPlaygroundProps,) { {response && (
= 200 && response.status < 300 ? 'bg-green-50 text-green-800' : response.status >= 400 - ? 'bg-red-50 text-red-800' - : 'bg-parchment-dark' + ? 'bg-red-50 text-red-800' + : 'bg-parchment-dark' }`} > - {response.status} {response.statusText} - {response.time}ms + + {response.status} {response.statusText} + + {response.time}ms
-
+              
                 {response.body}
               
)} {!response && !loading && ( -
+
Select an endpoint and hit Send to see the response.
)} diff --git a/src/components/BaseLayout.tsx b/src/components/BaseLayout.tsx index 6150311..5b09690 100644 --- a/src/components/BaseLayout.tsx +++ b/src/components/BaseLayout.tsx @@ -1,6 +1,7 @@ -import { Link, } from 'react-router' +import { Link } from 'react-router' + import UserMenu from '~/components/UserMenu' -import { useSSRData, } from '~/lib/ssr-data' +import { useSSRData } from '~/lib/ssr-data' interface MirrorConfig { enabled: boolean @@ -8,59 +9,79 @@ interface MirrorConfig { upstream: string } -export default function BaseLayout({ children, }: { children: React.ReactNode },) { - const currentUser = useSSRData('currentUser',) - const mirrorConfig = useSSRData('mirrorConfig',) +export default function BaseLayout({ children }: { children: React.ReactNode }) { + const currentUser = useSSRData('currentUser') + const mirrorConfig = useSSRData('mirrorConfig') return ( <> -
-