diff --git a/README.md b/README.md index 58bdbaa..78eafe1 100644 --- a/README.md +++ b/README.md @@ -62,6 +62,34 @@ persuasive caller can be pointed at. No API keys are needed. The pipeline runs end to end on a deterministic fixture provider, so a fresh checkout works with an empty `.env`. +### A person's OpenProfile.md + +The `openprofile` job assembles one [OpenProfile.md](https://logicsrc.com/openprofile) +per person from their public profiles, through `@profullstack/openprofile`. It +is private until somebody switches it on, and it can be corrected from every +surface: what the owner writes wins section by section, the rest is still +generated, and a rewrite by the job never touches the corrections. + +```bash +og profile per_... # the file, as this workspace sees it +og profile edit per_... [--file profile.md] # correct it ($EDITOR when no --file) +og profile publish per_... --public|--private # list it for directories, or stop +``` + +The same three over HTTP: `GET /api/v1/people/{id}/openprofile.md`, +`PUT /api/v1/people/{id}/openprofile` (the whole file as `text/markdown`, or a +JSON overlay of `identity`, `headline`, `sections`, `public`, `handle`), and +`POST /api/v1/people/{id}/openprofile/publish {public}`. MCP: +`get_openprofile`, `update_openprofile`, `publish_openprofile`. + +A public profile is served to anybody, minus email, phone and any Contact +section, and listed at `GET /api/v1/openprofiles?since=&limit=&cursor=` for a +directory such as nichedb.dev to pull. A suppressed person is never public. +The person may correct their own profile without a session here: an OpenAccess +bearer with the `openprofile:edit` scope is honoured when its principal is +provably them, by an email this deployment verified or by the OpenProfile.md +they publish. + ### Sending outreach Outreach leaves through the workspace's **own** SMTP server, connected on diff --git a/apps/api/package.json b/apps/api/package.json index b704780..938e8d5 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -11,6 +11,8 @@ "test": "bun test" }, "dependencies": { + "@logicsrc/openaccess": "^0.3.0", + "@outreachgraph/ai": "workspace:*", "@outreachgraph/contracts": "workspace:*", "@outreachgraph/db": "workspace:*", "@outreachgraph/domain": "workspace:*", @@ -22,8 +24,8 @@ "@outreachgraph/scoring": "workspace:*", "@outreachgraph/secrets": "workspace:*", "@outreachgraph/signals": "workspace:*", + "@profullstack/openprofile": "^0.1.0", "hono": "^4.6.14", - "zod": "^3.24.1", - "@outreachgraph/ai": "workspace:*" + "zod": "^3.24.1" } } diff --git a/apps/api/src/app.ts b/apps/api/src/app.ts index 2b159a1..9192277 100644 --- a/apps/api/src/app.ts +++ b/apps/api/src/app.ts @@ -176,6 +176,18 @@ import { listPublicDirectory, } from './public-directory'; import { ApiError, canApprove, type AppEnv, type RequestActor } from './context'; +import { + bearerMayEdit, + composeProfile, + decodeCursor, + encodeCursor, + listingEntry, + loadSettings, + overridesFromRequest, + saveSettings, + verifiedEmails, + type BearerClaims, +} from './openprofile'; import * as repo from './repository'; import { listProducts, @@ -248,6 +260,12 @@ export interface AppOptions { readonly coinpay?: CoinPayClient; /** Public origin of *this* API, so CoinPayPortal knows where to call back. */ readonly apiUrl?: string; + /** + * Verifies an OpenAccess bearer (openaccess.logicsrc.com) and returns its + * claims, or undefined for a token that is not one. Tests inject a stub; + * production verifies against the hub's published keys. + */ + readonly verifyBearer?: (token: string) => Promise; readonly version?: string; readonly commitHash?: string; } @@ -736,6 +754,251 @@ export function createApp(options: AppOptions): Hono { } }); + // ------------------------------------------------------- openprofiles + // + // A person's OpenProfile.md is public only when somebody switched it on, + // and then it is public to everyone: a directory such as nichedb.dev reads + // the listing and the files with no key. Until then the file is served to + // the workspace that holds the person, and to nobody else, with a 404 that + // says nothing about whether the person exists. Both routes sit above the + // authentication gate for that reason; each decides for itself. + + const origin = (options.apiUrl ?? 'https://outreachgraph.com').replace(/\/+$/, ''); + const PUBLIC_HEADERS = { + 'content-type': 'text/markdown; charset=utf-8', + 'cache-control': 'public, max-age=3600', + 'access-control-allow-origin': '*', + } as const; + + const publicPerson = async (db: Client, personId: string) => + queryOne<{ id: string }>( + db, + `SELECT p.id FROM people p JOIN openprofile_settings s ON s.person_id = p.id + WHERE p.id = ? AND p.status = 'active' AND s.public = 1`, + [personId], + ); + + const heldPerson = async (db: Client, personId: string, workspaceId: string) => + queryOne<{ person_id: string }>( + db, + `SELECT cp.person_id FROM campaign_people cp JOIN people p ON p.id = cp.person_id + WHERE cp.person_id = ? AND cp.workspace_id = ? AND p.status != 'deleted' LIMIT 1`, + [personId, workspaceId], + ); + + const generatedProfile = async (db: Client, personId: string) => + queryOne<{ markdown: string; generated_at: string; published_url: string | null }>( + db, + 'SELECT markdown, generated_at, published_url FROM openprofiles WHERE person_id = ?', + [personId], + ); + + /** Every public profile, newest change first, for a directory to pull. */ + api.get('/openprofiles', async (c) => { + const db = options.db; + const limit = Math.min(500, Math.max(1, Number(c.req.query('limit') ?? 100) || 100)); + const since = c.req.query('since'); + const cursor = decodeCursor(c.req.query('cursor')); + + const rows = await queryAll<{ + person_id: string; + markdown: string; + overrides_json: string; + updated_at: string; + generated_at: string; + }>( + db, + `SELECT s.person_id, o.markdown, s.overrides_json, + MAX(s.updated_at, o.generated_at) AS updated_at, o.generated_at + FROM openprofile_settings s + JOIN people p ON p.id = s.person_id AND p.status = 'active' + JOIN openprofiles o ON o.person_id = s.person_id + WHERE s.public = 1 + AND (? IS NULL OR MAX(s.updated_at, o.generated_at) >= ?) + AND (? IS NULL OR MAX(s.updated_at, o.generated_at) < ? + OR (MAX(s.updated_at, o.generated_at) = ? AND s.person_id > ?)) + ORDER BY updated_at DESC, s.person_id ASC + LIMIT ?`, + [ + since ?? null, + since ?? null, + cursor?.updatedAt ?? null, + cursor?.updatedAt ?? null, + cursor?.updatedAt ?? null, + cursor?.personId ?? null, + limit + 1, + ], + ); + + const page = rows.slice(0, limit); + const openprofiles = page.map((row) => { + const settings = { overrides: JSON.parse(row.overrides_json || '{}') }; + const { doc } = composeProfile(row.markdown, settings.overrides, 'public'); + return listingEntry(doc, row.person_id, row.updated_at, origin); + }); + const last = page[page.length - 1]; + const next = rows.length > limit && last ? encodeCursor(last.updated_at, last.person_id) : null; + + return c.json({ openprofiles, next }, 200, { + 'access-control-allow-origin': '*', + 'cache-control': 'public, max-age=300', + }); + }); + + /** + * The OpenProfile.md OutreachGraph assembled for a person, with the + * owner's corrections over it. Public when switched on; otherwise served to + * a workspace that holds the person, and 404 to everyone else. + */ + api.get('/people/:id/openprofile.md', async (c) => { + const db = options.db; + const personId = c.req.param('id'); + + const profile = await generatedProfile(db, personId); + const isPublic = profile ? await publicPerson(db, personId) : undefined; + + if (isPublic && profile) { + const settings = await loadSettings(db, personId); + const { markdown } = composeProfile(profile.markdown, settings.overrides, 'public'); + return c.body(markdown, 200, { + ...PUBLIC_HEADERS, + 'last-modified': new Date(settings.updatedAt ?? profile.generated_at).toUTCString(), + ...(profile.published_url ? { link: `<${profile.published_url}>; rel="canonical"` } : {}), + }); + } + + const actor = await resolveActor(c.req.raw); + const held = actor ? await heldPerson(db, personId, actor.workspaceId) : undefined; + if (!held) throw ApiError.notFound('person'); + if (!profile) throw ApiError.notFound('openprofile'); + + const settings = await loadSettings(db, personId); + const { markdown } = composeProfile(profile.markdown, settings.overrides, 'private'); + return c.body(markdown, 200, { + 'content-type': 'text/markdown; charset=utf-8', + 'last-modified': new Date(settings.updatedAt ?? profile.generated_at).toUTCString(), + ...(profile.published_url ? { link: `<${profile.published_url}>; rel="canonical"` } : {}), + }); + }); + + /** + * Correct the profile. The operator may, as with every other write about a + * person; so may the person, carrying an OpenAccess bearer with the + * `openprofile:edit` scope that is provably theirs. A Markdown body is the + * whole file; a JSON body is a partial overlay, and may also flip `public` + * and set `handle`. + */ + api.put('/people/:id/openprofile', async (c) => { + const db = options.db; + const personId = c.req.param('id'); + const stamp = now(); + + const actor = await resolveActor(c.req.raw); + let editor: + { kind: 'operator'; userId: string } | { kind: 'subject'; method: 'email' | 'profile' }; + if (actor) { + if (!(await heldPerson(db, personId, actor.workspaceId))) throw ApiError.notFound('person'); + if (!canApprove(actor)) throw ApiError.forbidden('editing a profile'); + editor = { kind: 'operator', userId: actor.userId }; + } else { + const header = c.req.header('authorization') ?? ''; + const token = /^Bearer\s+(\S+)$/i.exec(header.trim())?.[1]; + const claims = token && options.verifyBearer ? await options.verifyBearer(token) : undefined; + if (!claims) throw ApiError.unauthorized(); + const person = await queryOne<{ id: string }>( + db, + "SELECT id FROM people WHERE id = ? AND status != 'deleted'", + [personId], + ); + if (!person) throw ApiError.notFound('person'); + const profile = await generatedProfile(db, personId); + const verdict = bearerMayEdit(claims, { + emails: await verifiedEmails(db, personId), + publishedUrl: profile?.published_url ?? null, + }); + if (!verdict.ok) throw ApiError.forbidden(verdict.reason); + editor = { kind: 'subject', method: verdict.method }; + } + + const profile = await generatedProfile(db, personId); + if (!profile) throw ApiError.notFound('openprofile'); + const stored = await loadSettings(db, personId); + + const contentType = c.req.header('content-type') ?? null; + let body: string | Record; + if (/application\/json/i.test(contentType ?? '')) { + const parsed: unknown = await c.req.json().catch(() => undefined); + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) + throw ApiError.badRequest('expected a JSON object or a text/markdown body'); + body = parsed as Record; + const known = [ + 'markdown', + 'name', + 'headline', + 'prose', + 'identity', + 'sections', + 'public', + 'handle', + ]; + const overlay = body; + if (!known.some((key) => overlay[key] !== undefined)) + throw ApiError.badRequest( + 'nothing to change: send markdown, identity, headline, sections, public or handle', + ); + } else { + body = await c.req.text(); + if (!body.trim()) throw ApiError.badRequest('an empty body corrects nothing'); + } + + const overrides = overridesFromRequest(contentType, body, profile.markdown, stored.overrides); + const patch: Parameters[2] = { overrides }; + if (typeof body === 'object') { + if (typeof body.public === 'boolean') (patch as { public?: boolean }).public = body.public; + if (typeof body.handle === 'string' || body.handle === null) { + const handle = body.handle?.trim().replace(/^@/, '').toLowerCase() || null; + if (handle && !/^[a-z0-9][a-z0-9._-]{1,62}$/.test(handle)) + throw ApiError.badRequest( + 'a handle is 2 to 63 letters, digits, dots, dashes or underscores', + ); + (patch as { handle?: string | null }).handle = handle; + } + } + // The first edit by the person themselves is their claim; the operator's + // edit claims nothing on their behalf. + if (editor.kind === 'subject' && !stored.claimedAt) + ( + patch as { claim?: { userId: string | null; method: 'email' | 'profile' | 'operator' } } + ).claim = { userId: null, method: editor.method }; + + const saved = await saveSettings(db, personId, patch, stamp); + const { markdown } = composeProfile( + profile.markdown, + saved.overrides, + editor.kind === 'subject' || saved.public ? 'public' : 'private', + ); + + if (editor.kind === 'operator' && actor) { + await repo.audit(db, { + workspaceId: actor.workspaceId, + actorKind: 'user', + actorId: actor.userId, + eventType: 'openprofile.edited', + entityKind: 'person', + entityId: personId, + detail: { sections: Object.keys(saved.overrides.sections ?? {}), public: saved.public }, + }); + } + + return c.json({ + markdown, + updatedAt: saved.updatedAt, + public: saved.public, + handle: saved.handle, + editedBy: editor.kind, + }); + }); + /** * The public directory: companies, sites and self-published people, keyless. * @@ -1847,36 +2110,47 @@ export function createApp(options: AppOptions): Hono { }); /** - * The OpenProfile.md OutreachGraph assembled for a person, or the one they - * publish themselves. Workspace-scoped like everything else about a person: - * a reader has to hold the person in a campaign of their own. + * Switch a person's OpenProfile.md public or private. Operator only: making + * a profile public is publishing what this workspace holds about somebody, + * and that is the workspace's decision to make and to answer for. The + * reading and the editing routes sit above the authentication gate. */ - api.get('/people/:id/openprofile.md', async (c) => { + api.post('/people/:id/openprofile/publish', async (c) => { const actor = c.get('actor'); const db = c.get('db'); const personId = c.req.param('id'); + if (!canApprove(actor)) throw ApiError.forbidden('publishing a profile'); - const held = await queryOne<{ person_id: string }>( - db, - `SELECT cp.person_id FROM campaign_people cp JOIN people p ON p.id = cp.person_id - WHERE cp.person_id = ? AND cp.workspace_id = ? AND p.status != 'deleted' LIMIT 1`, - [personId, actor.workspaceId], - ); + const body = z.object({ public: z.boolean() }).safeParse(await c.req.json().catch(() => ({}))); + if (!body.success) throw ApiError.badRequest('expected { public: true | false }'); + + const held = await heldPerson(db, personId, actor.workspaceId); if (!held) throw ApiError.notFound('person'); + const person = await repo.getPerson(db, personId); + if (!person) throw ApiError.notFound('person'); + if (body.data.public && person.status !== 'active') + throw new ApiError(409, 'not_publishable', 'a suppressed person is never public'); + if (body.data.public && !(await generatedProfile(db, personId))) + throw new ApiError(409, 'not_publishable', 'no OpenProfile.md has been assembled yet'); - const profile = await queryOne<{ - markdown: string; - generated_at: string; - published_url: string | null; - }>(db, 'SELECT markdown, generated_at, published_url FROM openprofiles WHERE person_id = ?', [ - personId, - ]); - if (!profile) throw ApiError.notFound('openprofile'); + const saved = await saveSettings(db, personId, { public: body.data.public }); + await repo.audit(db, { + workspaceId: actor.workspaceId, + actorKind: 'user', + actorId: actor.userId, + eventType: saved.public ? 'openprofile.published' : 'openprofile.unpublished', + entityKind: 'person', + entityId: personId, + detail: { public: saved.public }, + }); - return c.body(profile.markdown, 200, { - 'content-type': 'text/markdown; charset=utf-8', - 'last-modified': new Date(profile.generated_at).toUTCString(), - ...(profile.published_url ? { link: `<${profile.published_url}>; rel="canonical"` } : {}), + return c.json({ + personId, + public: saved.public, + publishedAt: saved.publishedAt, + url: saved.public + ? `${origin}/api/v1/people/${encodeURIComponent(personId)}/openprofile.md` + : null, }); }); @@ -1896,6 +2170,7 @@ export function createApp(options: AppOptions): Hono { emailCandidates, membership, openprofile, + profileSettings, ] = await Promise.all([ repo.listIdentities(db, personId), repo.listCompanyIdentities(db, personId), @@ -1923,6 +2198,7 @@ export function createApp(options: AppOptions): Hono { 'SELECT generated_at, published_url FROM openprofiles WHERE person_id = ?', [personId], ), + loadSettings(db, personId), ]); return c.json({ @@ -1939,6 +2215,10 @@ export function createApp(options: AppOptions): Hono { url: `/api/v1/people/${personId}/openprofile.md`, generatedAt: openprofile.generated_at, publishedUrl: openprofile.published_url, + public: profileSettings.public, + handle: profileSettings.handle, + claimedAt: profileSettings.claimedAt, + editedAt: profileSettings.updatedAt, } : null, }); diff --git a/apps/api/src/openaccess.ts b/apps/api/src/openaccess.ts new file mode 100644 index 0000000..9908f9e --- /dev/null +++ b/apps/api/src/openaccess.ts @@ -0,0 +1,37 @@ +/** + * OpenAccess (openaccess.logicsrc.com): OAuth 2.1 with a grant you can carry. + * + * The descriptor at /.well-known/openaccess.json names the scopes this site + * honours. An access token the hub minted for `outreachgraph.com` is verified + * here against the hub's published keys, and its claims decide what the + * caller may do. Today that is one thing: a person correcting the + * OpenProfile.md this deployment holds about them (`openprofile:edit`), + * without a session here, because the profile is theirs before it is ours. + */ + +import { OpenAccessApp } from '@logicsrc/openaccess/client'; +import type { BearerClaims } from './openprofile'; + +export const HUB = 'https://openaccess.logicsrc.com'; +export const CLIENT_ID = 'outreachgraph.com'; + +let app: OpenAccessApp | undefined; + +function hub(): OpenAccessApp { + app ??= new OpenAccessApp({ + hub: HUB, + clientId: CLIENT_ID, + redirectUri: `https://${CLIENT_ID}/api/v1/openaccess/callback`, + }); + return app; +} + +/** The claims behind an OpenAccess bearer, or undefined for a token that is not one. */ +export async function verifyOpenAccessBearer(token: string): Promise { + try { + const claims = await hub().verify(token); + return typeof claims.sub === 'string' && claims.sub ? (claims as BearerClaims) : undefined; + } catch { + return undefined; + } +} diff --git a/apps/api/src/openprofile-public.test.ts b/apps/api/src/openprofile-public.test.ts new file mode 100644 index 0000000..b1a43e5 --- /dev/null +++ b/apps/api/src/openprofile-public.test.ts @@ -0,0 +1,336 @@ +/** + * A person's OpenProfile.md, public and corrected. + * + * What has to hold: a profile is private until switched on, and then it is + * listed and served to anybody minus email and phone; a suppressed person is + * never listed even when the switch is on; the owner's corrections win over + * the generator section by section and survive a regeneration; a whole + * edited file and a JSON overlay store the same thing; and a bearer edits + * only when it carries the scope and is provably the person. + */ + +import { afterEach, describe, expect, test } from 'bun:test'; +import type { Hono } from 'hono'; +import { createApp } from './app'; +import type { AppEnv, RequestActor } from './context'; +import type { BearerClaims } from './openprofile'; +import { seedDatabase, SEED, type SeededDatabase } from './test-seed'; + +const ACTOR: RequestActor = { + userId: SEED.userId, + workspaceId: SEED.workspaceId, + organizationId: SEED.organizationId, + role: 'owner', +}; + +const GENERATED = [ + '# Jane Doe', + '', + '- **Kind**: person', + '- **Handle**: @jane', + '- **Web**: https://jane.example', + '- **Email**: jane@example.com', + '- **Avatar**: https://jane.example/jane.png', + '', + 'Builds payment rails.', + '', + '## Accounts', + '', + '- [GitHub](https://github.com/jane)', + '- [Email](mailto:jane@example.com)', + '', + '## Topics', + '', + '- payments', + '', + '## Contact', + '', + '- Phone: +1 555 0100', + '', +].join('\n'); + +let active: SeededDatabase | undefined; + +afterEach(() => { + active?.cleanup(); + active = undefined; +}); + +interface Harness { + app: Hono; + seeded: SeededDatabase; +} + +async function harness( + label: string, + options: { + anonymous?: boolean; + bearer?: (token: string) => Promise; + } = {}, +): Promise { + const seeded = await seedDatabase(label); + active = seeded; + await seeded.db.execute({ + sql: `INSERT INTO openprofiles (person_id, markdown, sources_json, published_url, generated_at) + VALUES (?, ?, '[]', NULL, ?)`, + args: [SEED.personId, GENERATED, '2026-09-13T00:00:00.000Z'], + }); + const app = createApp({ + db: seeded.db, + apiUrl: 'https://og.test', + authenticate: async (request) => + options.anonymous || request.headers.get('x-anonymous') === '1' ? undefined : ACTOR, + ...(options.bearer ? { verifyBearer: options.bearer } : {}), + }); + return { app, seeded }; +} + +const get = (app: Hono, path: string, headers: Record = {}) => + app.request(`/api/v1${path}`, { headers }); +const post = (app: Hono, path: string, body: unknown = {}) => + app.request(`/api/v1${path}`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(body), + }); +const putJson = ( + app: Hono, + path: string, + body: unknown, + headers: Record = {}, +) => + app.request(`/api/v1${path}`, { + method: 'PUT', + headers: { 'content-type': 'application/json', ...headers }, + body: JSON.stringify(body), + }); +const putMarkdown = ( + app: Hono, + path: string, + body: string, + headers: Record = {}, +) => + app.request(`/api/v1${path}`, { + method: 'PUT', + headers: { 'content-type': 'text/markdown; charset=utf-8', ...headers }, + body, + }); + +const ANON = { 'x-anonymous': '1' }; +const profilePath = `/people/${SEED.personId}/openprofile`; + +describe('public profiles', () => { + test('private until switched on; then listed and served to anybody without email or phone', async () => { + const { app } = await harness('openprofile-public-flip'); + + // Nobody: a 404 that says nothing about whether the person exists. + expect((await get(app, `${profilePath}.md`, ANON)).status).toBe(404); + expect((await get(app, '/openprofiles', ANON)).json()).resolves.toEqual({ + openprofiles: [], + next: null, + }); + + // The workspace that holds her still reads the whole thing. + const held = await get(app, `${profilePath}.md`); + expect(held.status).toBe(200); + expect(await held.text()).toContain('jane@example.com'); + + const published = await post(app, `${profilePath}/publish`, { public: true }); + expect(published.status).toBe(200); + expect(await published.json()).toMatchObject({ + public: true, + url: `https://og.test/api/v1/people/${SEED.personId}/openprofile.md`, + }); + + const open = await get(app, `${profilePath}.md`, ANON); + expect(open.status).toBe(200); + expect(open.headers.get('content-type')).toBe('text/markdown; charset=utf-8'); + expect(open.headers.get('access-control-allow-origin')).toBe('*'); + const markdown = await open.text(); + expect(markdown).toContain('# Jane Doe'); + expect(markdown).toContain('- [GitHub](https://github.com/jane)'); + expect(markdown).not.toContain('jane@example.com'); + expect(markdown).not.toContain('mailto:'); + expect(markdown).not.toContain('Phone'); + expect(markdown).not.toContain('## Contact'); + + const listing = await (await get(app, '/openprofiles', ANON)).json(); + expect(listing.next).toBeNull(); + expect(listing.openprofiles).toHaveLength(1); + expect(listing.openprofiles[0]).toMatchObject({ + id: SEED.personId, + name: 'Jane Doe', + url: `https://og.test/api/v1/people/${SEED.personId}/openprofile.md`, + accounts: ['https://github.com/jane'], + web: 'https://jane.example', + }); + + // The detail says so, for the operator's screen. + const detail = await (await get(app, `/people/${SEED.personId}`)).json(); + expect(detail.openprofile.public).toBe(true); + + // And back to private: gone from the listing, 404 again. + expect((await post(app, `${profilePath}/publish`, { public: false })).status).toBe(200); + expect((await get(app, `${profilePath}.md`, ANON)).status).toBe(404); + expect((await (await get(app, '/openprofiles', ANON)).json()).openprofiles).toEqual([]); + }); + + test('a suppressed person is never public, even with the switch on', async () => { + const { app, seeded } = await harness('openprofile-public-suppressed'); + expect((await post(app, `${profilePath}/publish`, { public: true })).status).toBe(200); + await seeded.db.execute({ + sql: "UPDATE people SET status = 'suppressed' WHERE id = ?", + args: [SEED.personId], + }); + expect((await get(app, `${profilePath}.md`, ANON)).status).toBe(404); + expect((await (await get(app, '/openprofiles', ANON)).json()).openprofiles).toEqual([]); + // Switching it on again for a suppressed person is refused outright. + await seeded.db.execute({ + sql: 'DELETE FROM openprofile_settings WHERE person_id = ?', + args: [SEED.personId], + }); + expect((await post(app, `${profilePath}/publish`, { public: true })).status).toBe(409); + }); + + test('the listing pages by cursor and filters by since', async () => { + const { app, seeded } = await harness('openprofile-public-paging'); + // A second public person, held by the workspace. + await seeded.db.batch([ + { + sql: `INSERT INTO people (id, display_name, identity_confidence, status, created_at, updated_at) + VALUES ('per_bob', 'Bob', 0.5, 'active', '2026-09-13T00:00:00.000Z', '2026-09-13T00:00:00.000Z')`, + args: [], + }, + { + sql: `INSERT INTO openprofiles (person_id, markdown, sources_json, published_url, generated_at) + VALUES ('per_bob', '# Bob\n\n- **Kind**: person\n', '[]', NULL, '2026-09-12T00:00:00.000Z')`, + args: [], + }, + { + sql: `INSERT INTO openprofile_settings (person_id, public, overrides_json, updated_at) + VALUES ('per_bob', 1, '{}', '2026-09-12T00:00:00.000Z')`, + args: [], + }, + { + sql: `INSERT INTO openprofile_settings (person_id, public, overrides_json, updated_at) + VALUES (?, 1, '{}', '2026-09-14T00:00:00.000Z')`, + args: [SEED.personId], + }, + ]); + const first = await (await get(app, '/openprofiles?limit=1', ANON)).json(); + expect(first.openprofiles.map((p: { id: string }) => p.id)).toEqual([SEED.personId]); + expect(first.next).toBeTruthy(); + const second = await ( + await get(app, `/openprofiles?limit=1&cursor=${first.next}`, ANON) + ).json(); + expect(second.openprofiles.map((p: { id: string }) => p.id)).toEqual(['per_bob']); + expect(second.next).toBeNull(); + const since = await ( + await get(app, '/openprofiles?since=2026-09-13T12:00:00.000Z', ANON) + ).json(); + expect(since.openprofiles.map((p: { id: string }) => p.id)).toEqual([SEED.personId]); + }); +}); + +describe('corrections', () => { + test("the owner's sections win, the rest is still generated, and a regeneration keeps them", async () => { + const { app, seeded } = await harness('openprofile-overrides'); + + const patched = await putJson(app, profilePath, { + headline: 'Payments, from the rails up.', + identity: { Location: 'Lisbon', Email: null }, + sections: { topics: '- payments\n- rails', contact: 'none', guest: '- **Available**: yes' }, + }); + expect(patched.status).toBe(200); + const body = await patched.json(); + expect(body.editedBy).toBe('operator'); + expect(body.markdown).toContain('Payments, from the rails up.'); + expect(body.markdown).toContain('- **Location**: Lisbon'); + expect(body.markdown).not.toContain('- **Email**'); + expect(body.markdown).toContain('- rails'); + expect(body.markdown).not.toContain('## Contact'); + expect(body.markdown).toContain('## Guest'); + // Untouched: still generated. + expect(body.markdown).toContain('- [GitHub](https://github.com/jane)'); + + // The job rewrites the generated file; the corrections stand. + await seeded.db.execute({ + sql: 'UPDATE openprofiles SET markdown = ?, generated_at = ? WHERE person_id = ?', + args: [ + GENERATED.replace('Builds payment rails.', 'Regenerated.'), + '2026-09-15T00:00:00.000Z', + SEED.personId, + ], + }); + const after = await (await get(app, `${profilePath}.md`)).text(); + expect(after).toContain('Payments, from the rails up.'); + expect(after).not.toContain('Regenerated.'); + expect(after).toContain('- **Location**: Lisbon'); + }); + + test('a whole edited file stores the same overlay as JSON, and public and handle ride along', async () => { + const { app } = await harness('openprofile-markdown-put'); + const edited = GENERATED.replace('Builds payment rails.', 'Countess of payments.').replace( + '- payments', + '- payments\n- ledgers', + ); + const saved = await putMarkdown(app, profilePath, edited); + expect(saved.status).toBe(200); + const body = await saved.json(); + expect(body.markdown).toContain('Countess of payments.'); + expect(body.markdown).toContain('- ledgers'); + + const flagged = await putJson(app, profilePath, { public: true, handle: '@Jane.Doe' }); + expect(flagged.status).toBe(200); + expect(await flagged.json()).toMatchObject({ public: true, handle: 'jane.doe' }); + expect((await putJson(app, profilePath, { handle: 'x' })).status).toBe(400); + expect((await putJson(app, profilePath, {})).status).toBe(400); + expect((await putMarkdown(app, profilePath, ' ')).status).toBe(400); + + const detail = await (await get(app, `/people/${SEED.personId}`)).json(); + expect(detail.openprofile).toMatchObject({ public: true, handle: 'jane.doe' }); + }); + + test('a bearer edits only with the scope and only as the person', async () => { + const tokens: Record = { + jane: { sub: 'oa_jane', scope: 'openid email openprofile:edit', email: 'Jane@Example.com' }, + noscope: { sub: 'oa_jane', scope: 'openid email', email: 'jane@example.com' }, + stranger: { sub: 'oa_x', scope: 'openprofile:edit', email: 'x@example.com' }, + }; + const { app, seeded } = await harness('openprofile-bearer', { + bearer: async (token) => tokens[token], + }); + const bearer = (token: string) => ({ ...ANON, authorization: `Bearer ${token}` }); + + // Nothing verified for her yet: even the right email is not proof. + expect((await putJson(app, profilePath, { headline: 'x' }, bearer('jane'))).status).toBe(403); + + await seeded.db.execute({ + sql: `INSERT INTO person_emails (id, workspace_id, person_id, address, dedupe_key, source, verified, created_at) + VALUES ('pem_1', ?, ?, 'jane@example.com', 'jane@example.com', 'import', 1, ?)`, + args: [SEED.workspaceId, SEED.personId, '2026-09-13T00:00:00.000Z'], + }); + + expect((await putJson(app, profilePath, { headline: 'x' }, bearer('noscope'))).status).toBe( + 403, + ); + expect((await putJson(app, profilePath, { headline: 'x' }, bearer('stranger'))).status).toBe( + 403, + ); + expect((await putJson(app, profilePath, { headline: 'x' }, bearer('nonsense'))).status).toBe( + 401, + ); + + const own = await putJson(app, profilePath, { headline: 'In my own words.' }, bearer('jane')); + expect(own.status).toBe(200); + const body = await own.json(); + expect(body.editedBy).toBe('subject'); + expect(body.markdown).toContain('In my own words.'); + // The person sees the public view of their own file, so what they check is what strangers get. + expect(body.markdown).not.toContain('jane@example.com'); + + // The first edit by the person is their claim. + const detail = await (await get(app, `/people/${SEED.personId}`)).json(); + expect(detail.openprofile.claimedAt).toBeTruthy(); + }); +}); diff --git a/apps/api/src/openprofile.ts b/apps/api/src/openprofile.ts new file mode 100644 index 0000000..c05c73f --- /dev/null +++ b/apps/api/src/openprofile.ts @@ -0,0 +1,332 @@ +/** + * A person's OpenProfile.md as it is served: the generated document, the + * owner's corrections over it, and the public view of the result. + * + * Three facts decide what a reader gets. The openprofile job writes the + * generated file (`openprofiles`) and may rewrite it any time. The person, or + * the operator for them, writes an overlay (`openprofile_settings.overrides`) + * that a rewrite never touches: their identity keys, headline and sections + * win, section by section, exactly as @profullstack/openprofile applies them + * everywhere else. And `public` is off until somebody switches it on: a + * private profile is served only to the workspace that holds the person, a + * public one to anybody, minus the keys a public page never carries. + * + * Who may edit: the operator, as for every other write about a person; or the + * person themselves, carrying an OpenAccess bearer with the `openprofile:edit` + * scope whose principal is provably them, by an email this workspace has + * verified for them or by the OpenProfile.md they publish. Nobody else. + */ + +import { now, queryAll, queryOne, type Client } from '@outreachgraph/db'; +import { + EDIT_SCOPE, + accounts, + applyOverrides, + identityValue, + mergeOverrides, + normaliseEmail, + normaliseUrl, + overridesFromDocument, + parseOpenProfile, + renderOpenProfile, + type OpenProfileDoc, + type Overrides, +} from '@profullstack/openprofile'; + +export { EDIT_SCOPE }; + +export interface ProfileSettings { + readonly personId: string; + readonly public: boolean; + readonly handle: string | null; + readonly overrides: Overrides; + readonly ownerUserId: string | null; + readonly claimedAt: string | null; + readonly claimMethod: string | null; + readonly publishedAt: string | null; + /** Null until anything was ever saved. */ + readonly updatedAt: string | null; +} + +interface SettingsRow { + person_id: string; + public: number; + handle: string | null; + overrides_json: string; + owner_user_id: string | null; + claimed_at: string | null; + claim_method: string | null; + published_at: string | null; + updated_at: string; +} + +function parseOverrides(json: string): Overrides { + try { + const parsed: unknown = JSON.parse(json); + return parsed && typeof parsed === 'object' ? (parsed as Overrides) : {}; + } catch { + return {}; + } +} + +const DEFAULTS = (personId: string): ProfileSettings => ({ + personId, + public: false, + handle: null, + overrides: {}, + ownerUserId: null, + claimedAt: null, + claimMethod: null, + publishedAt: null, + updatedAt: null, +}); + +export async function loadSettings(db: Client, personId: string): Promise { + const row = await queryOne( + db, + 'SELECT * FROM openprofile_settings WHERE person_id = ?', + [personId], + ); + if (!row) return DEFAULTS(personId); + return { + personId, + public: row.public === 1, + handle: row.handle, + overrides: parseOverrides(row.overrides_json), + ownerUserId: row.owner_user_id, + claimedAt: row.claimed_at, + claimMethod: row.claim_method, + publishedAt: row.published_at, + updatedAt: row.updated_at, + }; +} + +export interface SettingsPatch { + readonly overrides?: Overrides; + readonly public?: boolean; + readonly handle?: string | null; + readonly claim?: { userId: string | null; method: 'email' | 'profile' | 'operator' }; +} + +/** Upsert; every field not in the patch keeps its stored value. Returns the row as now stored. */ +export async function saveSettings( + db: Client, + personId: string, + patch: SettingsPatch, + stamp = now(), +): Promise { + const current = await loadSettings(db, personId); + const isPublic = patch.public ?? current.public; + const next: ProfileSettings = { + personId, + public: isPublic, + handle: patch.handle === undefined ? current.handle : patch.handle, + overrides: patch.overrides ?? current.overrides, + ownerUserId: patch.claim ? patch.claim.userId : current.ownerUserId, + claimedAt: patch.claim ? stamp : current.claimedAt, + claimMethod: patch.claim ? patch.claim.method : current.claimMethod, + publishedAt: isPublic && !current.public ? stamp : isPublic ? current.publishedAt : null, + updatedAt: stamp, + }; + await db.execute({ + sql: `INSERT INTO openprofile_settings (person_id, public, handle, overrides_json, owner_user_id, + claimed_at, claim_method, published_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(person_id) DO UPDATE SET + public = excluded.public, handle = excluded.handle, overrides_json = excluded.overrides_json, + owner_user_id = excluded.owner_user_id, claimed_at = excluded.claimed_at, + claim_method = excluded.claim_method, published_at = excluded.published_at, + updated_at = excluded.updated_at`, + args: [ + personId, + next.public ? 1 : 0, + next.handle, + JSON.stringify(next.overrides), + next.ownerUserId, + next.claimedAt, + next.claimMethod, + next.publishedAt, + next.updatedAt, + ], + }); + return next; +} + +/** Identity keys a public page never carries, whatever a source said. */ +const PRIVATE_IDENTITY_KEYS = new Set(['email', 'phone', 'tel', 'mobile', 'address', 'whatsapp']); + +/** Sections that exist to be reached at, which a public directory has no business holding. */ +const PRIVATE_SECTIONS = new Set(['contact']); + +/** + * The document as a stranger may see it: no email, no phone, no contact + * section, no mailto bullets anywhere. Everything else was on the open web + * already, which is where the generator read it. + */ +export function publicView(doc: OpenProfileDoc): OpenProfileDoc { + return { + ...doc, + identity: doc.identity.filter((entry) => !PRIVATE_IDENTITY_KEYS.has(entry.key.toLowerCase())), + sections: doc.sections + .filter((section) => !PRIVATE_SECTIONS.has(section.name)) + .map((section) => ({ + ...section, + body: section.body + .split('\n') + .filter((line) => !/mailto:|\btel:/i.test(line)) + .join('\n') + .trim(), + })) + .filter((section) => section.body !== ''), + }; +} + +/** The generated Markdown with the owner's overlay applied, public or private view. */ +export function composeProfile( + generated: string, + overrides: Overrides, + view: 'public' | 'private', +): { doc: OpenProfileDoc; markdown: string } { + const withOverrides = applyOverrides(parseOpenProfile(generated), overrides); + const doc = view === 'public' ? publicView(withOverrides) : withOverrides; + return { doc, markdown: renderOpenProfile(doc) }; +} + +/** + * The overlay a request body means. A Markdown body is the whole file, so + * everything in it becomes an override and the sections it lacks stay with + * the generator; a JSON body is a partial merge over what is stored. + */ +export function overridesFromRequest( + contentType: string | null, + body: string | Record, + generated: string, + stored: Overrides, +): Overrides { + if (typeof body === 'string' || /text\/(markdown|plain)/i.test(contentType ?? '')) { + const markdown = typeof body === 'string' ? body : String(body.markdown ?? ''); + return mergeOverrides(stored, overridesFromDocument(markdown, parseOpenProfile(generated))); + } + if (typeof body.markdown === 'string') { + return mergeOverrides( + stored, + overridesFromDocument(body.markdown, parseOpenProfile(generated)), + ); + } + const patch: Overrides = {}; + if (typeof body.name === 'string' || body.name === null) patch.name = body.name as string | null; + if (typeof body.headline === 'string' || body.headline === null) + patch.headline = body.headline as string | null; + if (typeof body.prose === 'string' || body.prose === null) + patch.prose = body.prose as string | null; + if (body.identity && typeof body.identity === 'object') { + patch.identity = {}; + for (const [key, value] of Object.entries(body.identity as Record)) { + if (typeof value === 'string' || value === null) patch.identity[key] = value; + } + } + if (body.sections && typeof body.sections === 'object') { + patch.sections = {}; + for (const [key, value] of Object.entries(body.sections as Record)) { + if (typeof value === 'string') patch.sections[key] = value; + else if (value === null) patch.sections[key] = 'none'; + } + } + return mergeOverrides(stored, patch); +} + +/** Every email this deployment has verified as the person's own. */ +export async function verifiedEmails(db: Client, personId: string): Promise { + const rows = await queryAll<{ address: string }>( + db, + `SELECT address FROM person_emails WHERE person_id = ? AND verified = 1 + UNION + SELECT handle AS address FROM social_identities + WHERE person_id = ? AND network = 'email' AND handle IS NOT NULL AND confidence >= 0.9`, + [personId, personId], + ); + const out = new Set(); + for (const row of rows) { + const email = normaliseEmail(row.address); + if (email) out.add(email); + } + return [...out]; +} + +/** The claims of an OpenAccess access token, as far as this module reads them. */ +export interface BearerClaims { + readonly sub?: unknown; + readonly scope?: unknown; + readonly email?: unknown; + readonly profile?: unknown; + readonly [key: string]: unknown; +} + +/** + * Whether a bearer may edit this person's profile: the token must carry the + * edit scope, and its principal must be the person, shown by an email this + * deployment verified for them or by the OpenProfile.md they publish. + */ +export function bearerMayEdit( + claims: BearerClaims, + subject: { emails: readonly string[]; publishedUrl: string | null }, +): { ok: true; method: 'email' | 'profile' } | { ok: false; reason: string } { + const scopes = typeof claims.scope === 'string' ? claims.scope.split(/\s+/) : []; + if (!scopes.includes(EDIT_SCOPE)) + return { ok: false, reason: `token lacks the ${EDIT_SCOPE} scope` }; + const email = typeof claims.email === 'string' ? normaliseEmail(claims.email) : null; + if (email && subject.emails.includes(email)) return { ok: true, method: 'email' }; + const profile = typeof claims.profile === 'string' ? claims.profile : null; + if ( + profile && + subject.publishedUrl && + normaliseUrl(profile) === normaliseUrl(subject.publishedUrl) + ) + return { ok: true, method: 'profile' }; + return { ok: false, reason: 'the token is not for the person this profile is about' }; +} + +/** What the listing says about one public profile, derived from the served document. */ +export function listingEntry( + doc: OpenProfileDoc, + personId: string, + updatedAt: string, + origin: string, +): { + id: string; + name: string | null; + url: string; + page: string; + updatedAt: string; + accounts: string[]; + web: string | null; +} { + const url = `${origin}/api/v1/people/${encodeURIComponent(personId)}/openprofile.md`; + return { + id: personId, + name: doc.name, + url, + // OutreachGraph has no public person page; the file is the page. + page: url, + updatedAt, + accounts: accounts(doc) + .map((entry) => entry.url) + .filter((entry) => /^https?:\/\//i.test(entry)), + web: identityValue(doc, 'Web'), + }; +} + +/** The opaque cursor of the listing: where the previous page stopped. */ +export function encodeCursor(updatedAt: string, personId: string): string { + return Buffer.from(`${updatedAt}|${personId}`, 'utf8').toString('base64url'); +} + +export function decodeCursor( + cursor: string | undefined, +): { updatedAt: string; personId: string } | undefined { + if (!cursor) return undefined; + const text = Buffer.from(cursor, 'base64url').toString('utf8'); + const at = text.indexOf('|'); + if (at <= 0) return undefined; + return { updatedAt: text.slice(0, at), personId: text.slice(at + 1) }; +} diff --git a/apps/api/src/social-intake.test.ts b/apps/api/src/social-intake.test.ts index 46eaeaf..1769060 100644 --- a/apps/api/src/social-intake.test.ts +++ b/apps/api/src/social-intake.test.ts @@ -127,6 +127,11 @@ describe('GET /people/:id/openprofile.md', () => { url: `/api/v1/people/${SEED.personId}/openprofile.md`, generatedAt: '2026-09-13T00:00:00.000Z', publishedUrl: 'https://jane.example/.well-known/openprofile.md', + // Private until somebody switches it on; nobody has claimed or edited it. + public: false, + handle: null, + claimedAt: null, + editedAt: null, }); expect((await get(app, '/people/per_nobody/openprofile.md')).status).toBe(404); diff --git a/apps/cli/src/cli.test.ts b/apps/cli/src/cli.test.ts index 07402f9..6399d22 100644 --- a/apps/cli/src/cli.test.ts +++ b/apps/cli/src/cli.test.ts @@ -69,6 +69,51 @@ describe('parseArgv', () => { }); }); +describe('profile', () => { + test('show prints the file; edit sends the whole edited file; publish sends one flag', async () => { + const { client: reader } = client({ raw: '# Jane\n\n- **Kind**: person\n' }); + const shown = await commandByName('profile')!.run({ + client: reader, + args: ['per_1'], + flags: {}, + }); + expect(shown).toBe('# Jane\n\n- **Kind**: person'); + + // The editor is injected, so nothing is spawned; what it returns is what is sent. + const { client: editor, calls } = client({ + raw: '# Jane\n\n- **Kind**: person\n', + markdown: '# Jane\n\n- **Kind**: person\n\nEdited.\n', + updatedAt: '2026-09-13T00:00:00.000Z', + }); + const edited = await commandByName('profile')!.run({ + client: editor, + args: ['edit', 'per_1'], + flags: { edit: async (markdown: string) => `${markdown}\nEdited.\n` } as never, + }); + expect(edited.startsWith('Saved per_1 at 2026-09-13')).toBe(true); + const put = calls.find((call) => call.method === 'PUT'); + expect(put?.url).toBe('https://api.test/api/v1/people/per_1/openprofile'); + expect((put?.body as { markdown: string }).markdown).toContain('Edited.'); + + const { client: publisher, calls: publishCalls } = client({ + public: true, + url: 'https://og/x.md', + }); + const published = await commandByName('profile')!.run({ + client: publisher, + args: ['publish', 'per_1'], + flags: { public: true }, + }); + expect(published).toBe('Public: https://og/x.md'); + expect(publishCalls[0]?.url).toBe('https://api.test/api/v1/people/per_1/openprofile/publish'); + expect(publishCalls[0]?.body).toEqual({ public: true }); + + await expect( + commandByName('profile')!.run({ client: publisher, args: ['publish', 'per_1'], flags: {} }), + ).rejects.toThrow('--public | --private'); + }); +}); + describe('commands', () => { test('today lists the queue with ids first', async () => { const { client: api } = client({ diff --git a/apps/cli/src/commands.ts b/apps/cli/src/commands.ts index 285e936..6908b04 100644 --- a/apps/cli/src/commands.ts +++ b/apps/cli/src/commands.ts @@ -75,6 +75,77 @@ function handleFromUrl(url: string): string | undefined { return nested?.replace(/^@/, ''); } +/** + * Opens the person's editor on a file and returns what they saved. Injected + * through `edit` on the context so tests never spawn anything. + */ +async function editInEditor(markdown: string): Promise { + const { mkdtempSync, readFileSync, writeFileSync, rmSync } = await import('node:fs'); + const { tmpdir } = await import('node:os'); + const { join } = await import('node:path'); + const dir = mkdtempSync(join(tmpdir(), 'og-profile-')); + const path = join(dir, 'openprofile.md'); + writeFileSync(path, markdown); + try { + const editor = process.env.VISUAL ?? process.env.EDITOR ?? 'vi'; + const child = Bun.spawnSync([...editor.split(/\s+/), path], { + stdio: ['inherit', 'inherit', 'inherit'], + }); + if (child.exitCode !== 0) + throw new Error(`${editor} exited with ${child.exitCode}; nothing saved`); + return readFileSync(path, 'utf8'); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +} + +/** + * `og profile ` prints the file; `og profile edit ` corrects it, from + * `--file` or from $EDITOR; `og profile publish --public|--private` + * switches it. What is sent is always the whole file or one flag, so what the + * server stores is exactly what the person saw. + */ +async function runProfile({ client, args, flags }: CommandContext): Promise { + const [first, second] = args; + const verb = first === 'edit' || first === 'publish' || first === 'show' ? first : 'show'; + const personId = verb === 'show' && first !== 'show' ? first : second; + if (!personId) + throw new Error( + `a person id is required: og profile ${verb === 'show' ? '' : `${verb} `}`, + ); + const path = `/people/${encodeURIComponent(personId)}/openprofile`; + + if (verb === 'publish') { + const isPublic = flags.public === true ? true : flags.private === true ? false : undefined; + if (isPublic === undefined) + throw new Error('say which: og profile publish --public | --private'); + const result = (await client.post(`${path}/publish`, { public: isPublic })) as Record< + string, + unknown + >; + return result.public + ? `Public: ${text(result, 'url')}` + : `Private. ${personId} is served only to this workspace again.`; + } + + const current = (await client.get(`${path}.md`)) as Record; + // The route answers text/markdown; the client hands non-JSON back under `raw`. + const markdown = text(current, 'raw').trimEnd(); + if (verb === 'show') return markdown; + + const file = flagString(flags, 'file'); + const edited = file + ? await Bun.file(file).text() + : await ((flags as { edit?: (markdown: string) => Promise }).edit ?? editInEditor)( + `${markdown}\n`, + ); + if (!edited.trim()) throw new Error('an empty file corrects nothing; nothing saved'); + if (edited.trim() === markdown.trim()) return 'No change.'; + + const result = (await client.put(path, { markdown: edited })) as Record; + return `Saved ${personId} at ${text(result, 'updatedAt')}${result.public ? ' (public)' : ''}\n\n${text(result, 'markdown').trimEnd()}`; +} + export const COMMANDS: readonly Command[] = [ { name: 'today', @@ -189,17 +260,18 @@ export const COMMANDS: readonly Command[] = [ { name: 'openprofile', usage: 'og openprofile ', - summary: 'The OpenProfile.md assembled for one person.', - async run({ client, args }) { - const personId = args[0]; - if (!personId) throw new Error('a person id is required: og openprofile '); - const result = (await client.get( - `/people/${encodeURIComponent(personId)}/openprofile.md`, - )) as Record; - // The route answers text/markdown; the client hands non-JSON back under `raw`. - return text(result, 'raw').trimEnd(); + summary: 'The OpenProfile.md assembled for one person (same as `og profile `).', + async run(context) { + return runProfile({ ...context, args: ['show', ...context.args] }); }, }, + { + name: 'profile', + usage: + 'og profile | og profile edit [--file ] | og profile publish --public|--private', + summary: "A person's OpenProfile.md: read it, correct it, switch it public.", + run: runProfile, + }, { name: 'signals', usage: 'og signals ', diff --git a/apps/mcp/src/client.ts b/apps/mcp/src/client.ts index f835869..e5cdcbb 100644 --- a/apps/mcp/src/client.ts +++ b/apps/mcp/src/client.ts @@ -37,6 +37,7 @@ export class ApiError extends Error { export interface ApiClient { get(path: string, query?: Record): Promise; post(path: string, body?: unknown): Promise; + put(path: string, body?: unknown): Promise; } export type FetchLike = (input: string, init?: RequestInit) => Promise; @@ -120,6 +121,7 @@ export function createClient(config: ApiConfig, fetchImpl: FetchLike = fetch): A return call('GET', suffix ? `${path}?${suffix}` : path); }, post: (path, body) => call('POST', path, body), + put: (path, body) => call('PUT', path, body), }; } diff --git a/apps/mcp/src/mcp.test.ts b/apps/mcp/src/mcp.test.ts index 195a7c0..a1fe491 100644 --- a/apps/mcp/src/mcp.test.ts +++ b/apps/mcp/src/mcp.test.ts @@ -155,6 +155,23 @@ describe('tools', () => { } }); + test('update_openprofile sends the overlay as a PUT and refuses an empty one', async () => { + const { fetchImpl, calls } = recorder(ok()); + const client = createClient(CONFIG, fetchImpl); + const tool = toolByName('update_openprofile')!; + await runTool(tool, client, { + personId: 'per_1', + sections: { guest: '- **Available**: yes' }, + public: true, + }); + expect(calls[0]?.url).toBe('https://api.test/api/v1/people/per_1/openprofile'); + expect(calls[0]?.method).toBe('PUT'); + expect(calls[0]?.body).toEqual({ sections: { guest: '- **Available**: yes' }, public: true }); + // An empty overlay still goes to the server, which is where it is refused. + await runTool(tool, client, { personId: 'per_1' }); + expect(calls[1]?.body).toEqual({}); + }); + test('there is no tool that posts to a network directly', () => { // A tool named "post_to_linkedin" would be a way around the policy engine // whatever its implementation did today. diff --git a/apps/mcp/src/tools.ts b/apps/mcp/src/tools.ts index c12a2a7..0f44d4b 100644 --- a/apps/mcp/src/tools.ts +++ b/apps/mcp/src/tools.ts @@ -342,6 +342,79 @@ export const TOOLS: readonly ToolDefinition[] = [ return { markdown: result.raw ?? '' }; }, }, + { + name: 'update_openprofile', + title: "Correct a person's OpenProfile.md", + description: + 'Correct the OpenProfile.md OutreachGraph assembled for a person. Send the whole edited file ' + + 'as `markdown`, or a partial overlay: `identity` keys (null removes one), `headline`, and ' + + '`sections` by name (`accounts`, `topics`, `broadcast`, `guest`, ...; the single word `none` ' + + 'removes a generated section). What you write wins over what the generator wrote; the rest ' + + 'is still generated. `public` switches the profile public or private; `handle` names it.', + readOnly: false, + inputSchema: { + type: 'object', + properties: { + personId: { type: 'string' }, + markdown: { + type: 'string', + description: 'The whole OpenProfile.md, when editing the file.', + }, + identity: { + type: 'object', + additionalProperties: { type: ['string', 'null'] }, + description: 'Identity block keys: Kind, Handle, Web, Avatar, Location, Pronouns, ...', + }, + headline: { type: ['string', 'null'] }, + sections: { + type: 'object', + additionalProperties: { type: ['string', 'null'] }, + description: 'Section bodies in Markdown, by normalised section name.', + }, + public: { type: 'boolean' }, + handle: { type: ['string', 'null'] }, + }, + required: ['personId'], + }, + run: (client, args) => { + const personId = require(args, 'personId'); + const body: Record = {}; + for (const key of [ + 'markdown', + 'identity', + 'headline', + 'sections', + 'public', + 'handle', + ] as const) { + if (args[key] !== undefined) body[key] = args[key]; + } + // An empty overlay is the server's to refuse, so that every mutation + // still leaves this process as an HTTP call. + return client.put(`/people/${encodeURIComponent(personId)}/openprofile`, body); + }, + }, + { + name: 'publish_openprofile', + title: "Switch a person's OpenProfile.md public or private", + description: + 'Make the OpenProfile.md OutreachGraph holds about a person public, so directories such as ' + + 'nichedb.dev can read it at /api/v1/people/{id}/openprofile.md and through /api/v1/openprofiles, ' + + 'or private again. A public profile never carries an email or phone. A suppressed person is never public.', + readOnly: false, + inputSchema: { + type: 'object', + properties: { + personId: { type: 'string' }, + public: { type: 'boolean' }, + }, + required: ['personId', 'public'], + }, + run: (client, args) => + client.post(`/people/${encodeURIComponent(require(args, 'personId'))}/openprofile/publish`, { + public: args.public === true, + }), + }, { name: 'suppress', title: 'Never contact this person again', diff --git a/apps/server/src/index.ts b/apps/server/src/index.ts index fce5620..4206cd7 100644 --- a/apps/server/src/index.ts +++ b/apps/server/src/index.ts @@ -33,6 +33,7 @@ import { CoinPayClient } from '@outreachgraph/payments'; import { secretKeyFromEnv } from '@outreachgraph/secrets'; import { createApp } from '../../api/src/app'; import { prunePasswordResetTokens, pruneSessions } from '../../api/src/auth'; +import { verifyOpenAccessBearer } from '../../api/src/openaccess'; import { drainQueue, emitEvent, @@ -389,6 +390,9 @@ const api = createApp({ ...(encryptionKey ? { encryptionKey } : {}), ...(appUrl ? { appUrl } : {}), ...(process.env.API_TOKEN ? { serviceToken: process.env.API_TOKEN } : {}), + // A person editing their own OpenProfile.md carries an OpenAccess bearer + // rather than a session here. + verifyBearer: verifyOpenAccessBearer, // Cookies must not be Secure over plain HTTP, or local development can // never hold a session. secureCookies: ENVIRONMENT === 'production', diff --git a/apps/web/public/.well-known/openaccess.json b/apps/web/public/.well-known/openaccess.json index 3b71398..3e340e0 100644 --- a/apps/web/public/.well-known/openaccess.json +++ b/apps/web/public/.well-known/openaccess.json @@ -16,7 +16,9 @@ } ] }, - "scopes": {}, + "scopes": { + "openprofile:edit": "Correct the OpenProfile.md OutreachGraph holds about you: PUT /api/v1/people/{id}/openprofile with the file or a JSON overlay. Honoured only when the grant's principal is you, by an email this deployment has verified for you or by the OpenProfile.md you publish." + }, "honours": ["profullstack.com/all-access"], "webhooks": "https://outreachgraph.com/api/v1/openaccess/events", "hubs": ["https://openaccess.logicsrc.com"] diff --git a/bun.lock b/bun.lock index 46f72cd..3e54732 100644 --- a/bun.lock +++ b/bun.lock @@ -14,6 +14,7 @@ "name": "@outreachgraph/api", "version": "0.4.0", "dependencies": { + "@logicsrc/openaccess": "^0.3.0", "@outreachgraph/ai": "workspace:*", "@outreachgraph/contracts": "workspace:*", "@outreachgraph/db": "workspace:*", @@ -26,6 +27,7 @@ "@outreachgraph/scoring": "workspace:*", "@outreachgraph/secrets": "workspace:*", "@outreachgraph/signals": "workspace:*", + "@profullstack/openprofile": "^0.1.0", "hono": "^4.6.14", "zod": "^3.24.1", }, @@ -190,6 +192,7 @@ "dependencies": { "@outreachgraph/domain": "workspace:*", "@outreachgraph/identity": "workspace:*", + "@profullstack/openprofile": "^0.1.0", }, }, "packages/recommend": { @@ -324,6 +327,8 @@ "@libsql/win32-x64-msvc": ["@libsql/win32-x64-msvc@0.5.29", "", { "os": "win32", "cpu": "x64" }, "sha512-4/0CvEdhi6+KjMxMaVbFM2n2Z44escBRoEYpR+gZg64DdetzGnYm8mcNLcoySaDJZNaBd6wz5DNdgRmcI4hXcg=="], + "@logicsrc/openaccess": ["@logicsrc/openaccess@0.3.0", "", { "dependencies": { "@hono/node-server": "^2.1.1", "hono": "^4.13.7" }, "bin": { "openaccess": "bin/openaccess.mjs" } }, "sha512-zGhn/IJSiFXSc0TJqfXYxRo2Od+LF5zCWILeksurf0hVGGleo0N7WEfk6M0lipts6pm46z7JFN5JPO4qJQZsJQ=="], + "@modelcontextprotocol/sdk": ["@modelcontextprotocol/sdk@1.30.0", "", { "dependencies": { "@hono/node-server": "^1.19.9 || ^2.0.5", "ajv": "^8.17.1", "ajv-formats": "^3.0.1", "content-type": "^1.0.5", "cors": "^2.8.5", "cross-spawn": "^7.0.5", "eventsource": "^3.0.2", "eventsource-parser": "^3.0.0", "express": "^5.2.1", "express-rate-limit": "^8.2.1", "hono": "^4.11.4", "jose": "^6.1.3", "json-schema-typed": "^8.0.2", "pkce-challenge": "^5.0.0", "raw-body": "^3.0.0", "zod": "^3.25 || ^4.0", "zod-to-json-schema": "^3.25.1" }, "peerDependencies": { "@cfworker/json-schema": "^4.1.1" }, "optionalPeers": ["@cfworker/json-schema"] }, "sha512-xKd8OIzlqNzcqcNumGAa6g+PW2kjD5vrpcKOnfldAUPP3j7lnqMPwlTXQm8gF+UwH72z0lqaRbjr9hqGz0eITA=="], "@neon-rs/load": ["@neon-rs/load@0.0.4", "", {}, "sha512-kTPhdZyTQxB+2wpiRcFWrDcejc4JI6tkPuS7UZCG4l6Zvc5kU/gGQ/ozvHTh1XR5tS+UlfAfGuPajjzQjCiHCw=="], @@ -388,6 +393,8 @@ "@pinojs/redact": ["@pinojs/redact@0.4.0", "", {}, "sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg=="], + "@profullstack/openprofile": ["@profullstack/openprofile@0.1.0", "", {}, "sha512-3Gj5fQJW8wYAJ8iiYNIC0r/bnvbf+9piRhjJ8cktfgE2UFlfcv6A1nfABTK3wcoEGTbAs1x8SLVoIc0RjBQvJw=="], + "@profullstack/x402-gateway": ["@profullstack/x402-gateway@0.1.0", "", {}, "sha512-B7tWvWk/bIEoqyec6UoyRF1pO7X/+b+wFRv2ZFIClqskmEpyxoA559ZgdTvnxqAIvuDeE9v56nVpYRQ+lmOZQQ=="], "@swc/helpers": ["@swc/helpers@0.5.15", "", { "dependencies": { "tslib": "^2.8.0" } }, "sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g=="], @@ -756,6 +763,8 @@ "zod-to-json-schema": ["zod-to-json-schema@3.25.2", "", { "peerDependencies": { "zod": "^3.25.28 || ^4" } }, "sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA=="], + "@logicsrc/openaccess/hono": ["hono@4.13.7", "", {}, "sha512-c8/gF9ac8Y78/agExVocyLevgR+JlpNB444Py0FSX8pJoPdYUfUzRcXtYEYGwt6l19qIlVZPN5Mfsw9jFShmQQ=="], + "@tailwindcss/oxide-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.11.3", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.3", "tslib": "^2.4.0" }, "bundled": true }, "sha512-zLpS5asjEb7lq8jYLq37N6XKaE41DIexlY1rF/z4/tIl3wo13Sqm28fRyfIsKZD+NZ8mM5RoKkpW/rBcuoSZSg=="], "@tailwindcss/oxide-wasm32-wasi/@emnapi/runtime": ["@emnapi/runtime@1.11.3", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA=="], diff --git a/migrations/0037_openprofile_settings.sql b/migrations/0037_openprofile_settings.sql new file mode 100644 index 0000000..f8ae049 --- /dev/null +++ b/migrations/0037_openprofile_settings.sql @@ -0,0 +1,33 @@ +-- 0037_openprofile_settings.sql +-- +-- What the person, or the operator on their behalf, decided about the +-- OpenProfile.md OutreachGraph assembled for them. +-- +-- `openprofiles` holds the generated document and is rewritten by every run +-- of the openprofile job. This table holds what a run must never touch: the +-- owner's corrections (`overrides_json`, the overlay @profullstack/openprofile +-- applies over the generated file), whether the profile is public at all +-- (`public`, off until somebody switches it on), the handle they chose, and +-- who claimed it and how. A public profile is what /api/v1/openprofiles lists +-- for directories such as nichedb.dev; a private one is served only to the +-- workspace that holds the person, exactly as before. +-- +-- Keyed by person like `openprofiles`, and dropped with the person, because a +-- deleted person leaves a suppression tombstone and nothing else. + +CREATE TABLE openprofile_settings ( + person_id TEXT PRIMARY KEY REFERENCES people(id) ON DELETE CASCADE, + public INTEGER NOT NULL DEFAULT 0, + handle TEXT, + overrides_json TEXT NOT NULL DEFAULT '{}', + owner_user_id TEXT, + claimed_at TEXT, + -- email | profile | operator + claim_method TEXT, + published_at TEXT, + updated_at TEXT NOT NULL +); + +CREATE INDEX idx_openprofile_settings_public ON openprofile_settings(public, updated_at); +CREATE UNIQUE INDEX idx_openprofile_settings_handle + ON openprofile_settings(handle) WHERE handle IS NOT NULL; diff --git a/packages/providers/package.json b/packages/providers/package.json index 57e1f4e..bbfb207 100644 --- a/packages/providers/package.json +++ b/packages/providers/package.json @@ -12,6 +12,7 @@ }, "dependencies": { "@outreachgraph/domain": "workspace:*", - "@outreachgraph/identity": "workspace:*" + "@outreachgraph/identity": "workspace:*", + "@profullstack/openprofile": "^0.1.0" } } diff --git a/packages/providers/src/site/openprofile.test.ts b/packages/providers/src/site/openprofile.test.ts index 162bd0c..22dff63 100644 --- a/packages/providers/src/site/openprofile.test.ts +++ b/packages/providers/src/site/openprofile.test.ts @@ -259,7 +259,8 @@ describe('mergeFacts and buildOpenProfile', () => { '', '## Topics', '', - '- babbage, poetry', + '- babbage', + '- poetry', '', ].join('\n'), ); diff --git a/packages/providers/src/site/openprofile.ts b/packages/providers/src/site/openprofile.ts index fedd8c8..d701333 100644 --- a/packages/providers/src/site/openprofile.ts +++ b/packages/providers/src/site/openprofile.ts @@ -20,6 +20,7 @@ * take a `fetchImpl`, so a test can hand in pages and never touch the network. */ +import { listSection, makeOpenProfile, renderOpenProfile } from '@profullstack/openprofile'; import { anchors, collapse, decodeEntities, isRelMe, metaContent, networkForUrl } from './extract'; import { parseFediverseHandle, parseFediverseUrl } from './fediverse'; import type { FetchLike } from './fetch'; @@ -401,33 +402,37 @@ export function mergeFacts( }; } -/** Render the Markdown the spec describes. One `#`, an identity block, one line, sections. */ +/** + * Render the Markdown the spec describes, through the house reader and writer + * (@profullstack/openprofile), so what OutreachGraph assembles is the same + * shape every other house app serves: one `#`, an identity block, one line, + * then Accounts, Topics and Links in the spec's order. An empty value is not + * written; absence is unstated. + */ export function buildOpenProfile(input: ProfileInput): string { - const lines: string[] = [`# ${input.name.trim() || input.handle}`, '']; - lines.push(`- **Kind**: ${input.kind ?? 'person'}`); - lines.push(`- **Handle**: @${input.handle.replace(/^@/, '')}`); - if (input.web) lines.push(`- **Web**: ${input.web}`); - if (input.email) lines.push(`- **Email**: ${input.email}`); - if (input.avatar) lines.push(`- **Avatar**: ${input.avatar}`); - lines.push(''); - if (input.headline) lines.push(input.headline.trim(), ''); - + const handle = input.handle.replace(/^@/, ''); // The home page is the Web line; listing it again under Links says nothing new. const isWeb = (entry: ProfileAccount) => Boolean(input.web && samePage(entry.url, input.web)); const me = input.accounts.filter((entry) => entry.relation === 'me' && !isWeb(entry)); const links = input.accounts.filter((entry) => entry.relation === 'link' && !isWeb(entry)); - if (me.length) { - lines.push('## Accounts', ''); - for (const entry of me) lines.push(`- [${entry.label}](${entry.url})`); - lines.push(''); - } - if (input.topics.length) { - lines.push('## Topics', '', `- ${input.topics.join(', ')}`, ''); - } - if (links.length) { - lines.push('## Links', ''); - for (const entry of links) lines.push(`- [${entry.label}](${entry.url})`); - lines.push(''); - } - return `${lines.join('\n').trimEnd()}\n`; + const bullet = (entry: ProfileAccount) => `[${entry.label}](${entry.url})`; + + return renderOpenProfile( + makeOpenProfile({ + name: input.name.trim() || handle, + identity: { + Kind: input.kind ?? 'person', + Handle: `@${handle}`, + Web: input.web, + Email: input.email, + Avatar: input.avatar, + }, + headline: input.headline, + sections: [ + listSection('Accounts', me.map(bullet)), + listSection('Topics', input.topics), + listSection('Links', links.map(bullet)), + ], + }), + ); }