Skip to content
Merged
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
15 changes: 15 additions & 0 deletions .changeset/community-dids.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
---
'ePDS': minor
---

Service callers can now create ATProto accounts server-to-server with no email-OTP round-trip — enabling **community DIDs**: accounts that represent a community or group rather than a single human, provisioned on the community's behalf with no inbox to receive an OTP.

**Affects:** Client app developers, ePDS operators

**Client app developers:** a new headless endpoint, gated behind a dedicated per-client permission.

- `POST /_internal/account/create` — body `{ handle, email }`. Mints an invite code and creates the account directly, returning the same session payload as the OTP signup path: `{ did, handle, accessJwt, refreshJwt }`. The `handle` is the local part only (ePDS appends the handle domain) and is validated identically to the OAuth signup flow (5–20 chars, single-label, ATProto-spec-valid) — an invalid handle returns `400 { "error": "InvalidHandle" }`. The `email` is supplied by the caller and treated as opaque (no mail is sent); a community DID has no human inbox, so the address only needs to satisfy the PDS account email-uniqueness constraint.

The endpoint honours the calling client's `allowedOrigins` and `rateLimitPerHour` and rejects unknown keys with `401`, exactly like the `/_internal/otp/*` and `/_internal/recovery/*` endpoints. It additionally requires the new `can_create_directly` permission — a client without it gets `403 { "error": "DirectCreateNotAllowed" }`. Skipping the OTP step is a stronger capability than ordinary signup, so it is **off by default** and not implied by `can_signup`.

**ePDS operators:** grant the permission when minting a key with `scripts/create-api-client.mjs --can-create-directly`. A new schema migration (v11) adds the `can_create_directly` column to `api_clients`, defaulting to `0` for all existing keys.
200 changes: 200 additions & 0 deletions packages/auth-service/src/__tests__/headless-account.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,200 @@
/**
* Integration tests for the headless direct account-creation endpoint
* (POST /_internal/account/create).
*
* Mounts the real headless router on an ephemeral express server with a
* real EpdsDb and a stubbed better-auth instance. Covers the auth/validation
* surface that runs BEFORE any pds-core call: API-key rejection, origin and
* rate-limit checks, the can_create_directly permission gate, and handle/
* email validation. The account-minting path (handleSignup -> invite ->
* com.atproto.server.createAccount) is covered by end-to-end staging tests,
* since it requires a live pds-core.
*/
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
import express, { type Express } from 'express'
import type { AddressInfo } from 'node:net'
import type { Server } from 'node:http'
import { createHash, randomBytes, randomUUID } from 'node:crypto'
import * as fs from 'node:fs'
import * as path from 'node:path'
import * as os from 'node:os'
import { EpdsDb } from '@certified-app/shared'
import { createHeadlessOtpRouter } from '../routes/headless-otp.js'
import type { AuthServiceContext } from '../context.js'
import type { BetterAuthInstance } from '../better-auth.js'

let db: EpdsDb
let dbPath: string
let server: Server
let baseUrl: string
let app: Express

function hashKey(key: string): string {
return createHash('sha256').update(key).digest('hex')
}

function createTestClient(
overrides: Partial<{
apiKey: string
allowedOrigins: string | null
rateLimitPerHour: number
canCreateDirectly: boolean
}> = {},
): { apiKey: string } {
const apiKey = overrides.apiKey ?? randomBytes(32).toString('hex')
db.createApiClient({
id: randomUUID(),
name: 'TestApp',
clientId: null,
apiKeyHash: hashKey(apiKey),
allowedOrigins: overrides.allowedOrigins ?? null,
canSignup: true,
canCreateDirectly: overrides.canCreateDirectly ?? true,
rateLimitPerHour: overrides.rateLimitPerHour ?? 10000,
})
return { apiKey }
}

beforeAll(async () => {
dbPath = path.join(
os.tmpdir(),
`epds-account-test-${Date.now()}-${Math.random().toString(36).slice(2)}.sqlite`,
)
db = new EpdsDb(dbPath)

// Stub better-auth — the account-create path never calls it, but the
// router constructor requires an instance.
const auth = {
api: {
sendVerificationOTP() {
return Promise.resolve()
},
signInEmailOTP() {
return Promise.resolve({ token: 'stub' })
},
},
} as unknown as BetterAuthInstance

const ctx = { db } as unknown as AuthServiceContext

app = express()
app.use(express.json())
app.use(createHeadlessOtpRouter(ctx, auth))
await new Promise<void>((resolve) => {
server = app.listen(0, '127.0.0.1', () => {
resolve()
})
})
baseUrl = `http://127.0.0.1:${(server.address() as AddressInfo).port}`
})

