Skip to content

Commit 54d5f2a

Browse files
authored
dev: Second exploration of better-auth client (#16)
1 parent 16c70d2 commit 54d5f2a

71 files changed

Lines changed: 3231 additions & 17223 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

docker-compose.local.yml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ services:
2121
ports:
2222
- '5433:5432'
2323
volumes:
24-
- pgdata-dev:/var/lib/postgresql/data
24+
- pgdata:/var/lib/postgresql/data
2525
healthcheck:
2626
test: ['CMD-SHELL', 'pg_isready -U underlay']
2727
interval: 5s
@@ -49,5 +49,5 @@ services:
4949
condition: service_healthy
5050

5151
volumes:
52-
pgdata-dev:
52+
pgdata:
5353
app_node_modules:

package.json

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
"type": "module",
66
"scripts": {
77
"dev": "./dev.sh",
8-
"dev:app": "tsx --env-file=.env.local server.ts",
8+
"dev:app": "tsx watch --clear-screen=false --env-file=.env.local server.ts",
99
"build": "vite build --outDir dist/client && vite build --ssr src/entry-server.tsx --outDir dist/server",
1010
"start": "NODE_ENV=production node --import tsx/esm server.ts",
1111
"typecheck": "tsc --noEmit",
@@ -30,6 +30,7 @@
3030
},
3131
"dependencies": {
3232
"@aws-sdk/client-s3": "^3.750.0",
33+
"@better-auth/api-key": "^1.6.11",
3334
"@codemirror/autocomplete": "^6.20.1",
3435
"@codemirror/commands": "^6.10.3",
3536
"@codemirror/lang-sql": "^6.10.0",
@@ -41,6 +42,7 @@
4142
"ajv": "^8.17.0",
4243
"ajv-formats": "^3.0.0",
4344
"bcrypt": "^6.0.0",
45+
"better-auth": "^1.6.11",
4446
"better-sqlite3": "^12.9.0",
4547
"codemirror": "^6.0.2",
4648
"drizzle-orm": "^0.45.0",

pnpm-lock.yaml

Lines changed: 319 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

public/.well-known/ai.txt

Lines changed: 28 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -15,12 +15,14 @@ There are two auth methods:
1515
1. API Key (for programmatic access):
1616
Header: Authorization: Bearer ul_<key>
1717
Keys have scopes: read, write, admin.
18-
Create keys at https://underlay.org/settings/keys.
18+
Keys can optionally be scoped to specific collections via metadata.
19+
Create keys at https://underlay.org/settings/keys (personal) or /:owner/settings/keys (organization).
20+
Keys are managed via better-auth's apiKey plugin at /api/auth/api-key/*.
1921

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

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

4850
## Core Concepts
4951

50-
- Collection: a named, versioned body of data owned by an account. Identified by :owner/:slug.
52+
- 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/*.
53+
- Collection: a named, versioned body of data owned by an organization. Identified by :owner/:slug.
5154
- Version: an immutable snapshot containing a JSON Schema, records, and file references. Sequential integer numbers, auto-derived semver.
5255
- Record: a flat JSON object with { id, type, data }. Records reference other records by id and files by hash.
5356
- File: a binary blob stored by SHA-256 hash, referenced in record data as {"$file": "sha256:<hex>"}.
@@ -342,12 +345,24 @@ When schemas are returned via the collection schemas endpoint, known labels are
342345

343346
---
344347

348+
## Organization Management
349+
350+
Organizations are managed via better-auth's organization plugin at /api/auth/organization/*.
351+
Every user gets a default organization on signup. Users can create additional organizations.
352+
353+
POST /api/auth/organization/create → create org {"name", "slug"}
354+
GET /api/auth/organization/list → list user's organizations
355+
PATCH /api/auth/organization/update → update org
356+
DELETE /api/auth/organization/delete → delete org
357+
358+
Member management (invite, remove, update roles) is also under /api/auth/organization/*.
359+
345360
## Collection Management
346361

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

352367
---
353368

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

410425
## API Key Management
411426

412-
POST /api/accounts/keys → create key {"label": "my-app", "scope": "write"}
413-
Response includes the key once: {"key": "ul_abc123...", "id": "..."}
414-
GET /api/accounts/keys → list keys (id, label, scope, createdAt, lastUsedAt — not the key itself)
415-
DELETE /api/accounts/keys/:id → revoke a key
427+
API keys are managed via better-auth's apiKey plugin. All endpoints are under /api/auth/api-key/*.
428+
429+
POST /api/auth/api-key/create → create key {"name": "my-app", "metadata": {"scope": "write"}, "prefix": "ul"}
430+
The scope in metadata is translated to permissions server-side.
431+
Response includes the key once: {"key": "ul_abc123...", "id": "..."}
432+
GET /api/auth/api-key/list → list keys (id, name, start, permissions, metadata, createdAt, expiresAt)
433+
POST /api/auth/api-key/delete → revoke a key {"keyId": "..."}
416434

417435
---
418436

server.ts

Lines changed: 51 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
import { existsSync, readFileSync } from 'node:fs'
2+
import { createServer as createHttpServer } from 'node:http'
23
import { resolve } from 'node:path'
34

4-
import { serve } from '@hono/node-server'
5+
import { getRequestListener, serve } from '@hono/node-server'
56
import { serveStatic } from '@hono/node-server/serve-static'
67
import { Hono } from 'hono'
78
import { cors } from 'hono/cors'
@@ -17,17 +18,17 @@ import { authMiddleware, requireAuth } from '~/api/auth.server'
1718
import * as _collections from '~/api/collections'
1819
import * as _files from '~/api/files'
1920
import * as _health from '~/api/health'
20-
import * as _kfAuth from '~/api/kf-auth'
2121
import * as _kfSummary from '~/api/kf-summary'
2222
import * as _query from '~/api/query'
2323
import * as _schemas from '~/api/schemas'
2424
import * as _uploads from '~/api/uploads'
2525
import * as _versions from '~/api/versions'
26+
import { auth } from '~/lib/auth'
2627
import { getMirrorConfig } from '~/lib/mirror-config'
27-
import { initOidc } from '~/lib/oidc.server'
2828

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

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

79-
// --- KF Auth (OIDC login) ---
79+
// --- Better-auth handler (OIDC login, sessions, API keys) ---
80+
app.on(['GET', 'POST'], '/api/auth/*', async (c) => {
81+
return auth.handler(c.req.raw)
82+
})
83+
84+
// /login redirect — fall through to React route only when there's an error to display
8085
app.get('/login', async (c, next) => {
81-
// Server-side redirect to avoid client-side "Redirecting..." flash.
82-
// Fall through to the React route only when there's an error to display.
8386
const url = new URL(c.req.url)
8487
if (!url.searchParams.has('error')) {
85-
const returnTo = url.searchParams.get('return_to') ?? ''
86-
const target = returnTo
87-
? `/auth/login?return_to=${encodeURIComponent(returnTo)}`
88-
: '/auth/login'
89-
return c.redirect(target)
88+
const signInUrl = new URL('/api/auth/sign-in/oauth2', url.origin)
89+
const authRes = await auth.handler(
90+
new Request(signInUrl, {
91+
method: 'POST',
92+
headers: new Headers({
93+
'Content-Type': 'application/json',
94+
Cookie: c.req.header('cookie') ?? '',
95+
Origin: url.origin,
96+
}),
97+
body: JSON.stringify({ providerId: 'kf-auth', callbackURL: '/dashboard' }),
98+
}),
99+
)
100+
const body = await authRes.json()
101+
if (body.url) {
102+
const redirect = new Response(null, { status: 302, headers: { Location: body.url } })
103+
for (const [key, value] of authRes.headers.entries()) {
104+
if (key.toLowerCase() === 'set-cookie') redirect.headers.append(key, value)
105+
}
106+
return redirect
107+
}
90108
}
91109
await next()
92110
})
93-
app.get('/auth/login', kfAuth.login)
94-
app.get('/auth/callback', kfAuth.callback)
95-
app.post('/auth/logout', kfAuth.logout)
96111

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

194-
// Accounts
209+
// Accounts (custom routes — org CRUD, members, invitations, API keys handled by /api/auth/*)
195210
app.get('/api/accounts/me', requireAuth(), accounts.getMe)
196211
app.get('/api/accounts/available-kf-orgs', requireAuth(), accounts.availableKfOrgs)
197212
app.get('/api/accounts/:slug', accounts.getBySlug)
213+
app.get('/api/accounts/:slug/members', accounts.listMembers)
198214
app.patch('/api/accounts/me', requireAuth(), accounts.updateMe)
199-
app.get('/api/accounts/me/sessions', requireAuth(), accounts.listSessions)
200-
app.delete('/api/accounts/me/sessions/:sessionId', requireAuth(), accounts.deleteSession)
201-
app.delete('/api/accounts/me', requireAuth(), accounts.deleteMe)
202-
app.post('/api/accounts/keys', requireAuth(), accounts.createKey)
203-
app.get('/api/accounts/keys', requireAuth(), accounts.listKeys)
204-
app.delete('/api/accounts/keys/:id', requireAuth(), accounts.deleteKey)
205-
app.post('/api/accounts/:slug/keys', requireAuth(), accounts.createOrgKey)
206-
app.get('/api/accounts/:slug/keys', requireAuth(), accounts.listOrgKeys)
207-
app.delete('/api/accounts/:slug/keys/:id', requireAuth(), accounts.deleteOrgKey)
208-
app.post('/api/accounts/orgs', requireAuth(), accounts.createOrg)
209-
app.get('/api/accounts/:slug/members', requireAuth(), accounts.listMembers)
210-
app.post('/api/accounts/:slug/members', requireAuth(), accounts.addMember)
211-
app.patch('/api/accounts/:slug/members/:userId', requireAuth(), accounts.updateMember)
212-
app.delete('/api/accounts/:slug/members/:userId', requireAuth(), accounts.removeMember)
213-
app.patch('/api/accounts/:slug', requireAuth(), accounts.updateOrg)
214215
app.post('/api/accounts/:slug/avatar', requireAuth(), accounts.uploadOrgAvatar)
215-
app.post('/api/accounts/:slug/invitations', requireAuth(), accounts.createInvitation)
216-
app.get('/api/accounts/:slug/invitations', requireAuth(), accounts.listInvitations)
217-
app.delete('/api/accounts/:slug/invitations/:id', requireAuth(), accounts.deleteInvitation)
218-
app.post('/api/accounts/invitations/accept', requireAuth(), accounts.acceptInvitation)
219-
app.delete('/api/accounts/:slug', requireAuth(), accounts.deleteOrg)
216+
app.delete('/api/accounts/me', requireAuth(), accounts.deleteMe)
220217

221218
// --- Blog content API (serves rendered markdown) ---
222219
app.get('/api/blog/:slug', (c) => {
@@ -285,9 +282,10 @@ if (isProd) {
285282
return c.html(page, statusCode ?? 200)
286283
})
287284
} else {
285+
devHttpServer = createHttpServer()
288286
const { createServer: createViteServer } = await import('vite')
289287
vite = await createViteServer({
290-
server: { middlewareMode: true },
288+
server: { middlewareMode: true, hmr: { server: devHttpServer } },
291289
appType: 'custom',
292290
})
293291

@@ -336,12 +334,23 @@ if (isProd) {
336334

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

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

346350
console.log(`Server running at http://localhost:${port}`)
347-
serve({ fetch: app.fetch, port })
351+
if (devHttpServer) {
352+
devHttpServer.on('request', getRequestListener(app.fetch))
353+
devHttpServer.listen(port)
354+
} else {
355+
serve({ fetch: app.fetch, port })
356+
}

0 commit comments

Comments
 (0)