From d8307009e4f63d7424a1f0577c38fbf3912524ae Mon Sep 17 00:00:00 2001 From: Travis Rich Date: Wed, 5 Aug 2026 22:41:05 -0400 Subject: [PATCH 1/6] Add sharing panel --- src/api/auth.server.ts | 74 ++++-- src/api/collections.ts | 11 +- src/api/files.ts | 23 +- src/api/query.ts | 39 ++- src/api/schemas.ts | 7 +- src/api/versions.ts | 56 +++- src/lib/share-token.tsx | 48 ++++ src/lib/version-helpers.server.ts | 8 +- src/routes/[owner]/[collection]/diff.data.ts | 9 +- src/routes/[owner]/[collection]/diff.tsx | 15 +- src/routes/[owner]/[collection]/index.data.ts | 9 +- src/routes/[owner]/[collection]/index.tsx | 247 +++++++++++++----- .../[owner]/[collection]/schemas.data.ts | 7 +- src/routes/[owner]/[collection]/v/[n].data.ts | 9 +- src/routes/[owner]/[collection]/v/[n].tsx | 38 +-- .../[owner]/[collection]/versions.data.ts | 9 +- src/routes/[owner]/[collection]/versions.tsx | 9 +- 17 files changed, 468 insertions(+), 150 deletions(-) create mode 100644 src/lib/share-token.tsx diff --git a/src/api/auth.server.ts b/src/api/auth.server.ts index d0fba32..4bc998e 100644 --- a/src/api/auth.server.ts +++ b/src/api/auth.server.ts @@ -1,6 +1,6 @@ import crypto from 'node:crypto' -import type { MiddlewareHandler } from 'hono' +import type { Context, MiddlewareHandler } from 'hono' import { createMiddleware } from 'hono/factory' import { auth } from '../lib/auth.js' @@ -22,6 +22,37 @@ export type AuthEnv = { const publicPaths = new Set(['/api/health', '/api/query/generate-sql']) +/** Verify an API key and load its identity/scope into the request context. */ +async function applyApiKey( + c: Context, + key: string, +): Promise<'ok' | 'invalid' | 'rate-limited'> { + try { + const result = await auth.api.verifyApiKey({ body: { key } }) + if (result?.valid && result.key) { + c.set('userId', (result.key as any).userId ?? (result.key as any).referenceId) + const perms = (result.key.permissions as Record) ?? {} + if (perms['collections']?.includes('admin')) { + c.set('apiKeyScope', 'admin') + } else if (perms['collections']?.includes('write')) { + c.set('apiKeyScope', 'write') + } else { + c.set('apiKeyScope', 'read') + } + const meta = (result.key as any).metadata as Record | null + if (meta?.collectionIds?.length) { + c.set('apiKeyCollectionIds', meta.collectionIds) + } + return 'ok' + } + } catch (err: any) { + if (err?.status === 'TOO_MANY_REQUESTS' || err?.statusCode === 429) { + return 'rate-limited' + } + } + return 'invalid' +} + const internalToken = process.env.INTERNAL_API_TOKEN ?? '' const authInternalApiKey = process.env.AUTH_INTERNAL_API_KEY ?? '' @@ -47,30 +78,29 @@ export const authMiddleware = createMiddleware(async (c, next) => { // API key auth via Bearer token (better-auth apiKey plugin) if (authorization?.startsWith('Bearer ')) { const key = authorization.slice(7) - try { - const result = await auth.api.verifyApiKey({ body: { key } }) - if (result?.valid && result.key) { - c.set('userId', (result.key as any).userId ?? (result.key as any).referenceId) - const perms = (result.key.permissions as Record) ?? {} - if (perms['collections']?.includes('admin')) { - c.set('apiKeyScope', 'admin') - } else if (perms['collections']?.includes('write')) { - c.set('apiKeyScope', 'write') - } else { - c.set('apiKeyScope', 'read') - } - const meta = (result.key as any).metadata as Record | null - if (meta?.collectionIds?.length) { - c.set('apiKeyCollectionIds', meta.collectionIds) - } - return next() - } - } catch (err: any) { - if (err?.status === 'TOO_MANY_REQUESTS' || err?.statusCode === 429) { + const outcome = await applyApiKey(c, key) + if (outcome === 'rate-limited') { + return c.json({ error: 'Rate limit exceeded', statusCode: 429 }, 429) + } + if (outcome === 'invalid') { + return c.json({ error: 'Invalid API key', statusCode: 401 }, 401) + } + return next() + } + + // API key in the query string (?token=...) — capability URLs (read-only share + // links, export downloads) authenticate plain browser GETs that can't set + // headers. An invalid or expired token falls through to anonymous access + // rather than 401, so the page still renders whatever is public. + if (c.req.method === 'GET' || c.req.method === 'HEAD') { + const queryToken = new URL(c.req.url).searchParams.get('token') + if (queryToken) { + const outcome = await applyApiKey(c, queryToken) + if (outcome === 'rate-limited') { return c.json({ error: 'Rate limit exceeded', statusCode: 429 }, 429) } + if (outcome === 'ok') return next() } - return c.json({ error: 'Invalid API key', statusCode: 401 }, 401) } // Session cookie auth (better-auth managed) diff --git a/src/api/collections.ts b/src/api/collections.ts index a4473da..f9f2383 100644 --- a/src/api/collections.ts +++ b/src/api/collections.ts @@ -356,9 +356,14 @@ const app = new Hono() return c.json({ error: 'Collection not found', statusCode: 404 }, 404) } + // A collection-scoped API key (share/agent link) only grants access to + // the collections it is scoped to. + const scopedCollections = c.get('apiKeyCollectionIds') + const keyScopeOk = !scopedCollections || scopedCollections.includes(result.id) + const userId = c.get('userId') let hasAccess = false - if (userId) { + if (userId && keyScopeOk) { const [membership] = await db .select() .from(schema.member) @@ -795,7 +800,9 @@ const app = new Hono() if (!collection.public) { const userId = c.get('userId') - if (!userId || !(await hasOrgAccess(userId, collection.organizationId))) { + const scopedCollections = c.get('apiKeyCollectionIds') + const keyScopeOk = !scopedCollections || scopedCollections.includes(collection.id) + if (!userId || !keyScopeOk || !(await hasOrgAccess(userId, collection.organizationId))) { return c.json({ error: 'Collection not found', statusCode: 404 }, 404) } } diff --git a/src/api/files.ts b/src/api/files.ts index f605d93..c382089 100644 --- a/src/api/files.ts +++ b/src/api/files.ts @@ -17,6 +17,7 @@ async function isFilePubliclyAccessible( slug: string, fileHash: string, userId: string | undefined, + apiKeyCollectionIds?: string[], ): Promise { const [collection] = await db .select({ @@ -30,7 +31,11 @@ async function isFilePubliclyAccessible( if (!collection) return false - if (userId != null) { + // A collection-scoped API key (share/agent link) only counts for the + // collections it is scoped to. + const keyScopeOk = !apiKeyCollectionIds || apiKeyCollectionIds.includes(collection.id) + + if (userId != null && keyScopeOk) { const [membership] = await db .select() .from(schema.member) @@ -152,7 +157,13 @@ const app = new Hono() return c.body(null, 404) } - const accessible = await isFilePubliclyAccessible(owner, slug, cleanHash, c.get('userId')) + const accessible = await isFilePubliclyAccessible( + owner, + slug, + cleanHash, + c.get('userId'), + c.get('apiKeyCollectionIds'), + ) if (!accessible) { return c.body(null, 404) } @@ -183,7 +194,13 @@ const app = new Hono() return c.json({ error: 'File not found', statusCode: 404 }, 404) } - const accessible = await isFilePubliclyAccessible(owner, slug, cleanHash, c.get('userId')) + const accessible = await isFilePubliclyAccessible( + owner, + slug, + cleanHash, + c.get('userId'), + c.get('apiKeyCollectionIds'), + ) if (!accessible) { return c.json({ error: 'File not found', statusCode: 404 }, 404) } diff --git a/src/api/query.ts b/src/api/query.ts index 6e16ee6..b4c5a93 100644 --- a/src/api/query.ts +++ b/src/api/query.ts @@ -77,6 +77,7 @@ async function getOrBuildSqlite( slug: string, versionSemver: string, userId: string | undefined, + apiKeyCollectionIds?: string[], ) { const { semver: normalizedSemver } = parseSemver(versionSemver) @@ -95,8 +96,10 @@ async function getOrBuildSqlite( if (!collection) return null // Access: private collections are only visible to org members; non-members of - // public collections get a privacy-filtered build - const ownerAccess = await hasOrgAccess(userId, collection.organizationId) + // public collections get a privacy-filtered build. A collection-scoped API + // key (share/agent link) only counts for the collections it is scoped to. + const keyScopeOk = !apiKeyCollectionIds || apiKeyCollectionIds.includes(collection.id) + const ownerAccess = keyScopeOk && (await hasOrgAccess(userId, collection.organizationId)) if (!collection.public && !ownerAccess) return null // Resolve version @@ -254,7 +257,13 @@ export async function sqlite(c: Context) { const versionSemver = c.req.param('version')! const { semver } = parseSemver(versionSemver) - const result = await getOrBuildSqlite(owner, slug, versionSemver, c.get('userId')) + const result = await getOrBuildSqlite( + owner, + slug, + versionSemver, + c.get('userId'), + c.get('apiKeyCollectionIds'), + ) if (!result) return c.json({ error: 'Collection or version not found', statusCode: 404 }, 404) if ('tooLarge' in result) return tooLargeResponse(c, result.recordCount) @@ -274,7 +283,13 @@ export async function ddl(c: Context) { const slug = c.req.param('slug')! const versionSemver = c.req.param('version')! - const result = await getOrBuildSqlite(owner, slug, versionSemver, c.get('userId')) + const result = await getOrBuildSqlite( + owner, + slug, + versionSemver, + c.get('userId'), + c.get('apiKeyCollectionIds'), + ) if (!result) return c.json({ error: 'Collection or version not found', statusCode: 404 }, 404) if ('tooLarge' in result) return tooLargeResponse(c, result.recordCount) @@ -318,7 +333,13 @@ export async function generateSql(c: Context) { if (collectionRefs.length === 1) { const ref = collectionRefs[0] - const result = await getOrBuildSqlite(ref.owner, ref.slug, ref.version, c.get('userId')) + const result = await getOrBuildSqlite( + ref.owner, + ref.slug, + ref.version, + c.get('userId'), + c.get('apiKeyCollectionIds'), + ) if (!result) return c.json( { error: `Collection ${ref.owner}/${ref.slug} v${ref.version} not found`, statusCode: 404 }, @@ -330,7 +351,13 @@ export async function generateSql(c: Context) { } else { const parts: string[] = [] for (const ref of collectionRefs) { - const result = await getOrBuildSqlite(ref.owner, ref.slug, ref.version, c.get('userId')) + const result = await getOrBuildSqlite( + ref.owner, + ref.slug, + ref.version, + c.get('userId'), + c.get('apiKeyCollectionIds'), + ) if (!result) return c.json( { error: `Collection ${ref.owner}/${ref.slug} v${ref.version} not found` }, diff --git a/src/api/schemas.ts b/src/api/schemas.ts index 5fad27d..18ffe38 100644 --- a/src/api/schemas.ts +++ b/src/api/schemas.ts @@ -311,7 +311,12 @@ const app = new Hono() if (!collection) return c.json({ error: 'Collection not found', statusCode: 404 }, 404) - if (!collection.public && !(await hasOrgAccess(c.get('userId'), collection.organizationId))) { + const scopedCollections = c.get('apiKeyCollectionIds') + const keyScopeOk = !scopedCollections || scopedCollections.includes(collection.id) + if ( + !collection.public && + (!keyScopeOk || !(await hasOrgAccess(c.get('userId'), collection.organizationId))) + ) { return c.json({ error: 'Collection not found', statusCode: 404 }, 404) } diff --git a/src/api/versions.ts b/src/api/versions.ts index a5ab483..21ada93 100644 --- a/src/api/versions.ts +++ b/src/api/versions.ts @@ -184,7 +184,12 @@ const app = new Hono() const limit = c.req.query('limit') const offset = c.req.query('offset') - const collection = await resolveAccessibleCollection(owner, slug, c.get('userId')) + const collection = await resolveAccessibleCollection( + owner, + slug, + c.get('userId'), + c.get('apiKeyCollectionIds'), + ) if (!collection) return c.json({ error: 'Collection not found', statusCode: 404 }, 404) const ownerAccess = collection.ownerAccess @@ -242,7 +247,12 @@ const app = new Hono() }), async (c) => { const { owner, slug } = c.req.valid('param') - const collection = await resolveAccessibleCollection(owner, slug, c.get('userId')) + const collection = await resolveAccessibleCollection( + owner, + slug, + c.get('userId'), + c.get('apiKeyCollectionIds'), + ) if (!collection) return c.json({ error: 'Collection not found', statusCode: 404 }, 404) const version = await getLatestReadyVersion(collection.id) @@ -278,7 +288,12 @@ const app = new Hono() }), async (c) => { const { owner, slug, n } = c.req.valid('param') - const collection = await resolveAccessibleCollection(owner, slug, c.get('userId')) + const collection = await resolveAccessibleCollection( + owner, + slug, + c.get('userId'), + c.get('apiKeyCollectionIds'), + ) if (!collection) return c.json({ error: 'Collection not found', statusCode: 404 }, 404) const { semver } = parseSemver(n) @@ -332,7 +347,12 @@ const app = new Hono() // client that sends ?cursor= isn't silently reset to offset 0. const after = c.req.query('after') ?? c.req.query('cursor') - const collection = await resolveAccessibleCollection(owner, slug, c.get('userId')) + const collection = await resolveAccessibleCollection( + owner, + slug, + c.get('userId'), + c.get('apiKeyCollectionIds'), + ) if (!collection) return c.json({ error: 'Collection not found', statusCode: 404 }, 404) const { semver } = parseSemver(n) @@ -530,7 +550,12 @@ const app = new Hono() const type = c.req.query('type') const after = c.req.query('after') - const collection = await resolveAccessibleCollection(owner, slug, c.get('userId')) + const collection = await resolveAccessibleCollection( + owner, + slug, + c.get('userId'), + c.get('apiKeyCollectionIds'), + ) if (!collection) return c.json({ error: 'Collection not found', statusCode: 404 }, 404) const { semver } = parseSemver(n) @@ -697,7 +722,12 @@ const app = new Hono() }), async (c) => { const { owner, slug, n } = c.req.valid('param') - const collection = await resolveAccessibleCollection(owner, slug, c.get('userId')) + const collection = await resolveAccessibleCollection( + owner, + slug, + c.get('userId'), + c.get('apiKeyCollectionIds'), + ) if (!collection) return c.json({ error: 'Collection not found', statusCode: 404 }, 404) const { semver } = parseSemver(n) @@ -777,7 +807,12 @@ const app = new Hono() async (c) => { const { owner, slug, n } = c.req.valid('param') const sinceParam = c.req.query('since') - const collection = await resolveAccessibleCollection(owner, slug, c.get('userId')) + const collection = await resolveAccessibleCollection( + owner, + slug, + c.get('userId'), + c.get('apiKeyCollectionIds'), + ) if (!collection) return c.json({ error: 'Collection not found', statusCode: 404 }, 404) const { semver } = parseSemver(n) @@ -1000,7 +1035,12 @@ const app = new Hono() const diffLimit = Math.min(parseInt(c.req.query('limit') ?? '500', 10), MAX_DIFF_LIMIT) const diffCursor = decodeDeltaCursor(c.req.query('cursor')) - const collection = await resolveAccessibleCollection(owner, slug, c.get('userId')) + const collection = await resolveAccessibleCollection( + owner, + slug, + c.get('userId'), + c.get('apiKeyCollectionIds'), + ) if (!collection) return c.json({ error: 'Collection not found', statusCode: 404 }, 404) const { semver: targetSemver } = parseSemver(n) diff --git a/src/lib/share-token.tsx b/src/lib/share-token.tsx new file mode 100644 index 0000000..26aa187 --- /dev/null +++ b/src/lib/share-token.tsx @@ -0,0 +1,48 @@ +import { Link, useSearchParams } from 'react-router' + +/** + * Read-only share links carry a collection-scoped API key in the page URL + * (?token=ul_...). These helpers keep that token attached as the viewer + * navigates between collection pages, and forward it to API requests. + */ + +/** The share token from the current page URL, or null. */ +export function useShareToken(): string | null { + const [searchParams] = useSearchParams() + return searchParams.get('token') +} + +/** Append a share token to an internal path, preserving existing query params. */ +export function withToken(to: string, token: string | null): string { + if (!token) return to + const sep = to.includes('?') ? '&' : '?' + return `${to}${sep}token=${encodeURIComponent(token)}` +} + +/** + * Drop-in replacement for react-router's Link that carries the current share + * token across page clicks. Use for links between pages of the same collection + * so a shared-link viewer keeps their access as they navigate. + */ +export function TokenLink({ to, ...props }: React.ComponentProps) { + const token = useShareToken() + return +} + +/** Extract the share token from a loader's request URL, or null. */ +export function shareTokenFromRequest(requestUrl: string): string | null { + return new URL(requestUrl).searchParams.get('token') +} + +/** + * Loader helper: builds API URLs that forward the page's share token as a + * ?token= query param (the API's auth middleware accepts it on GETs). + */ +export function apiUrlBuilder(request: Request, base: string): (path: string) => URL { + const token = shareTokenFromRequest(request.url) + return (path: string) => { + const url = new URL(path, base) + if (token) url.searchParams.set('token', token) + return url + } +} diff --git a/src/lib/version-helpers.server.ts b/src/lib/version-helpers.server.ts index feee1d6..cb7387f 100644 --- a/src/lib/version-helpers.server.ts +++ b/src/lib/version-helpers.server.ts @@ -63,11 +63,16 @@ export async function resolveCollection(owner: string, slug: string) { * Returns null when the collection doesn't exist OR is private and the caller * isn't an org member — indistinguishable to the caller (404 either way). * `ownerAccess` is true when the caller is a member of the owning org. + * + * When the request authenticated with a collection-scoped API key (share/agent + * links), pass `apiKeyCollectionIds` — the key's identity only counts for the + * collections it is scoped to; anything else is treated as anonymous. */ export async function resolveAccessibleCollection( owner: string, slug: string, userId: string | undefined, + apiKeyCollectionIds?: string[], ) { const [result] = await db .select({ @@ -81,7 +86,8 @@ export async function resolveAccessibleCollection( .where(and(eq(schema.organization.slug, owner), eq(schema.collections.slug, slug))) .limit(1) if (!result) return null - const ownerAccess = await hasOrgAccess(userId, result.organizationId) + const keyScopeOk = !apiKeyCollectionIds || apiKeyCollectionIds.includes(result.id) + const ownerAccess = keyScopeOk && (await hasOrgAccess(userId, result.organizationId)) if (!result.public && !ownerAccess) return null return { ...result, ownerAccess } } diff --git a/src/routes/[owner]/[collection]/diff.data.ts b/src/routes/[owner]/[collection]/diff.data.ts index 00022b8..c6b384a 100644 --- a/src/routes/[owner]/[collection]/diff.data.ts +++ b/src/routes/[owner]/[collection]/diff.data.ts @@ -1,6 +1,7 @@ import type { LoaderFunctionArgs } from 'react-router' import { fetchBase } from '~/lib/fetch-base' +import { apiUrlBuilder } from '~/lib/share-token' export const handle = { title: (params: Record) => @@ -8,15 +9,13 @@ export const handle = { } export async function loader({ params, request }: LoaderFunctionArgs) { - const base = fetchBase(request.url) + const api = apiUrlBuilder(request, fetchBase(request.url)) const headers = { Cookie: request.headers.get('Cookie') ?? '' } const prefix = `/api/collections/${params.owner}/${params.collection}` const [data, versions] = await Promise.all([ - fetch(new URL(prefix, base), { headers }).then((r) => (r.ok ? r.json() : null)), - fetch(new URL(`${prefix}/versions?limit=100`, base), { headers }).then((r) => - r.ok ? r.json() : [], - ), + fetch(api(prefix), { headers }).then((r) => (r.ok ? r.json() : null)), + fetch(api(`${prefix}/versions?limit=100`), { headers }).then((r) => (r.ok ? r.json() : [])), ]) if (!data) throw new Response('Not Found', { status: 404 }) diff --git a/src/routes/[owner]/[collection]/diff.tsx b/src/routes/[owner]/[collection]/diff.tsx index 730d1d9..127fc43 100644 --- a/src/routes/[owner]/[collection]/diff.tsx +++ b/src/routes/[owner]/[collection]/diff.tsx @@ -3,6 +3,7 @@ import { useLoaderData, useParams, useSearchParams } from 'react-router' import BaseLayout from '~/components/BaseLayout' import { useAppContext } from '~/lib/app-context' +import { useShareToken, withToken } from '~/lib/share-token' import { CollectionNav } from '.' @@ -23,6 +24,7 @@ export default function CollectionDiffPage() { const isOwner = currentUser?.slug === owner || currentUser?.orgs?.some((o: any) => o.slug === owner) + const shareToken = useShareToken() const [diff, setDiff] = useState(null) const [diffError, setDiffError] = useState(null) @@ -39,9 +41,13 @@ export default function CollectionDiffPage() { setDiffError(null) const fromParam = fromVer ? `?from=${fromVer}` : '' - fetch(`/api/collections/${owner}/${collection}/versions/${toVer}/diff${fromParam}`, { - credentials: 'include', - }) + fetch( + withToken( + `/api/collections/${owner}/${collection}/versions/${toVer}/diff${fromParam}`, + shareToken, + ), + { credentials: 'include' }, + ) .then(async (r) => { if (r.ok) { setDiff(await r.json()) @@ -51,7 +57,7 @@ export default function CollectionDiffPage() { } }) .finally(() => setDiffLoading(false)) - }, [fromVer, toVer, owner, collection]) + }, [fromVer, toVer, owner, collection, shareToken]) function handleCompare(e: React.FormEvent) { e.preventDefault() @@ -63,6 +69,7 @@ export default function CollectionDiffPage() { setToVer(t) const params: Record = { to: t } if (f) params.from = f + if (shareToken) params.token = shareToken setSearchParams(params) } diff --git a/src/routes/[owner]/[collection]/index.data.ts b/src/routes/[owner]/[collection]/index.data.ts index c770b31..63f038a 100644 --- a/src/routes/[owner]/[collection]/index.data.ts +++ b/src/routes/[owner]/[collection]/index.data.ts @@ -1,16 +1,17 @@ import type { LoaderFunctionArgs } from 'react-router' import { fetchBase } from '~/lib/fetch-base' +import { apiUrlBuilder } from '~/lib/share-token' 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}`, fetchBase(request.url)), - { headers: { Cookie: request.headers.get('Cookie') ?? '' } }, - ) + const api = apiUrlBuilder(request, fetchBase(request.url)) + const res = await fetch(api(`/api/collections/${params.owner}/${params.collection}`), { + headers: { Cookie: request.headers.get('Cookie') ?? '' }, + }) if (!res.ok) throw new Response('Not Found', { status: 404 }) return res.json() } diff --git a/src/routes/[owner]/[collection]/index.tsx b/src/routes/[owner]/[collection]/index.tsx index 0d19f03..dcaf53c 100644 --- a/src/routes/[owner]/[collection]/index.tsx +++ b/src/routes/[owner]/[collection]/index.tsx @@ -6,6 +6,7 @@ import { Link, useLoaderData, useParams } from 'react-router' import BaseLayout from '~/components/BaseLayout' import { useAppContext } from '~/lib/app-context' import { authClient } from '~/lib/auth-client' +import { TokenLink, useShareToken, withToken } from '~/lib/share-token' function CollectionNav({ owner, @@ -25,6 +26,7 @@ function CollectionNav({ const linkClass = 'px-3 py-2 text-sm font-medium border-b-2 -mb-px transition-colors' const activeClass = `${linkClass} border-ink text-ink` const inactiveClass = `${linkClass} border-transparent text-ink-muted hover:text-ink hover:border-rule` + const shareToken = useShareToken() return ( <> @@ -34,36 +36,44 @@ function CollectionNav({ {owner} / - + {collection} - + {isPublic !== undefined && ( {isPublic ? 'public' : 'private'} )} + {shareToken && !isOwner && ( + + shared link + + )}
- Overview - - + Versions - + {versionLabel && {versionLabel}} - Schemas - + {isOwner && (
- {data.latestVersion.semver} - + · {data.latestVersion.recordCount.toLocaleString()} records @@ -223,7 +233,7 @@ export default function CollectionPage() { timeZone: 'UTC', })} - {totalVersions} - +
)} @@ -246,7 +256,7 @@ export default function CollectionPage() { {allTypes.length > 0 && (
{allTypes.map((t: any, i: number) => ( - {t.count.toLocaleString()} records - + ))}
)} @@ -444,18 +454,26 @@ export default function CollectionPage() { {data.latestVersion && ( - Download .tar.gz - + )} - {/* Agent Share */} - {isOwner && } + {/* Share */} + {isOwner && ( + + )} {/* ARK */} {collectionArkPath && ( @@ -478,20 +496,52 @@ export default function CollectionPage() { ) } -function AgentShareSection({ +const VIEW_LINK_EXPIRES_SECONDS = 30 * 24 * 3600 + +function SharePanel({ + owner, collection, collectionId, + isPublic, }: { + owner: string collection: string collectionId: string + isPublic: boolean }) { - const [showModal, setShowModal] = useState(false) + const [modal, setModal] = useState<'view' | 'agent' | null>(null) + const [viewUrl, setViewUrl] = useState(null) const [agentUrl, setAgentUrl] = useState(null) - const [loading, setLoading] = useState(false) + const [loading, setLoading] = useState<'view' | 'agent' | null>(null) const [copied, setCopied] = useState<'link' | 'blurb' | null>(null) - const generate = useCallback(async () => { - setLoading(true) + const generateView = useCallback(async () => { + setLoading('view') + setCopied(null) + try { + const { data: keyData } = await authClient.apiKey.create({ + name: `share-${collection}`, + metadata: { + scope: 'read', + collectionIds: [collectionId], + linkShare: true, + }, + expiresIn: VIEW_LINK_EXPIRES_SECONDS, + prefix: 'ul', + } as any) + if (keyData) { + setViewUrl( + withToken(`${window.location.origin}/${owner}/${collection}`, (keyData as any).key), + ) + setModal('view') + } + } finally { + setLoading(null) + } + }, [owner, collection, collectionId]) + + const generateAgent = useCallback(async () => { + setLoading('agent') setCopied(null) try { const { data: keyData } = await authClient.apiKey.create({ @@ -505,60 +555,136 @@ function AgentShareSection({ prefix: 'ul', } as any) if (keyData) { - const url = `${window.location.origin}/agent/${(keyData as any).key}` - setAgentUrl(url) - setShowModal(true) + setAgentUrl(`${window.location.origin}/agent/${(keyData as any).key}`) + setModal('agent') } } finally { - setLoading(false) + setLoading(null) } }, [collection, collectionId]) - const copyLink = useCallback(() => { - if (!agentUrl) return - navigator.clipboard.writeText(agentUrl) - setCopied('link') + const copy = useCallback((text: string, which: 'link' | 'blurb') => { + navigator.clipboard.writeText(text) + setCopied(which) setTimeout(() => setCopied(null), 2000) - }, [agentUrl]) + }, []) - const copyBlurb = useCallback(() => { - if (!agentUrl) return - const blurb = `Will you create an update that captures this conversation. Here is a link with reference how to do that: ${agentUrl}` - navigator.clipboard.writeText(blurb) - setCopied('blurb') - setTimeout(() => setCopied(null), 2000) - }, [agentUrl]) + const agentBlurb = agentUrl + ? `Will you create an update that captures this conversation. Here is a link with reference how to do that: ${agentUrl}` + : '' return ( <>
-

- Update via Agent -

-

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

- +

Share

+ +
+

+ {isPublic + ? 'This collection is public — anyone with its URL can view it.' + : 'Create a read-only link that lets anyone view this collection without signing in or becoming a member.'} +

+ {isPublic ? ( + + ) : ( + + )} +
+ +
+

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

+ +
- {showModal && agentUrl && ( + {modal === 'view' && viewUrl && ( +
{ + if (e.target === e.currentTarget) setModal(null) + }} + > +
+
+

View-only Link

+ +
+ +

+ Anyone with this link can browse this collection — overview, versions, records, + schemas, and exports — without signing in. They cannot make changes. The link stays + attached as they click between pages. +

+ +
+ +
+ {viewUrl} +
+ +
+ +
+

+ Expires in 30 days. Revoke it anytime from Settings → API Keys. +

+ +
+
+
+ )} + + {modal === 'agent' && agentUrl && (
{ - if (e.target === e.currentTarget) setShowModal(false) + if (e.target === e.currentTarget) setModal(null) }} >

Agent Update Link