afterAll(async () => {
await new Promise<void>((resolve, reject) => {
server.close((err) => {
if (err) reject(err)
else resolve()
})
})
db.close()
for (const suffix of ['', '-wal', '-shm']) {
try {
fs.unlinkSync(dbPath + suffix)
// eslint-disable-next-line no-empty
} catch {}
}
})

async function post(
routePath: string,
body: unknown,
headers: Record<string, string> = {},
): Promise<{ status: number; json: Record<string, unknown> }> {
const res = await fetch(`${baseUrl}${routePath}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', ...headers },
body: JSON.stringify(body),
})
const json = (await res.json().catch(() => ({}))) as Record<string, unknown>
return { status: res.status, json }
}

describe('POST /_internal/account/create', () => {
it('rejects a missing/invalid API key with 401', async () => {
const res = await post('/_internal/account/create', {
handle: 'communityone',
email: 'community-abc123@example.internal',
})
expect(res.status).toBe(401)
expect(res.json.error).toBe('Unauthorized')
})

it('rejects a client lacking can_create_directly with 403', async () => {
const { apiKey } = createTestClient({ canCreateDirectly: false })
const res = await post(
'/_internal/account/create',
{ handle: 'communityone', email: 'community-abc123@example.internal' },
{ 'x-api-key': apiKey },
)
expect(res.status).toBe(403)
expect(res.json.error).toBe('DirectCreateNotAllowed')
})

it('rejects a disallowed origin with 403', async () => {
const { apiKey } = createTestClient({
allowedOrigins: 'https://allowed.example.com',
})
const res = await post(
'/_internal/account/create',
{ handle: 'communityone', email: 'community-abc123@example.internal' },
{ 'x-api-key': apiKey, origin: 'https://evil.example.com' },
)
expect(res.status).toBe(403)
expect(res.json.error).toBe('OriginNotAllowed')
})

it('rejects missing handle/email with 400', async () => {
const { apiKey } = createTestClient()
const res = await post(
'/_internal/account/create',
{ email: 'community-abc123@example.internal' },
{ 'x-api-key': apiKey },
)
expect(res.status).toBe(400)
})

it('rejects an invalid handle (contains a dot) with 400 InvalidHandle', async () => {
const { apiKey } = createTestClient()
const res = await post(
'/_internal/account/create',
{ handle: 'has.dot', email: 'community-abc123@example.internal' },
{ 'x-api-key': apiKey },
)
expect(res.status).toBe(400)
expect(res.json.error).toBe('InvalidHandle')
})

it('rejects a handle that is too short with 400 InvalidHandle', async () => {
const { apiKey } = createTestClient()
const res = await post(
'/_internal/account/create',
{ handle: 'ab', email: 'community-abc123@example.internal' },
{ 'x-api-key': apiKey },
)
expect(res.status).toBe(400)
expect(res.json.error).toBe('InvalidHandle')
})

it('rejects a handle that is too long with 400 InvalidHandle', async () => {
const { apiKey } = createTestClient()
const res = await post(
'/_internal/account/create',
{
handle: 'thishandleiswaytoolongtobevalid',
email: 'community-abc123@example.internal',
},
{ 'x-api-key': apiKey },
)
expect(res.status).toBe(400)
expect(res.json.error).toBe('InvalidHandle')
})
})
100 changes: 98 additions & 2 deletions packages/auth-service/src/routes/headless-otp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
* POST /_internal/otp/verify — verify code, return AT Proto session tokens
*/
import { Router, type Request, type Response } from 'express'
import { createLogger } from '@certified-app/shared'
import { createLogger, validateLocalPart } from '@certified-app/shared'
import type { AuthServiceContext } from '../context.js'
import type { BetterAuthInstance } from '../better-auth.js'
import { getDidByEmail } from '../lib/get-did-by-email.js'
Expand Down Expand Up @@ -371,7 +371,10 @@ export function createHeadlessOtpRouter(
const result = await handleLogin(resolved.email, pdsUrl)
res.json(result)
} catch (err) {
logger.error({ err, backupEmail }, 'Headless recovery post-verify failed')
logger.error(
{ err, backupEmail },
'Headless recovery post-verify failed',
)
const message = err instanceof Error ? err.message : 'Recovery failed'
res.status(500).json({ error: message })
}
Expand Down Expand Up @@ -432,5 +435,98 @@ export function createHeadlessOtpRouter(
},
)

// ─── POST /_internal/account/create ─────────────────────────────────
// Create an ATProto account server-to-server, with no OTP round-trip.
// For service callers that provision community DIDs — accounts that
// represent a community or group rather than a single human, and so have
// no inbox to receive an OTP. Gated by the dedicated can_create_directly
// permission — NOT can_signup — because skipping the email-OTP step is a
// stronger capability than ordinary signup.
//
// The `handle` is the local part only; ePDS appends the handle domain.
// The `email` is supplied by the caller and treated as opaque — a
// community DID has no human inbox, so the address only needs to satisfy
// the PDS account email-uniqueness constraint; no mail is ever sent.
router.post(
'/_internal/account/create',
async (req: Request, res: Response) => {
const apiClient = authenticateApiKey(req, ctx.db)
if (!apiClient) {
logger.warn({ ip: req.ip }, 'Headless account create: invalid API key')
res.status(401).json({ error: 'Unauthorized' })
return
}

if (!checkAllowedOrigin(apiClient.allowedOrigins, req.headers.origin)) {
res.status(403).json({ error: 'OriginNotAllowed' })
return
}

if (
!checkApiClientRateLimit(
ctx.db,
apiClient.id,
apiClient.rateLimitPerHour,
)
) {
res.status(429).json({ error: 'RateLimitExceeded' })
return
}

if (!apiClient.canCreateDirectly) {
logger.warn(
{ clientId: apiClient.id },
'Headless account create: client lacks can_create_directly',
)
res.status(403).json({ error: 'DirectCreateNotAllowed' })
return
}

const email = ((req.body?.email as string) || '').trim().toLowerCase()
const rawHandle = ((req.body?.handle as string) || '').trim()

if (!email || !rawHandle) {
res.status(400).json({ error: 'handle and email are required' })
return
}

// Same validation the OAuth signup flow applies: 5–20 chars,
// single-label (no dots), ATProto-spec-valid. Returns the
// normalized (lowercased) local part, or null if invalid.
const handleDomain = getHandleDomain()
const normalizedLocal = validateLocalPart(rawHandle, handleDomain)
if (!normalizedLocal) {
res.status(400).json({ error: 'InvalidHandle' })
return
}

ctx.db.recordApiClientUsage(apiClient.id, 'account_create')
ctx.db.updateApiClientLastUsed(apiClient.id)

const pdsUrl = getPdsUrl()

try {
// handleSignup mints an invite code (admin API) and calls
// com.atproto.server.createAccount — exactly the signup path the
// OTP flow uses, minus the OTP verification we never ran.
const result = await handleSignup(
email,
normalizedLocal,
handleDomain,
pdsUrl,
)
res.status(201).json(result)
} catch (err) {
logger.error(
{ err, handle: normalizedLocal },
'Headless account create failed',
)
const message =
err instanceof Error ? err.message : 'Account creation failed'
res.status(500).json({ error: message })
}
},
)

return router
}
23 changes: 22 additions & 1 deletion packages/shared/src/__tests__/db-api-clients.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,8 @@ describe('API Client Operations', () => {
expect(client!.apiKeyHash).toBe(apiKeyHash)
expect(client!.allowedOrigins).toBe('https://example.com')
expect(client!.canSignup).toBe(1)
// Defaults to 0 when not explicitly granted.
expect(client!.canCreateDirectly).toBe(0)
expect(client!.rateLimitPerHour).toBe(500)
expect(client!.revokedAt).toBeNull()
expect(client!.lastUsedAt).toBeNull()
Expand Down Expand Up @@ -130,6 +132,25 @@ describe('API Client Operations', () => {
const client = db.getApiClientByKeyHash(apiKeyHash)
expect(client!.canSignup).toBe(0)
})

it('stores canCreateDirectly=true correctly', () => {
const id = randomUUID()
const apiKeyHash = hashKey('direct-create')

db.createApiClient({
id,
name: 'DirectCreate',
clientId: null,
apiKeyHash,
allowedOrigins: null,
canSignup: true,
canCreateDirectly: true,
rateLimitPerHour: 10000,
})

const client = db.getApiClientByKeyHash(apiKeyHash)
expect(client!.canCreateDirectly).toBe(1)
})
})

describe('API Client Usage Tracking', () => {
Expand Down Expand Up @@ -165,7 +186,7 @@ describe('API Client Usage Tracking', () => {
})

describe('Schema Version', () => {
it('is at version 10 after all migrations', () => {
it('is at version 11 after all migrations', () => {
// EpdsDb runs migrations in constructor, so just check the version
// by creating a fresh db and verifying api_clients table exists
const id = randomUUID()
Expand Down
Loading
Loading