From 1e6cb2afa6480f678fa19a9d84dd0daa7c995b6d Mon Sep 17 00:00:00 2001 From: Travis Rich Date: Thu, 11 Jun 2026 20:32:03 -0400 Subject: [PATCH 01/10] Improved explore page and docs --- README.md | 16 +- docker-compose.withauth.yml | 69 +- server.ts | 80 +- src/api/collections.ts | 89 +- src/components/CollectionExplorer.tsx | 130 +- src/components/ExploreTagsAdmin.tsx | 134 + src/components/FeaturedCollectionsAdmin.tsx | 162 ++ src/db/migrations/0005_instance_settings.sql | 5 + src/db/migrations/meta/0005_snapshot.json | 2315 ++++++++++++++++++ src/db/migrations/meta/_journal.json | 7 + src/db/schema.ts | 8 + src/routes/admin/explore-tags.tsx | 31 + src/routes/docs/self-host.tsx | 292 ++- src/routes/superadmin.tsx | 6 + 14 files changed, 3183 insertions(+), 161 deletions(-) create mode 100644 src/components/ExploreTagsAdmin.tsx create mode 100644 src/components/FeaturedCollectionsAdmin.tsx create mode 100644 src/db/migrations/0005_instance_settings.sql create mode 100644 src/db/migrations/meta/0005_snapshot.json create mode 100644 src/routes/admin/explore-tags.tsx diff --git a/README.md b/README.md index 4d7a94f..68998cb 100644 --- a/README.md +++ b/README.md @@ -329,19 +329,27 @@ Required GitHub secrets: `SSH_PRIVATE_KEY`, `SSH_USER`, `GHCR_USER`, `GHCR_TOKEN | ----------------------------- | ---------------------------------------------- | | `docker-compose.yml` | Deployed stacks (prod & dev via Swarm) | | `docker-compose.local.yml` | Local development (source-mounted, hot reload) | -| `docker-compose.withauth.yml` | Self-hosted: app + bundled KF Auth stack | +| `docker-compose.withauth.yml` | Self-hosted: app + KF Auth + MinIO + Caddy | ### Self-Hosting Run the Underlay with a bundled auth server (no external auth provider needed): ```bash -DOMAIN=https://my-instance.com docker compose -f docker-compose.withauth.yml up +DOMAIN=https://my-instance.com docker compose -f docker-compose.withauth.yml up -d ``` -This starts Postgres, KF Auth (auth + account), the Underlay app, and Caddy with TLS. On first boot, secrets are auto-generated. Set `SMTP_*` vars for email delivery. +This starts Postgres, KF Auth (auth + account), MinIO (S3-compatible storage), the Underlay app, and Caddy with automatic TLS. On first boot, an init container auto-generates all secrets (session keys, OAuth client credentials, S3 credentials). -Supporting files live in `selfhost/` (Caddyfile, Postgres init script). +Optional configuration (via environment variables or `.env` file): +- `SMTP_*` vars for email delivery (password resets, invitations) +- `GITHUB_CLIENT_ID`/`GITHUB_CLIENT_SECRET` for GitHub login +- `GOOGLE_CLIENT_ID`/`GOOGLE_CLIENT_SECRET` for Google login +- `ORCID_CLIENT_ID`/`ORCID_CLIENT_SECRET` for ORCID login + +To use external S3 (AWS, Cloudflare R2, etc.) instead of bundled MinIO, remove the `minio` and `minio-init` services and set `S3_BUCKET`, `S3_REGION`, `S3_ENDPOINT`, `S3_ACCESS_KEY`, `S3_SECRET_KEY` in the app environment. + +Supporting files live in `selfhost/` (Caddyfile, Postgres init script). See [/docs/self-host](https://underlay.org/docs/self-host) for full details. ## Environment Variables diff --git a/docker-compose.withauth.yml b/docker-compose.withauth.yml index 6a848c5..701dd51 100644 --- a/docker-compose.withauth.yml +++ b/docker-compose.withauth.yml @@ -1,10 +1,15 @@ # docker-compose.withauth.yml — Self-hosted Underlay with bundled auth. # -# Runs: Underlay app + KF Auth (auth + account) + Postgres + Caddy +# Runs: Underlay app + KF Auth (auth + account) + Postgres + MinIO (S3) + Caddy # One command: docker compose -f docker-compose.withauth.yml up # # First run generates secrets automatically via the init container. # Set DOMAIN=https://your-domain.com in your shell or .env file. +# +# To use external S3 instead of bundled MinIO, remove the minio service and set +# S3_BUCKET, S3_REGION, S3_ENDPOINT, S3_ACCESS_KEY, S3_SECRET_KEY in the app +# environment block (or pass them as env vars that the init container writes +# into .env.app). name: underlay-withauth @@ -22,8 +27,11 @@ services: fi apk add --no-cache openssl AUTH_SECRET=$$(openssl rand -hex 32) + SESSION_SECRET=$$(openssl rand -hex 32) CLIENT_SECRET=$$(openssl rand -hex 32) INTERNAL_KEY=$$(openssl rand -hex 32) + MINIO_ACCESS=$$(openssl rand -hex 16) + MINIO_SECRET=$$(openssl rand -hex 32) printf '%s\n' \ "BETTER_AUTH_SECRET=$$AUTH_SECRET" \ "BETTER_AUTH_URL=$${DOMAIN:-http://localhost}/auth" \ @@ -49,19 +57,30 @@ services: "- client_id: underlay" \ " client_secret: $$CLIENT_SECRET" \ " redirect_uris:" \ - " - $${DOMAIN:-http://localhost}/auth/callback" \ + " - $${DOMAIN:-http://localhost}/api/auth/oauth2/callback/kf-auth" \ " skip_consent: true" \ " display_name: \"Underlay\"" \ " allow_sign_up: true" \ > /config/apps.withauth.yaml printf '%s\n' \ + "SESSION_SECRET=$$SESSION_SECRET" \ "OIDC_ISSUER_URL=$${DOMAIN:-http://localhost}/auth" \ "OIDC_ISSUER_INTERNAL_URL=http://auth:3000" \ + "OIDC_ACCOUNT_URL=$${DOMAIN:-http://localhost}/account" \ "OIDC_CLIENT_ID=underlay" \ "OIDC_CLIENT_SECRET=$$CLIENT_SECRET" \ "AUTH_INTERNAL_API_KEY=$$INTERNAL_KEY" \ "DATABASE_URL=postgres://kfauth:kfauth@postgres:5432/app" \ + "S3_BUCKET=underlay" \ + "S3_REGION=us-east-1" \ + "S3_ENDPOINT=http://minio:9000" \ + "S3_ACCESS_KEY=$$MINIO_ACCESS" \ + "S3_SECRET_KEY=$$MINIO_SECRET" \ > /config/.env.app + printf '%s\n' \ + "MINIO_ROOT_USER=$$MINIO_ACCESS" \ + "MINIO_ROOT_PASSWORD=$$MINIO_SECRET" \ + > /config/.env.minio echo "Init complete." volumes: - withauth-config:/config @@ -96,6 +115,47 @@ services: timeout: 5s retries: 10 + # --- MinIO (S3-compatible object storage) --- + # Remove this service if using external S3/R2 — set S3_* vars in app environment instead. + minio: + image: minio/minio:latest + depends_on: + withauth-init: + condition: service_completed_successfully + volumes: + - minio-data:/data + - withauth-config:/config:ro + entrypoint: /bin/sh + command: + - -c + - | + set -a && . /config/.env.minio && set +a + exec minio server /data --console-address ":9001" + healthcheck: + test: ['CMD', 'mc', 'ready', 'local'] + interval: 5s + timeout: 5s + retries: 10 + + # Create the default bucket on first run + minio-init: + image: minio/mc:latest + depends_on: + minio: + condition: service_healthy + withauth-init: + condition: service_completed_successfully + volumes: + - withauth-config:/config:ro + entrypoint: /bin/sh + command: + - -c + - | + set -a && . /config/.env.minio && set +a + mc alias set local http://minio:9000 "$$MINIO_ROOT_USER" "$$MINIO_ROOT_PASSWORD" + mc mb --ignore-existing local/underlay + echo "Bucket ready." + # --- Auth server (kf-auth) --- auth: image: ghcr.io/knowledgefutures/kf-auth:latest @@ -136,13 +196,15 @@ services: condition: service_completed_successfully auth: condition: service_started + minio-init: + condition: service_completed_successfully environment: NODE_ENV: production PORT: 4100 APP_URL: ${DOMAIN:-http://localhost} volumes: - withauth-config:/config:ro - command: sh -c "set -a && . /config/.env.app && set +a && node dist/server.js" + command: sh -c "set -a && . /config/.env.app && set +a && node --import tsx/esm server.ts" # --- Caddy reverse proxy --- caddy: @@ -164,6 +226,7 @@ services: volumes: pgdata: + minio-data: withauth-config: caddy-data: caddy-config: diff --git a/server.ts b/server.ts index 6e9760a..24b0665 100644 --- a/server.ts +++ b/server.ts @@ -98,9 +98,9 @@ app.get('/agent/:token', agentHandlers.agentPage) app.use('/api/*', authMiddleware) app.use('/api/*', rateLimitMiddleware) -// --- Mirror mode guard for admin routes --- -// Operator-only: admin-scoped API key, or a session user listed in MIRROR_ADMIN_EMAILS -app.use('/api/admin/*', async (c, next) => { +// --- Admin route guards --- +// Mirror admin: requires mirror mode + admin API key or MIRROR_ADMIN_EMAILS +app.use('/api/admin/mirror/*', async (c, next) => { const config = getMirrorConfig() if (!config.enabled) { return c.json({ error: 'Not found', statusCode: 404 }, 404) @@ -122,6 +122,18 @@ app.use('/api/admin/*', async (c, next) => { ) }) +// Steward admin: requires kfRole === 'admin' +// Steward admin: requires kfRole === 'admin' +app.use('/api/admin/explore-*', async (c, next) => { + const userId = c.get('userId') + if (!userId) return c.json({ error: 'Unauthorized', statusCode: 401 }, 401) + const sessionUser = await getSessionUser(c.req.raw) + if (sessionUser?.kfRole !== 'admin') { + return c.json({ error: 'Forbidden', statusCode: 403 }, 403) + } + return next() +}) + // --- ARK resolution middleware --- app.use('/ark\\:*', arkMiddleware) @@ -204,6 +216,68 @@ 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) +// Steward-only: explore featured tags +app.get('/api/admin/explore-tags', async (c) => { + const { db, schema } = await import('~/db/client.server') + const { eq } = await import('drizzle-orm') + const [row] = await db + .select({ value: schema.instanceSettings.value }) + .from(schema.instanceSettings) + .where(eq(schema.instanceSettings.key, 'explore_featured_tags')) + .limit(1) + return c.json({ tags: Array.isArray(row?.value) ? row.value : [] }) +}) + +app.put('/api/admin/explore-tags', async (c) => { + const body = await c.req.json<{ tags: string[] }>() + if (!Array.isArray(body.tags) || !body.tags.every((t: unknown) => typeof t === 'string')) { + return c.json({ error: 'tags must be an array of strings', statusCode: 422 }, 422) + } + const { db, schema } = await import('~/db/client.server') + await db + .insert(schema.instanceSettings) + .values({ key: 'explore_featured_tags', value: body.tags, updatedAt: new Date() }) + .onConflictDoUpdate({ + target: schema.instanceSettings.key, + set: { value: body.tags, updatedAt: new Date() }, + }) + return c.json({ ok: true, tags: body.tags }) +}) + +// Steward-only: explore featured collections +app.get('/api/admin/explore-collections', async (c) => { + const { db, schema } = await import('~/db/client.server') + const { eq } = await import('drizzle-orm') + const [row] = await db + .select({ value: schema.instanceSettings.value }) + .from(schema.instanceSettings) + .where(eq(schema.instanceSettings.key, 'explore_featured_collections')) + .limit(1) + return c.json({ collections: Array.isArray(row?.value) ? row.value : [] }) +}) + +app.put('/api/admin/explore-collections', async (c) => { + const body = await c.req.json<{ collections: string[] }>() + if ( + !Array.isArray(body.collections) || + !body.collections.every((s: unknown) => typeof s === 'string') + ) { + return c.json( + { error: 'collections must be an array of "owner/slug" strings', statusCode: 422 }, + 422, + ) + } + const { db, schema } = await import('~/db/client.server') + await db + .insert(schema.instanceSettings) + .values({ key: 'explore_featured_collections', value: body.collections, updatedAt: new Date() }) + .onConflictDoUpdate({ + target: schema.instanceSettings.key, + set: { value: body.collections, updatedAt: new Date() }, + }) + return c.json({ ok: true, collections: body.collections }) +}) + // Query app.get('/api/query/sqlite/:owner/:slug/:version', query.sqlite) app.get('/api/query/ddl/:owner/:slug/:version', query.ddl) diff --git a/src/api/collections.ts b/src/api/collections.ts index 9d4ae05..c0c2f92 100644 --- a/src/api/collections.ts +++ b/src/api/collections.ts @@ -26,6 +26,7 @@ const app = new Hono() async (c) => { const q = c.req.query('q') const owner = c.req.query('owner') + const tag = c.req.query('tag') const sort = c.req.query('sort') const take = Math.min(parseInt(c.req.query('limit') ?? '50', 10), 100) const skip = parseInt(c.req.query('offset') ?? '0', 10) @@ -54,7 +55,7 @@ const app = new Hono() eq(schema.collections.organizationId, schema.organization.id), ) .where(and(...conditions)) - .limit(take) + .limit(take + 200) .offset(skip) .orderBy(sort === 'name' ? schema.collections.name : desc(schema.collections.updatedAt)) @@ -98,6 +99,36 @@ const app = new Hono() } } + // Build enriched results with tags, then apply tag filter + const enriched = results.map((r) => { + const stats = statsMap.get(r.id) + const meta = stats?.metadata as Record | null | undefined + const tags = Array.isArray(meta?.tags) ? (meta.tags as string[]) : [] + return { + ...r, + description: (meta?.description as string) ?? null, + tags, + latestVersion: stats?.semver ?? null, + recordCount: stats?.recordCount ?? null, + fileCount: stats?.fileCount ?? null, + totalBytes: stats?.totalBytes ?? null, + lastPushAt: stats?.lastPushAt ?? null, + } + }) + + const filtered = tag ? enriched.filter((c) => c.tags.includes(tag)) : enriched + + // Compute tag facets from all visible collections (before tag filter, after search/owner) + const tagCounts = new Map() + for (const c of enriched) { + for (const t of c.tags) { + tagCounts.set(t, (tagCounts.get(t) ?? 0) + 1) + } + } + const tagFacets = [...tagCounts.entries()] + .map(([name, count]) => ({ name, count })) + .sort((a, b) => b.count - a.count) + const facetConditions = [eq(schema.collections.public, true)] if (q) { facetConditions.push(ilike(schema.collections.name, `%${q}%`)) @@ -118,21 +149,49 @@ const app = new Hono() .groupBy(schema.organization.slug, schema.organization.name) .orderBy(sql`count(*) DESC`) + // Load instance settings for explore page + const settingsRows = await db + .select({ key: schema.instanceSettings.key, value: schema.instanceSettings.value }) + .from(schema.instanceSettings) + .where( + inArray(schema.instanceSettings.key, [ + 'explore_featured_tags', + 'explore_featured_collections', + ]), + ) + const settingsMap = new Map(settingsRows.map((r) => [r.key, r.value])) + const featuredTags = Array.isArray(settingsMap.get('explore_featured_tags')) + ? (settingsMap.get('explore_featured_tags') as string[]) + : [] + const featuredSlugs = Array.isArray(settingsMap.get('explore_featured_collections')) + ? (settingsMap.get('explore_featured_collections') as string[]) + : [] + + // Apply sort to the full filtered set, then slice for pagination + if (sort === 'records') { + filtered.sort((a, b) => (b.recordCount ?? 0) - (a.recordCount ?? 0)) + } else if (sort === 'featured') { + const featuredSet = new Set(featuredSlugs) + filtered.sort((a, b) => { + const aFeat = featuredSet.has(`${a.ownerSlug}/${a.slug}`) ? 0 : 1 + const bFeat = featuredSet.has(`${b.ownerSlug}/${b.slug}`) ? 0 : 1 + if (aFeat !== bFeat) return aFeat - bFeat + return (a.name ?? '').localeCompare(b.name ?? '') + }) + } + + const page = filtered.slice(0, take) + + // Build featured collections list from the enriched set + const featuredCollections = featuredSlugs + .map((s) => enriched.find((c) => `${c.ownerSlug}/${c.slug}` === s)) + .filter(Boolean) + return c.json({ - collections: results.map((r) => { - const stats = statsMap.get(r.id) - const meta = stats?.metadata as Record | null | undefined - return { - ...r, - description: (meta?.description as string) ?? null, - latestVersion: stats?.semver ?? null, - recordCount: stats?.recordCount ?? null, - fileCount: stats?.fileCount ?? null, - totalBytes: stats?.totalBytes ?? null, - lastPushAt: stats?.lastPushAt ?? null, - } - }), - facets: { owners: ownerFacets }, + collections: page, + facets: { owners: ownerFacets, tags: tagFacets }, + featuredTags, + featuredCollections, }) }, ) diff --git a/src/components/CollectionExplorer.tsx b/src/components/CollectionExplorer.tsx index 3c8a98d..f631005 100644 --- a/src/components/CollectionExplorer.tsx +++ b/src/components/CollectionExplorer.tsx @@ -6,6 +6,7 @@ interface Collection { slug: string name: string description?: string + tags?: string[] ownerSlug: string ownerName?: string createdAt: string @@ -24,6 +25,11 @@ interface OwnerFacet { count: number } +interface TagFacet { + name: string + count: number +} + function formatBytes(bytes: number): string { if (bytes < 1024) return `${bytes} B` if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB` @@ -51,29 +57,47 @@ function timeAgo(dateStr: string): string { return `${Math.floor(months / 12)}y ago` } +type SortKey = 'featured' | 'updated' | 'name' | 'records' + export default function CollectionExplorer() { const [query, setQuery] = useState('') const [selectedOwner, setSelectedOwner] = useState(null) - const [sort, setSort] = useState<'updated' | 'name'>('updated') + const [selectedTag, setSelectedTag] = useState(null) + const [sort, setSort] = useState('featured') const [collections, setCollections] = useState([]) const [owners, setOwners] = useState([]) + const [tagFacets, setTagFacets] = useState([]) + const [featuredTags, setFeaturedTags] = useState([]) + const [featuredCollections, setFeaturedCollections] = useState([]) const [loading, setLoading] = useState(true) const timerRef = useRef>(undefined) - async function load(q = '', owner: string | null = null, sortBy = sort) { + const isFiltered = !!(query || selectedOwner || selectedTag) + + async function load( + q = '', + owner: string | null = null, + sortBy: SortKey = sort, + tag: string | null = selectedTag, + ) { setLoading(true) const params = new URLSearchParams() if (q) params.set('q', q) if (owner) params.set('owner', owner) + if (tag) params.set('tag', tag) params.set('sort', sortBy) try { const res = await fetch(`/api/collections?${params}`) const data = await res.json() setCollections(data.collections) setOwners(data.facets.owners) + setTagFacets(data.facets.tags ?? []) + if (data.featuredTags) setFeaturedTags(data.featuredTags) + if (data.featuredCollections) setFeaturedCollections(data.featuredCollections) } catch { setCollections([]) setOwners([]) + setTagFacets([]) } setLoading(false) } @@ -85,22 +109,32 @@ export default function CollectionExplorer() { function handleInput(value: string) { setQuery(value) clearTimeout(timerRef.current) - timerRef.current = setTimeout(() => load(value, selectedOwner), 300) + timerRef.current = setTimeout(() => load(value, selectedOwner, sort, selectedTag), 300) } function handleOwnerClick(ownerSlug: string | null) { setSelectedOwner(ownerSlug) - load(query, ownerSlug) + load(query, ownerSlug, sort, selectedTag) + } + + function handleTagClick(tag: string | null) { + setSelectedTag(tag) + load(query, selectedOwner, sort, tag) } function handleSortChange(value: string) { - const s = value as 'updated' | 'name' + const s = value as SortKey setSort(s) - load(query, selectedOwner, s) + load(query, selectedOwner, s, selectedTag) } const totalCount = owners.reduce((sum, o) => sum + o.count, 0) + const visibleTags = + featuredTags.length > 0 + ? featuredTags.filter((t) => tagFacets.some((f) => f.name === t)) + : tagFacets.slice(0, 12).map((f) => f.name) + return (
{/* Sidebar facets */} @@ -144,6 +178,42 @@ export default function CollectionExplorer() { {/* Main content */}
+ {/* Featured collections hero */} + {featuredCollections.length > 0 && !isFiltered && ( +
+

+ Featured +

+
+ {featuredCollections.map((c) => ( + +
+ {c.ownerSlug}/ + {c.slug} +
+ {c.description && ( +

+ {c.description} +

+ )} +
+ {c.recordCount != null && {formatCount(c.recordCount)} records} + {c.tags && c.tags.length > 0 && ( + + {c.tags[0]} + + )} +
+ + ))} +
+
+ )} + {/* Search + sort bar */}
@@ -170,11 +240,42 @@ export default function CollectionExplorer() { onChange={(e) => handleSortChange(e.target.value)} className="bg-parchment border-rule text-ink-muted rounded-sm border px-3 py-2 text-sm focus:outline-none" > + +
+ {/* Tag filter chips */} + {visibleTags.length > 0 && ( +
+ + {visibleTags.map((tag) => ( + + ))} +
+ )} + {/* Mobile owner filter */} {owners.length > 0 && (
@@ -209,8 +310,8 @@ export default function CollectionExplorer() { ) : collections.length === 0 ? (

- {query || selectedOwner - ? 'No collections match your search.' + {query || selectedOwner || selectedTag + ? 'No collections match your filters.' : 'No public collections yet.'}

@@ -218,6 +319,7 @@ export default function CollectionExplorer() { <>

{collections.length} collection{collections.length !== 1 ? 's' : ''} + {selectedTag && ` in ${selectedTag}`} {selectedOwner && ` from ${selectedOwner}`} {query && ` matching "${query}"`}

@@ -232,6 +334,18 @@ export default function CollectionExplorer() {
{c.ownerSlug}/ {c.slug} + {c.tags && c.tags.length > 0 && ( + + {c.tags.slice(0, 2).map((tag) => ( + + {tag} + + ))} + + )}
{c.description && (

diff --git a/src/components/ExploreTagsAdmin.tsx b/src/components/ExploreTagsAdmin.tsx new file mode 100644 index 0000000..eca81da --- /dev/null +++ b/src/components/ExploreTagsAdmin.tsx @@ -0,0 +1,134 @@ +import { useEffect, useState } from 'react' + +export default function ExploreTagsAdmin() { + const [tags, setTags] = useState([]) + const [newTag, setNewTag] = useState('') + const [saving, setSaving] = useState(false) + const [loading, setLoading] = useState(true) + const [message, setMessage] = useState(null) + + useEffect(() => { + fetch('/api/admin/explore-tags') + .then((r) => r.json()) + .then((data) => setTags(data.tags ?? [])) + .catch(() => {}) + .finally(() => setLoading(false)) + }, []) + + async function save(updated: string[]) { + setSaving(true) + setMessage(null) + try { + const res = await fetch('/api/admin/explore-tags', { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ tags: updated }), + }) + if (res.ok) { + setTags(updated) + setMessage('Saved') + } else { + const data = await res.json() + setMessage(data.error ?? 'Failed to save') + } + } catch { + setMessage('Failed to save') + } + setSaving(false) + } + + function addTag() { + const tag = newTag.trim() + if (!tag || tags.includes(tag)) return + const updated = [...tags, tag] + setNewTag('') + save(updated) + } + + function removeTag(tag: string) { + save(tags.filter((t) => t !== tag)) + } + + function moveTag(index: number, direction: -1 | 1) { + const target = index + direction + if (target < 0 || target >= tags.length) return + const updated = [...tags] + ;[updated[index], updated[target]] = [updated[target]!, updated[index]!] + save(updated) + } + + if (loading) { + return

Loading...

+ } + + return ( +
+
+

Explore Page Tags

+

+ These tags appear as filter chips on the explore page. Collections set their own tags in + version metadata; this list controls which tags are featured. +

+
+ +
+ {tags.length === 0 && ( +

+ No featured tags yet. The explore page will show the most common tags automatically. +

+ )} + {tags.map((tag, i) => ( +
+ {tag} + + + +
+ ))} +
+ +
{ + e.preventDefault() + addTag() + }} + className="flex gap-2" + > + setNewTag(e.target.value)} + placeholder="New tag name..." + className="bg-parchment border-rule placeholder:text-ink-muted flex-1 rounded border px-3 py-2 text-sm focus:outline-none" + /> + +
+ + {message &&

{message}

} +
+ ) +} diff --git a/src/components/FeaturedCollectionsAdmin.tsx b/src/components/FeaturedCollectionsAdmin.tsx new file mode 100644 index 0000000..b3490f1 --- /dev/null +++ b/src/components/FeaturedCollectionsAdmin.tsx @@ -0,0 +1,162 @@ +import { useEffect, useState } from 'react' + +interface CollectionOption { + ownerSlug: string + slug: string + name: string + description: string | null + recordCount: number | null +} + +export default function FeaturedCollectionsAdmin() { + const [featured, setFeatured] = useState([]) + const [allCollections, setAllCollections] = useState([]) + const [saving, setSaving] = useState(false) + const [loading, setLoading] = useState(true) + const [message, setMessage] = useState(null) + + useEffect(() => { + Promise.all([ + fetch('/api/admin/explore-collections') + .then((r) => r.json()) + .then((data) => data.collections ?? []), + fetch('/api/collections?limit=100&sort=name') + .then((r) => r.json()) + .then((data) => data.collections ?? []), + ]) + .then(([feat, all]) => { + setFeatured(feat) + setAllCollections(all) + }) + .catch(() => {}) + .finally(() => setLoading(false)) + }, []) + + async function save(updated: string[]) { + setSaving(true) + setMessage(null) + try { + const res = await fetch('/api/admin/explore-collections', { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ collections: updated }), + }) + if (res.ok) { + setFeatured(updated) + setMessage('Saved') + } else { + const data = await res.json() + setMessage(data.error ?? 'Failed to save') + } + } catch { + setMessage('Failed to save') + } + setSaving(false) + } + + function addCollection(slug: string) { + if (featured.includes(slug)) return + save([...featured, slug]) + } + + function removeCollection(slug: string) { + save(featured.filter((s) => s !== slug)) + } + + function moveCollection(index: number, direction: -1 | 1) { + const target = index + direction + if (target < 0 || target >= featured.length) return + const updated = [...featured] + ;[updated[index], updated[target]] = [updated[target]!, updated[index]!] + save(updated) + } + + const available = allCollections.filter((c) => !featured.includes(`${c.ownerSlug}/${c.slug}`)) + + if (loading) { + return

Loading...

+ } + + return ( +
+
+

Featured Collections

+

+ These collections appear in a hero row at the top of the explore page. Pick 3-6 that + showcase diversity. +

+
+ +
+ {featured.length === 0 && ( +

+ No featured collections yet. The hero row won't appear. +

+ )} + {featured.map((slug, i) => { + const col = allCollections.find((c) => `${c.ownerSlug}/${c.slug}` === slug) + return ( +
+
+ {slug} + {col?.description && ( +

{col.description}

+ )} +
+ + + +
+ ) + })} +
+ + {available.length > 0 && ( +
+ + +
+ )} + + {message &&

{message}

} +
+ ) +} diff --git a/src/db/migrations/0005_instance_settings.sql b/src/db/migrations/0005_instance_settings.sql new file mode 100644 index 0000000..a0abd33 --- /dev/null +++ b/src/db/migrations/0005_instance_settings.sql @@ -0,0 +1,5 @@ +CREATE TABLE IF NOT EXISTS "instance_settings" ( + "key" text PRIMARY KEY NOT NULL, + "value" jsonb NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL +); diff --git a/src/db/migrations/meta/0005_snapshot.json b/src/db/migrations/meta/0005_snapshot.json new file mode 100644 index 0000000..a450c23 --- /dev/null +++ b/src/db/migrations/meta/0005_snapshot.json @@ -0,0 +1,2315 @@ +{ + "id": "d572506e-2ebc-4aa5-9838-44abbc70e395", + "prevId": "c7cee44e-06b1-4f1f-a5c5-c9bcd999d546", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.account": { + "name": "account", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "password": { + "name": "password", + "type": "text", + "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": { + "account_user_id_idx": { + "name": "account_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "account_user_id_user_id_fk": { + "name": "account_user_id_user_id_fk", + "tableFrom": "account", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.apikey": { + "name": "apikey", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "config_id": { + "name": "config_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'default'" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "start": { + "name": "start", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "reference_id": { + "name": "reference_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "prefix": { + "name": "prefix", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "refill_interval": { + "name": "refill_interval", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "refill_amount": { + "name": "refill_amount", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "last_refill_at": { + "name": "last_refill_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": true + }, + "rate_limit_enabled": { + "name": "rate_limit_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": true + }, + "rate_limit_time_window": { + "name": "rate_limit_time_window", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 86400000 + }, + "rate_limit_max": { + "name": "rate_limit_max", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 10 + }, + "request_count": { + "name": "request_count", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "remaining": { + "name": "remaining", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "last_request": { + "name": "last_request", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "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()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "permissions": { + "name": "permissions", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "apikey_config_id_idx": { + "name": "apikey_config_id_idx", + "columns": [ + { + "expression": "config_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "apikey_reference_id_idx": { + "name": "apikey_reference_id_idx", + "columns": [ + { + "expression": "reference_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "apikey_key_idx": { + "name": "apikey_key_idx", + "columns": [ + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "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()" + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "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_organization_id_organization_id_fk": { + "name": "ark_shoulders_organization_id_organization_id_fk", + "tableFrom": "ark_shoulders", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "ark_shoulders_organization_id_unique": { + "name": "ark_shoulders_organization_id_unique", + "nullsNotDistinct": false, + "columns": ["organization_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()" + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "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_organization_id_idx": { + "name": "collections_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "collections_organization_id_organization_id_fk": { + "name": "collections_organization_id_organization_id_fk", + "tableFrom": "collections", + "tableTo": "organization", + "columnsFrom": ["organization_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_organization_id_slug_unique": { + "name": "collections_organization_id_slug_unique", + "nullsNotDistinct": false, + "columns": ["organization_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.instance_settings": { + "name": "instance_settings", + "schema": "", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "value": { + "name": "value", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.invitation": { + "name": "invitation", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "inviter_id": { + "name": "inviter_id", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "invitation_organization_id_idx": { + "name": "invitation_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "invitation_email_idx": { + "name": "invitation_email_idx", + "columns": [ + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "invitation_organization_id_organization_id_fk": { + "name": "invitation_organization_id_organization_id_fk", + "tableFrom": "invitation", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "invitation_inviter_id_user_id_fk": { + "name": "invitation_inviter_id_user_id_fk", + "tableFrom": "invitation", + "tableTo": "user", + "columnsFrom": ["inviter_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.member": { + "name": "member", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'member'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "member_organization_id_idx": { + "name": "member_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "member_user_id_idx": { + "name": "member_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "member_organization_id_organization_id_fk": { + "name": "member_organization_id_organization_id_fk", + "tableFrom": "member", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "member_user_id_user_id_fk": { + "name": "member_user_id_user_id_fk", + "tableFrom": "member", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.negotiate_session_manifest": { + "name": "negotiate_session_manifest", + "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": true + }, + "hash": { + "name": "hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "private": { + "name": "private", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "needed": { + "name": "needed", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + } + }, + "indexes": { + "nsm_session_needed_idx": { + "name": "nsm_session_needed_idx", + "columns": [ + { + "expression": "session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "needed", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "negotiate_session_manifest_session_id_negotiate_sessions_id_fk": { + "name": "negotiate_session_manifest_session_id_negotiate_sessions_id_fk", + "tableFrom": "negotiate_session_manifest", + "tableTo": "negotiate_sessions", + "columnsFrom": ["session_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "negotiate_session_manifest_session_id_hash_pk": { + "name": "negotiate_session_manifest_session_id_hash_pk", + "columns": ["session_id", "hash"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.negotiate_sessions": { + "name": "negotiate_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 + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "base_semver": { + "name": "base_semver", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "schemas": { + "name": "schemas", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "file_hashes": { + "name": "file_hashes", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "needed_files": { + "name": "needed_files", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "message": { + "name": "message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "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 + }, + "strip_unknown_fields": { + "name": "strip_unknown_fields", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'open'" + }, + "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": { + "negotiate_sessions_collection_id_collections_id_fk": { + "name": "negotiate_sessions_collection_id_collections_id_fk", + "tableFrom": "negotiate_sessions", + "tableTo": "collections", + "columnsFrom": ["collection_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "negotiate_sessions_user_id_user_id_fk": { + "name": "negotiate_sessions_user_id_user_id_fk", + "tableFrom": "negotiate_sessions", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.organization": { + "name": "organization", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "logo": { + "name": "logo", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "metadata": { + "name": "metadata", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "bio": { + "name": "bio", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "website": { + "name": "website", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "avatar_url": { + "name": "avatar_url", + "type": "text", + "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 + }, + "is_default": { + "name": "is_default", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + } + }, + "indexes": { + "organization_slug_uidx": { + "name": "organization_slug_uidx", + "columns": [ + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "organization_slug_unique": { + "name": "organization_slug_unique", + "nullsNotDistinct": false, + "columns": ["slug"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.page_comments": { + "name": "page_comments", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "page": { + "name": "page", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "anchor": { + "name": "anchor", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "quote": { + "name": "quote", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "quote_context": { + "name": "quote_context", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "parent_id": { + "name": "parent_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "body": { + "name": "body", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "approved_at": { + "name": "approved_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "approved_by": { + "name": "approved_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'open'" + }, + "resolution_note": { + "name": "resolution_note", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "edited_at": { + "name": "edited_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "page_comments_page_anchor_idx": { + "name": "page_comments_page_anchor_idx", + "columns": [ + { + "expression": "page", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "anchor", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "page_comments_user_id_idx": { + "name": "page_comments_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "page_comments_user_id_user_id_fk": { + "name": "page_comments_user_id_user_id_fk", + "tableFrom": "page_comments", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.record_objects": { + "name": "record_objects", + "schema": "", + "columns": { + "hash": { + "name": "hash", + "type": "text", + "primaryKey": true, + "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 + }, + "size": { + "name": "size", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "record_objects_record_id_idx": { + "name": "record_objects_record_id_idx", + "columns": [ + { + "expression": "record_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "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.session": { + "name": "session", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "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()" + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "active_organization_id": { + "name": "active_organization_id", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "session_user_id_idx": { + "name": "session_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "session_user_id_user_id_fk": { + "name": "session_user_id_user_id_fk", + "tableFrom": "session", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "session_token_unique": { + "name": "session_token_unique", + "nullsNotDistinct": false, + "columns": ["token"] + } + }, + "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.user": { + "name": "user", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email_verified": { + "name": "email_verified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "image": { + "name": "image", + "type": "text", + "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": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_email_unique": { + "name": "user_email_unique", + "nullsNotDistinct": false, + "columns": ["email"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.verification": { + "name": "verification", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "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": { + "verification_identifier_idx": { + "name": "verification_identifier_idx", + "columns": [ + { + "expression": "identifier", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "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_records": { + "name": "version_records", + "schema": "", + "columns": { + "version_id": { + "name": "version_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "record_hash": { + "name": "record_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "public_record_hash": { + "name": "public_record_hash", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "version_records_record_hash_idx": { + "name": "version_records_record_hash_idx", + "columns": [ + { + "expression": "record_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "version_records_public_record_hash_idx": { + "name": "version_records_public_record_hash_idx", + "columns": [ + { + "expression": "public_record_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "public_record_hash IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "version_records_version_id_versions_id_fk": { + "name": "version_records_version_id_versions_id_fk", + "tableFrom": "version_records", + "tableTo": "versions", + "columnsFrom": ["version_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "version_records_record_hash_record_objects_hash_fk": { + "name": "version_records_record_hash_record_objects_hash_fk", + "tableFrom": "version_records", + "tableTo": "record_objects", + "columnsFrom": ["record_hash"], + "columnsTo": ["hash"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "version_records_version_id_record_hash_pk": { + "name": "version_records_version_id_record_hash_pk", + "columns": ["version_id", "record_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 + }, + "semver": { + "name": "semver", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "major": { + "name": "major", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "minor": { + "name": "minor", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "patch": { + "name": "patch", + "type": "integer", + "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_semver": { + "name": "base_semver", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "message": { + "name": "message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "pushed_by": { + "name": "pushed_by", + "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 + }, + "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 + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'ready'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "versions_ordering_idx": { + "name": "versions_ordering_idx", + "columns": [ + { + "expression": "collection_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "major", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "minor", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "patch", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "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_user_id_fk": { + "name": "versions_pushed_by_user_id_fk", + "tableFrom": "versions", + "tableTo": "user", + "columnsFrom": ["pushed_by"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "versions_collection_id_semver_unique": { + "name": "versions_collection_id_semver_unique", + "nullsNotDistinct": false, + "columns": ["collection_id", "semver"] + }, + "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 4d4a76a..aefbb5a 100644 --- a/src/db/migrations/meta/_journal.json +++ b/src/db/migrations/meta/_journal.json @@ -36,6 +36,13 @@ "when": 1781195924011, "tag": "0004_fancy_nightcrawler", "breakpoints": true + }, + { + "idx": 5, + "version": "7", + "when": 1781223203253, + "tag": "0005_instance_settings", + "breakpoints": true } ] } diff --git a/src/db/schema.ts b/src/db/schema.ts index de2992c..f2102c9 100644 --- a/src/db/schema.ts +++ b/src/db/schema.ts @@ -483,3 +483,11 @@ export const arkRecordTypes = pgTable( }, (t) => [primaryKey({ columns: [t.collectionId, t.recordType] })], ) + +// --- Instance-wide settings (key-value) --- + +export const instanceSettings = pgTable('instance_settings', { + key: text('key').primaryKey(), + value: jsonb('value').notNull(), + updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(), +}) diff --git a/src/routes/admin/explore-tags.tsx b/src/routes/admin/explore-tags.tsx new file mode 100644 index 0000000..aa73fe7 --- /dev/null +++ b/src/routes/admin/explore-tags.tsx @@ -0,0 +1,31 @@ +import BaseLayout from '~/components/BaseLayout' +import ExploreTagsAdmin from '~/components/ExploreTagsAdmin' +import FeaturedCollectionsAdmin from '~/components/FeaturedCollectionsAdmin' +import { useAppContext } from '~/lib/app-context' + +export default function AdminExploreTags() { + const { currentUser } = useAppContext() + + if (currentUser?.kfRole !== 'admin') { + if (typeof window !== 'undefined') { + window.location.href = '/' + } + return null + } + + return ( + +
+

Explore Page

+

+ Configure what appears on the explore page — featured collections and tag filters. +

+ +
+ + +
+
+
+ ) +} diff --git a/src/routes/docs/self-host.tsx b/src/routes/docs/self-host.tsx index db3e86d..ff0cb17 100644 --- a/src/routes/docs/self-host.tsx +++ b/src/routes/docs/self-host.tsx @@ -1,186 +1,222 @@ +import { Link } from 'react-router' + import DocsLayout from '~/components/DocsLayout' -const devShCode = `git clone https://github.com/knowledgefutures/underlay.git -cd underlay -./dev.sh` +const quickStart = `DOMAIN=https://your-domain.com docker compose -f docker-compose.withauth.yml up -d` + +const localStart = `docker compose -f docker-compose.withauth.yml up -d` -const secretsCode = `# Generate a keypair -age-keygen -o key.txt +const envExample = `# Required +DOMAIN=https://your-domain.com -# Add the public key to .sops.yaml, then: -npm run secrets:encrypt # .env → .env.enc -npm run secrets:decrypt # .env.enc → .env -npm run secrets:encrypt:dev # .env.dev → .env.dev.enc -npm run secrets:decrypt:dev # .env.dev.enc → .env.dev` +# Optional: email delivery (for password resets, invitations) +SMTP_HOST=smtp.example.com +SMTP_PORT=587 +SMTP_FROM=noreply@your-domain.com +SMTP_USER=apikey +SMTP_PASS=your-smtp-password -const backupCode = `# Manual backup -npm run tool:backup +# Optional: social login providers +GITHUB_CLIENT_ID=... +GITHUB_CLIENT_SECRET=... +GOOGLE_CLIENT_ID=... +GOOGLE_CLIENT_SECRET=... +ORCID_CLIENT_ID=... +ORCID_CLIENT_SECRET=...` -# Backups are stored at: -# s3://{bucket}/{BACKUP_S3_PREFIX}{timestamp}/underlay.sql.gz` +const externalS3 = `# In docker-compose.withauth.yml, remove the minio and minio-init services, +# then add these to the app service's environment block: +S3_BUCKET: your-bucket-name +S3_REGION: us-east-1 +S3_ENDPOINT: https://your-account-id.r2.cloudflarestorage.com # omit for AWS S3 +S3_ACCESS_KEY: your-access-key +S3_SECRET_KEY: your-secret-key` + +const resetCmd = `docker compose -f docker-compose.withauth.yml down -v +docker compose -f docker-compose.withauth.yml up -d` export default function DocsSelfHost() { return (

- Underlay is designed to be self-hosted. You need three things: a Node.js runtime, a - PostgreSQL database, and S3-compatible object storage. + The Underlay{' '} + + protocol + {' '} + is an open specification. This repository is the reference implementation, but anyone can + build an Underlay-compatible server tailored to their infrastructure, language, or use case + — as long as it implements the protocol (content-addressed records, hash negotiation, + immutable versioning). The protocol is the contract; the implementation is yours. +

+

+ What follows is how to self-host this implementation. It ships with a + self-contained Docker Compose setup that bundles everything you need: the app, auth server, + PostgreSQL, S3-compatible storage, and a reverse proxy. One command, no external + dependencies.

-

Requirements

+

What gets deployed

  • - Node.js ≥ 22.12 + Underlay app — the main application (API + web UI) +
  • +
  • + KF Auth — authentication server (OAuth2/OIDC) + account management UI
  • - PostgreSQL 16+ + PostgreSQL 16 — two databases: one for auth, one for the app
  • - S3-compatible storage — AWS S3, MinIO, Cloudflare R2, etc. + MinIO — S3-compatible object storage for file uploads (replaceable with + external S3)
  • - Docker (recommended) — or run directly with Node + Caddy — reverse proxy with automatic TLS
+

+ On first boot, an init container auto-generates all secrets (session keys, OAuth client + credentials, S3 credentials). No manual secret management required. +

-

Quick start with Docker

+

Requirements

+
    +
  • + Docker and Docker Compose (v2) +
  • +
  • + A server with at least 2 GB RAM and 10 GB disk +
  • +
  • + A domain name pointed at your server (for TLS) — or localhost for local + testing +
  • +
+ +

Quick start

Clone the repo and run:

-        {devShCode}
+        {quickStart}
+      
+

+ That's it. Caddy handles TLS automatically via Let's Encrypt. Visit your domain to create + your first account. +

+

+ For local testing without a domain, omit DOMAIN — it defaults to{' '} + http://localhost: +

+
+        {localStart}
+      
+ +

Configuration

+

+ Set environment variables in your shell or create a .env file next to the + compose file. Only DOMAIN is required — everything else has sensible defaults + or is auto-generated. +

+
+        {envExample}
       
+ +

Social login

- This starts Postgres, MinIO (S3), and the Underlay app in development mode. The dev script - auto-creates a .env.dev from defaults if one doesn't exist. + Without social login configured, users sign up and log in with email/password. To enable + GitHub, Google, or ORCID login, set the corresponding client ID and secret. You'll need to + register an OAuth app with each provider — use{' '} + {'https://your-domain.com/auth/callback/'} as the callback URL.

-

Environment variables

- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
- 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
- S3_ENDPOINT - S3 endpoint URL (for MinIO, R2, etc.)
- S3_ACCESS_KEY - S3 access key
- S3_SECRET_KEY - S3 secret key
- BACKUP_S3_PREFIX - S3 key prefix for database backups
- -

Production deployment

-

The recommended production setup:

-
    -
  1. - Build the Docker image: docker build -t underlay . -
  2. -
  3. - Create a .env with production values (or use SOPS encryption) -
  4. -
  5. - Run with docker compose up -d -
  6. -
+

Using external S3

+

+ The bundled MinIO service works out of the box, but you can replace it with any + S3-compatible storage (AWS S3, Cloudflare R2, DigitalOcean Spaces, etc.): +

+
+        {externalS3}
+      

- The production docker-compose.yml includes: + Also remove the minio-init service dependency from the app{' '} + service. The S3_ENDPOINT variable is only needed for non-AWS providers — omit + it for standard AWS S3.

+ +

Data and persistence

+

All state is in Docker volumes:

  • - postgres — PostgreSQL 16 with a named volume + pgdata — PostgreSQL databases (auth + app)
  • - app — Runs migrations, then starts the Hono server + minio-data — uploaded files (if using bundled MinIO)
  • - cron — Scheduled tasks (database backups) + withauth-config — auto-generated secrets and config (created once on first + boot) +
  • +
  • + caddy-data — TLS certificates
- -

Secrets management

- We use SOPS with{' '} - age encryption. Encrypted{' '} - .env.enc files are committed to the repo; plaintext .env files are - gitignored. + To completely reset and start fresh, remove all volumes and re-run. The init container will + regenerate secrets:

-        {secretsCode}
+        {resetCmd}
       
-

CI/CD

+

Updating

- The included GitHub Actions workflow (.github/workflows/deploy.yml) handles the - full pipeline: + Pull new images and restart. The app runs database migrations automatically on startup — no + manual migration step needed.

-
    +
    +        
    +          {`docker compose -f docker-compose.withauth.yml pull\ndocker compose -f docker-compose.withauth.yml up -d`}
    +        
    +      
    + +

    Architecture

    +

    Caddy listens on ports 80 and 443 and routes requests by path:

    +
    • - Push to main + /auth/* → KF Auth server (authentication, OAuth2)
    • -
    • Build Docker image → push to GHCR
    • - SSH to server → pull image → decrypt secrets → docker compose up + /account/* → KF Auth account UI (profile, password, sessions)
    • -
+
  • + Everything else → Underlay app (API at /api/*, web UI for all other paths) +
  • +

    - Required GitHub secrets: SSH_PRIVATE_KEY, SSH_HOST,{' '} - SSH_USER, GHCR_USER, GHCR_TOKEN. + The app server handles both the JSON API and server-side rendered React UI on a single port. + All services communicate internally over a Docker network — only Caddy is exposed to the + internet.

    -

    Backups

    -

    The cron container runs daily Postgres backups to S3:

    -
    -        {backupCode}
    -      
    - -

    Reverse proxy

    +

    Source code

    +

    The self-hosting setup lives in the main repo:

    +
      +
    • + docker-compose.withauth.yml — the compose file +
    • +
    • + selfhost/Caddyfile — Caddy reverse proxy config +
    • +
    • + selfhost/init-db.sh — Postgres init script (creates the app database) +
    • +

    - Put Caddy, nginx, or Cloudflare in front. The app exposes a single port (default 3000) - serving both the API and SSR. + Report issues at{' '} + + github.com/knowledgefutures/underlay + + . Built by Knowledge Futures, a 501(c)(3) + public charity.

    ) diff --git a/src/routes/superadmin.tsx b/src/routes/superadmin.tsx index e995b63..3893afb 100644 --- a/src/routes/superadmin.tsx +++ b/src/routes/superadmin.tsx @@ -4,6 +4,12 @@ import BaseLayout from '~/components/BaseLayout' import { useAppContext } from '~/lib/app-context' const tools = [ + { + name: 'Explore Page', + href: '/admin/explore-tags', + description: 'Manage featured collections and tag filters on the explore page.', + stewardOnly: true, + }, { name: 'Discussion Review', href: '/admin/discussion', From 12f7efa56e75825dd5cea8a5b8017678e256eefc Mon Sep 17 00:00:00 2001 From: Travis Rich Date: Thu, 11 Jun 2026 23:23:59 -0400 Subject: [PATCH 02/10] design updates --- server.ts | 1 + src/components/BaseLayout.tsx | 4 +- src/components/UserMenu.tsx | 30 +- src/lib/auth.server.ts | 62 +++- src/lib/auth.ts | 19 +- src/routes/[owner]/[collection]/index.tsx | 11 +- src/routes/docs/quickstart.tsx | 13 + src/routes/index.data.ts | 21 ++ src/routes/index.tsx | 399 ++++++++++++++++------ src/routes/superadmin.tsx | 2 +- 10 files changed, 420 insertions(+), 142 deletions(-) diff --git a/server.ts b/server.ts index 24b0665..7054d95 100644 --- a/server.ts +++ b/server.ts @@ -177,6 +177,7 @@ app.get('/login', async (c, next) => { }), ) const body = await authRes.json() + console.log('[login] auth sign-in response:', { status: authRes.status, body }) if (body.url) { const redirect = new Response(null, { status: 302, headers: { Location: body.url } }) for (const [key, value] of authRes.headers.entries()) { diff --git a/src/components/BaseLayout.tsx b/src/components/BaseLayout.tsx index d07acd4..1e053da 100644 --- a/src/components/BaseLayout.tsx +++ b/src/components/BaseLayout.tsx @@ -24,9 +24,6 @@ export default function BaseLayout({ children }: { children: React.ReactNode }) Explore - - Schemas - Docs @@ -49,6 +46,7 @@ export default function BaseLayout({ children }: { children: React.ReactNode }) diff --git a/src/components/UserMenu.tsx b/src/components/UserMenu.tsx index 4323cb0..35ed515 100644 --- a/src/components/UserMenu.tsx +++ b/src/components/UserMenu.tsx @@ -9,6 +9,7 @@ interface Org { interface UserMenuProps { slug: string displayName?: string | null + avatarUrl?: string | null orgs?: Org[] isSteward?: boolean } @@ -16,21 +17,12 @@ interface UserMenuProps { export default function UserMenu({ slug, displayName, + avatarUrl, orgs = [], isSteward = false, }: UserMenuProps) { const [open, setOpen] = useState(false) const rootRef = useRef(null) - const hideTimeout = useRef>(undefined) - - function show() { - clearTimeout(hideTimeout.current) - setOpen(true) - } - - function scheduleHide() { - hideTimeout.current = setTimeout(() => setOpen(false), 150) - } useEffect(() => { function handleClickOutside(e: MouseEvent) { @@ -42,14 +34,20 @@ export default function UserMenu({ return () => document.removeEventListener('click', handleClickOutside) }, []) + const initial = (displayName || slug || '?').charAt(0).toUpperCase() + return ( -
    +
    {open && (
    @@ -57,18 +55,21 @@ export default function UserMenu({ setOpen(false)} > Your Profile setOpen(false)} > Dashboard setOpen(false)} > Settings @@ -76,6 +77,7 @@ export default function UserMenu({ setOpen(false)} > Admin @@ -91,6 +93,7 @@ export default function UserMenu({ key={org.slug} to={`/${org.slug}`} className="text-ink-light hover:bg-parchment-dark block px-3 py-1.5 text-sm transition-colors" + onClick={() => setOpen(false)} > {org.displayName} @@ -101,6 +104,7 @@ export default function UserMenu({ setOpen(false)} > Sign out diff --git a/src/lib/auth.server.ts b/src/lib/auth.server.ts index 990f57b..46d8780 100644 --- a/src/lib/auth.server.ts +++ b/src/lib/auth.server.ts @@ -1,4 +1,4 @@ -import { eq } from 'drizzle-orm' +import { and, eq } from 'drizzle-orm' import { db, schema } from '../db/client.server.js' import { auth, KF_AUTH_INTERNAL_URL } from './auth.js' @@ -19,20 +19,70 @@ export interface SessionUser { }> } +async function refreshAccessToken(acct: { + refreshToken: string | null + accessToken: string | null + accessTokenExpiresAt: Date | null +}): Promise { + if ( + acct.accessToken && + acct.accessTokenExpiresAt && + acct.accessTokenExpiresAt.getTime() > Date.now() + 30_000 + ) { + return acct.accessToken + } + if (!acct.refreshToken) return null + try { + const tokenUrl = `${KF_AUTH_INTERNAL_URL}/api/auth/oauth2/token` + const res = await fetch(tokenUrl, { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: new URLSearchParams({ + grant_type: 'refresh_token', + refresh_token: acct.refreshToken, + client_id: process.env.OIDC_CLIENT_ID ?? 'kf_underlay', + client_secret: process.env.OIDC_CLIENT_SECRET ?? '', + }), + }) + if (!res.ok) return null + const tokens = await res.json() + if (!tokens.access_token) return null + await db + .update(schema.account) + .set({ + accessToken: tokens.access_token, + refreshToken: tokens.refresh_token ?? acct.refreshToken, + accessTokenExpiresAt: tokens.expires_in + ? new Date(Date.now() + tokens.expires_in * 1000) + : null, + }) + .where(eq(schema.account.accessToken, acct.accessToken!)) + return tokens.access_token + } catch { + return null + } +} + async function fetchKfRole(userId: string): Promise { try { const [acct] = await db - .select({ accessToken: schema.account.accessToken }) + .select({ + accessToken: schema.account.accessToken, + refreshToken: schema.account.refreshToken, + accessTokenExpiresAt: schema.account.accessTokenExpiresAt, + }) .from(schema.account) - .where(eq(schema.account.userId, userId)) + .where(and(eq(schema.account.userId, userId), eq(schema.account.providerId, 'kf-auth'))) .limit(1) - if (!acct?.accessToken) return null + if (!acct) return null + const token = await refreshAccessToken(acct) + if (!token) return null const res = await fetch(`${KF_AUTH_INTERNAL_URL}/api/auth/oauth2/userinfo`, { - headers: { Authorization: `Bearer ${acct.accessToken}` }, + headers: { Authorization: `Bearer ${token}` }, }) if (!res.ok) return null const profile = await res.json() - return profile.role ?? null + return profile['https://knowledgefutures.org/role'] ?? profile.role ?? null } catch { return null } diff --git a/src/lib/auth.ts b/src/lib/auth.ts index b0a57ae..e8b487e 100644 --- a/src/lib/auth.ts +++ b/src/lib/auth.ts @@ -3,7 +3,7 @@ import { betterAuth } from 'better-auth' import { drizzleAdapter } from 'better-auth/adapters/drizzle' import { genericOAuth } from 'better-auth/plugins' import { organization } from 'better-auth/plugins/organization' -import { eq } from 'drizzle-orm' +import { and, eq, ne } from 'drizzle-orm' import { db, schema } from '../db/client.server.js' @@ -36,7 +36,7 @@ export const auth = betterAuth({ userInfoUrl: `${KF_AUTH_INTERNAL_URL}/api/auth/oauth2/userinfo`, clientId: process.env.OIDC_CLIENT_ID ?? 'kf_underlay', clientSecret: process.env.OIDC_CLIENT_SECRET ?? '', - scopes: ['openid', 'profile', 'email'], + scopes: ['openid', 'profile', 'email', 'offline_access'], pkce: true, mapProfileToUser: (profile) => ({ name: profile.name ?? profile.email?.split('@')[0] ?? 'User', @@ -85,6 +85,21 @@ export const auth = betterAuth({ ], databaseHooks: { + account: { + create: { + after: async (account) => { + await db + .delete(schema.account) + .where( + and( + eq(schema.account.userId, account.userId), + eq(schema.account.providerId, account.providerId), + ne(schema.account.id, account.id), + ), + ) + }, + }, + }, user: { create: { after: async (user) => { diff --git a/src/routes/[owner]/[collection]/index.tsx b/src/routes/[owner]/[collection]/index.tsx index da37c29..2611015 100644 --- a/src/routes/[owner]/[collection]/index.tsx +++ b/src/routes/[owner]/[collection]/index.tsx @@ -469,14 +469,17 @@ function AgentShareSection({ <>

    - Share via Agent + Update via Agent

    +

    + Generate a temporary link that lets an AI agent push updates to this collection. +

    @@ -489,7 +492,7 @@ function AgentShareSection({ >
    -

    Agent Share Link

    +

    Agent Update Link

    @@ -101,18 +211,23 @@ export default function Home() { return ( -
    - {/* Hero */} -
    + {/* Hero */} +
    + +
    -

    - Knowledge that lasts. +

    + A protocol for radically accessible structured knowledge.

    -

    - Underlay is a public registry where organizations publish versioned snapshots of their - structured data — making it permanently discoverable, verifiable, and citable. - Research datasets, publication archives, open knowledge: published once, preserved - indefinitely. +

    + Publish structured data as versioned, permanent collections with schemas, provenance, + and content addressing built in. Whether you're a research lab, a newsroom, a + community archive, or a single developer, Underlay makes the knowledge you hold + discoverable, verifiable, and easy to build on. +

    +

    + Stewarded by Knowledge Futures, a 501(c)(3) public charity dedicated to building + open-source knowledge infrastructure.

    - Start publishing + Get started
    -
    +
    + - {/* Value propositions */} -
    -
    -
    -

    Permanent

    -

    - Every version of your data is stored immutably. Research doesn't disappear when a - project ends or a server goes down. Once published, it's preserved. -

    -
    -
    -

    Verifiable

    -

    - Content-addressed storage means anyone can confirm that data hasn't been altered. - Cryptographic hashes at every level make tampering evident and trust auditable. -

    + {/* In the registry */} + {collections.length > 0 && ( +
    +
    +
    +

    + In the registry +

    + + Explore all collections → +
    -
    -

    Discoverable

    -

    - Structured data with shared schemas across collections. Browse, search, and export — - every dataset is openly accessible through both the web and a REST API. -

    +
    + + + + + + + + + + + + {collections.map((c, i) => ( + + + + + + + + ))} + +
    CollectionRecordsLatestSizeUpdated
    + + {c.ownerSlug}/{c.slug} + + + {c.recordCount != null ? formatCount(c.recordCount) : '—'} + + {c.semver ?? '—'} + + {c.totalBytes != null ? formatBytes(c.totalBytes) : '—'} + + {c.lastPushAt ? timeAgo(c.lastPushAt) : '—'} +
    + )} - {/* Who it's for */} -
    -

    - Built for -

    -
    -
    -

    Research institutions

    -

    - Preserve datasets beyond the life of a grant. Publish versioned snapshots that are - citable and independently verifiable. -

    -
    -
    -

    Academic publishers

    -

    - Archive publication metadata, review data, and supplementary materials in a format - that's structured, searchable, and open. -

    -
    -
    -

    Open data organizations

    -

    - Share curated datasets with the public. Underlay handles versioning, integrity, and - access — so you can focus on the data itself. + {/* The workflow */} +

    +
    +
    +
    +

    + The workflow +

    +

    + An agent, an app, a scraper, a researcher — any tool that can push JSON records and + pull versions. Five commands from JSON to a permanent version.

    + + Read the quickstart → +
    -
    -

    Developers

    -

    - Build on open knowledge. Pull snapshots via API, integrate with existing workflows, - or run your own Underlay instance. -

    +
    +
    +
    + # point at a collection +
    +
    + $ underlay init --schema + ./schema.json +
    +
    + # stage and push records +
    +
    + $ underlay add ./records.jsonl +
    +
    + $ underlay commit -m "Q2 article + refresh" +
    +
    + # done — versioned and permanent +
    +
    + $ underlay push +
    +
    + published v1.2.0 · 4,218 records · immutable +
    +
    -
    +
    +
    - {/* How it works */} -
    -

    - How it works + {/* Bottom dark section */} +
    +
    +

    + Push what you have. The schemas make it legible. The models make it interoperable.

    -
    +

    + Aligning structured data across organizations used to require everyone to agree on a + common schema upfront. Modern tooling has changed that. Schemas travel with the data, + and alignment can happen at the point of use rather than at the point of publication. + All you need to do is publish what you have, in whatever structure you already have it. +

    +
    -
    01
    -

    Publish

    -

    - Push structured data via a simple REST API. Define your schema, add records and - files, and create a versioned snapshot. +

    Explore collections

    +

    + Browse the public registry.

    + + Explore → +
    -
    02
    -

    Preserve

    -

    - Each version is validated, deduplicated, and stored immutably. Files are - content-addressed. Every byte is accounted for. +

    Read the docs

    +

    + Quickstart, concepts, API.

    + + Docs → +
    -
    03
    -

    Discover

    -

    - Anyone can browse collections, inspect schemas, view diffs between versions, and - export full archives. The data is the interface. +

    Read the protocol

    +

    + The reference-grade spec.

    + + Protocol → +
    -
    +

    +
    - {/* Bottom */} -
    -
    -
    -

    Open source

    -

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

    -
    -
    -

    Built by Knowledge Futures

    -

    - A 501(c)(3) nonprofit building open infrastructure for the production, curation, and - preservation of knowledge. -

    -
    + {/* Footer note */} +
    +
    +
    + + Knowledge Futures · A 501(c)(3) nonprofit · Open source +
    -
    -
    +
    + ) } diff --git a/src/routes/superadmin.tsx b/src/routes/superadmin.tsx index 3893afb..117667a 100644 --- a/src/routes/superadmin.tsx +++ b/src/routes/superadmin.tsx @@ -46,7 +46,7 @@ export default function Superadmin() { return (
    -

    This page is only available to stewards.

    +

    This page is only available to admins.

    ) From c3a513653d2e7b725ac6ef91516739ed77b52948 Mon Sep 17 00:00:00 2001 From: Travis Rich Date: Thu, 11 Jun 2026 23:29:21 -0400 Subject: [PATCH 03/10] login fix --- server.ts | 5 +++-- src/components/BaseLayout.tsx | 4 +--- src/routes/index.tsx | 36 +++++++++++++---------------------- 3 files changed, 17 insertions(+), 28 deletions(-) diff --git a/server.ts b/server.ts index 7054d95..a035c35 100644 --- a/server.ts +++ b/server.ts @@ -163,15 +163,16 @@ app.on(['GET', 'POST'], '/api/auth/*', async (c) => { // /login redirect — fall through to React route only when there's an error to display app.get('/login', async (c, next) => { const url = new URL(c.req.url) + const appOrigin = new URL(process.env.APP_URL ?? 'http://localhost:4100').origin if (!url.searchParams.has('error')) { - const signInUrl = new URL('/api/auth/sign-in/oauth2', url.origin) + const signInUrl = new URL('/api/auth/sign-in/oauth2', appOrigin) const authRes = await auth.handler( new Request(signInUrl, { method: 'POST', headers: new Headers({ 'Content-Type': 'application/json', Cookie: c.req.header('cookie') ?? '', - Origin: url.origin, + Origin: appOrigin, }), body: JSON.stringify({ providerId: 'kf-auth', callbackURL: '/dashboard' }), }), diff --git a/src/components/BaseLayout.tsx b/src/components/BaseLayout.tsx index 1e053da..bb00602 100644 --- a/src/components/BaseLayout.tsx +++ b/src/components/BaseLayout.tsx @@ -69,12 +69,10 @@ export default function BaseLayout({ children }: { children: React.ReactNode }) Knowledge Futures
    -
    +
    GitHub - · - v0.1.0
    diff --git a/src/routes/index.tsx b/src/routes/index.tsx index a6b0fb6..1b519a5 100644 --- a/src/routes/index.tsx +++ b/src/routes/index.tsx @@ -26,24 +26,25 @@ function HeroBackground() { const boxes = Array.from({ length: count }, (_, i) => { const el = document.createElement('div') - const size = 30 + rng(i * 5) * 80 + const size = 20 + rng(i * 7) * 120 + const aspect = 0.5 + rng(i * 7 + 5) * 1.0 el.style.cssText = ` position:absolute; width:${size}px; - height:${size}px; - border:1px solid rgba(201,193,176,${0.25 + rng(i * 5 + 4) * 0.2}); - border-radius:3px; + height:${size * aspect}px; + background:rgba(235,228,214,${0.25 + rng(i * 7 + 6) * 0.3}); + border-radius:${2 + rng(i * 7 + 4) * 4}px; pointer-events:none; ` container.appendChild(el) return { el, - x: rng(i * 5 + 1) * w, - y: rng(i * 5 + 2) * h, - vx: (rng(i * 5 + 3) - 0.5) * 0.12, - vy: (rng(i * 5 + 4) - 0.5) * 0.1, - rot: rng(i * 5) * 360, - vr: (rng(i * 5 + 3) - 0.5) * 0.04, + x: rng(i * 7 + 1) * w, + y: rng(i * 7 + 2) * h, + vx: (rng(i * 7 + 3) - 0.5) * 0.35, + vy: (rng(i * 7 + 4) - 0.5) * 0.3, + rot: rng(i * 7 + 5) * 360, + vr: (rng(i * 7 + 6) - 0.5) * 0.08, size, } }) @@ -250,7 +251,7 @@ export default function Home() { {/* In the registry */} {collections.length > 0 && (
    -
    +

    In the registry @@ -310,7 +311,7 @@ export default function Home() { {/* The workflow */}
    -
    +

    @@ -410,17 +411,6 @@ export default function Home() {

    - - {/* Footer note */} -
    -
    -
    - - Knowledge Futures · A 501(c)(3) nonprofit · Open source - -
    -
    -
    ) } From d888445f9fa2f47d957127237bcb8255b92b0d71 Mon Sep 17 00:00:00 2001 From: Travis Rich Date: Thu, 11 Jun 2026 23:55:45 -0400 Subject: [PATCH 04/10] progress on design and login bug --- .env.prod.enc | 60 +-- public/logoLight.svg | 9 + src/api/collections.ts | 9 +- src/components/BaseLayout.tsx | 20 +- src/components/CollectionExplorer.tsx | 207 ++++---- src/components/CreateMenu.tsx | 46 ++ src/lib/auth.ts | 88 ++-- src/routes/[owner]/[collection]/index.tsx | 44 ++ src/routes/[owner]/index.tsx | 248 +++++---- src/routes/dashboard.tsx | 608 ++++++++++------------ src/routes/new.data.ts | 4 + src/routes/new.tsx | 181 +++++++ 12 files changed, 910 insertions(+), 614 deletions(-) create mode 100644 public/logoLight.svg create mode 100644 src/components/CreateMenu.tsx create mode 100644 src/routes/new.data.ts create mode 100644 src/routes/new.tsx diff --git a/.env.prod.enc b/.env.prod.enc index 2454674..c0e320c 100644 --- a/.env.prod.enc +++ b/.env.prod.enc @@ -1,38 +1,38 @@ -#ENC[AES256_GCM,data:TS4j3twMFoZh,iv:67VeU7kyhgPK0TptCh60HYxLTe56pK2862zEboOr26M=,tag:4JiTyCGuL3fqU7LlqtYzkA==,type:comment] -DATABASE_URL=ENC[AES256_GCM,data:9c2V8Ezkdb4sXC+mrz6BhrtesdQ7pnA5PjrffBIRBHNfPSkZft1HrIGAEvgde25jFeXt1Ck=,iv:LSzvRX/PSto7ROQ5TXCM3G5pg/Qm2RiQnX1ZOw2Gc3Y=,tag:OkYD9v2+pZpQBNQay1Cxyg==,type:str] -SESSION_SECRET=ENC[AES256_GCM,data:W2tBSuCsF69dFbX+uYYa/5H6KKnVWYl3Bibez0btM8g=,iv:DE3NdgXvRJbd6Xvnu9ZVCmOhzbANvvw9X/DkcZAGGHw=,tag:gs+2u6AmFGISNW+3mCjSLQ==,type:str] -PORT=ENC[AES256_GCM,data:ZbjWrQ==,iv:LNymntIzDLZO3Y4+ROk0StHRoD73LP5ympAFIeihC9s=,tag:PiiFCezINAJjqOAyOFM8Rg==,type:str] -#ENC[AES256_GCM,data:T5oV,iv:rsC4Pk0F4XQ2lVXl+mKL+hrnBYQAMuWuaJPgvfleXPg=,tag:eC85ejuc/0YgfrrtIxCXSA==,type:comment] -S3_BUCKET=ENC[AES256_GCM,data:uguPyh8NpEh5UQtgeBBd,iv:trAHyJZ0JDG4d4G2mudHLcvsq3Mimlw+GnGvf4DrbmU=,tag:Cs1RVRextKulmyknzr6qYg==,type:str] -S3_ENDPOINT=ENC[AES256_GCM,data:XmbQiGK/QP7SEazjUrAZWX0daHnXNWNmWqzbSzh5goFBkR62aKB9IefC8wtQ9NTySQlNv+kuisVOHQJ8/SFvVx4=,iv:AIur7LSlRhHwcPYnuasW89dt/Zr6I/qtmwVJfy3gHdE=,tag:IuTmLbTUmIw8fKm2xCKXiQ==,type:str] -S3_ACCESS_KEY=ENC[AES256_GCM,data:3pnGOvtCorluqErK6kZtUz7cYJQaUsJgl2X7mqZDgg0=,iv:L2D4WRE45Q2L7S6WY0tffaS9KRqNTvXZLtwJMU6tHSw=,tag:ZOpEFvpihR0tcsQ4WVSDsg==,type:str] -S3_SECRET_KEY=ENC[AES256_GCM,data:e+wGR1nZbyDZ8J/inqOooffPYlFHxa7mS1XujmP45LZx704GIs6RLZ4v1WPkjuoq1NoGV3HPe3gk5u2vU3vz5w==,iv:6TtKzHjhQG5xCnhLFPmp/CEIlBGB6sZIEkDjeIU+OWE=,tag:O0biF8VDHRYz7YBy2OgDAg==,type:str] -CF_ACCOUNT_ID=ENC[AES256_GCM,data:OO7KrYgpLwbj/VWx+6bSCL4qo7LhjcjNPFQfl0Gg0KA=,iv:h1vVwXbxdRBbU+FbGpdZrbFarKdYzDn+aUS8fvLhyyc=,tag:y80M9a7h45UIrCFDO8h/Ow==,type:str] -CF_API_TOKEN=ENC[AES256_GCM,data:iJdQyRQScMxcrcU4CEQgW7+qKWmb7s93QRcYgh291Qzf3NC59ExxnOpBU++ZV6LFWjUDdUU=,iv:xztI5eMbEk1WNw3o9zrjbi8jJ8NtAuMJl9FbWTUsaTE=,tag:1lOSYrww6hZ/WjWzSTW04Q==,type:str] -ARK_DEFAULT_NAAN=ENC[AES256_GCM,data:8xsRwK8=,iv:tCaPuOCVI5aoxnwxTKa7OMqGFQD+W8ll76Q8Srw9gt4=,tag:C03OWlm2jOOcSrCGNn2whA==,type:str] -#ENC[AES256_GCM,data:+BWEFM6L6aD1rMG+g/Fypx73,iv:7ez6bhjdrh4suF7FnzeqbV9YaGWQjdzHwrg5C12POdA=,tag:lPwrgKv9U94oei4l9wnPsg==,type:comment] -UNDERLAY_UPSTREAM_API_KEY=ENC[AES256_GCM,data:gg8Fm3ngcXfMSqnravyzd4F7nVaOfjF0kLq6akC4c41pqoc=,iv:3tjfQSU3RoapeTpZ/26b/cRyXEET1/plU/zAAZqBUek=,tag:SchlwcyYhcStKabyhHXHsA==,type:str] -DEPLOY_HOST=ENC[AES256_GCM,data:R3wMJvEgnZLS2sUzJQ==,iv:ILujgHggz/XnjDQajRC5UPSdiKWGPqDtpG7MxbfycBI=,tag:gjmxSKYwdxKt0PZzoxE5Uw==,type:str] -#ENC[AES256_GCM,data:kYe5O3MvtkE=,iv:SsygxrvIPlPYGwdmgghhWQEmhYXfHTqjYEcqQBwdBn8=,tag:c7IPDL4NUvHDY/HvALw4Qg==,type:comment] -OIDC_ISSUER_URL=ENC[AES256_GCM,data:6EoGyzM6mMjE7gkG2VHCmf7wjfoUAebL2xNX2b4AlohZ,iv:J9UCzieBnS67x1SF99LQxNXRFtXnzAsx/nKgHZAsEtg=,tag:aXKXAXOhWUiqts1j/85y1A==,type:str] -OIDC_CLIENT_ID=ENC[AES256_GCM,data:YJZQDDDbW3SCUsw=,iv:i02lGIh0O1Ni0pnQzFQwdA4yGW7DMaGBern62zEurdc=,tag:HyQbPDlIRPkodn9mMixrgw==,type:str] -OIDC_CLIENT_SECRET=ENC[AES256_GCM,data:CKbYqzPEHYnffkosxGMzbaUQrjmAr1mbpkEObM/COucG+nr35sJ0eZCVUgq9rh4fSXh7XX8R5MUGrjSamTZBtg==,iv:2JwORSKnfi+7apoQKkxYAxwBK0WjWLSADDowRExcRik=,tag:AYwc/V95qREMocDAo7cMSA==,type:str] -AUTH_INTERNAL_API_KEY=ENC[AES256_GCM,data:T80WuhiPSGm1FMNsvrM+4wRINOSvxhxoVMXeybBOjiNrKb4V6PxX54R3rweyqe2AJNdYlv2zaiQVYX3C3cvQ9Q==,iv:ycYFuwUGrSJGXw9gwxWQrG4Xj1DZfBW0SoUaBRopBAo=,tag:+FZQLFaWnwQSBZQUynAwxw==,type:str] -OIDC_ACCOUNT_URL=ENC[AES256_GCM,data:bqv5vgziTgy55XFOdtYm+qPzmFNrs7Xn4kTxCPzcHy7ikbSJ,iv:+9jMnI8mQgZm9c2+5BQCpO8fkOdDoXj/4WifPjH8LHE=,tag:1fHxiv8ST6DOYCnw2kWn3g==,type:str] -APP_URL=ENC[AES256_GCM,data:GgfRNwfNJ7O3mCanfl+EyvDT8bqH/qE=,iv:4soW9wM1vyXRMWSSU9ZuGKwwXWr0/BNQt9eU+K7Ahjc=,tag:PfFGv8qnIOufUXfORF5D3w==,type:str] -sops_age__list_0__map_enc=-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBGN01qQVdQTHlHcWJHbHNI\nUXVnaE1EcEUxRG12UGx4YTU3SmdBUmhuYmwwClkvaUZHelB6a1FTQ1YrbzJia2dR\nRkRaQUJJejhMeUJLMnpqUmRTTVBBbm8KLS0tIFZmNkcrTWVZUnpaMGU2N3VsOVc2\nNTYxemxzQmxxbDhDeTQybnRxd0NrQzgK6AwczPTgFWItfLq6+jc3d8AwHBJ6UfOo\nnJoEcNu1hjcXWxb3PLeEVer9ICR1VcYBZEtyOZ1dPKgb3OLOjTym7g==\n-----END AGE ENCRYPTED FILE-----\n +#ENC[AES256_GCM,data:O+nchjkaQ9wK,iv:c0fbl/PM6wnYbj+HDT4d8mOuHCq9VngfpJsxQqYrmt4=,tag:mGQWYUJh0wZFcUcWS57f+Q==,type:comment] +DATABASE_URL=ENC[AES256_GCM,data:nC9pqB4zDScCNkW/QdgTcvF19uhmuZm8WtrmQ0AtGO2vHaevdDx8EUAAuLF5hQWmGfW6LFQ=,iv:xo5W+yttEazn8AckWFnc9IoALdQ8uRM6TS/GFLrVNiQ=,tag:sjmfGbyejGCZDGxOxz+oBg==,type:str] +SESSION_SECRET=ENC[AES256_GCM,data:AfAiynfkB7KFNpcs13IwUULNR4uJNvSA1sRcimBdXxe+k7rcozMk83fD/lMgNLclv8wv2qQ3EIKs/+Ar6UlB3g==,iv:CUtywxHxGTANbpPsoebYLWoWaUMnuuHThxxQdUnEaEY=,tag:Kpm2rU+4BmKCy6h05bPq5A==,type:str] +PORT=ENC[AES256_GCM,data:EVfPrw==,iv:YI4epRUkR0+1ejKcBIja+CrPu/HuCjgeg8mN4djU4+0=,tag:JVlgeXZ4x6gSOWAdjO8/lQ==,type:str] +#ENC[AES256_GCM,data:+sXw,iv:jL1yf95M/jAyYcPcJagLa7gAURDw3HdhPQ8lAmY5AN4=,tag:5D9nLPujVjks9fCqieYbZg==,type:comment] +S3_BUCKET=ENC[AES256_GCM,data:4vG/sXkvLEKbq88Sd9/K,iv:T7tyAHgcMeFP7BvW9Xbq5nRUtw4imj4+qZLWDe6QhuM=,tag:P3dlKD6FMP0MBQTUz1HwIQ==,type:str] +S3_ENDPOINT=ENC[AES256_GCM,data:mbSEK3wkiUuxg3ETQtVIg8fLfODrc0QFjl1WX0x1YFHu9md1T5/Yn7ibq1QaOI1db3okrwnBTBn6tEmgU5QSWjg=,iv:4j9Q1fquwwcCeCC6kyOf7ZkfRDxanBrDnC7CVVlavrU=,tag:Tt8fsgbzn/DBaoA9Vn/IYw==,type:str] +S3_ACCESS_KEY=ENC[AES256_GCM,data:A+prb2tXs2GMeIDYRY3+bcSdn12JoSZ77QHeXQIXKYI=,iv:7Nix4bEZh/uSRNJDiDE0oiYJFZ/tGTBSi0Nn7+ZL6NU=,tag:E1RpsL61fnXaC5C02KZbZQ==,type:str] +S3_SECRET_KEY=ENC[AES256_GCM,data:cyRoknQkJi5xabRdE1NovpibJJFpIb0JQsX00Jrd/2m7kRrbu056KqWdW+QXcVNdokXunbz1wTP+3dyP8DgwhQ==,iv:wXh6W76thveWGu0Iq2Fe9cxJJrMRzrpg/4cGWGC/8ro=,tag:kXn7sCPU04LYuOSWz78+rg==,type:str] +CF_ACCOUNT_ID=ENC[AES256_GCM,data:FHIDA3WRC7/t+NHQgGqULG6a5xI+A+e4pPeMz5ZvbMs=,iv:1Vk0BSWIqZGhA5og0nVGxZDFI4ylEm+XuzxbGltwpLA=,tag:kAUpABomnLzd3VKWR7/R4g==,type:str] +CF_API_TOKEN=ENC[AES256_GCM,data:bB4adE2h8VyRDzv6kXpBa0IoCiRY3aMWYAMtHeWYxjZbyMuvyeGfeoX4symD05H4BCm1oDs=,iv:hVwnSPZjjIDbOnaUN8ggbWuQ7PqrxzzpSiaTospY5Qc=,tag:HwoZBW1cehaPDcHwzVKAXw==,type:str] +ARK_DEFAULT_NAAN=ENC[AES256_GCM,data:TzSzgMI=,iv:aue3Wv7kPd7d1U745YalEjV//rTq0BtzYXzPOJPfync=,tag:sgbJapnwMSaNaxlXXmPBEA==,type:str] +#ENC[AES256_GCM,data:bm0c2TWxwFykuTIMW/2YLesr,iv:H1vQLauzzMMGndOsh2AHIhNAlH2VV+FlN0l7bOjv5SY=,tag:8hvh877Rl0zh1FeL0kDxDQ==,type:comment] +UNDERLAY_UPSTREAM_API_KEY=ENC[AES256_GCM,data:gHG8XChBFKsZwySUsIsmbwmRqPCOj5dJ4Rj0mu1i1Iuz0Cw=,iv:WCPG3HIUrraUdaLUZ/EotGbpcXTtkncWYA4KBck/QqU=,tag:CknwtSknBCNmQIrzdjQaVw==,type:str] +DEPLOY_HOST=ENC[AES256_GCM,data:LAyqgvv86wbjaZfI1w==,iv:Bo647hzSqyvJDQ+r1zWjWEWY5PQ1yXd396rIaZzLC+g=,tag:qCiRHP9gUpn3bf4AWYs/Og==,type:str] +#ENC[AES256_GCM,data:YQpq1XCNqmk=,iv:mhg6Kldq246GC5Qh0peXp7xOOa7LjCpaGauBkzIrP3A=,tag:rmMCKHxrEX3SByAnBJh+UA==,type:comment] +OIDC_ISSUER_URL=ENC[AES256_GCM,data:Wi31C6exKWScoJpohcQ9FVnNIB0RwPczf6XNt+5skQaO,iv:ZFRHNXVBuNBJkrLoePKPpQB+awzWvKPrujcuF9uc0lY=,tag:hAikIZJTvfRriZ3JecVaew==,type:str] +OIDC_CLIENT_ID=ENC[AES256_GCM,data:8tjJVzF7aH796rw=,iv:od+vi7jOtMjBe8oFZqtuODNeQS7QISxMJMUd/X9yrJA=,tag:upZ0nyb1pNTYEsy1lNEOjQ==,type:str] +OIDC_CLIENT_SECRET=ENC[AES256_GCM,data:51ggvZnGCb568LUFmXvp15pRo52yKUwxc8VVoaM2AYUdikxsinkUvL1FsESfO9j4a6cTaxMIZUkUhQ6nNNQAXQ==,iv:8wVy9lJZhe+3u9rPf6lcDIS1JnkyNxUGt6bq17TIhyY=,tag:gtBQie5BFm2sgPlbYpBvCQ==,type:str] +AUTH_INTERNAL_API_KEY=ENC[AES256_GCM,data:Eqt1KQyxlZ4JEMN1RBdcwzogqQ6eyUEljj6FFIULjQ1wV5tE1ZgxrOKp4inyx1Mfdz6kpKhf8nH/vX3S4P9xJg==,iv:L4VBSArsYDoPmQHDi7Mo4qjItJlV8AdfTaP097y4laQ=,tag:oEuGjoPRE5BUNxmO+lpjqA==,type:str] +OIDC_ACCOUNT_URL=ENC[AES256_GCM,data:joXmwqCkLkvlF/2tDN0i03R1vzcseoskA+eznd9PGzKiwnyI,iv:ibZJXcxCKIm185jIg42p8sk0q2Y0GvCJi4FKfrICYK4=,tag:i6ny1ZdCRsaqNil+CX76Lg==,type:str] +APP_URL=ENC[AES256_GCM,data:0Cc7cFdS5esZGYQXPISblLRNuvIvBe1r,iv:nmjOnMgLcojCOElb7ZT97NZO4YAOWoQB2fxOzwm8Vgw=,tag:IqFinV59tu8kaTfR0Tj5DA==,type:str] +sops_age__list_0__map_enc=-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBOVmpOUHpjV1AvbDY0eVpZ\nV3htMzB6SU43eHRtNDlmUW92NjR0Yk5CWVdFCmxzRDNZQXFLY3hMVXQ4WkFwNWtD\nYmx0STY5QjgzeUlYL0xpcnlKRmk2UXMKLS0tIGpuQ29QaVdPSFM1bktBb3VnbHI0\naVBSeGViSWRFcHE5VHF3UVFoK2E4WGMKo0o66lKkVKBrw5QAvZRjplj4ySIdn021\nS494v7X+emlBKfDCD4XHnSfyktgitSu5KTDVaeViix+EeiXLfsDeAQ==\n-----END AGE ENCRYPTED FILE-----\n sops_age__list_0__map_recipient=age1wravpjmed26772xfjhawmnsnc4933htapg6y5xseqml0jdv8z9hqemzhcr -sops_age__list_1__map_enc=-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBjWEN5L1BtRloyWHNzZklN\nL05uazJLTWRGS0pXQVMvQU5kd1p6K0RDQVM0ClJBd0lsQ1N4bmp0OTMwRmpNQXAr\ndTd1ZEZqaFhoYzU2RzRYN1Y0aHNXSEkKLS0tIG1uMDZvRWF3VnN3ZXZwb1VwVGNm\nMmd6TmxxeUkxdi9CQkQ4Wm5qQVVya2MK9TRB9XCAtavdWC+rQnGd9MAfn4KenAQI\nludEN39eo296ZzAUE6WJ5/7y4U4CcpCyKBPILKdCjpeNRYQ6pmR8wg==\n-----END AGE ENCRYPTED FILE-----\n +sops_age__list_1__map_enc=-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBHZGI0NnpoY3VrelFrZjZw\nWStqK2VIU0NPMGs3Mk8ySU5uQS9saVFmQ0JFCnkvZjczWkQvKzdFdG5lTThyNTJr\nbGd0dzVsZlp5U0M4ZG92TUtteXFSR00KLS0tIGY0OGJteHBjN1A3MkV0aGpUS3M3\nRXZYbng1MVVITXJEQ0lpY1lJdllLa2MKHzX1gXYmdiMyid18xDUtdk8jej7DYG4W\nwbum7nH+/KmJbCS0NZDIpPkAUmDth1AZUUnFujMqtkLZ7YNChWmIAg==\n-----END AGE ENCRYPTED FILE-----\n sops_age__list_1__map_recipient=age1ysddqggsx3h8zkv7xn3z26sjak5pqms6pyqhnky9ukrvpk7es5jsayz8w7 -sops_age__list_2__map_enc=-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBCcCtkYTNvUm51TmV2YWZ1\nRitKQkw2RUJwdkltYlhvUWpNazI5TUJwcnpjCjNrTms3ZFVXUGhlaXBlamRETXBE\nN0Vud20wbU1FUXNmQVp5YnFVZncwalkKLS0tIDdPQk5KbUo4ZmRpa1AzWk9pRlFB\nNU8zN3lLRnNpQ3RMRnBXWGlOTTBWQzQKXtQDtLS0TNZiW46EsrW6tnbxXc773oMW\nH8BWDxRavEpiRyqkH1EKjEBzTA3xKfwaG3RT6d6tUY6g9PnuL0BkoA==\n-----END AGE ENCRYPTED FILE-----\n +sops_age__list_2__map_enc=-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBDSkl2eVJXV3FGMkZ2S1c4\nOGlneUdERTFCTkJpTE92WVBXWW5qR2RLTkRRCmFxTkVlMVJ3RDk3N0VQb2ZuV0Qy\nYmlvcDdNei9mdUQxcnhjdXgxYWtBem8KLS0tIFJ0WkRDM25nL04wNFpCOTVMZFpN\ncWNlTTdFNXEvQzE4b1VaY3IyUVIyTmcKtk/miVOnklKdwunwSK3teaU2zVQzFOjW\n0e5FEBfMfs4Th840y62f594v6z8/rRnYeH6vYEk4Tj0Rjr2ISIqXGQ==\n-----END AGE ENCRYPTED FILE-----\n sops_age__list_2__map_recipient=age1pgxk292zq30wafwg03gge7hu5dlu3h7yfldp2y8kqekfaljjky7s752uwy -sops_age__list_3__map_enc=-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBvbU9wVnlZUjBDQTVQT21i\ndU01TTEvUzlPTzZGMFVwc0M1bkkvNUU0YkNvCm91NHdGWTVvU1JaOWRmQVJ3cHBo\nMXdmc0syVncvZzNKMlBCbHJDdm5ZblEKLS0tIE5lOWJvMDhyM01VbU0wbGNEc2xI\nVkZrbER1LzhWQ2swS0ZTV1FiWGx3K0EKIoezkGxEUfWDjlkeAnKH20UxsDFQJ+1b\nzdcghmt6gnzIxR3FWSCFHHKAzfi3rL8QlkW3Ro5UBoAEEDWy7nvz2w==\n-----END AGE ENCRYPTED FILE-----\n +sops_age__list_3__map_enc=-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBQQUNGd0daLzFsYVFiMGhk\nY1dJMHZQcjBqTTVpM29XS1NnbmdEMnUzNUZvCnh0bDFIcWtWWGdvRGhOV0RTdkY2\nTkVPTENGeUdjb0d2TjRxVXVxd04wbVUKLS0tIGdWRGFZTU5tWU1nbEd5Zkl5anVr\naUhuWTRhRURCZDYySkczSUJaRFozUkkKzkr5HK5nmEsLcCLmSEILCAlE/773tboY\nS8ZuKoHds2NmWrZGDbodwVD7kYQ9l89rQa4WTjrgFwprOayp7e/P1g==\n-----END AGE ENCRYPTED FILE-----\n sops_age__list_3__map_recipient=age1qn0x93jhqjpqwvx5tgxnrwq5e3vuzur9whrkdnrvapd58esm45rqfkuxqh -sops_age__list_4__map_enc=-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBWU1drSWRUbEVYSVNUTzFy\nM1JJbSthM3VpU1Y0ejgzbjNUSEhYMENGRUNZCmowZE13TUtYclVmMS9VenBVQXp0\nd09hM0lsa01Cd0lzTDlCRDduOWd0aFEKLS0tIHI0M0xOSFhndW5XdUJJV3cwV09X\nWUx4T05INWRvU01OU3UwSUFxcC9nM00Keb2AeDtqamChWI3PAYxLDT3qZhmCf8Q9\nZi8Qe6LJ6a8C6wsFN+auPy7bIJdmVYcfoHFgsRVdyEXx9bcj1+hIGg==\n-----END AGE ENCRYPTED FILE-----\n +sops_age__list_4__map_enc=-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSAwbnFuZ2JKbFpZalRWeUQ0\nbkJKZk1IVkovOW5jLzdUSzNkRmVQeHpNeGs0ClcwcnZFWGpiQWpIT0N6bnRkQXlx\nWDVPUUJ6cDRyckNxL3J0UXAxVUdnL1kKLS0tIHFVN0pzVXVUUUREOW0ycndoUVV5\nWUJNblhoa2ZOU0VoTGFoZzZGRlZwVW8K30qpBsvJHwWeLiiNTb0Yn41mkr2GZo58\nlSn9w97P9+vsK98izcZhSEp5CSJDQEH67ltCcR1LUuGNc2ILRphX6g==\n-----END AGE ENCRYPTED FILE-----\n sops_age__list_4__map_recipient=age1h86dek80u5t677tsparz395uk3zvz4yuj9m5t2v2nsdfsvyjmafsra5yt7 -sops_age__list_5__map_enc=-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBSclNPdDJGeWlCSzJtQi92\nYVlaVFhGd2krM2J1VEVqVSt0REJwRnI2ZmlJClBzTCtSZFBvMWdBcU1IeXFzaThz\nMFRCazJmTFQ4WkNPRU5xU0VqOEJZbkUKLS0tIFF5eitRekJUSDBhZmhsZHpMdHpi\nK0N4L1FqazJOVUNYaUIxL0dMMm9kZjgKr3PaMpDWqnt0J3yMZxuD0hvCVDnSuX+G\nhSSLmO0gaXuiw2/m7ehzkTtcl8BlN6o8iVPfdbA09lOAl0U9vOPSaA==\n-----END AGE ENCRYPTED FILE-----\n +sops_age__list_5__map_enc=-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBYUVN1OFRMQlFuT05IbnVx\nZitFblpoenNEQ1lBSjUrbWhqVXN4TVU5SXhFClh1WnptaUNiQjVNMXdRVkVvS09m\nbkoxRkI4MHNGajRxNnpwZ1FCV3ZXb28KLS0tIHBnaTBXUUcyQlB5WWpBaDY0MVA5\na0Jjd2xpMDhOeG9EVzUxRUl5KythOUEKENX9NZo03ACFxom+yNFyf3Ywzvxc92w9\nwSodCz2+yJiTbFlInSj8bmNy2tsTI5KKfTEl2Qh58uTQXDTMCotWRg==\n-----END AGE ENCRYPTED FILE-----\n sops_age__list_5__map_recipient=age1vfhyk6wmt993dezz5wjf6n3ynkd6xptv2dr0qdl6kmttev9lh5dsfjfs3h -sops_lastmodified=2026-05-20T17:24:41Z -sops_mac=ENC[AES256_GCM,data:JUtXxPbs16EWlzLgiPArQMZWlIhixP9KQyBnHSW9vAbApSzop+MCAhVcBXrbBsozhn+EPej+br3r3zfAjR9gof+lSh1WBqSKkAw9sR1gMcfV8T4eOKVk7nCUZRuKVhoSHyDvAv6gJZF0LKhvwBc1TR0A4msZaecDDG5jNSaMsDw=,iv:RbmNhPgiPozA3WGpZAqk5xTMhuFtSOk2bsbollKT174=,tag:UTGqsTB8uMcMV7esNHQS3w==,type:str] +sops_lastmodified=2026-06-12T03:40:05Z +sops_mac=ENC[AES256_GCM,data:z6RsG4cEMF0LmGGRCYgrMTYn0plJMbSitgTqOKY5xumVOagyWpzH1RF8rTknRj/91QdecWmso0fISkHnDyFO/vPYixKY54eaYMPVTlLCrUH3Ywqyd00z5KxKIQqQWtQR/z/PFP+K65Wl4i/NmP2Mx5WCGIjszT2T+SwkfxZcjJQ=,iv:c16LMsP/cf09kzz9e6WFZ3PCA3DHlGfqPze0ojqMZew=,tag:5aR1cM6kOR8X0ebv2ebHXA==,type:str] sops_unencrypted_suffix=_unencrypted sops_version=3.11.0 diff --git a/public/logoLight.svg b/public/logoLight.svg new file mode 100644 index 0000000..6c64b24 --- /dev/null +++ b/public/logoLight.svg @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/src/api/collections.ts b/src/api/collections.ts index c0c2f92..8a61444 100644 --- a/src/api/collections.ts +++ b/src/api/collections.ts @@ -204,13 +204,18 @@ const app = new Hono() summary: 'Create a collection', request: { param: z.object({ owner: z.string() }), - json: z.object({ slug: z.string(), name: z.string(), public: z.boolean().optional() }), + json: z.object({ + slug: z.string(), + name: z.string().optional(), + public: z.boolean().optional(), + }), }, responses: { 200: z.any() }, }), async (c) => { const { owner } = c.req.valid('param') - const { slug, name, public: isPublic } = c.req.valid('json') + const { slug, name: rawName, public: isPublic } = c.req.valid('json') + const name = rawName || slug // Resolve owner org const [org] = await db diff --git a/src/components/BaseLayout.tsx b/src/components/BaseLayout.tsx index bb00602..b3004d7 100644 --- a/src/components/BaseLayout.tsx +++ b/src/components/BaseLayout.tsx @@ -1,5 +1,6 @@ import { Link } from 'react-router' +import CreateMenu from '~/components/CreateMenu' import UserMenu from '~/components/UserMenu' import { useAppContext } from '~/lib/app-context' @@ -12,7 +13,7 @@ export default function BaseLayout({ children }: { children: React.ReactNode })

    diff --git a/src/routes/new.tsx b/src/routes/new.tsx index 11877c1..57829a1 100644 --- a/src/routes/new.tsx +++ b/src/routes/new.tsx @@ -10,7 +10,7 @@ export default function NewCollection() { const ownerOptions = (currentUser?.orgs ?? []).map((o: any) => ({ slug: o.slug, - label: o.name ?? o.displayName ?? o.slug, + label: `${o.name ?? o.displayName ?? o.slug}${o.isDefault ? ' (personal)' : ''}`, })) const defaultOwner = currentUser?.orgs?.find((o: any) => o.isDefault) ?? currentUser?.orgs?.[0] From 2bf48bd7325e210aeeece0d065c7b5f48cc341fb Mon Sep 17 00:00:00 2001 From: Travis Rich Date: Fri, 12 Jun 2026 00:18:43 -0400 Subject: [PATCH 06/10] fix http again --- src/components/CollectionExplorer.tsx | 26 ++++++++++++++++++++------ src/lib/auth.ts | 5 ++++- 2 files changed, 24 insertions(+), 7 deletions(-) diff --git a/src/components/CollectionExplorer.tsx b/src/components/CollectionExplorer.tsx index e1d22f3..11152df 100644 --- a/src/components/CollectionExplorer.tsx +++ b/src/components/CollectionExplorer.tsx @@ -1,5 +1,5 @@ import { useEffect, useRef, useState } from 'react' -import { Link } from 'react-router' +import { Link, useSearchParams } from 'react-router' interface Collection { id: string @@ -60,10 +60,14 @@ function timeAgo(dateStr: string): string { type SortKey = 'featured' | 'updated' | 'name' | 'records' export default function CollectionExplorer() { - const [query, setQuery] = useState('') - const [selectedOwner, setSelectedOwner] = useState(null) - const [selectedTag, setSelectedTag] = useState(null) - const [sort, setSort] = useState('featured') + const [searchParams, setSearchParams] = useSearchParams() + const [query, setQuery] = useState(searchParams.get('q') ?? '') + const [selectedOwner, setSelectedOwner] = useState(searchParams.get('owner')) + const [selectedTag, setSelectedTag] = useState(searchParams.get('tag')) + const initSort = searchParams.get('sort') + const [sort, setSort] = useState( + initSort === 'updated' || initSort === 'name' || initSort === 'records' ? initSort : 'featured', + ) const [collections, setCollections] = useState([]) const [owners, setOwners] = useState([]) const [tagFacets, setTagFacets] = useState([]) @@ -74,6 +78,15 @@ export default function CollectionExplorer() { const isFiltered = !!(query || selectedOwner || selectedTag) + function syncUrl(q: string, owner: string | null, sortBy: SortKey, tag: string | null) { + const next = new URLSearchParams() + if (q) next.set('q', q) + if (owner) next.set('owner', owner) + if (tag) next.set('tag', tag) + if (sortBy !== 'featured') next.set('sort', sortBy) + setSearchParams(next, { replace: true }) + } + async function load( q = '', owner: string | null = null, @@ -81,6 +94,7 @@ export default function CollectionExplorer() { tag: string | null = selectedTag, ) { setLoading(true) + syncUrl(q, owner, sortBy, tag) const params = new URLSearchParams() if (q) params.set('q', q) if (owner) params.set('owner', owner) @@ -103,7 +117,7 @@ export default function CollectionExplorer() { } useEffect(() => { - load() + load(query, selectedOwner, sort, selectedTag) }, []) function handleInput(value: string) { diff --git a/src/lib/auth.ts b/src/lib/auth.ts index 725796f..7841943 100644 --- a/src/lib/auth.ts +++ b/src/lib/auth.ts @@ -14,11 +14,14 @@ if (process.env.NODE_ENV === 'production' && !process.env.SESSION_SECRET) { throw new Error('SESSION_SECRET must be set in production') } +const APP_URL = process.env.APP_URL ?? 'http://localhost:4100' + export const auth = betterAuth({ database: drizzleAdapter(db, { provider: 'pg' }), - baseURL: process.env.APP_URL ?? 'http://localhost:4100', + baseURL: APP_URL, basePath: '/api/auth', secret: process.env.SESSION_SECRET ?? 'dev-secret-change-me', + trustedOrigins: [APP_URL, new URL(APP_URL).origin.replace('https://', 'http://')], advanced: { database: { From f015467cf95a38eec7be8365c5fe1c282ca2e792 Mon Sep 17 00:00:00 2001 From: Travis Rich Date: Fri, 12 Jun 2026 00:36:10 -0400 Subject: [PATCH 07/10] login debug 2 --- README.md | 11 ++- server.ts | 45 +++++++-- src/components/CreateMenu.tsx | 2 +- src/routes/dashboard.tsx | 130 +++---------------------- src/routes/new-org.tsx | 176 ++++++++++++++++++++++++++++++++++ 5 files changed, 231 insertions(+), 133 deletions(-) create mode 100644 src/routes/new-org.tsx diff --git a/README.md b/README.md index 68998cb..47de1d6 100644 --- a/README.md +++ b/README.md @@ -207,11 +207,11 @@ tools/ The protocol and the platform are documented together: -| Resource | URL | Purpose | -| ------------- | -------------------------------------------------------------- | ------------------------------------------------------------------- | -| Protocol spec | [/protocol](https://underlay.org/protocol) | Full protocol: data model, hashing, push, pull, provenance, privacy | -| User docs | [/docs](https://underlay.org/docs) | Concepts, integration guide, API reference, quickstart | -| llms.txt | [/llms.txt](https://underlay.org/llms.txt) | Machine-readable API docs for LLMs and bots | +| Resource | URL | Purpose | +| ------------- | ------------------------------------------ | ------------------------------------------------------------------- | +| Protocol spec | [/protocol](https://underlay.org/protocol) | Full protocol: data model, hashing, push, pull, provenance, privacy | +| User docs | [/docs](https://underlay.org/docs) | Concepts, integration guide, API reference, quickstart | +| llms.txt | [/llms.txt](https://underlay.org/llms.txt) | Machine-readable API docs for LLMs and bots | ### Key API endpoints @@ -342,6 +342,7 @@ DOMAIN=https://my-instance.com docker compose -f docker-compose.withauth.yml up This starts Postgres, KF Auth (auth + account), MinIO (S3-compatible storage), the Underlay app, and Caddy with automatic TLS. On first boot, an init container auto-generates all secrets (session keys, OAuth client credentials, S3 credentials). Optional configuration (via environment variables or `.env` file): + - `SMTP_*` vars for email delivery (password resets, invitations) - `GITHUB_CLIENT_ID`/`GITHUB_CLIENT_SECRET` for GitHub login - `GOOGLE_CLIENT_ID`/`GOOGLE_CLIENT_SECRET` for Google login diff --git a/server.ts b/server.ts index a035c35..cf25029 100644 --- a/server.ts +++ b/server.ts @@ -157,7 +157,29 @@ if (!isProd) { // --- Better-auth handler (OIDC login, sessions, API keys) --- app.on(['GET', 'POST'], '/api/auth/*', async (c) => { - return auth.handler(c.req.raw) + const url = new URL(c.req.url) + const isCallback = url.pathname.includes('/callback/') + if (isCallback) { + console.log('[auth callback] incoming:', { + method: c.req.method, + path: url.pathname, + hasCode: url.searchParams.has('code'), + hasState: url.searchParams.has('state'), + hasError: url.searchParams.has('error'), + error: url.searchParams.get('error'), + rawUrl: c.req.url, + cookieHeader: c.req.header('cookie')?.substring(0, 200), + }) + } + const res = await auth.handler(c.req.raw) + if (isCallback) { + console.log('[auth callback] response:', { + status: res.status, + location: res.headers.get('location'), + setCookies: res.headers.getSetCookie?.()?.map((s: string) => s.substring(0, 80)), + }) + } + return res }) // /login redirect — fall through to React route only when there's an error to display @@ -166,14 +188,17 @@ app.get('/login', async (c, next) => { const appOrigin = new URL(process.env.APP_URL ?? 'http://localhost:4100').origin if (!url.searchParams.has('error')) { const signInUrl = new URL('/api/auth/sign-in/oauth2', appOrigin) + const headers = new Headers({ + 'Content-Type': 'application/json', + Cookie: c.req.header('cookie') ?? '', + Origin: appOrigin, + }) + const xff = c.req.header('x-forwarded-for') + if (xff) headers.set('X-Forwarded-For', xff) const authRes = await auth.handler( new Request(signInUrl, { method: 'POST', - headers: new Headers({ - 'Content-Type': 'application/json', - Cookie: c.req.header('cookie') ?? '', - Origin: appOrigin, - }), + headers, body: JSON.stringify({ providerId: 'kf-auth', callbackURL: '/dashboard' }), }), ) @@ -181,9 +206,13 @@ app.get('/login', async (c, next) => { console.log('[login] auth sign-in response:', { status: authRes.status, body }) if (body.url) { const redirect = new Response(null, { status: 302, headers: { Location: body.url } }) - for (const [key, value] of authRes.headers.entries()) { - if (key.toLowerCase() === 'set-cookie') redirect.headers.append(key, value) + for (const cookie of authRes.headers.getSetCookie()) { + redirect.headers.append('set-cookie', cookie) } + console.log( + '[login] forwarding cookies:', + authRes.headers.getSetCookie().map((s: string) => s.substring(0, 80)), + ) return redirect } } diff --git a/src/components/CreateMenu.tsx b/src/components/CreateMenu.tsx index bc53434..20d4577 100644 --- a/src/components/CreateMenu.tsx +++ b/src/components/CreateMenu.tsx @@ -33,7 +33,7 @@ export default function CreateMenu() { New collection setOpen(false)} className="text-ink hover:bg-parchment-dark block px-3 py-2 text-sm transition-colors" > diff --git a/src/routes/dashboard.tsx b/src/routes/dashboard.tsx index e9fc3ab..f7ffb47 100644 --- a/src/routes/dashboard.tsx +++ b/src/routes/dashboard.tsx @@ -1,9 +1,8 @@ -import { type FormEvent, useEffect, useState } from 'react' -import { Link, useSearchParams } from 'react-router' +import { useEffect, useState } from 'react' +import { Link } from 'react-router' import BaseLayout from '~/components/BaseLayout' import { useAppContext } from '~/lib/app-context' -import { authClient } from '~/lib/auth-client' interface Collection { id: string @@ -25,12 +24,6 @@ interface Org { collections: Collection[] } -interface KfOrg { - id: string - name: string - slug: string -} - function timeAgo(dateStr: string): string { const seconds = Math.floor((Date.now() - new Date(dateStr).getTime()) / 1000) if (seconds < 60) return 'just now' @@ -45,102 +38,10 @@ function timeAgo(dateStr: string): string { return `${Math.floor(months / 12)}y ago` } -function NewOrgForm({ availableKfOrgs, onDone }: { availableKfOrgs: KfOrg[]; onDone: () => void }) { - const [slug, setSlug] = useState('') - const [displayName, setDisplayName] = useState('') - const [kfOrgId, setKfOrgId] = useState(availableKfOrgs.length === 1 ? availableKfOrgs[0]!.id : '') - const [error, setError] = useState('') - const [submitting, setSubmitting] = useState(false) - - async function handleSubmit(e: FormEvent) { - e.preventDefault() - setError('') - setSubmitting(true) - try { - const { data, error: err } = await authClient.organization.create({ - name: displayName, - slug, - kfOrgId: kfOrgId || undefined, - } as any) - if (err) { - setError(err.message ?? 'Failed to create organization') - } else if (data) { - window.location.reload() - } - } finally { - setSubmitting(false) - } - } - - return ( -
    - {error &&

    {error}

    } - {availableKfOrgs.length > 1 && ( - - )} -
    - setSlug(e.target.value)} - className="border-rule bg-parchment focus:border-ink min-w-0 flex-1 rounded border px-2 py-1.5 text-xs focus:outline-none" - /> - setDisplayName(e.target.value)} - className="border-rule bg-parchment focus:border-ink min-w-0 flex-1 rounded border px-2 py-1.5 text-xs focus:outline-none" - /> -
    -
    - - -
    -
    - ) -} - export default function Dashboard() { const { currentUser } = useAppContext() const [orgs, setOrgs] = useState([]) const [filter, setFilter] = useState('') - const [searchParams] = useSearchParams() - const [availableKfOrgs, setAvailableKfOrgs] = useState([]) - const [showNewOrg, setShowNewOrg] = useState(searchParams.get('newOrg') === '1') useEffect(() => { if (!currentUser) return @@ -161,10 +62,6 @@ export default function Dashboard() { }), ).then(setOrgs) } - - fetch('/api/accounts/available-kf-orgs', { credentials: 'include' }) - .then((r) => (r.ok ? r.json() : [])) - .then(setAvailableKfOrgs) }, [currentUser]) const allCollections = orgs @@ -198,15 +95,12 @@ export default function Dashboard() {

    Organizations

    - {!showNewOrg && availableKfOrgs.length > 0 && ( - - )} + + + New +
    {orgs.map((org) => ( @@ -227,11 +121,6 @@ export default function Dashboard() { {org.collections.length} ))} - {showNewOrg && availableKfOrgs.length > 0 && ( -
    - setShowNewOrg(false)} /> -
    - )}

    @@ -241,6 +130,9 @@ export default function Dashboard() { New collection + + New organization + API keys diff --git a/src/routes/new-org.tsx b/src/routes/new-org.tsx new file mode 100644 index 0000000..24bf945 --- /dev/null +++ b/src/routes/new-org.tsx @@ -0,0 +1,176 @@ +import { type FormEvent, useEffect, useState } from 'react' +import { useNavigate } from 'react-router' + +import BaseLayout from '~/components/BaseLayout' +import { useAppContext } from '~/lib/app-context' +import { authClient } from '~/lib/auth-client' + +interface KfOrg { + id: string + name: string + slug: string +} + +function slugify(value: string) { + return value + .toLowerCase() + .replace(/\s+/g, '-') + .replace(/[^a-z0-9-]/g, '') + .replace(/-{2,}/g, '-') +} + +export default function NewOrg() { + const { currentUser } = useAppContext() + const navigate = useNavigate() + + const [availableKfOrgs, setAvailableKfOrgs] = useState([]) + const [kfOrgId, setKfOrgId] = useState('') + const [slug, setSlug] = useState('') + const [displayName, setDisplayName] = useState('') + const [error, setError] = useState('') + const [submitting, setSubmitting] = useState(false) + const [loaded, setLoaded] = useState(false) + + useEffect(() => { + fetch('/api/accounts/available-kf-orgs', { credentials: 'include' }) + .then((r) => (r.ok ? r.json() : [])) + .then((orgs) => { + setAvailableKfOrgs(orgs) + if (orgs.length === 1) setKfOrgId(orgs[0].id) + setLoaded(true) + }) + }, []) + + async function handleSubmit(e: FormEvent) { + e.preventDefault() + setError('') + setSubmitting(true) + try { + const { data, error: err } = await authClient.organization.create({ + name: displayName, + slug, + kfOrgId: kfOrgId || undefined, + } as any) + if (err) { + setError(err.message ?? 'Failed to create organization') + } else if (data) { + navigate(`/${slug}`) + } + } finally { + setSubmitting(false) + } + } + + if (!currentUser) { + window.location.href = '/login' + return null + } + + return ( + +
    +

    Create a new organization

    +

    + Organizations let you publish collections and manage members under a shared account. +

    + + {!loaded ? ( +

    Loading...

    + ) : availableKfOrgs.length === 0 ? ( +
    +

    + No KF organizations are available to link. Each Underlay organization must be linked + to a Knowledge Futures organization. +

    +
    + ) : ( +
    + {error && ( +
    + {error} +
    + )} + + {/* KF Org */} +
    + + {availableKfOrgs.length > 1 ? ( + + ) : ( +
    + {availableKfOrgs[0]?.name} +
    + )} +

    + Each Underlay organization is linked to a Knowledge Futures organization. +

    +
    + + {/* Display Name */} +
    + + setDisplayName(e.target.value)} + className="bg-parchment border-rule focus:border-ink w-full rounded border px-3 py-2 text-sm focus:outline-none" + /> +
    + + {/* Slug */} +
    + + setSlug(slugify(e.target.value))} + className="bg-parchment border-rule focus:border-ink w-full rounded border px-3 py-2 text-sm focus:outline-none" + /> +

    + Lowercase letters, numbers, and hyphens. This becomes the URL:{' '} + underlay.org/{slug || '...'} +

    +
    + +
    + + +
    + )} +
    +
    + ) +} From c8923f42077fe9f5bbdf63463c75893bc24cec42 Mon Sep 17 00:00:00 2001 From: Travis Rich Date: Fri, 12 Jun 2026 00:49:42 -0400 Subject: [PATCH 08/10] Handle ssr fetches --- src/App.tsx | 3 +- src/api/accounts.ts | 20 ++++- src/components/CollectionExplorer.tsx | 19 +++-- src/lib/auth-middleware.ts | 4 +- src/lib/auth.ts | 2 +- src/lib/fetch-base.ts | 9 ++ src/routes/[owner]/[collection]/diff.data.ts | 4 +- src/routes/[owner]/[collection]/index.data.ts | 4 +- .../[owner]/[collection]/schemas.data.ts | 4 +- .../[owner]/[collection]/settings.data.ts | 4 +- src/routes/[owner]/[collection]/v/[n].data.ts | 4 +- .../[owner]/[collection]/versions.data.ts | 4 +- src/routes/[owner]/index.data.ts | 4 +- src/routes/[owner]/settings/index.data.ts | 4 +- src/routes/[owner]/settings/keys.data.ts | 4 +- src/routes/[owner]/settings/members.data.ts | 4 +- src/routes/blog/[slug].data.ts | 4 +- src/routes/index.data.ts | 4 +- src/routes/new-org.tsx | 85 ++++++++----------- src/routes/protocol.data.ts | 4 +- src/routes/records/[hash].data.ts | 4 +- src/routes/schemas/[id].data.ts | 4 +- 22 files changed, 123 insertions(+), 79 deletions(-) create mode 100644 src/lib/fetch-base.ts diff --git a/src/App.tsx b/src/App.tsx index 8c21d97..edfd22b 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,6 +1,7 @@ import type { LoaderFunctionArgs, RouteObject } from 'react-router' import Root from '~/components/Root' +import { fetchBase } from '~/lib/fetch-base' import { buildDataRoutes } from '~/route-gen' const components = import.meta.glob<{ default: React.ComponentType }>('./routes/**/[!_]*.tsx') @@ -11,7 +12,7 @@ const dataModules = import.meta.glob<{ }>('./routes/**/*.data.ts', { eager: true }) async function rootLoader({ request }: LoaderFunctionArgs) { - const res = await fetch(new URL('/api/context', request.url), { + const res = await fetch(`${fetchBase(request.url)}/api/context`, { headers: { Cookie: request.headers.get('Cookie') ?? '' }, }) if (!res.ok) { diff --git a/src/api/accounts.ts b/src/api/accounts.ts index 1d8adf4..9cfe30d 100644 --- a/src/api/accounts.ts +++ b/src/api/accounts.ts @@ -130,10 +130,24 @@ const app = new Hono() .where(and(eq(schema.account.userId, userId), eq(schema.account.providerId, 'kf-auth'))) .limit(1) - if (!acct) return c.json([]) + if (!acct) { + console.warn(`[available-kf-orgs] No kf-auth account row for userId=${userId}`) + return c.json([]) + } + + const { fetchAuthOrgs, hasInternalApi } = await import('../lib/auth-internal.server.js') + if (!hasInternalApi) { + console.warn( + '[available-kf-orgs] Internal API not configured (AUTH_INTERNAL_API_KEY missing)', + ) + return c.json([]) + } - const { fetchAuthOrgs } = await import('../lib/auth-internal.server.js') - return c.json(await fetchAuthOrgs(acct.accountId)) + const orgs = await fetchAuthOrgs(acct.accountId) + console.log( + `[available-kf-orgs] userId=${userId} accountId=${acct.accountId} → ${orgs.length} orgs`, + ) + return c.json(orgs) }, ) .get( diff --git a/src/components/CollectionExplorer.tsx b/src/components/CollectionExplorer.tsx index 11152df..0de2fee 100644 --- a/src/components/CollectionExplorer.tsx +++ b/src/components/CollectionExplorer.tsx @@ -61,13 +61,16 @@ type SortKey = 'featured' | 'updated' | 'name' | 'records' export default function CollectionExplorer() { const [searchParams, setSearchParams] = useSearchParams() - const [query, setQuery] = useState(searchParams.get('q') ?? '') - const [selectedOwner, setSelectedOwner] = useState(searchParams.get('owner')) - const [selectedTag, setSelectedTag] = useState(searchParams.get('tag')) + const initQuery = searchParams.get('q') ?? '' + const initOwner = searchParams.get('owner') + const initTag = searchParams.get('tag') const initSort = searchParams.get('sort') - const [sort, setSort] = useState( - initSort === 'updated' || initSort === 'name' || initSort === 'records' ? initSort : 'featured', - ) + const initSortKey: SortKey = + initSort === 'updated' || initSort === 'name' || initSort === 'records' ? initSort : 'featured' + const [query, setQuery] = useState(initQuery) + const [selectedOwner, setSelectedOwner] = useState(initOwner) + const [selectedTag, setSelectedTag] = useState(initTag) + const [sort, setSort] = useState(initSortKey) const [collections, setCollections] = useState([]) const [owners, setOwners] = useState([]) const [tagFacets, setTagFacets] = useState([]) @@ -117,7 +120,7 @@ export default function CollectionExplorer() { } useEffect(() => { - load(query, selectedOwner, sort, selectedTag) + load(initQuery, initOwner, initSortKey, initTag) }, []) function handleInput(value: string) { @@ -142,8 +145,6 @@ export default function CollectionExplorer() { load(query, selectedOwner, s, selectedTag) } - const totalCount = owners.reduce((sum, o) => sum + o.count, 0) - const visibleTags = featuredTags.length > 0 ? featuredTags.filter((t) => tagFacets.some((f) => f.name === t)) diff --git a/src/lib/auth-middleware.ts b/src/lib/auth-middleware.ts index 6e629ec..4e8d9fd 100644 --- a/src/lib/auth-middleware.ts +++ b/src/lib/auth-middleware.ts @@ -1,7 +1,9 @@ import { redirect, type MiddlewareFunction } from 'react-router' +import { fetchBase } from '~/lib/fetch-base' + export const requireAuth: MiddlewareFunction = async ({ request }, next) => { - const res = await fetch(new URL('/api/context', request.url), { + const res = await fetch(`${fetchBase(request.url)}/api/context`, { headers: { Cookie: request.headers.get('Cookie') ?? '' }, }) const { currentUser } = await res.json() diff --git a/src/lib/auth.ts b/src/lib/auth.ts index 7841943..80a44dd 100644 --- a/src/lib/auth.ts +++ b/src/lib/auth.ts @@ -57,7 +57,7 @@ export const auth = betterAuth({ website: { type: 'string', required: false, input: true }, avatarUrl: { type: 'string', required: false, input: true }, arkNaan: { type: 'string', required: false, input: true }, - kfOrgId: { type: 'string', required: false, input: true }, + kfOrgId: { type: 'string', required: true, input: true }, isDefault: { type: 'boolean', required: false, input: true, defaultValue: false }, }, }, diff --git a/src/lib/fetch-base.ts b/src/lib/fetch-base.ts new file mode 100644 index 0000000..40349c7 --- /dev/null +++ b/src/lib/fetch-base.ts @@ -0,0 +1,9 @@ +// During SSR, fetches to the app's own API must go through localhost to avoid +// TLS/DNS issues behind reverse proxies (Caddy tls internal, Docker networking). +// On the client, use the page origin so the browser handles cookies and TLS normally. +export function fetchBase(requestUrl: string): string { + if (import.meta.env.SSR) { + return `http://127.0.0.1:${process.env.PORT || 3000}` + } + return new URL(requestUrl).origin +} diff --git a/src/routes/[owner]/[collection]/diff.data.ts b/src/routes/[owner]/[collection]/diff.data.ts index 11bd6fd..00022b8 100644 --- a/src/routes/[owner]/[collection]/diff.data.ts +++ b/src/routes/[owner]/[collection]/diff.data.ts @@ -1,12 +1,14 @@ import type { LoaderFunctionArgs } from 'react-router' +import { fetchBase } from '~/lib/fetch-base' + export const handle = { title: (params: Record) => `Diff — ${params.owner}/${params.collection} · Underlay`, } export async function loader({ params, request }: LoaderFunctionArgs) { - const base = new URL(request.url).origin + const base = fetchBase(request.url) const headers = { Cookie: request.headers.get('Cookie') ?? '' } const prefix = `/api/collections/${params.owner}/${params.collection}` diff --git a/src/routes/[owner]/[collection]/index.data.ts b/src/routes/[owner]/[collection]/index.data.ts index 147eae4..c770b31 100644 --- a/src/routes/[owner]/[collection]/index.data.ts +++ b/src/routes/[owner]/[collection]/index.data.ts @@ -1,12 +1,14 @@ import type { LoaderFunctionArgs } from 'react-router' +import { fetchBase } from '~/lib/fetch-base' + export const handle = { title: (params: Record) => `${params.owner}/${params.collection} · Underlay`, } export async function loader({ params, request }: LoaderFunctionArgs) { const res = await fetch( - new URL(`/api/collections/${params.owner}/${params.collection}`, request.url), + new URL(`/api/collections/${params.owner}/${params.collection}`, fetchBase(request.url)), { headers: { Cookie: request.headers.get('Cookie') ?? '' } }, ) if (!res.ok) throw new Response('Not Found', { status: 404 }) diff --git a/src/routes/[owner]/[collection]/schemas.data.ts b/src/routes/[owner]/[collection]/schemas.data.ts index ce275df..2fd5b1a 100644 --- a/src/routes/[owner]/[collection]/schemas.data.ts +++ b/src/routes/[owner]/[collection]/schemas.data.ts @@ -1,12 +1,14 @@ import type { LoaderFunctionArgs } from 'react-router' +import { fetchBase } from '~/lib/fetch-base' + export const handle = { title: (params: Record) => `Schemas — ${params.owner}/${params.collection} · Underlay`, } export async function loader({ params, request }: LoaderFunctionArgs) { - const base = new URL(request.url).origin + const base = fetchBase(request.url) const headers = { Cookie: request.headers.get('Cookie') ?? '' } const prefix = `/api/collections/${params.owner}/${params.collection}` diff --git a/src/routes/[owner]/[collection]/settings.data.ts b/src/routes/[owner]/[collection]/settings.data.ts index 42d17b0..8b47da8 100644 --- a/src/routes/[owner]/[collection]/settings.data.ts +++ b/src/routes/[owner]/[collection]/settings.data.ts @@ -1,12 +1,14 @@ import type { LoaderFunctionArgs } from 'react-router' +import { fetchBase } from '~/lib/fetch-base' + export const handle = { title: (params: Record) => `Settings — ${params.owner}/${params.collection} · Underlay`, } export async function loader({ params, request }: LoaderFunctionArgs) { - const base = new URL(request.url).origin + const base = fetchBase(request.url) const headers = { Cookie: request.headers.get('Cookie') ?? '' } const prefix = `/api/collections/${params.owner}/${params.collection}` diff --git a/src/routes/[owner]/[collection]/v/[n].data.ts b/src/routes/[owner]/[collection]/v/[n].data.ts index c2e65d6..2dd2ca2 100644 --- a/src/routes/[owner]/[collection]/v/[n].data.ts +++ b/src/routes/[owner]/[collection]/v/[n].data.ts @@ -1,12 +1,14 @@ import type { LoaderFunctionArgs } from 'react-router' +import { fetchBase } from '~/lib/fetch-base' + export const handle = { title: (params: Record) => `Version ${params.n} — ${params.owner}/${params.collection} · Underlay`, } export async function loader({ params, request }: LoaderFunctionArgs) { - const base = new URL(request.url).origin + const base = fetchBase(request.url) const headers = { Cookie: request.headers.get('Cookie') ?? '' } const prefix = `/api/collections/${params.owner}/${params.collection}` diff --git a/src/routes/[owner]/[collection]/versions.data.ts b/src/routes/[owner]/[collection]/versions.data.ts index 0c0501c..251fdeb 100644 --- a/src/routes/[owner]/[collection]/versions.data.ts +++ b/src/routes/[owner]/[collection]/versions.data.ts @@ -1,12 +1,14 @@ import type { LoaderFunctionArgs } from 'react-router' +import { fetchBase } from '~/lib/fetch-base' + export const handle = { title: (params: Record) => `Versions — ${params.owner}/${params.collection} · Underlay`, } export async function loader({ params, request }: LoaderFunctionArgs) { - const base = new URL(request.url).origin + const base = fetchBase(request.url) const headers = { Cookie: request.headers.get('Cookie') ?? '' } const prefix = `/api/collections/${params.owner}/${params.collection}` diff --git a/src/routes/[owner]/index.data.ts b/src/routes/[owner]/index.data.ts index ae95727..aa7bf14 100644 --- a/src/routes/[owner]/index.data.ts +++ b/src/routes/[owner]/index.data.ts @@ -1,11 +1,13 @@ import type { LoaderFunctionArgs } from 'react-router' +import { fetchBase } from '~/lib/fetch-base' + export const handle = { title: (params: Record) => `${params.owner} · Underlay`, } export async function loader({ params, request }: LoaderFunctionArgs) { - const base = new URL(request.url).origin + const base = fetchBase(request.url) const headers = { Cookie: request.headers.get('Cookie') ?? '' } const [account, collections, members] = await Promise.all([ diff --git a/src/routes/[owner]/settings/index.data.ts b/src/routes/[owner]/settings/index.data.ts index 802509e..907687d 100644 --- a/src/routes/[owner]/settings/index.data.ts +++ b/src/routes/[owner]/settings/index.data.ts @@ -1,11 +1,13 @@ import type { LoaderFunctionArgs } from 'react-router' +import { fetchBase } from '~/lib/fetch-base' + export const handle = { title: (params: Record) => `Settings — ${params.owner} · Underlay`, } export async function loader({ params, request }: LoaderFunctionArgs) { - const base = new URL(request.url).origin + const base = fetchBase(request.url) const headers = { Cookie: request.headers.get('Cookie') ?? '' } const [orgData, kfOrgs] = await Promise.all([ diff --git a/src/routes/[owner]/settings/keys.data.ts b/src/routes/[owner]/settings/keys.data.ts index c47f9f0..738facb 100644 --- a/src/routes/[owner]/settings/keys.data.ts +++ b/src/routes/[owner]/settings/keys.data.ts @@ -1,11 +1,13 @@ import type { LoaderFunctionArgs } from 'react-router' +import { fetchBase } from '~/lib/fetch-base' + export const handle = { title: (params: Record) => `API Keys — ${params.owner} · Underlay`, } export async function loader({ params, request }: LoaderFunctionArgs) { - const base = new URL(request.url).origin + const base = fetchBase(request.url) const headers = { Cookie: request.headers.get('Cookie') ?? '' } const [orgData, collections] = await Promise.all([ diff --git a/src/routes/[owner]/settings/members.data.ts b/src/routes/[owner]/settings/members.data.ts index 18693fd..e54bd7a 100644 --- a/src/routes/[owner]/settings/members.data.ts +++ b/src/routes/[owner]/settings/members.data.ts @@ -1,11 +1,13 @@ import type { LoaderFunctionArgs } from 'react-router' +import { fetchBase } from '~/lib/fetch-base' + export const handle = { title: (params: Record) => `Members — ${params.owner} · Underlay`, } export async function loader({ params, request }: LoaderFunctionArgs) { - const base = new URL(request.url).origin + const base = fetchBase(request.url) const headers = { Cookie: request.headers.get('Cookie') ?? '' } const res = await fetch(new URL(`/api/accounts/${params.owner}`, base), { headers }) diff --git a/src/routes/blog/[slug].data.ts b/src/routes/blog/[slug].data.ts index c36c154..ba3a773 100644 --- a/src/routes/blog/[slug].data.ts +++ b/src/routes/blog/[slug].data.ts @@ -1,5 +1,7 @@ import type { LoaderFunctionArgs } from 'react-router' +import { fetchBase } from '~/lib/fetch-base' + export const posts: Record = { '2024-04-27-underlay-revived': { title: 'Underlay, Revived', @@ -42,7 +44,7 @@ export const handle = { } export async function loader({ params, request }: LoaderFunctionArgs) { - const base = new URL(request.url).origin + const base = fetchBase(request.url) const res = await fetch(new URL(`/api/blog/${params.slug}`, base)) return { content: res.ok ? await res.text() : '' } } diff --git a/src/routes/index.data.ts b/src/routes/index.data.ts index dc762cd..11991bf 100644 --- a/src/routes/index.data.ts +++ b/src/routes/index.data.ts @@ -1,9 +1,11 @@ import type { LoaderFunctionArgs } from 'react-router' +import { fetchBase } from '~/lib/fetch-base' + export const handle = { title: 'Underlay' } export async function loader({ request }: LoaderFunctionArgs) { - const base = new URL(request.url).origin + const base = fetchBase(request.url) const res = await fetch(new URL('/api/collections?sort=featured&take=6', base), { headers: { Cookie: request.headers.get('Cookie') ?? '' }, }) diff --git a/src/routes/new-org.tsx b/src/routes/new-org.tsx index 24bf945..ed7e15a 100644 --- a/src/routes/new-org.tsx +++ b/src/routes/new-org.tsx @@ -5,7 +5,7 @@ import BaseLayout from '~/components/BaseLayout' import { useAppContext } from '~/lib/app-context' import { authClient } from '~/lib/auth-client' -interface KfOrg { +interface KfAccount { id: string name: string slug: string @@ -23,7 +23,7 @@ export default function NewOrg() { const { currentUser } = useAppContext() const navigate = useNavigate() - const [availableKfOrgs, setAvailableKfOrgs] = useState([]) + const [kfAccounts, setKfAccounts] = useState([]) const [kfOrgId, setKfOrgId] = useState('') const [slug, setSlug] = useState('') const [displayName, setDisplayName] = useState('') @@ -34,9 +34,9 @@ export default function NewOrg() { useEffect(() => { fetch('/api/accounts/available-kf-orgs', { credentials: 'include' }) .then((r) => (r.ok ? r.json() : [])) - .then((orgs) => { - setAvailableKfOrgs(orgs) - if (orgs.length === 1) setKfOrgId(orgs[0].id) + .then((accounts: KfAccount[]) => { + setKfAccounts(accounts) + if (accounts.length === 1 && accounts[0]) setKfOrgId(accounts[0].id) setLoaded(true) }) }, []) @@ -76,13 +76,6 @@ export default function NewOrg() { {!loaded ? (

    Loading...

    - ) : availableKfOrgs.length === 0 ? ( -
    -

    - No KF organizations are available to link. Each Underlay organization must be linked - to a Knowledge Futures organization. -

    -
    ) : (
    {error && ( @@ -91,40 +84,6 @@ export default function NewOrg() {

    )} - {/* KF Org */} -
    - - {availableKfOrgs.length > 1 ? ( - - ) : ( -
    - {availableKfOrgs[0]?.name} -
    - )} -

    - Each Underlay organization is linked to a Knowledge Futures organization. -

    -
    - {/* Display Name */}
    @@ -157,13 +116,41 @@ export default function NewOrg() {

    + {/* KF Account — only shown when user has multiple */} + {kfAccounts.length > 1 && ( +
    + + +

    + Choose which Knowledge Futures account this organization belongs to. +

    +
    + )} +