+ {/* Featured collections hero */}
+ {featuredCollections.length > 0 && !isFiltered && (
+
+ )}
+
{/* Mobile owner filter */}
{owners.length > 0 && (
@@ -209,8 +299,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 +308,7 @@ export default function CollectionExplorer() {
<>
{collections.length} collection{collections.length !== 1 ? 's' : ''}
+ {selectedTag && ` in ${selectedTag}`}
{selectedOwner && ` from ${selectedOwner}`}
{query && ` matching "${query}"`}
@@ -232,6 +323,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/CreateMenu.tsx b/src/components/CreateMenu.tsx
new file mode 100644
index 0000000..20d4577
--- /dev/null
+++ b/src/components/CreateMenu.tsx
@@ -0,0 +1,46 @@
+import { useEffect, useRef, useState } from 'react'
+import { Link } from 'react-router'
+
+export default function CreateMenu() {
+ const [open, setOpen] = useState(false)
+ const ref = useRef(null)
+
+ useEffect(() => {
+ if (!open) return
+ function handleClick(e: MouseEvent) {
+ if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false)
+ }
+ document.addEventListener('mousedown', handleClick)
+ return () => document.removeEventListener('mousedown', handleClick)
+ }, [open])
+
+ return (
+
+
+ {open && (
+
+ setOpen(false)}
+ className="text-ink hover:bg-parchment-dark block px-3 py-2 text-sm transition-colors"
+ >
+ New collection
+
+ setOpen(false)}
+ className="text-ink hover:bg-parchment-dark block px-3 py-2 text-sm transition-colors"
+ >
+ New organization
+
+
+ )}
+
+ )
+}
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}
+
+
+
+
+ ))}
+
+
+
+
+ {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/components/UserMenu.tsx b/src/components/UserMenu.tsx
index 4323cb0..c45bb11 100644
--- a/src/components/UserMenu.tsx
+++ b/src/components/UserMenu.tsx
@@ -4,11 +4,13 @@ import { Link } from 'react-router'
interface Org {
slug: string
displayName: string
+ isDefault?: boolean
}
interface UserMenuProps {
slug: string
displayName?: string | null
+ avatarUrl?: string | null
orgs?: Org[]
isSteward?: boolean
}
@@ -16,21 +18,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,33 +35,35 @@ export default function UserMenu({
return () => document.removeEventListener('click', handleClickOutside)
}, [])
+ const initial = (displayName || slug || '?').charAt(0).toUpperCase()
+
return (
-
+
{open && (
-
- Your Profile
-
setOpen(false)}
>
Dashboard
setOpen(false)}
>
Settings
@@ -76,6 +71,7 @@ export default function UserMenu({
setOpen(false)}
>
Admin
@@ -91,8 +87,12 @@ 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}
+ {org.isDefault && (
+
(personal)
+ )}
))}
>
@@ -101,6 +101,7 @@ export default function UserMenu({
setOpen(false)}
>
Sign out
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/lib/auth-internal.server.ts b/src/lib/auth-internal.server.ts
index 4003985..3c843a8 100644
--- a/src/lib/auth-internal.server.ts
+++ b/src/lib/auth-internal.server.ts
@@ -119,4 +119,67 @@ export async function getAuthUserWithEmail(
}
}
+/**
+ * Resolve a kf-auth user ID by email address using the search endpoint.
+ * Returns the first matching user's ID, or null.
+ */
+export async function resolveKfUserByEmail(email: string): Promise
{
+ if (!hasInternalApi) return null
+
+ try {
+ const res = await fetch(
+ `${AUTH_INTERNAL_API_URL}/api/internal/users/search?q=${encodeURIComponent(email)}`,
+ { headers: { Authorization: `Bearer ${AUTH_INTERNAL_API_KEY}` } },
+ )
+ if (!res.ok) return null
+ const data = (await res.json()) as { users: { id: string; name: string | null }[] }
+ return data.users?.[0]?.id ?? null
+ } catch {
+ return null
+ }
+}
+
+/**
+ * Resolve a user's KF Auth orgs given their Underlay userId.
+ * Tries the account's OIDC subject first, falls back to email-based lookup.
+ */
+export async function resolveUserKfOrgs(userId: string, db: any, schema: any): Promise {
+ if (!hasInternalApi) return []
+
+ const { and, eq } = await import('drizzle-orm')
+ const [acct] = await db
+ .select({ accountId: schema.account.accountId })
+ .from(schema.account)
+ .where(and(eq(schema.account.userId, userId), eq(schema.account.providerId, 'kf-auth')))
+ .limit(1)
+ if (!acct) return []
+
+ let orgs = await fetchAuthOrgs(acct.accountId)
+ if (orgs.length === 0) {
+ const [u] = await db
+ .select({ email: schema.user.email })
+ .from(schema.user)
+ .where(eq(schema.user.id, userId))
+ .limit(1)
+ if (u?.email) {
+ const kfUserId = await resolveKfUserByEmail(u.email)
+ if (kfUserId) orgs = await fetchAuthOrgs(kfUserId)
+ }
+ }
+ return orgs
+}
+
+/**
+ * Resolve the personal KF org ID for a user. Returns null if unavailable.
+ */
+export async function resolveDefaultKfOrgId(
+ userId: string,
+ db: any,
+ schema: any,
+): Promise {
+ const orgs = await resolveUserKfOrgs(userId, db, schema)
+ const personal = orgs.find((o) => o.type === 'personal')
+ return personal?.id ?? orgs[0]?.id ?? null
+}
+
export { AUTH_INTERNAL_API_URL, AUTH_INTERNAL_API_KEY, OIDC_ISSUER_INTERNAL_URL }
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.server.ts b/src/lib/auth.server.ts
index 990f57b..49d8a01 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,80 @@ export interface SessionUser {
}>
}
-async function fetchKfRole(userId: string): Promise {
+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
+ }
+}
+
+interface KfProfile {
+ name: string | null
+ image: string | null
+ role: string | null
+}
+
+async function fetchKfProfile(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 {
+ name: profile.name ?? null,
+ image: profile.picture ?? null,
+ role: profile['https://knowledgefutures.org/role'] ?? profile.role ?? null,
+ }
} catch {
return null
}
@@ -49,7 +109,7 @@ export async function getSessionUser(request: Request): Promise m.isDefault) ?? null
@@ -69,9 +129,9 @@ export async function getSessionUser(request: Request): Promise ({
organizationId: m.orgId,
diff --git a/src/lib/auth.ts b/src/lib/auth.ts
index b0a57ae..126f628 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'
@@ -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: {
@@ -36,7 +39,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',
@@ -59,6 +62,18 @@ export const auth = betterAuth({
},
},
},
+ organizationHooks: {
+ beforeCreateOrganization: async ({ organization: orgData, user }) => {
+ if (orgData.kfOrgId) return
+ try {
+ const { resolveDefaultKfOrgId } = await import('./auth-internal.server.js')
+ const kfOrgId = await resolveDefaultKfOrgId(user.id, db, schema)
+ if (kfOrgId) return { data: { kfOrgId } }
+ } catch (err) {
+ console.error('[org hook] failed to resolve kfOrgId:', err)
+ }
+ },
+ },
}),
apiKey({
defaultPrefix: 'ul',
@@ -85,42 +100,101 @@ export const auth = betterAuth({
],
databaseHooks: {
+ account: {
+ create: {
+ after: async (account) => {
+ try {
+ 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),
+ ),
+ )
+ } catch (err) {
+ console.error('[auth hook] account.create.after cleanup failed:', err)
+ }
+ if (account.providerId === 'kf-auth' && account.accessToken) {
+ try {
+ const res = await fetch(`${KF_AUTH_INTERNAL_URL}/api/auth/oauth2/userinfo`, {
+ headers: { Authorization: `Bearer ${account.accessToken}` },
+ })
+ if (res.ok) {
+ const profile = await res.json()
+ const updates: Record = {}
+ if (profile.name) updates.name = profile.name
+ if (profile.picture) updates.image = profile.picture
+ if (Object.keys(updates).length > 0) {
+ await db
+ .update(schema.user)
+ .set(updates)
+ .where(eq(schema.user.id, account.userId))
+ }
+ }
+ } catch (err) {
+ console.error('[auth hook] profile sync failed:', err)
+ }
+ }
+ },
+ },
+ },
user: {
create: {
after: async (user) => {
- const baseSlug = (user.email.split('@')[0] ?? 'user')
- .toLowerCase()
- .replace(/[^a-z0-9-]/g, '-')
- .replace(/-+/g, '-')
- .slice(0, 30)
+ console.log('[auth hook] user.create.after starting for:', user.email)
+ try {
+ const baseSlug = (user.email.split('@')[0] ?? 'user')
+ .toLowerCase()
+ .replace(/[^a-z0-9-]/g, '-')
+ .replace(/-+/g, '-')
+ .slice(0, 30)
- let slug = baseSlug
- let attempt = 0
- while (true) {
- const [conflict] = await db
- .select({ id: schema.organization.id })
- .from(schema.organization)
- .where(eq(schema.organization.slug, slug))
- .limit(1)
- if (!conflict) break
- attempt++
- slug = `${baseSlug}-${attempt}`
- }
+ let slug = baseSlug
+ let attempt = 0
+ while (true) {
+ const [conflict] = await db
+ .select({ id: schema.organization.id })
+ .from(schema.organization)
+ .where(eq(schema.organization.slug, slug))
+ .limit(1)
+ if (!conflict) break
+ attempt++
+ slug = `${baseSlug}-${attempt}`
+ }
+
+ let kfOrgId: string | null = null
+ try {
+ const { resolveDefaultKfOrgId } = await import('./auth-internal.server.js')
+ kfOrgId = await resolveDefaultKfOrgId(user.id, db, schema)
+ } catch (err) {
+ console.error('[auth hook] failed to resolve kfOrgId for default org:', err)
+ }
- const orgId = crypto.randomUUID()
- await db.insert(schema.organization).values({
- id: orgId,
- name: user.name,
- slug,
- isDefault: true,
- })
+ const orgId = crypto.randomUUID()
+ await db.insert(schema.organization).values({
+ id: orgId,
+ name: user.name,
+ slug,
+ isDefault: true,
+ ...(kfOrgId ? { kfOrgId } : {}),
+ })
- await db.insert(schema.member).values({
- id: crypto.randomUUID(),
- organizationId: orgId,
- userId: user.id,
- role: 'owner',
- })
+ await db.insert(schema.member).values({
+ id: crypto.randomUUID(),
+ organizationId: orgId,
+ userId: user.id,
+ role: 'owner',
+ })
+ console.log(
+ '[auth hook] default org created:',
+ slug,
+ kfOrgId ? `kfOrgId=${kfOrgId}` : '(no kfOrgId)',
+ )
+ } catch (err) {
+ console.error('[auth hook] user.create.after failed:', err)
+ }
},
},
},
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]/index.tsx b/src/routes/[owner]/[collection]/index.tsx
index da37c29..0d19f03 100644
--- a/src/routes/[owner]/[collection]/index.tsx
+++ b/src/routes/[owner]/[collection]/index.tsx
@@ -143,6 +143,50 @@ export default function CollectionPage() {
)}
+ {/* Empty state for new collections */}
+ {!data.latestVersion && isOwner && (
+
+
Get started with {collection}
+
+ This collection is empty. Push your first version using the CLI or API.
+
+
+
+
+ # initialize and push
+
+
+ $ underlay init --remote {owner}/
+ {collection}
+
+
+ $ underlay add --schema ./schema.json
+ ./records.jsonl
+
+
+ $ underlay commit -m "Initial
+ version"
+
+
+ $ underlay push
+
+
+
+
+
+ Read the quickstart
+
+ ·
+
+ API:{' '}
+
+ POST /api/collections/{owner}/{collection}/versions/negotiate
+
+
+
+
+ )}
+
{/* Two-column layout */}
{/* Main column */}
@@ -267,6 +311,26 @@ export default function CollectionPage() {
{data.ownerName}
+ {(() => {
+ const meta = data.latestVersion?.metadata as
+ | Record
+ | null
+ | undefined
+ const tags = Array.isArray(meta?.tags) ? (meta.tags as string[]) : []
+ return tags.length > 0 ? (
+
+ {tags.map((tag: string) => (
+
+ {tag}
+
+ ))}
+
+ ) : null
+ })()}
{/* Stats */}
@@ -469,14 +533,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 +556,7 @@ function AgentShareSection({
>
-
Agent Share Link
+ Agent Update Link
+
+
+ {tags.length > 0 && (
+
+ {tags.map((tag) => (
+
+ {tag}
+
+
+ ))}
+
+ )}
+
+ setTagInput(e.target.value)}
+ onKeyDown={(e) => {
+ if (e.key === 'Enter') {
+ e.preventDefault()
+ const val = tagInput.trim().toLowerCase()
+ if (val && !tags.includes(val)) {
+ setTags([...tags, val])
+ }
+ setTagInput('')
+ }
+ }}
+ placeholder="Add a tag and press Enter"
+ className="bg-parchment border-rule focus:border-ink min-w-0 flex-1 border px-3 py-2 text-sm focus:outline-none"
+ />
+
+
+