Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions docker-compose.local.yml
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ services:
ports:
- '5433:5432'
volumes:
- pgdata-dev:/var/lib/postgresql/data
- pgdata:/var/lib/postgresql/data
healthcheck:
test: ['CMD-SHELL', 'pg_isready -U underlay']
interval: 5s
Expand Down Expand Up @@ -49,5 +49,5 @@ services:
condition: service_healthy

volumes:
pgdata-dev:
pgdata:
app_node_modules:
4 changes: 3 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
"type": "module",
"scripts": {
"dev": "./dev.sh",
"dev:app": "tsx --env-file=.env.local server.ts",
"dev:app": "tsx watch --clear-screen=false --env-file=.env.local server.ts",
"build": "vite build --outDir dist/client && vite build --ssr src/entry-server.tsx --outDir dist/server",
"start": "NODE_ENV=production node --import tsx/esm server.ts",
"typecheck": "tsc --noEmit",
Expand All @@ -30,6 +30,7 @@
},
"dependencies": {
"@aws-sdk/client-s3": "^3.750.0",
"@better-auth/api-key": "^1.6.11",
"@codemirror/autocomplete": "^6.20.1",
"@codemirror/commands": "^6.10.3",
"@codemirror/lang-sql": "^6.10.0",
Expand All @@ -41,6 +42,7 @@
"ajv": "^8.17.0",
"ajv-formats": "^3.0.0",
"bcrypt": "^6.0.0",
"better-auth": "^1.6.11",
"better-sqlite3": "^12.9.0",
"codemirror": "^6.0.2",
"drizzle-orm": "^0.45.0",
Expand Down
321 changes: 319 additions & 2 deletions pnpm-lock.yaml

Large diffs are not rendered by default.

38 changes: 28 additions & 10 deletions public/.well-known/ai.txt
Original file line number Diff line number Diff line change
Expand Up @@ -15,12 +15,14 @@ There are two auth methods:
1. API Key (for programmatic access):
Header: Authorization: Bearer ul_<key>
Keys have scopes: read, write, admin.
Create keys at https://underlay.org/settings/keys.
Keys can optionally be scoped to specific collections via metadata.
Create keys at https://underlay.org/settings/keys (personal) or /:owner/settings/keys (organization).
Keys are managed via better-auth's apiKey plugin at /api/auth/api-key/*.

2. Session cookie (for browser use):
Users sign in via KF Auth SSO (OIDC) at https://underlay.org/login.
Accounts are created automatically on first sign-in.
GET /api/accounts/me returns the current user (works with either auth method).
Users sign in via KF Auth SSO (OAuth2/PKCE) at https://underlay.org/login.
Accounts are created automatically on first sign-in, along with a default organization.
GET /api/accounts/me returns the current user and their organization memberships.

All GET requests are public — no auth required to read public data.
All write requests (POST, PATCH, PUT, DELETE) require authentication.
Expand All @@ -47,7 +49,8 @@ To get the higher limit, authenticate with an API key (recommended for any autom

## Core Concepts

- Collection: a named, versioned body of data owned by an account. Identified by :owner/:slug.
- Organization: an entity that owns collections. Every user gets a default organization on signup. Identified by :slug. Managed via better-auth's organization plugin at /api/auth/organization/*.
- Collection: a named, versioned body of data owned by an organization. Identified by :owner/:slug.
- Version: an immutable snapshot containing a JSON Schema, records, and file references. Sequential integer numbers, auto-derived semver.
- Record: a flat JSON object with { id, type, data }. Records reference other records by id and files by hash.
- File: a binary blob stored by SHA-256 hash, referenced in record data as {"$file": "sha256:<hex>"}.
Expand Down Expand Up @@ -342,12 +345,24 @@ When schemas are returned via the collection schemas endpoint, known labels are

---

## Organization Management

Organizations are managed via better-auth's organization plugin at /api/auth/organization/*.
Every user gets a default organization on signup. Users can create additional organizations.

POST /api/auth/organization/create → create org {"name", "slug"}
GET /api/auth/organization/list → list user's organizations
PATCH /api/auth/organization/update → update org
DELETE /api/auth/organization/delete → delete org

Member management (invite, remove, update roles) is also under /api/auth/organization/*.

## Collection Management

POST /api/accounts/:owner/collections → create collection {"slug", "name", "description", "public"}
PATCH /api/collections/:owner/:slug → update {"name", "description", "public"}
DELETE /api/collections/:owner/:slug → delete collection (requires admin scope)
GET /api/accounts/:owner/collections → list collections for an account
GET /api/accounts/:owner/collections → list collections for an organization

---

Expand Down Expand Up @@ -409,10 +424,13 @@ article-2 is only visible to the collection owner. Public readers see article-1

## API Key Management

POST /api/accounts/keys → create key {"label": "my-app", "scope": "write"}
Response includes the key once: {"key": "ul_abc123...", "id": "..."}
GET /api/accounts/keys → list keys (id, label, scope, createdAt, lastUsedAt — not the key itself)
DELETE /api/accounts/keys/:id → revoke a key
API keys are managed via better-auth's apiKey plugin. All endpoints are under /api/auth/api-key/*.

POST /api/auth/api-key/create → create key {"name": "my-app", "metadata": {"scope": "write"}, "prefix": "ul"}
The scope in metadata is translated to permissions server-side.
Response includes the key once: {"key": "ul_abc123...", "id": "..."}
GET /api/auth/api-key/list → list keys (id, name, start, permissions, metadata, createdAt, expiresAt)
POST /api/auth/api-key/delete → revoke a key {"keyId": "..."}

---

Expand Down
93 changes: 51 additions & 42 deletions server.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import { existsSync, readFileSync } from 'node:fs'
import { createServer as createHttpServer } from 'node:http'
import { resolve } from 'node:path'

import { serve } from '@hono/node-server'
import { getRequestListener, serve } from '@hono/node-server'
import { serveStatic } from '@hono/node-server/serve-static'
import { Hono } from 'hono'
import { cors } from 'hono/cors'
Expand All @@ -17,17 +18,17 @@ import { authMiddleware, requireAuth } from '~/api/auth.server'
import * as _collections from '~/api/collections'
import * as _files from '~/api/files'
import * as _health from '~/api/health'
import * as _kfAuth from '~/api/kf-auth'
import * as _kfSummary from '~/api/kf-summary'
import * as _query from '~/api/query'
import * as _schemas from '~/api/schemas'
import * as _uploads from '~/api/uploads'
import * as _versions from '~/api/versions'
import { auth } from '~/lib/auth'
import { getMirrorConfig } from '~/lib/mirror-config'
import { initOidc } from '~/lib/oidc.server'

const isProd = process.env.NODE_ENV === 'production'
let vite: ViteDevServer | undefined
let devHttpServer: import('node:http').Server | undefined

// In dev, proxy API modules through Vite's SSR loader for hot reload
function hot<T extends Record<string, any>>(staticMod: T, modulePath: string): T {
Expand All @@ -49,7 +50,6 @@ const ark = hot(_ark, '/src/api/ark.ts')
const collections = hot(_collections, '/src/api/collections.ts')
const files = hot(_files, '/src/api/files.ts')
const health = hot(_health, '/src/api/health.ts')
const kfAuth = hot(_kfAuth, '/src/api/kf-auth.ts')
const kfSummary = hot(_kfSummary, '/src/api/kf-summary.ts')
const query = hot(_query, '/src/api/query.ts')
const schemas = hot(_schemas, '/src/api/schemas.ts')
Expand All @@ -76,23 +76,38 @@ app.use('/api/admin/*', async (c, next) => {
// --- ARK resolution middleware ---
app.use('/ark\\:*', arkMiddleware)

// --- KF Auth (OIDC login) ---
// --- Better-auth handler (OIDC login, sessions, API keys) ---
app.on(['GET', 'POST'], '/api/auth/*', async (c) => {
return auth.handler(c.req.raw)
})

// /login redirect — fall through to React route only when there's an error to display
app.get('/login', async (c, next) => {
// Server-side redirect to avoid client-side "Redirecting..." flash.
// Fall through to the React route only when there's an error to display.
const url = new URL(c.req.url)
if (!url.searchParams.has('error')) {
const returnTo = url.searchParams.get('return_to') ?? ''
const target = returnTo
? `/auth/login?return_to=${encodeURIComponent(returnTo)}`
: '/auth/login'
return c.redirect(target)
const signInUrl = new URL('/api/auth/sign-in/oauth2', url.origin)
const authRes = await auth.handler(
new Request(signInUrl, {
method: 'POST',
headers: new Headers({
'Content-Type': 'application/json',
Cookie: c.req.header('cookie') ?? '',
Origin: url.origin,
}),
body: JSON.stringify({ providerId: 'kf-auth', callbackURL: '/dashboard' }),
}),
)
const body = await authRes.json()
if (body.url) {
const redirect = new Response(null, { status: 302, headers: { Location: body.url } })
for (const [key, value] of authRes.headers.entries()) {
if (key.toLowerCase() === 'set-cookie') redirect.headers.append(key, value)
}
return redirect
}
}
await next()
})
app.get('/auth/login', kfAuth.login)
app.get('/auth/callback', kfAuth.callback)
app.post('/auth/logout', kfAuth.logout)

// --- API routes ---
app.get('/api/health', health.check)
Expand Down Expand Up @@ -191,32 +206,14 @@ app.get('/api/collections/:owner/:slug/versions/:n/manifest', versions.manifest)
app.post('/api/collections/:owner/:slug/versions', requireAuth('write'), versions.push)
app.get('/api/collections/:owner/:slug/versions/:n/diff', versions.diff)

// Accounts
// Accounts (custom routes — org CRUD, members, invitations, API keys handled by /api/auth/*)
app.get('/api/accounts/me', requireAuth(), accounts.getMe)
app.get('/api/accounts/available-kf-orgs', requireAuth(), accounts.availableKfOrgs)
app.get('/api/accounts/:slug', accounts.getBySlug)
app.get('/api/accounts/:slug/members', accounts.listMembers)
app.patch('/api/accounts/me', requireAuth(), accounts.updateMe)
app.get('/api/accounts/me/sessions', requireAuth(), accounts.listSessions)
app.delete('/api/accounts/me/sessions/:sessionId', requireAuth(), accounts.deleteSession)
app.delete('/api/accounts/me', requireAuth(), accounts.deleteMe)
app.post('/api/accounts/keys', requireAuth(), accounts.createKey)
app.get('/api/accounts/keys', requireAuth(), accounts.listKeys)
app.delete('/api/accounts/keys/:id', requireAuth(), accounts.deleteKey)
app.post('/api/accounts/:slug/keys', requireAuth(), accounts.createOrgKey)
app.get('/api/accounts/:slug/keys', requireAuth(), accounts.listOrgKeys)
app.delete('/api/accounts/:slug/keys/:id', requireAuth(), accounts.deleteOrgKey)
app.post('/api/accounts/orgs', requireAuth(), accounts.createOrg)
app.get('/api/accounts/:slug/members', requireAuth(), accounts.listMembers)
app.post('/api/accounts/:slug/members', requireAuth(), accounts.addMember)
app.patch('/api/accounts/:slug/members/:userId', requireAuth(), accounts.updateMember)
app.delete('/api/accounts/:slug/members/:userId', requireAuth(), accounts.removeMember)
app.patch('/api/accounts/:slug', requireAuth(), accounts.updateOrg)
app.post('/api/accounts/:slug/avatar', requireAuth(), accounts.uploadOrgAvatar)
app.post('/api/accounts/:slug/invitations', requireAuth(), accounts.createInvitation)
app.get('/api/accounts/:slug/invitations', requireAuth(), accounts.listInvitations)
app.delete('/api/accounts/:slug/invitations/:id', requireAuth(), accounts.deleteInvitation)
app.post('/api/accounts/invitations/accept', requireAuth(), accounts.acceptInvitation)
app.delete('/api/accounts/:slug', requireAuth(), accounts.deleteOrg)
app.delete('/api/accounts/me', requireAuth(), accounts.deleteMe)

// --- Blog content API (serves rendered markdown) ---
app.get('/api/blog/:slug', (c) => {
Expand Down Expand Up @@ -285,9 +282,10 @@ if (isProd) {
return c.html(page, statusCode ?? 200)
})
} else {
devHttpServer = createHttpServer()
const { createServer: createViteServer } = await import('vite')
vite = await createViteServer({
server: { middlewareMode: true },
server: { middlewareMode: true, hmr: { server: devHttpServer } },
appType: 'custom',
})

Expand Down Expand Up @@ -336,12 +334,23 @@ if (isProd) {

const port = Number(process.env.PORT) || 3000

// Validate OIDC provider is reachable before accepting requests
await initOidc().catch((err) => {
console.error('FATAL: OIDC discovery failed — cannot start without a valid OIDC provider.')
const KF_AUTH_INTERNAL_URL =
process.env.OIDC_ISSUER_INTERNAL_URL ?? process.env.OIDC_ISSUER_URL ?? 'http://localhost:3000'
try {
const res = await fetch(`${KF_AUTH_INTERNAL_URL}/api/health`, {
signal: AbortSignal.timeout(5000),
})
if (!res.ok) throw new Error(`status ${res.status}`)
} catch (err: any) {
console.error(`FATAL: KF Auth not reachable at ${KF_AUTH_INTERNAL_URL}/api/health`)
console.error(err.message)
process.exit(1)
})
}

console.log(`Server running at http://localhost:${port}`)
serve({ fetch: app.fetch, port })
if (devHttpServer) {
devHttpServer.on('request', getRequestListener(app.fetch))
devHttpServer.listen(port)
} else {
serve({ fetch: app.fetch, port })
}
Loading
Loading