From 5db85023521088f8168994528389c8331ab56891 Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Sun, 13 Sep 2026 04:31:49 +0000 Subject: [PATCH] Serve every author as an OpenProfile.md, and let the person correct it The directory already knew who is behind a feed, from the markup they published: a rel="me" link, an h-card, the feed's own author element. That knowledge now leaves the site as one portable file per person, /authors/{slug}/openprofile.md (logicsrc.com/openprofile), pointed at by on their page: identity block, Accounts, Topics from their feeds, and a Broadcast section (logicsrc.com/openbroadcast) for every podcast or show they own, saying only what their own feed says. Nothing the person did not write is filled in: no Seeking, Pays or Charges, no Guest section, and no email even where the API republishes one. GET /api/authors?feed={url} finds the people behind a feed, so a directory that holds only feed URLs can reach them. Then the person's word over ours. A claim is verified on the spot: the signed-in address is the one they published, or their site links back at the profile. After that the file is theirs to correct from anywhere, and every way in stores the same overlay (@profullstack/openprofile): the edit form at /authors/{slug}/edit, PUT /api/authors/{slug}/openprofile as the whole file or a JSON patch under an API key or an OpenAccess grant for openprofile:edit, rssamp profile edit from the CLI, update_openprofile over MCP. What they wrote wins per section, what they left alone is still generated, and `none` drops a section. A public switch hides the file without touching the page. Also: authorBySlug now reads the live `category` under the `kind` name, which the author page and get_author already expected; the old column said blog for every podcast. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01S7yeJUHGxA4P5N74xnsRPQ --- README.md | 24 ++ apps/cli/package.json | 19 +- apps/cli/src/index.js | 122 +++++++- apps/cli/test/profile.test.js | 144 +++++++++ apps/web/package.json | 2 + apps/web/public/.well-known/openaccess.json | 4 +- apps/web/public/.well-known/openmcp.json | 5 +- apps/web/src/app/account/page.jsx | 27 +- .../src/app/api/authors/[slug]/claim/route.js | 84 +++++ .../api/authors/[slug]/openprofile/route.js | 155 ++++++++++ apps/web/src/app/api/authors/route.js | 23 +- apps/web/src/app/api/feeds/[slug]/route.js | 2 + apps/web/src/app/authors/[slug]/edit/page.jsx | 144 +++++++++ .../authors/[slug]/openprofile.md/route.js | 33 ++ apps/web/src/app/authors/[slug]/page.jsx | 48 ++- apps/web/src/app/globals.css | 31 ++ apps/web/src/app/skill.md/route.js | 6 + apps/web/src/lib/authorProfile.js | 58 ++++ apps/web/src/lib/llms.js | 5 +- apps/web/src/lib/mcp/tools.js | 172 +++++++++++ apps/web/src/lib/openaccess.js | 73 +++++ apps/web/src/lib/openprofile.js | 287 ++++++++++++++++++ apps/web/src/lib/profileAuth.js | 271 +++++++++++++++++ apps/web/test/mcp.test.js | 10 +- apps/web/test/openprofile.test.js | 241 +++++++++++++++ packages/db/index.js | 1 + .../20260913042000_author_profiles.sql | 28 ++ packages/db/src/authors.js | 9 +- packages/db/src/profiles.js | 197 ++++++++++++ packages/db/test/profiles.test.js | 120 ++++++++ pnpm-lock.yaml | 38 +++ pnpm-workspace.yaml | 2 + 32 files changed, 2365 insertions(+), 20 deletions(-) create mode 100644 apps/cli/test/profile.test.js create mode 100644 apps/web/src/app/api/authors/[slug]/claim/route.js create mode 100644 apps/web/src/app/api/authors/[slug]/openprofile/route.js create mode 100644 apps/web/src/app/authors/[slug]/edit/page.jsx create mode 100644 apps/web/src/app/authors/[slug]/openprofile.md/route.js create mode 100644 apps/web/src/lib/authorProfile.js create mode 100644 apps/web/src/lib/openaccess.js create mode 100644 apps/web/src/lib/openprofile.js create mode 100644 apps/web/src/lib/profileAuth.js create mode 100644 apps/web/test/openprofile.test.js create mode 100644 packages/db/migrations/20260913042000_author_profiles.sql create mode 100644 packages/db/src/profiles.js create mode 100644 packages/db/test/profiles.test.js diff --git a/README.md b/README.md index ae99d4e..c44110b 100644 --- a/README.md +++ b/README.md @@ -238,6 +238,10 @@ All send `access-control-allow-origin: *` and need no key. | `/api/topics/{keyword}` | The feeds on a topic, its category breakdown, `?group=` to narrow | | `/topics/{keyword}/{group}.rss` | One category of a topic, as a feed — also `.atom`, `.json`, `.m3u`, `.pls` | | `/mcp` | MCP endpoint — and the documentation page, in a browser | +| `/api/authors` | The people behind the feeds; `?feed={url}` finds the people behind one feed | +| `/authors/{slug}/openprofile.md` | One person as an [OpenProfile.md](https://logicsrc.com/openprofile), Broadcast section per show they publish | +| `/api/authors/{slug}/openprofile` | The same file, `?format=json` for the parsed shape; `PUT` it to correct it (owner only) | +| `/api/authors/{slug}/claim` | `POST` to claim an author as yourself; verified by the address they published or by their site linking back | ```bash curl -X POST https://rssamplifier.com/api/submit \ @@ -249,6 +253,26 @@ curl -X POST https://rssamplifier.com/api/discover \ -d '{"keywords":["siberian huskies"]}' ``` +### Profiles: the person's own word over ours + +Every author page carries `` to `/authors/{slug}/openprofile.md`, the +person as one portable file: identity block, Accounts (their `rel="me"` links), Topics (their feeds' +subjects), and a Broadcast section ([OpenBroadcast](https://logicsrc.com/openbroadcast)) for every +podcast or show they publish, with only the facts their own feed states. The file never fills in +what the person did not say: no `Seeking`, `Pays`, `Charges`, no Guest section, and no email even +when the API republishes one. + +The person it is about can claim it and correct it, and the correction is theirs wherever it is +made. A claim is verified on the spot, no reviewer: the signed-in address is the one they published, +or their site links back at the profile with `rel="openprofile"` or `rel="me"`. After that, edits +come through the form at `/authors/{slug}/edit`, `PUT /api/authors/{slug}/openprofile` (the whole +file as `text/markdown`, or a JSON patch of `headline`, `identity`, `sections`, `public`) with an +API key from `/account` or an [OpenAccess](https://logicsrc.com/openaccess) grant for +`openprofile:edit`, `rssamp profile edit {slug}` from the CLI, or `update_openprofile` over MCP. +What they wrote wins per section; what they did not touch is still generated; a section written +as `none` is dropped. The parser, renderer and overlay are +[`@profullstack/openprofile`](https://www.npmjs.com/package/@profullstack/openprofile). + ## Topics, and topics by category A topic page is every feed filed under a subject. On a well-covered one that is diff --git a/apps/cli/package.json b/apps/cli/package.json index 7700684..40255e3 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -1,20 +1,31 @@ { "name": "@profullstack/rssamplifier", - "version": "0.3.0", - "description": "CLI for rssamplifier.com — find feeds by topic, search the directory, export OPML, submit blogs", + "version": "0.4.0", + "description": "CLI for rssamplifier.com \u2014 find feeds by topic, search the directory, export OPML, submit blogs", "type": "module", "main": "src/index.js", "bin": { "rssamplifier": "./bin/rssamplifier.js", "rssamp": "./bin/rssamplifier.js" }, - "files": ["bin", "src", "README.md"], + "files": [ + "bin", + "src", + "README.md" + ], "scripts": { "test": "node --test test/*.test.js" }, "engines": { "node": ">=22" }, - "keywords": ["rss", "opml", "feeds", "directory", "cli", "agents"], + "keywords": [ + "rss", + "opml", + "feeds", + "directory", + "cli", + "agents" + ], "license": "MIT" } diff --git a/apps/cli/src/index.js b/apps/cli/src/index.js index 6a05a15..ba403ad 100755 --- a/apps/cli/src/index.js +++ b/apps/cli/src/index.js @@ -20,7 +20,7 @@ import { fileURLToPath, pathToFileURL } from 'node:url'; -export const VERSION = '0.3.0'; +export const VERSION = '0.4.0'; const DEFAULT_API = 'https://rssamplifier.com'; @@ -103,6 +103,21 @@ export const COMMANDS = [ options: ['--json'], examples: ['rssamp show technotim-live'], }, + { + name: 'profile', + usage: 'profile | profile edit [--file f.md] | profile claim ', + summary: "An author's OpenProfile.md: read it, claim it, correct it", + detail: + 'The person behind a feed as a portable profile file (logicsrc.com/openprofile): identity, accounts, topics, and a Broadcast section for every show they publish. `profile ` prints it. `profile claim ` says the author is you, verified on the spot by the address you published or by your site linking back. `profile edit ` opens the file in $EDITOR and sends it back; with --file it sends that file instead. Claiming and editing need a credential: --token, or RSSAMPLIFIER_TOKEN, or OPENACCESS_TOKEN in the environment (an rssamplifier API key from /account, or an OpenAccess grant for openprofile:edit).', + options: ['--file ', '--token ', '--feed ', '--json'], + examples: [ + 'rssamp profile ada-lovelace', + 'rssamp profile --feed https://ada.example/podcast/feed.xml', + 'rssamp profile claim ada-lovelace --token rsa_...', + 'EDITOR=vim rssamp profile edit ada-lovelace', + 'rssamp profile edit ada-lovelace --file profile.md', + ], + }, { name: 'submit', usage: 'submit …', @@ -337,6 +352,35 @@ async function requestText(url) { return res.text(); } +/** + * Open text in the user's editor and hand back what they saved, or null when + * there is no editor to open. A temp file beside the system's, removed after. + * + * @param {string} text + * @param {string} slug + * @returns {Promise} + */ +async function editInEditor(text, slug) { + const editor = process.env['VISUAL'] || process.env['EDITOR']; + if (!editor) return null; + const [fs, os, path, child] = await Promise.all([ + import('node:fs/promises'), + import('node:os'), + import('node:path'), + import('node:child_process'), + ]); + const file = path.join(await fs.mkdtemp(path.join(os.tmpdir(), 'rssamp-profile-')), `${slug}.openprofile.md`); + await fs.writeFile(file, text); + try { + // `sh -c` so `EDITOR="code --wait"` works the way every other tool lets it. + const result = child.spawnSync('sh', ['-c', `${editor} "$1"`, 'rssamp', file], { stdio: 'inherit' }); + if (result.status !== 0) throw new Error(`${editor} exited with ${result.status}`); + return await fs.readFile(file, 'utf8'); + } finally { + await fs.rm(path.dirname(file), { recursive: true, force: true }).catch(() => {}); + } +} + /** * The names the installer writes. `update` and `remove` will touch a file only * if it is called one of these, which is the guard that keeps them from acting @@ -474,6 +518,82 @@ export async function run(argv, io = {}) { try { switch (command) { + case 'profile': { + const sub = args[0] === 'edit' || args[0] === 'claim' ? args[0] : 'show'; + const slugArg = sub === 'show' ? args[0] : args[1]; + const token = + (typeof flags.token === 'string' && flags.token) || + process.env['RSSAMPLIFIER_TOKEN'] || + process.env['OPENACCESS_TOKEN'] || + ''; + const authed = token ? { authorization: `Bearer ${token}` } : {}; + + // A feed URL instead of a slug: the owner of that feed. + let slug = slugArg ? String(slugArg).toLowerCase() : ''; + if (!slug && typeof flags.feed === 'string') { + const found = await request(`${base}/api/authors?feed=${encodeURIComponent(flags.feed)}`); + slug = found.authors?.[0]?.slug ?? ''; + if (!slug) { + err(`profile: nobody is credited on ${flags.feed} yet`); + return 1; + } + } + if (!slug) { + err('profile: give an author slug, or --feed '); + return 1; + } + const endpoint = `${base}/api/authors/${encodeURIComponent(slug)}/openprofile`; + + if (sub === 'show') { + if (asJson) { + log(JSON.stringify(await request(`${endpoint}?format=json`), null, 2)); + return 0; + } + log((await requestText(endpoint)).replace(/\n$/, '')); + return 0; + } + + if (!token) { + err(`profile ${sub}: needs a credential. Pass --token, or set RSSAMPLIFIER_TOKEN (an API key from ${base}/account) or OPENACCESS_TOKEN.`); + return 1; + } + + if (sub === 'claim') { + const body = await request(`${base}/api/authors/${encodeURIComponent(slug)}/claim`, { + method: 'POST', + headers: { ...authed, 'content-type': 'application/json' }, + body: '{}', + }); + log(asJson ? JSON.stringify(body, null, 2) : `Claimed ${slug} (${body.method}). Edit it: rssamp profile edit ${slug}`); + return 0; + } + + // edit: from a file, or through $EDITOR on the file as served now. + let markdown; + if (typeof flags.file === 'string') { + markdown = await (io.readFile ?? ((p) => import('node:fs/promises').then((fs) => fs.readFile(p, 'utf8'))))(flags.file); + } else { + const current = await requestText(endpoint); + markdown = await (io.edit ?? editInEditor)(current, slug); + if (markdown == null) { + err('profile edit: no $EDITOR, and no --file. Set one, or write the file and pass --file.'); + return 1; + } + if (markdown === current) { + log('Unchanged.'); + return 0; + } + } + const saved = await request(endpoint, { + method: 'PUT', + headers: { ...authed, 'content-type': 'text/markdown; charset=utf-8' }, + body: markdown, + }); + if (asJson) log(JSON.stringify(saved, null, 2)); + else log(`Saved. ${saved.url ?? endpoint}`); + return 0; + } + case 'submit': { if (args.length === 0) { err('submit: give at least one URL or an .opml file'); diff --git a/apps/cli/test/profile.test.js b/apps/cli/test/profile.test.js new file mode 100644 index 0000000..38b6dea --- /dev/null +++ b/apps/cli/test/profile.test.js @@ -0,0 +1,144 @@ +import assert from 'node:assert/strict'; +import { test } from 'node:test'; + +import { COMMANDS, run } from '../src/index.js'; + +const MD = '# Ada Lovelace\n\n- Kind: person\n\nHost.\n'; + +/** + * @param {(url: string, init?: RequestInit) => Response|Promise} handler + * @param {() => Promise} body + */ +async function withFetch(handler, body) { + const original = globalThis.fetch; + globalThis.fetch = async (url, init) => handler(String(url), init); + try { + await body(); + } finally { + globalThis.fetch = original; + } +} + +test('profile is documented like every other command', () => { + const cmd = COMMANDS.find((c) => c.name === 'profile'); + assert.ok(cmd); + assert.match(cmd.usage, /profile /); + assert.ok(cmd.options.includes('--token ')); +}); + +test('profile prints the file as served', async () => { + const out = []; + await withFetch( + (url) => { + assert.equal(url, 'http://t.example/api/authors/ada-lovelace/openprofile'); + return new Response(MD, { status: 200, headers: { 'content-type': 'text/markdown' } }); + }, + async () => { + const code = await run(['profile', 'ada-lovelace', '--api', 'http://t.example'], { log: (s) => out.push(s), error: () => {} }); + assert.equal(code, 0); + assert.equal(out.join('\n'), MD.replace(/\n$/, '')); + }, + ); +}); + +test('profile --feed looks the owner up by feed URL first', async () => { + const urls = []; + await withFetch( + (url) => { + urls.push(url); + if (url.includes('/api/authors?feed=')) { + return new Response(JSON.stringify({ found: true, authors: [{ slug: 'ada-lovelace' }] }), { status: 200 }); + } + return new Response(MD, { status: 200 }); + }, + async () => { + const code = await run(['profile', '--feed', 'https://ada.example/podcast/feed.xml', '--api', 'http://t.example'], { log: () => {}, error: () => {} }); + assert.equal(code, 0); + assert.equal(urls[0], 'http://t.example/api/authors?feed=https%3A%2F%2Fada.example%2Fpodcast%2Ffeed.xml'); + assert.equal(urls[1], 'http://t.example/api/authors/ada-lovelace/openprofile'); + }, + ); +}); + +test('claim and edit refuse to run without a credential, and say where to get one', async () => { + const errs = []; + const prev = { a: process.env['RSSAMPLIFIER_TOKEN'], b: process.env['OPENACCESS_TOKEN'] }; + delete process.env['RSSAMPLIFIER_TOKEN']; + delete process.env['OPENACCESS_TOKEN']; + try { + assert.equal(await run(['profile', 'claim', 'ada-lovelace'], { log: () => {}, error: (s) => errs.push(s) }), 1); + assert.match(errs.join(' '), /--token|RSSAMPLIFIER_TOKEN/); + } finally { + if (prev.a !== undefined) process.env['RSSAMPLIFIER_TOKEN'] = prev.a; + if (prev.b !== undefined) process.env['OPENACCESS_TOKEN'] = prev.b; + } +}); + +test('profile edit --file sends the file as Markdown with the bearer', async () => { + let seen = null; + await withFetch( + (url, init) => { + seen = { url, init }; + return new Response(JSON.stringify({ ok: true, url: 'http://t.example/authors/ada-lovelace/openprofile.md' }), { status: 200 }); + }, + async () => { + const out = []; + const code = await run( + ['profile', 'edit', 'ada-lovelace', '--file', 'p.md', '--token', 'rsa_x_y', '--api', 'http://t.example'], + { log: (s) => out.push(s), error: () => {}, readFile: async () => MD }, + ); + assert.equal(code, 0); + assert.equal(seen.init.method, 'PUT'); + assert.equal(seen.init.headers.authorization, 'Bearer rsa_x_y'); + assert.match(seen.init.headers['content-type'], /text\/markdown/); + assert.equal(seen.init.body, MD); + assert.match(out.join(' '), /Saved/); + }, + ); +}); + +test('profile edit through the editor sends only when something changed', async () => { + const calls = []; + await withFetch( + (url, init) => { + calls.push(init?.method ?? 'GET'); + if (!init?.method) return new Response(MD, { status: 200 }); + return new Response(JSON.stringify({ ok: true }), { status: 200 }); + }, + async () => { + const out = []; + const unchanged = await run(['profile', 'edit', 'ada-lovelace', '--token', 't', '--api', 'http://t.example'], { + log: (s) => out.push(s), + error: () => {}, + edit: async (text) => text, + }); + assert.equal(unchanged, 0); + assert.deepEqual(calls, ['GET']); + assert.match(out.join(' '), /Unchanged/); + + const changed = await run(['profile', 'edit', 'ada-lovelace', '--token', 't', '--api', 'http://t.example'], { + log: () => {}, + error: () => {}, + edit: async (text) => `${text}\n## Guest\n\n- **Available**: yes\n`, + }); + assert.equal(changed, 0); + assert.deepEqual(calls, ['GET', 'GET', 'PUT']); + }, + ); +}); + +test('claim posts to the claim route and reports the method', async () => { + const out = []; + await withFetch( + (url, init) => { + assert.equal(url, 'http://t.example/api/authors/ada-lovelace/claim'); + assert.equal(init.method, 'POST'); + assert.equal(init.headers.authorization, 'Bearer t'); + return new Response(JSON.stringify({ ok: true, method: 'linkback' }), { status: 200 }); + }, + async () => { + assert.equal(await run(['profile', 'claim', 'ada-lovelace', '--token', 't', '--api', 'http://t.example'], { log: (s) => out.push(s), error: () => {} }), 0); + assert.match(out.join(' '), /linkback/); + }, + ); +}); diff --git a/apps/web/package.json b/apps/web/package.json index ed957d3..b619609 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -10,7 +10,9 @@ "start": "node server.mjs" }, "dependencies": { + "@logicsrc/openaccess": "^0.3.0", "@profullstack/leaderboard": "^0.3.0", + "@profullstack/openprofile": "^0.1.0", "@profullstack/partners": "^0.2.0", "@profullstack/player": "^0.3.1", "@profullstack/rssamplifier": "workspace:*", diff --git a/apps/web/public/.well-known/openaccess.json b/apps/web/public/.well-known/openaccess.json index 7fc35c8..094f5c5 100644 --- a/apps/web/public/.well-known/openaccess.json +++ b/apps/web/public/.well-known/openaccess.json @@ -18,7 +18,9 @@ } ] }, - "scopes": {}, + "scopes": { + "openprofile:edit": "Correct an author's OpenProfile.md you have claimed: PUT /api/authors/{slug}/openprofile (text/markdown, the whole file, or a JSON patch), POST /api/authors/{slug}/claim. The grant's principal is recorded as the owner; a claim is verified by the address the author published or by their site linking back." + }, "honours": [ "profullstack.com/all-access" ], diff --git a/apps/web/public/.well-known/openmcp.json b/apps/web/public/.well-known/openmcp.json index afed10d..e3062ca 100644 --- a/apps/web/public/.well-known/openmcp.json +++ b/apps/web/public/.well-known/openmcp.json @@ -26,7 +26,10 @@ "list_authors", "get_author", "directory_stats", - "submit_feed" + "submit_feed", + "get_openprofile", + "update_openprofile", + "claim_openprofile" ], "catalogs": [ "https://openmcp.logicsrc.com" diff --git a/apps/web/src/app/account/page.jsx b/apps/web/src/app/account/page.jsx index f729b67..5bc17d0 100644 --- a/apps/web/src/app/account/page.jsx +++ b/apps/web/src/app/account/page.jsx @@ -1,5 +1,5 @@ import { redirect } from 'next/navigation'; -import { accounts, apikeys, dataset } from '@rssamplifier/db'; +import { accounts, apikeys, dataset, profiles } from '@rssamplifier/db'; import Toolbar from '../Toolbar.jsx'; import { AddPasskey } from '../Passkey.jsx'; @@ -30,12 +30,15 @@ export default async function AccountPage({ searchParams }) { const client = db(); const userId = String(user.id); - const [follows, credentials, topics, keys, grant] = await Promise.all([ + const [follows, credentials, topics, keys, grant, claimed] = await Promise.all([ accounts.followedFeeds(client, userId), accounts.credentialsForUser(client, userId), accounts.followedTopics(client, userId), apikeys.keysForUser(client, userId), dataset.activeGrant(client, userId), + // The author profiles this account has claimed, so the way back to + // editing one is here and not only on the page it was claimed from. + profiles.profilesForUser(client, userId), ]); // Only for an account that has one, so the overwhelming majority of readers — @@ -61,6 +64,26 @@ export default async function AccountPage({ searchParams }) { {params.revoked &&

That passkey has been removed.

} + {claimed.length > 0 && ( + <> +

Your profiles

+

+ Author pages you have claimed. Each serves an{' '} + OpenProfile.md you can correct here, from + the CLI, or over the API with one of the keys below. +

+ + + )} +

