From b70d82f3d3c49a41ba48d5097233259315dbd2be Mon Sep 17 00:00:00 2001 From: daveselfsurf <207135236+daveselfsurf@users.noreply.github.com> Date: Thu, 28 May 2026 22:17:54 +0100 Subject: [PATCH] feat(auth-service): add /_internal/account/create for community DIDs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a headless, no-OTP account-creation endpoint so service callers can provision community DIDs — accounts that represent a community or group rather than a single human, and so have no inbox to receive an OTP. The endpoint mirrors the existing /_internal/otp/* and /_internal/recovery/* handlers (same x-api-key auth, allowedOrigins, and rate limiting) and reuses handleSignup() for the invite-mint + createAccount path. It is gated behind a new can_create_directly permission — NOT can_signup — because skipping the email-OTP step is a stronger capability than ordinary signup, so it is off by default. A v11 migration adds the can_create_directly column to api_clients (default 0), and create-api-client.mjs gains a --can-create-directly flag. The handle is validated identically to the OAuth signup flow via validateLocalPart (5-20 chars, single-label, ATProto-spec-valid); the caller- supplied email is treated as opaque since no mail is sent for these accounts. --- .changeset/community-dids.md | 15 ++ .../src/__tests__/headless-account.test.ts | 200 ++++++++++++++++++ .../auth-service/src/routes/headless-otp.ts | 100 ++++++++- .../src/__tests__/db-api-clients.test.ts | 23 +- packages/shared/src/db.ts | 28 ++- scripts/create-api-client.mjs | 9 +- 6 files changed, 369 insertions(+), 6 deletions(-) create mode 100644 .changeset/community-dids.md create mode 100644 packages/auth-service/src/__tests__/headless-account.test.ts diff --git a/.changeset/community-dids.md b/.changeset/community-dids.md new file mode 100644 index 00000000..a7ac7da6 --- /dev/null +++ b/.changeset/community-dids.md @@ -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. diff --git a/packages/auth-service/src/__tests__/headless-account.test.ts b/packages/auth-service/src/__tests__/headless-account.test.ts new file mode 100644 index 00000000..b3cd8b48 --- /dev/null +++ b/packages/auth-service/src/__tests__/headless-account.test.ts @@ -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((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((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 = {}, +): Promise<{ status: number; json: Record }> { + 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 + 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') + }) +}) diff --git a/packages/auth-service/src/routes/headless-otp.ts b/packages/auth-service/src/routes/headless-otp.ts index b148284a..6aa48051 100644 --- a/packages/auth-service/src/routes/headless-otp.ts +++ b/packages/auth-service/src/routes/headless-otp.ts @@ -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' @@ -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 }) } @@ -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 } diff --git a/packages/shared/src/__tests__/db-api-clients.test.ts b/packages/shared/src/__tests__/db-api-clients.test.ts index ecfaa67a..64bb2baa 100644 --- a/packages/shared/src/__tests__/db-api-clients.test.ts +++ b/packages/shared/src/__tests__/db-api-clients.test.ts @@ -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() @@ -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', () => { @@ -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() diff --git a/packages/shared/src/db.ts b/packages/shared/src/db.ts index 69881b11..c8f87fb5 100644 --- a/packages/shared/src/db.ts +++ b/packages/shared/src/db.ts @@ -39,6 +39,7 @@ export interface ApiClientRow { apiKeyHash: string allowedOrigins: string | null canSignup: number + canCreateDirectly: number rateLimitPerHour: number createdAt: number revokedAt: number | null @@ -327,6 +328,26 @@ export class EpdsDb { // tolerates a stuck schema_version or a fresh table). This entry exists // only to advance the version counter cleanly. () => {}, + + // v13: Per-client permission to create accounts directly (no OTP). + // Defaults to 0 — only explicitly-granted keys may use + // POST /_internal/account/create. Used by service callers that + // provision accounts server-to-server (e.g. community accounts). + // + // Idempotent: this column was originally added as v11 on an earlier + // branch, so it already exists on some persistent volumes (it would + // crash the bare ALTER with "duplicate column name"). Guard on + // PRAGMA table_info the same way the v12 Mastodon backstop does. + () => { + const cols = this.db + .prepare(`PRAGMA table_info(api_clients)`) + .all() as Array<{ name: string }> + if (!cols.some((c) => c.name === 'can_create_directly')) { + this.db.exec( + `ALTER TABLE api_clients ADD COLUMN can_create_directly INTEGER DEFAULT 0;`, + ) + } + }, ] for (let i = currentVersion; i < migrations.length; i++) { @@ -701,12 +722,13 @@ export class EpdsDb { apiKeyHash: string allowedOrigins: string | null canSignup: boolean + canCreateDirectly?: boolean rateLimitPerHour: number }): void { this.db .prepare( - `INSERT INTO api_clients (id, name, client_id, api_key_hash, allowed_origins, can_signup, rate_limit_per_hour, created_at) - VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, + `INSERT INTO api_clients (id, name, client_id, api_key_hash, allowed_origins, can_signup, can_create_directly, rate_limit_per_hour, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, ) .run( data.id, @@ -715,6 +737,7 @@ export class EpdsDb { data.apiKeyHash, data.allowedOrigins, data.canSignup ? 1 : 0, + data.canCreateDirectly ? 1 : 0, data.rateLimitPerHour, Date.now(), ) @@ -726,6 +749,7 @@ export class EpdsDb { `SELECT id, name, client_id as clientId, api_key_hash as apiKeyHash, allowed_origins as allowedOrigins, can_signup as canSignup, + can_create_directly as canCreateDirectly, rate_limit_per_hour as rateLimitPerHour, created_at as createdAt, revoked_at as revokedAt, last_used_at as lastUsedAt FROM api_clients WHERE api_key_hash = ? AND revoked_at IS NULL`, diff --git a/scripts/create-api-client.mjs b/scripts/create-api-client.mjs index bb442cb2..7dd8b3ea 100755 --- a/scripts/create-api-client.mjs +++ b/scripts/create-api-client.mjs @@ -10,7 +10,9 @@ * --name Client display name (required) * --client-id client-metadata.json URL for email branding * --allowed-origins Comma-separated allowed origins - * --no-signup Disable account creation for this client + * --no-signup Disable OTP-based account creation + * --can-create-directly Allow no-OTP account creation via + * POST /_internal/account/create (default: off) * --rate-limit Requests per hour (default: 10000) * --db Path to ePDS SQLite database * (default: ./data/epds.sqlite) @@ -25,6 +27,7 @@ function parseArgs(argv) { clientId: null, allowedOrigins: null, canSignup: true, + canCreateDirectly: false, rateLimit: 10000, db: './data/epds.sqlite', } @@ -43,6 +46,9 @@ function parseArgs(argv) { case '--no-signup': args.canSignup = false break + case '--can-create-directly': + args.canCreateDirectly = true + break case '--rate-limit': args.rateLimit = parseInt(argv[++i], 10) break @@ -87,6 +93,7 @@ try { apiKeyHash, allowedOrigins: args.allowedOrigins, canSignup: args.canSignup, + canCreateDirectly: args.canCreateDirectly, rateLimitPerHour: args.rateLimit, })