Passkeys

{credentials.length === 0 ? (

diff --git a/apps/web/src/app/api/authors/[slug]/claim/route.js b/apps/web/src/app/api/authors/[slug]/claim/route.js new file mode 100644 index 0000000..3b7df69 --- /dev/null +++ b/apps/web/src/app/api/authors/[slug]/claim/route.js @@ -0,0 +1,84 @@ +import { authors, profiles } from '@rssamplifier/db'; + +import { db, siteUrl } from '../../../../../lib/db.js'; +import { profileUrl } from '../../../../../lib/openprofile.js'; +import { adminEmails, callerOf, claimVerdict, fetchPage } from '../../../../../lib/profileAuth.js'; +import { json } from '../../route.js'; + +export const dynamic = 'force-dynamic'; + +/** + * Claim an author's profile as your own. + * + * Verified automatically, one of two ways: the signed-in address is the one + * the author published about themselves, or the author's own site links back + * at this profile with rel="openprofile" or rel="me". Either proof is the + * person vouching for the page from a place only they control, which is the + * verification rule OpenProfile.md itself names. No form to fill in and + * nobody to wait for; an admin can claim on somebody's behalf for the case + * neither proof covers. + * + * Form-first like every write here: the author page posts a plain form and is + * sent to the edit page; a JSON or bearer caller gets JSON. + * + * @param {Request} req + * @param {{ params: Promise<{ slug: string }> }} ctx + */ +export async function POST(req, ctx) { + const { slug: raw } = await ctx.params; + const slug = raw.toLowerCase(); + const wantsHtml = (req.headers.get('accept') ?? '').includes('text/html'); + const page = `/authors/${encodeURIComponent(slug)}`; + + const client = db(); + const person = await authors.authorBySlug(client, slug); + if (!person) return wantsHtml ? redirect('/authors') : json({ error: 'not-found', slug }, 404); + + const caller = await callerOf(req); + if (!caller.kind) { + return wantsHtml ? redirect(`/login?next=${encodeURIComponent(page)}`) : json({ error: 'sign-in-required' }, 401); + } + + const profile = await profiles.profileForAuthor(client, String(person.id)); + const base = siteUrl(); + const verdict = await claimVerdict({ + caller, + person, + profile, + profileUrl: profileUrl(base, slug), + pageUrl: `${base}${page}`, + admins: adminEmails(), + fetchText: fetchPage, + }); + + if (!verdict.ok) { + return wantsHtml + ? redirect(`${page}?claim=${encodeURIComponent(verdict.error)}`) + : json({ error: verdict.error }, verdict.status); + } + + const claimed = await profiles.claimProfile(client, String(person.id), { + userId: caller.userId, + principal: caller.principal, + method: verdict.method, + }); + + if (wantsHtml) return redirect(`${page}/edit?claimed=1`); + + return json({ + ok: true, + slug, + method: verdict.method, + claimedAt: claimed.claimed_at, + url: profileUrl(base, slug), + edit: `${base}/api/authors/${encodeURIComponent(slug)}/openprofile`, + }); +} + +/** + * @param {string} to + * @returns {Response} + */ +function redirect(to) { + return new Response(null, { status: 303, headers: { location: to } }); +} diff --git a/apps/web/src/app/api/authors/[slug]/openprofile/route.js b/apps/web/src/app/api/authors/[slug]/openprofile/route.js new file mode 100644 index 0000000..82759cc --- /dev/null +++ b/apps/web/src/app/api/authors/[slug]/openprofile/route.js @@ -0,0 +1,155 @@ +import { profiles } from '@rssamplifier/db'; + +import { db } from '../../../../../lib/db.js'; +import { PROFILE_HEADERS, loadAuthorProfile } from '../../../../../lib/authorProfile.js'; +import { overridesFromBody, overridesFromForm } from '../../../../../lib/openprofile.js'; +import { callerOf, isOwner } from '../../../../../lib/profileAuth.js'; +import { json } from '../../route.js'; + +export const dynamic = 'force-dynamic'; + +/** The most an edited profile may be: the file is a page, not a book. */ +const BODY_LIMIT = 64 * 1024; + +/** + * The author's OpenProfile.md, read and written. + * + * GET answers the same Markdown as /authors/{slug}/openprofile.md, or the + * parsed shape as JSON with `?format=json` (or an Accept for it), so an agent + * that wants sections does not parse Markdown to get them. + * + * PUT is the owner correcting it. The body is either the whole file as + * `text/markdown` (what a CLI hands back after $EDITOR) or a JSON patch + * `{ name, headline, prose, identity: {Key: value|null}, sections: {name: + * body|null}, public, markdown }`. Both store the same overlay + * (@profullstack/openprofile), so a later GET renders what was sent, with the + * sections the owner did not mention still generated. Write `none` (or null) + * as a section to drop a generated one. + * + * POST is PUT for an HTML form: the edit page posts here and is sent back. + * + * Auth: the site's session, one of the account's API keys, or an OpenAccess + * bearer with `openprofile:edit`. The caller must be the one who claimed the + * profile (see ../claim). + */ + +/** + * @param {Request} req + * @param {{ params: Promise<{ slug: string }> }} ctx + */ +export async function GET(req, ctx) { + const { slug } = await ctx.params; + const loaded = await loadAuthorProfile(slug.toLowerCase()); + if (!loaded || (loaded.profile && !loaded.profile.public)) return json({ error: 'not-found', slug }, 404); + + const url = new URL(req.url); + const wantsJson = + url.searchParams.get('format') === 'json' || + (req.headers.get('accept') ?? '').includes('application/json'); + + if (!wantsJson) return new Response(loaded.markdown, { headers: PROFILE_HEADERS }); + + return json({ + slug: String(loaded.person.slug), + url: loaded.url, + page: loaded.page, + claimed: Boolean(loaded.profile?.claimed_at), + public: loaded.profile?.public ?? true, + updatedAt: loaded.profile?.updated_at ?? null, + name: loaded.doc.name, + identity: Object.fromEntries(loaded.doc.identity.map((e) => [e.key, e.value])), + headline: loaded.doc.headline, + sections: loaded.doc.sections.map((s) => ({ title: s.title, name: s.name, body: s.body })), + markdown: loaded.markdown, + }); +} + +/** + * @param {Request} req + * @param {{ params: Promise<{ slug: string }> }} ctx + */ +export async function PUT(req, ctx) { + const { slug } = await ctx.params; + return write(req, slug.toLowerCase(), false); +} + +/** + * @param {Request} req + * @param {{ params: Promise<{ slug: string }> }} ctx + */ +export async function POST(req, ctx) { + const { slug } = await ctx.params; + return write(req, slug.toLowerCase(), true); +} + +/** + * @param {Request} req + * @param {string} slug + * @param {boolean} fromForm + */ +async function write(req, slug, fromForm) { + const wantsHtml = fromForm && (req.headers.get('accept') ?? '').includes('text/html'); + const editPage = `/authors/${encodeURIComponent(slug)}/edit`; + + const loaded = await loadAuthorProfile(slug); + if (!loaded) return wantsHtml ? redirect('/authors') : json({ error: 'not-found', slug }, 404); + + const caller = await callerOf(req); + if (!caller.kind) { + return wantsHtml + ? redirect(`/login?next=${encodeURIComponent(editPage)}`) + : json({ error: 'sign-in-required' }, 401); + } + if (!isOwner(caller, loaded.profile)) { + const error = loaded.profile?.claimed_at + ? 'not-the-owner' + : `unclaimed: POST /api/authors/${slug}/claim first`; + return wantsHtml ? redirect(`${editPage}?error=${encodeURIComponent(error)}`) : json({ error }, 403); + } + + let next; + if (fromForm && !(req.headers.get('content-type') ?? '').includes('json') && !/markdown|text\/plain/i.test(req.headers.get('content-type') ?? '')) { + let form; + try { + form = await req.formData(); + } catch { + return wantsHtml ? redirect(`${editPage}?error=bad-form`) : json({ error: 'bad-form' }, 400); + } + next = overridesFromForm(form); + } else { + const text = await req.text(); + if (text.length > BODY_LIMIT) return json({ error: `body over ${BODY_LIMIT} bytes` }, 413); + next = overridesFromBody({ + contentType: req.headers.get('content-type') ?? '', + text, + existing: loaded.profile?.overrides ?? null, + generated: loaded.generated, + }); + if (next.error) return json({ error: next.error }, 400); + } + + const saved = await profiles.saveProfile(db(), String(loaded.person.id), { + overrides: next.overrides, + ...(typeof next.public === 'boolean' ? { public: next.public } : {}), + }); + + if (wantsHtml) return redirect(`${editPage}?saved=1`); + + const after = await loadAuthorProfile(slug); + return json({ + ok: true, + slug, + url: loaded.url, + public: saved.public, + updatedAt: saved.updated_at, + markdown: after?.markdown ?? loaded.markdown, + }); +} + +/** + * @param {string} to + * @returns {Response} + */ +function redirect(to) { + return new Response(null, { status: 303, headers: { location: to } }); +} diff --git a/apps/web/src/app/api/authors/route.js b/apps/web/src/app/api/authors/route.js index 3ad1963..374649d 100644 --- a/apps/web/src/app/api/authors/route.js +++ b/apps/web/src/app/api/authors/route.js @@ -1,6 +1,7 @@ -import { authors } from '@rssamplifier/db'; +import { authors, q } from '@rssamplifier/db'; import { db, siteUrl } from '../../../lib/db.js'; +import { profileUrl } from '../../../lib/openprofile.js'; export const dynamic = 'force-dynamic'; @@ -37,8 +38,25 @@ export async function GET(req) { const minConfidence = clampFloat(url.searchParams.get('min'), 0.6); const network = (url.searchParams.get('network') ?? '').trim() || ''; const query = (url.searchParams.get('q') ?? '').trim() || ''; + const feedUrl = (url.searchParams.get('feed') ?? '').trim(); const client = db(); + + // `?feed=`: the people behind one feed, owner first. This is how another + // directory that holds a feed URL and nothing else (a podcast index, say) + // finds the person and their OpenProfile.md without knowing our slugs. + if (feedUrl) { + const feed = await q.feedByUrl(client, feedUrl); + if (!feed) return json({ feed: feedUrl, found: false, authors: [] }, 404); + const credited = await authors.authorsForFeed(client, String(feed.id)); + return json({ + feed: feedUrl, + found: true, + slug: String(feed.slug), + page: `${siteUrl()}/${encodeURIComponent(String(feed.slug))}`, + authors: credited.map((person) => ({ ...shape(person), role: String(person.role ?? 'author') })), + }); + } const [rows, total, stats] = await Promise.all([ authors.listAuthors(client, { limit, offset, minConfidence, network, query }), authors.countAuthors(client, { minConfidence }), @@ -82,6 +100,9 @@ export function shape(person) { confidence: Number(person.confidence ?? 0), feedCount: person.feed_count == null ? undefined : Number(person.feed_count), page: `${siteUrl()}/authors/${encodeURIComponent(String(person.slug))}`, + // Their OpenProfile.md (logicsrc.com/openprofile): the same facts as a + // portable file, with the person's own corrections once they claim it. + openprofile: profileUrl(siteUrl(), String(person.slug)), links: (person.links ?? []).map((link) => ({ network: link.network, url: link.url, diff --git a/apps/web/src/app/api/feeds/[slug]/route.js b/apps/web/src/app/api/feeds/[slug]/route.js index d1bee38..c1dd288 100644 --- a/apps/web/src/app/api/feeds/[slug]/route.js +++ b/apps/web/src/app/api/feeds/[slug]/route.js @@ -2,6 +2,7 @@ import { q, authors } from '@rssamplifier/db'; import { db, siteUrl } from '../../../../lib/db.js'; import { freshness } from '../../../../lib/freshness.js'; +import { profileUrl } from '../../../../lib/openprofile.js'; export const dynamic = 'force-dynamic'; @@ -90,6 +91,7 @@ export async function GET(req, { params }) { email: person.email, confidence: Number(person.confidence ?? 0), page: `${siteUrl()}/authors/${encodeURIComponent(String(person.slug))}`, + openprofile: profileUrl(siteUrl(), String(person.slug)), links: (person.links ?? []).map((l) => ({ network: l.network, url: l.url, diff --git a/apps/web/src/app/authors/[slug]/edit/page.jsx b/apps/web/src/app/authors/[slug]/edit/page.jsx new file mode 100644 index 0000000..2de75ec --- /dev/null +++ b/apps/web/src/app/authors/[slug]/edit/page.jsx @@ -0,0 +1,144 @@ +import { notFound, redirect } from 'next/navigation'; + +import { loadAuthorProfile } from '../../../../lib/authorProfile.js'; +import { callerOf, isOwner } from '../../../../lib/profileAuth.js'; + +export const dynamic = 'force-dynamic'; + +/** The sections the form offers a box for, in the order the file renders them. */ +const SECTIONS = [ + ['about', 'About', 'A paragraph or two in your own words.'], + ['accounts', 'Accounts', 'One per line: a URL, or [Label](https://url). The URL is the identity.'], + ['topics', 'Topics', 'One per line, or comma-separated. The words you would use to find yourself.'], + [ + 'broadcast', + 'Broadcast', + 'Your show, as OpenBroadcast keys: - **Show**: ..., - **Seeking**: ..., - **Not**: ..., - **Book**: ..., - **Pays**: no. Two shows: a ### heading per show.', + ], + [ + 'guest', + 'Guest', + 'That you will appear on other shows, as OpenGuest keys: - **Available**: yes, - **Expertise**: ..., - **Pitch**: ..., - **Rate**: free, - **Book**: ...', + ], + ['links', 'Links', 'One per line: [Label](https://url).'], +]; + +/** + * @param {{ params: Promise<{ slug: string }> }} props + */ +export async function generateMetadata({ params }) { + const { slug } = await params; + return { title: `Edit profile · ${slug}`, robots: { index: false } }; +} + +/** + * The owner correcting their own OpenProfile.md. + * + * One field per part of the file, each holding the whole of that part as it + * is served now, generated text included. Saving stores what is in the boxes + * as the owner's word; a box left as generated stays generated, a box emptied + * drops the section, and every box is Markdown exactly as the file will carry + * it. There is no second form language to learn: what you type here is what + * /authors/{slug}/openprofile.md will say. + * + * @param {{ params: Promise<{ slug: string }>, searchParams: Promise<{ saved?: string, claimed?: string, error?: string }> }} props + */ +export default async function EditProfilePage({ params, searchParams }) { + const { slug: raw } = await params; + const slug = raw.toLowerCase(); + const query = await searchParams; + + const loaded = await loadAuthorProfile(slug); + if (!loaded) notFound(); + + const caller = await callerOf(new Request('http://localhost/')); + if (!caller.kind) redirect(`/login?next=${encodeURIComponent(`/authors/${slug}/edit`)}`); + + const page = `/authors/${encodeURIComponent(slug)}`; + if (!isOwner(caller, loaded.profile)) { + return ( + <> +

+ Authors · {loaded.person.name} +

+

Not yours to edit yet

+

+ {loaded.profile?.claimed_at + ? 'This profile has been claimed by somebody else. If that is you under another account, sign in as that account.' + : 'Claim the profile first. It is verified automatically when you are signed in with the address you published on your site, or when your site links back here.'} +

+ {!loaded.profile?.claimed_at && ( +
+ +
+ )} + {query.error &&

{query.error}

} + + ); + } + + const doc = loaded.doc; + const identityText = doc.identity.map((e) => `${e.key}: ${e.value}`).join('\n'); + const bodyOf = (name) => doc.sections.find((s) => s.name === name)?.body ?? ''; + + return ( + <> +

+ Authors · {loaded.person.name} +

+

Your profile

+

+ What you save here is what {loaded.url.replace(/^https?:\/\//, '')}{' '} + says, in the shape every directory that reads{' '} + OpenProfile.md understands. Boxes hold what + the file says now; change any of them, or empty one to drop that section. A key or a + section the spec does not know is kept as written. +

+ + {query.claimed &&

The profile is yours. Edit it below, or leave it as read.

} + {query.saved &&

Saved.

} + {query.error &&

{query.error}

} + +
+ + + + + + + +

+ One Key: value per line. Kind, Handle, Web, Email, Location, Pronouns, + Timezone, Languages, Avatar, DID, Pay, Resume; any other key is kept too. Email is only + published if you put it here. +

+