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.
+
+ {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.'}
+
+ 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}
}
+
+
+
+
+ The same edit works from anywhere: PUT {`/api/authors/${slug}/openprofile`} with
+ the whole file as text/markdown or a JSON patch, with one of your API keys or an
+ OpenAccess grant for openprofile:edit; rssamp profile edit {slug}{' '}
+ from the CLI; update_openprofile over MCP.
+
+ >
+ );
+}
diff --git a/apps/web/src/app/authors/[slug]/openprofile.md/route.js b/apps/web/src/app/authors/[slug]/openprofile.md/route.js
new file mode 100644
index 0000000..2fa987e
--- /dev/null
+++ b/apps/web/src/app/authors/[slug]/openprofile.md/route.js
@@ -0,0 +1,33 @@
+import { PROFILE_HEADERS, loadAuthorProfile } from '../../../../lib/authorProfile.js';
+
+export const dynamic = 'force-dynamic';
+
+/**
+ * The author's OpenProfile.md, at the conventional platform path
+ * (logicsrc.com/openprofile, Discovery, rule 3): next to the profile page,
+ * pointed at by `` on it.
+ *
+ * Generated from what the author published, corrected by what they told us.
+ * An owner who switched the file off gets a 404 here while their page stays.
+ *
+ * @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 new Response('not found\n', {
+ status: 404,
+ headers: { 'content-type': 'text/plain; charset=utf-8', 'access-control-allow-origin': '*' },
+ });
+ }
+
+ return new Response(loaded.markdown, {
+ headers: {
+ ...PROFILE_HEADERS,
+ link: `<${loaded.page}>; rel="alternate"; type="text/html"`,
+ },
+ });
+}
diff --git a/apps/web/src/app/authors/[slug]/page.jsx b/apps/web/src/app/authors/[slug]/page.jsx
index 56fdb3f..e883242 100644
--- a/apps/web/src/app/authors/[slug]/page.jsx
+++ b/apps/web/src/app/authors/[slug]/page.jsx
@@ -12,6 +12,8 @@ import { CATEGORIES } from '../../CategoryIndex.jsx';
import ListFilter from '../../ListFilter.jsx';
import { FILTER_FROM } from '../../../lib/listFilter.js';
import { jsonLdScript } from '../../../lib/jsonld.js';
+import { profileUrl } from '../../../lib/openprofile.js';
+import { profiles } from '@rssamplifier/db';
export const dynamic = 'force-dynamic';
@@ -52,9 +54,9 @@ export async function generateMetadata({ params }) {
* anything back; this way the next crawler along — ours or anybody else's —
* gets the answer in the form it was asked in.
*
- * @param {{ params: Promise<{ slug: string }> }} props
+ * @param {{ params: Promise<{ slug: string }>, searchParams: Promise<{ claim?: string }> }} props
*/
-export default async function AuthorPage({ params }) {
+export default async function AuthorPage({ params, searchParams }) {
const { slug } = await params;
const person = await authors.authorBySlug(db(), slug);
if (!person) notFound();
@@ -71,6 +73,14 @@ export default async function AuthorPage({ params }) {
? await alerts.authorFollowState(db(), String(user.id), String(person.id))
: { following: false, alerts: false };
+ // Their OpenProfile.md (logicsrc.com/openprofile): generated from this same
+ // page's rows, corrected by the person once they claim it. Whether it is
+ // claimed decides which of two buttons the page offers.
+ const profile = await profiles.profileForAuthor(db(), String(person.id));
+ const openprofile = profileUrl(siteUrl(), slug);
+ const ownsProfile = Boolean(user && profile?.claimed_at && profile.owner_user_id === String(user.id));
+ const query = await searchParams;
+
// What they have published lately, read off their own feeds' ids rather than
// searched for -- see `postsByAuthor`. A profile that lists the blogs but not
// the writing is a card catalogue entry; the point of a page about a person
@@ -114,6 +124,10 @@ export default async function AuthorPage({ params }) {
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: jsonLdScript(jsonLd) }}
/>
+ {/* The profile file, where OpenProfile.md says a platform points at it:
+ a on the person's page. React hoists it into
+ . Only while it is served. */}
+ {(!profile || profile.public) && }
Authors
@@ -154,6 +168,31 @@ export default async function AuthorPage({ params }) {
+ {/* The profile as a file, and the person's way in to it. Unclaimed: a
+ claim button, verified on the spot by the address they published or by
+ their site linking back. Claimed by this reader: edit. Claimed by
+ somebody else: just the file. */}
+
}
+
{/* Follow the person, and then decide whether to be told. Above the
subscribe links deliberately: those hand the reader a document to take
somewhere else, and this keeps them here, which is the thing the page
@@ -263,8 +302,9 @@ export default async function AuthorPage({ params }) {
Everything on this page was read from markup {person.name} published — a{' '}
rel="me" link, an h-card, or the feed’s own author element.
Nothing was inferred from anywhere else. To correct or remove it,{' '}
- get in touch. Machine-readable:{' '}
- JSON
+ get in touch, or claim the profile above and correct it yourself.
+ Machine-readable: JSON,{' '}
+ OpenProfile.md
diff --git a/apps/web/src/app/globals.css b/apps/web/src/app/globals.css
index fc02116..7cc91dd 100644
--- a/apps/web/src/app/globals.css
+++ b/apps/web/src/app/globals.css
@@ -3889,3 +3889,34 @@ html:not([data-dock]) .playlist-hint {
.filtered-out {
display: none !important;
}
+
+/* The profile row on an author page: the file, and the way in to it. A form
+ with one button sits inline among links and reads as one of them. */
+.profile-actions .inline-form {
+ display: inline;
+}
+.profile-actions .linklike {
+ background: none;
+ border: 0;
+ padding: 0;
+ margin: 0;
+ font: inherit;
+ color: inherit;
+ text-decoration: underline;
+ cursor: pointer;
+}
+.profile-edit label {
+ display: block;
+ margin-top: 1rem;
+ font-weight: 600;
+}
+.profile-edit textarea,
+.profile-edit input[type='text'] {
+ width: 100%;
+ box-sizing: border-box;
+ font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
+ font-size: 0.9rem;
+}
+.profile-edit .hint {
+ margin: 0.15rem 0 0.35rem;
+}
diff --git a/apps/web/src/app/skill.md/route.js b/apps/web/src/app/skill.md/route.js
index 934cc9d..beed234 100644
--- a/apps/web/src/app/skill.md/route.js
+++ b/apps/web/src/app/skill.md/route.js
@@ -49,6 +49,12 @@ is not a search engine for the whole web.
their own site as a \`rel="me"\` claim, an h-card or JSON-LD; role mailboxes
are dropped at extraction, so an address here belongs to a person.
- \`GET ${base}/api/authors/{slug}\` — one author, with everything they publish.
+ \`GET ${base}/api/authors?feed={url}\` finds the people behind one feed.
+- \`GET ${base}/authors/{slug}/openprofile.md\` — the same person as an
+ OpenProfile.md (logicsrc.com/openprofile), Broadcast section per show. The
+ person claims it (\`POST /api/authors/{slug}/claim\`) and corrects it with
+ \`PUT /api/authors/{slug}/openprofile\` (text/markdown or a JSON patch; API key
+ or an OpenAccess grant for \`openprofile:edit\`).
- \`GET ${base}/api/feeds/{slug}\` also carries \`authors\` and \`links\`. \`links\` is
the blog's own accounts — Mastodon, Bluesky, X, LinkedIn, GitHub and the rest
— which is what a blog with no byline has instead of an author, and roughly a
diff --git a/apps/web/src/lib/authorProfile.js b/apps/web/src/lib/authorProfile.js
new file mode 100644
index 0000000..e1e5791
--- /dev/null
+++ b/apps/web/src/lib/authorProfile.js
@@ -0,0 +1,58 @@
+import { authors, profiles } from '@rssamplifier/db';
+
+import { db, siteUrl } from './db.js';
+import { authorProfile, profileUrl } from './openprofile.js';
+
+/**
+ * One author's OpenProfile.md, loaded and built, for every surface that serves
+ * it: the .md route, the JSON route, the MCP tools and the edit page.
+ *
+ * @param {string} slug
+ * @returns {Promise}
+ */
+export async function loadAuthorProfile(slug) {
+ const client = db();
+ const person = await authors.authorBySlug(client, slug);
+ if (!person) return null;
+
+ const feeds = person.feeds ?? [];
+ const feedIds = feeds.map((f) => String(f.id));
+ const [profile, topicsByFeed, firstByFeed] = await Promise.all([
+ profiles.profileForAuthor(client, String(person.id)),
+ profiles.keywordsForFeeds(client, feedIds),
+ profiles.firstPublishedAt(client, feedIds),
+ ]);
+
+ const base = siteUrl();
+ const built = authorProfile({
+ person,
+ feeds,
+ topicsByFeed,
+ firstByFeed,
+ base,
+ overrides: profile?.overrides ?? null,
+ });
+
+ return {
+ person,
+ profile,
+ ...built,
+ url: profileUrl(base, String(person.slug)),
+ page: `${base}/authors/${encodeURIComponent(String(person.slug))}`,
+ };
+}
+
+/** The headers a served profile carries. */
+export const PROFILE_HEADERS = {
+ 'content-type': 'text/markdown; charset=utf-8',
+ 'access-control-allow-origin': '*',
+ 'cache-control': 'public, max-age=3600, s-maxage=3600, stale-while-revalidate=86400',
+};
diff --git a/apps/web/src/lib/llms.js b/apps/web/src/lib/llms.js
index ab26a0a..55aa625 100644
--- a/apps/web/src/lib/llms.js
+++ b/apps/web/src/lib/llms.js
@@ -168,7 +168,8 @@ export async function llmsTxt(opts = {}) {
`- [One topic, JSON](${base}/api/topics/{keyword}): the feeds filed under it, plus which categories it has; \`?group=\` narrows it to one`,
`- [One topic, as a feed](${base}/topics/{keyword}.json): what those feeds published — also .rss, .atom, .m3u, .pls`,
`- [Authors, JSON](${base}/api/authors): the people behind the feeds and where else they publish; ?network=email|fediverse|bluesky|github|website|linktree, ?q= searches names, ?min= sets the confidence floor`,
- `- [One author, JSON](${base}/api/authors/{slug}): their links and everything they publish here`,
+ `- [One author, JSON](${base}/api/authors/{slug}): their links and everything they publish here; ?feed= on /api/authors finds the people behind one feed`,
+ `- [One author, OpenProfile.md](${base}/authors/{slug}/openprofile.md): the same person as a portable profile file (logicsrc.com/openprofile) with a Broadcast section per show they publish; the person claims it at /authors/{slug} and edits it with PUT ${base}/api/authors/{slug}/openprofile`,
`- [OPML export](${base}/opml): the whole directory as a subscription list, one category with ?kind=, or one subject with ?topic=`,
`- [Submit](${base}/api/submit): POST {"url":"..."} or {"urls":[...]} or {"opml":"..."}`,
`- [Discover](${base}/api/discover): POST {"keywords":["..."]} — find blogs by subject`,
@@ -184,7 +185,7 @@ export async function llmsTxt(opts = {}) {
'- Every endpoint above sends `access-control-allow-origin: *` and needs no key. The bulk corpus streams under `/api/dataset/{name}` are the one exception, and nothing else here depends on them.',
'- Summaries are plain text, already stripped of markup.',
'- Each feed has a stable page at /{slug} carrying schema.org Blog or PodcastSeries JSON-LD.',
- '- Each author has a page at /authors/{slug} carrying schema.org Person with sameAs.',
+ '- Each author has a page at /authors/{slug} carrying schema.org Person with sameAs, and a to /authors/{slug}/openprofile.md.',
'- Author links come from what the author published about themselves — rel="me", h-card, JSON-LD sameAs — never from a data broker. Role mailboxes are dropped at extraction, so an email in /api/authors belongs to a person. Each link carries the source it was read from and whether the account links back, so you can decide how much to trust it.',
'- Recognised networks: their own site, email, Mastodon and the wider fediverse, Bluesky, X, LinkedIn, GitHub, GitLab, Codeberg, SourceHut, YouTube, Twitch, Instagram, Threads, TikTok, Reddit, Mastodon-style links pages (Linktree, bio.link and friends), Micro.blog, Substack, Medium, DEV, Tumblr, Telegram, Matrix, XMPP, Nostr, Keybase, ORCID, Flickr, SoundCloud, Bandcamp, Goodreads, Stack Overflow, Patreon and Ko-fi.',
'- A blog with accounts but no byline has them at /api/feeds/{slug} under `links` rather than under `authors`. About a third of the directory is this shape, so check both.',
diff --git a/apps/web/src/lib/mcp/tools.js b/apps/web/src/lib/mcp/tools.js
index 58623d6..5a07903 100644
--- a/apps/web/src/lib/mcp/tools.js
+++ b/apps/web/src/lib/mcp/tools.js
@@ -2,6 +2,9 @@ import { q, newId, authors as people } from '@rssamplifier/db';
import { topicSlug } from '@rssamplifier/feed';
import { submitCatalogue, hashIp, EXPRESS_MAX } from '@rssamplifier/ingest';
import { submitFeedTool } from '@profullstack/submit-feed/core';
+import { loadAuthorProfile } from '../authorProfile.js';
+import { overridesFromBody, profileUrl } from '../openprofile.js';
+import { adminEmails, callerOf, claimVerdict, fetchPage, isOwner, profiles as profileStore } from '../profileAuth.js';
import { db, siteUrl } from '../db.js';
import { readerView } from '../reader.js';
@@ -508,6 +511,121 @@ export const TOOLS = [
},
},
+ {
+ name: 'get_openprofile',
+ title: "One author's OpenProfile.md",
+ description:
+ "The person as a portable profile file (logicsrc.com/openprofile): identity block, Accounts, Topics, a Broadcast section (logicsrc.com/openbroadcast) for every show they publish, corrected by the person once they have claimed it. Returns the Markdown and the parsed sections. Pass either the author's slug or a feed URL; with a feed URL you get the profile of the feed's owner.",
+ inputSchema: {
+ type: 'object',
+ properties: {
+ slug: { type: 'string', description: "The author's slug in this directory." },
+ feed: { type: 'string', description: 'A feed URL; the owner of that feed is looked up.' },
+ },
+ },
+ annotations: { readOnlyHint: true, openWorldHint: false },
+ async run(args) {
+ const slug = await profileSlug(args);
+ const loaded = await loadAuthorProfile(slug);
+ if (!loaded) throw invalid(`no author with slug '${slug}'`);
+ if (loaded.profile && !loaded.profile.public) throw invalid(`the profile of '${slug}' is not public`);
+ return profileShape(loaded);
+ },
+ },
+
+ {
+ name: 'update_openprofile',
+ title: "Correct an author's OpenProfile.md",
+ description:
+ "Edit a profile you have claimed. Send the whole file as `markdown` (everything in it becomes your word; a section you leave out is dropped) or a patch: `headline`, `identity` {Key: value, or null to remove}, `sections` {name: Markdown body, or null to drop}, `public`. Sections you do not mention stay as generated. Needs a credential in the Authorization header: one of your rssamplifier API keys, or an OpenAccess bearer for `openprofile:edit` (openaccess.logicsrc.com). Claim first with claim_openprofile.",
+ inputSchema: {
+ type: 'object',
+ properties: {
+ slug: { type: 'string', description: "The author's slug." },
+ markdown: { type: 'string', description: 'The whole OpenProfile.md, replacing every section.' },
+ name: { type: 'string' },
+ headline: { type: 'string' },
+ prose: { type: 'string' },
+ identity: { type: 'object', additionalProperties: { type: ['string', 'null'] } },
+ sections: { type: 'object', additionalProperties: { type: ['string', 'null'] } },
+ public: { type: 'boolean', description: 'false hides the file; the author page stays.' },
+ },
+ required: ['slug'],
+ },
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false },
+ async run(args, ctx) {
+ const slug = String(args?.slug ?? '').trim().toLowerCase();
+ if (!slug) throw invalid('slug is required');
+ const loaded = await loadAuthorProfile(slug);
+ if (!loaded) throw invalid(`no author with slug '${slug}'`);
+
+ const caller = await callerOf(requestFrom(ctx), { user: async () => null });
+ if (!caller.kind) throw invalid('send an API key or an OpenAccess bearer in the Authorization header');
+ if (!isOwner(caller, loaded.profile)) {
+ throw invalid(loaded.profile?.claimed_at ? 'you are not the owner of this profile' : 'unclaimed: call claim_openprofile first');
+ }
+
+ const { slug: _s, ...body } = args ?? {};
+ const next = overridesFromBody({
+ contentType: 'application/json',
+ text: JSON.stringify(body),
+ existing: loaded.profile?.overrides ?? null,
+ generated: loaded.generated,
+ });
+ if (next.error) throw invalid(next.error);
+
+ await profileStore.saveProfile(db(), String(loaded.person.id), {
+ overrides: next.overrides,
+ ...(typeof next.public === 'boolean' ? { public: next.public } : {}),
+ });
+ const after = await loadAuthorProfile(slug);
+ return { ok: true, ...profileShape(after ?? loaded) };
+ },
+ },
+
+ {
+ name: 'claim_openprofile',
+ title: 'Claim an author profile as yourself',
+ description:
+ "Say that an author in this directory is you, so you can correct the profile. Verified on the spot, no reviewer: the email behind your credential matches the address the author published about themselves, or the author's own site links back at the profile with rel=\"openprofile\" or rel=\"me\". Needs a credential in the Authorization header: an rssamplifier API key, or an OpenAccess bearer for `openprofile:edit`.",
+ inputSchema: {
+ type: 'object',
+ properties: { slug: { type: 'string', description: "The author's slug." } },
+ required: ['slug'],
+ },
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: true },
+ async run(args, ctx) {
+ const slug = String(args?.slug ?? '').trim().toLowerCase();
+ if (!slug) throw invalid('slug is required');
+ const client = db();
+ const person = await people.authorBySlug(client, slug);
+ if (!person) throw invalid(`no author with slug '${slug}'`);
+
+ const caller = await callerOf(requestFrom(ctx), { user: async () => null });
+ if (!caller.kind) throw invalid('send an API key or an OpenAccess bearer in the Authorization header');
+
+ const profile = await profileStore.profileForAuthor(client, String(person.id));
+ const base = siteUrl();
+ const verdict = await claimVerdict({
+ caller,
+ person,
+ profile,
+ profileUrl: profileUrl(base, slug),
+ pageUrl: `${base}/authors/${encodeURIComponent(slug)}`,
+ admins: adminEmails(),
+ fetchText: fetchPage,
+ });
+ if (!verdict.ok) throw invalid(verdict.error);
+
+ const claimed = await profileStore.claimProfile(client, String(person.id), {
+ userId: caller.userId,
+ principal: caller.principal,
+ method: verdict.method,
+ });
+ return { ok: true, slug, method: verdict.method, claimedAt: claimed.claimed_at, url: profileUrl(base, slug) };
+ },
+ },
+
{
name: 'directory_stats',
title: 'Directory and crawler status',
@@ -680,6 +798,7 @@ function author(a) {
role: a.role,
confidence: Number(a.confidence ?? 0),
page: `${siteUrl()}/authors/${a.slug}`,
+ openprofile: profileUrl(siteUrl(), String(a.slug)),
links: (a.links ?? []).map((l) => ({
network: l.network,
url: l.url,
@@ -766,3 +885,56 @@ function invalid(message) {
err.toolError = true;
return err;
}
+
+/**
+ * A Request the profile auth can read, from the tool context's headers.
+ *
+ * @param {ToolContext} ctx
+ * @returns {Request}
+ */
+function requestFrom(ctx) {
+ const headers = new Headers();
+ const auth = ctx?.header?.('authorization');
+ if (auth) headers.set('authorization', auth);
+ return new Request('http://mcp.local/', { headers });
+}
+
+/**
+ * The slug an OpenProfile tool was asked about: given, or looked up from a
+ * feed URL (its owner, else its first credited author).
+ *
+ * @param {any} args
+ * @returns {Promise}
+ */
+async function profileSlug(args) {
+ const slug = String(args?.slug ?? '').trim().toLowerCase();
+ if (slug) return slug;
+ const feedUrl = String(args?.feed ?? '').trim();
+ if (!feedUrl) throw invalid('slug or feed is required');
+ const feed = await q.feedByUrl(db(), feedUrl);
+ if (!feed) throw invalid(`no feed with url '${feedUrl}' in the directory`);
+ const credited = await people.authorsForFeed(db(), String(feed.id));
+ if (!credited[0]) throw invalid(`nobody is credited on '${feedUrl}' yet`);
+ return String(credited[0].slug);
+}
+
+/**
+ * A loaded profile, in the shape the profile tools return.
+ *
+ * @param {NonNullable>>} loaded
+ * @returns {object}
+ */
+function profileShape(loaded) {
+ return {
+ slug: String(loaded.person.slug),
+ url: loaded.url,
+ page: loaded.page,
+ claimed: Boolean(loaded.profile?.claimed_at),
+ public: loaded.profile?.public ?? true,
+ 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,
+ };
+}
diff --git a/apps/web/src/lib/openaccess.js b/apps/web/src/lib/openaccess.js
new file mode 100644
index 0000000..0c5c4a4
--- /dev/null
+++ b/apps/web/src/lib/openaccess.js
@@ -0,0 +1,73 @@
+import { OpenAccessApp } from '@logicsrc/openaccess/client';
+
+/**
+ * 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 `rssamplifier.com` is verified
+ * here offline against the hub's published keys, and its `scope` decides what
+ * the caller may do. That is how a person's own agent edits their profile
+ * from anywhere without holding a session cookie for this site.
+ */
+export const HUB = 'https://openaccess.logicsrc.com';
+export const CLIENT_ID = 'rssamplifier.com';
+
+/** Edit the OpenProfile.md of an author the principal has claimed. */
+export const SCOPE_PROFILE_EDIT = 'openprofile:edit';
+
+/** @type {OpenAccessApp|null} */
+let app = null;
+
+/** @returns {OpenAccessApp} */
+function hub() {
+ if (!app) {
+ app = new OpenAccessApp({
+ hub: HUB,
+ clientId: CLIENT_ID,
+ redirectUri: `https://${CLIENT_ID}/api/v1/openaccess/callback`,
+ });
+ }
+ return app;
+}
+
+/**
+ * @typedef {{ sub: string, scopes: string[], email: string|null }} Principal
+ */
+
+/**
+ * The OpenAccess principal behind a bearer token, or null for none or a bad one.
+ *
+ * `email` rides along when the hub put one in the claims, because a claim on
+ * a profile is verified by matching it against the address the author
+ * published; a token without one can still edit a profile already claimed
+ * under its `sub`.
+ *
+ * @param {string|null|undefined} token
+ * @param {{ verify?: (token: string) => Promise }} [deps]
+ * @returns {Promise}
+ */
+export async function principalFromToken(token, deps = {}) {
+ if (!token) return null;
+ try {
+ const claims = await (deps.verify ?? ((t) => hub().verify(t)))(token);
+ const sub = typeof claims?.sub === 'string' ? claims.sub : '';
+ if (!sub) return null;
+ const scope = typeof claims.scope === 'string' ? claims.scope : '';
+ const email = typeof claims.email === 'string' ? claims.email.trim().toLowerCase() : null;
+ return { sub, scopes: scope.split(/\s+/).filter(Boolean), email: email || null };
+ } catch {
+ return null;
+ }
+}
+
+/**
+ * The bearer token on a request, if any.
+ *
+ * @param {Request} req
+ * @returns {string|null}
+ */
+export function bearerToken(req) {
+ const header = req.headers.get('authorization') ?? '';
+ const m = /^Bearer\s+(\S+)$/i.exec(header.trim());
+ return m ? m[1] : null;
+}
diff --git a/apps/web/src/lib/openprofile.js b/apps/web/src/lib/openprofile.js
new file mode 100644
index 0000000..96d6bbb
--- /dev/null
+++ b/apps/web/src/lib/openprofile.js
@@ -0,0 +1,287 @@
+import {
+ applyOverrides,
+ keyedSection,
+ listSection,
+ makeOpenProfile,
+ mergeOverrides,
+ overridesFromDocument,
+ renderOpenProfile,
+} from '@profullstack/openprofile';
+
+/**
+ * An author's OpenProfile.md (logicsrc.com/openprofile), built from what the
+ * directory read off their own markup, corrected by what they told us.
+ *
+ * The generated file says only what the author published: their name, site
+ * and avatar from an h-card or JSON-LD, the accounts they claimed with
+ * rel="me", the subjects their feeds are filed under, and for every podcast or
+ * show they publish, a Broadcast section (logicsrc.com/openbroadcast) with the
+ * facts their own feed states. It never fills `Seeking`, `Pays`, `Charges` or a
+ * Guest section: those are the person's to write, through the overlay, and
+ * absent means unstated.
+ *
+ * Email is deliberately not in the generated file even when the table has it.
+ * The API republishes an address the author published; the profile is a
+ * document other directories will copy, and the owner adds `Email` to it
+ * themselves if they want it carried that far.
+ */
+
+/** Feed kinds that are a show, for which a Broadcast section is written. */
+const BROADCAST_KINDS = new Map([
+ ['podcast', 'podcast'],
+ ['music', 'music'],
+ ['video', 'video'],
+ ['live', 'stream'],
+]);
+
+/** Most topics a profile lists; the union of its feeds' strongest. */
+const MAX_TOPICS = 12;
+
+/** Networks whose handle is the one a person would write on a slide. */
+const HANDLE_NETWORKS = ['fediverse', 'bluesky', 'github', 'x', 'nostr', 'microblog'];
+
+/**
+ * What a network is called in a link label.
+ *
+ * @param {string} network
+ * @returns {string}
+ */
+function label(network) {
+ const names = {
+ fediverse: 'Mastodon',
+ bluesky: 'Bluesky',
+ github: 'GitHub',
+ gitlab: 'GitLab',
+ linkedin: 'LinkedIn',
+ youtube: 'YouTube',
+ devto: 'DEV',
+ kofi: 'Ko-fi',
+ buymeacoffee: 'Buy Me a Coffee',
+ linktree: 'Links page',
+ microblog: 'Micro.blog',
+ stackoverflow: 'Stack Overflow',
+ soundcloud: 'SoundCloud',
+ tiktok: 'TikTok',
+ x: 'X',
+ website: 'Website',
+ };
+ return names[network] ?? network.charAt(0).toUpperCase() + network.slice(1);
+}
+
+/**
+ * `YYYY-MM` from an ISO stamp, or null when it does not parse.
+ *
+ * @param {string|null|undefined} iso
+ * @returns {string|null}
+ */
+export function yearMonth(iso) {
+ if (!iso) return null;
+ const m = /^(\d{4})-(\d{2})/.exec(String(iso));
+ return m ? `${m[1]}-${m[2]}` : null;
+}
+
+/**
+ * The generated document, before the owner's corrections.
+ *
+ * @param {{
+ * person: { slug: string, name: string, bio?: string|null, avatar_url?: string|null,
+ * site_url?: string|null, links?: Array<{ network: string, url: string, handle?: string|null }> },
+ * feeds: Array<{ id: string, slug: string, title: string, kind?: string|null, role?: string|null,
+ * feed_url?: string|null, site_url?: string|null, language?: string|null }>,
+ * topicsByFeed?: Map,
+ * firstByFeed?: Map,
+ * base: string,
+ * }} input
+ * @returns {import('@profullstack/openprofile').OpenProfileDoc}
+ */
+export function generateAuthorProfile({ person, feeds, topicsByFeed, firstByFeed, base }) {
+ const links = (person.links ?? []).filter((l) => l.network !== 'email');
+ const handleLink = HANDLE_NETWORKS.map((n) => links.find((l) => l.network === n && l.handle)).find(
+ Boolean,
+ );
+
+ const identity = {
+ Kind: 'person',
+ Handle: handleLink?.handle ? String(handleLink.handle).replace(/^@/, '') : null,
+ Web: person.site_url ?? null,
+ Avatar: person.avatar_url ?? null,
+ };
+
+ const headline = person.bio ? String(person.bio).split(/\r?\n/)[0].trim() : null;
+
+ const accounts = links.map((l) =>
+ l.network && l.network !== 'website' ? `[${label(l.network)}](${l.url})` : l.url,
+ );
+
+ // Topics: the union of every credited feed's strongest keywords, strongest
+ // feed first so a podcaster's podcast outranks the group blog they write in.
+ const topics = [];
+ const seen = new Set();
+ for (const f of feeds) {
+ for (const t of topicsByFeed?.get(String(f.id)) ?? []) {
+ const key = t.toLowerCase();
+ if (seen.has(key) || topics.length >= MAX_TOPICS) continue;
+ seen.add(key);
+ topics.push(t);
+ }
+ }
+
+ // Broadcast: one `### ` group per show they own. A feed they merely
+ // write in is somebody else's show and is not theirs to describe.
+ const shows = feeds.filter(
+ (f) => BROADCAST_KINDS.has(String(f.kind ?? '')) && String(f.role ?? 'author') === 'owner',
+ );
+ const groups = shows.map((f) => {
+ const keys = {
+ Show: f.title,
+ Kind: BROADCAST_KINDS.get(String(f.kind)) ?? null,
+ Language: f.language ?? null,
+ Since: yearMonth(firstByFeed?.get(String(f.id))),
+ Feed: f.feed_url ?? null,
+ Listen: f.site_url ?? `${base}/${encodeURIComponent(String(f.slug))}`,
+ Topics: (topicsByFeed?.get(String(f.id)) ?? []).slice(0, 8).join(', ') || null,
+ };
+ const section = keyedSection('Broadcast', keys);
+ return section ? `### ${f.title}\n\n${section.body}` : null;
+ });
+ const broadcast =
+ shows.length === 1
+ ? keyedSection('Broadcast', {
+ Show: shows[0].title,
+ Kind: BROADCAST_KINDS.get(String(shows[0].kind)) ?? null,
+ Language: shows[0].language ?? null,
+ Since: yearMonth(firstByFeed?.get(String(shows[0].id))),
+ Feed: shows[0].feed_url ?? null,
+ Listen: shows[0].site_url ?? `${base}/${encodeURIComponent(String(shows[0].slug))}`,
+ Topics: (topicsByFeed?.get(String(shows[0].id)) ?? []).slice(0, 8).join(', ') || null,
+ })
+ : groups.some(Boolean)
+ ? { title: 'Broadcast', name: 'broadcast', body: groups.filter(Boolean).join('\n\n') }
+ : null;
+
+ // Links: the feeds themselves, so a reader that only has the profile can
+ // still reach the writing. Owned feeds first.
+ const feedLinks = [...feeds]
+ .sort((a, b) => Number(b.role === 'owner') - Number(a.role === 'owner'))
+ .slice(0, 20)
+ .map((f) => `[${f.title}](${base}/${encodeURIComponent(String(f.slug))})`);
+
+ return makeOpenProfile({
+ name: String(person.name),
+ identity,
+ headline,
+ sections: [
+ listSection('Accounts', accounts),
+ listSection('Topics', topics),
+ broadcast,
+ listSection('Links', feedLinks),
+ ],
+ });
+}
+
+/**
+ * The document as served: generated, then the owner's overlay on top.
+ *
+ * @param {Parameters[0] & { overrides?: Record|null }} input
+ * @returns {{ generated: import('@profullstack/openprofile').OpenProfileDoc,
+ * doc: import('@profullstack/openprofile').OpenProfileDoc, markdown: string }}
+ */
+export function authorProfile(input) {
+ const generated = generateAuthorProfile(input);
+ const doc = applyOverrides(generated, input.overrides ?? null);
+ return { generated, doc, markdown: renderOpenProfile(doc) };
+}
+
+/**
+ * The overlay a PUT produces, from either body shape.
+ *
+ * A Markdown body is the whole file the owner edited: everything in it
+ * becomes an override, and a section the file dropped is dropped. A JSON body
+ * is a partial patch, merged over what is stored.
+ *
+ * @param {{ contentType: string, text: string, existing: Record|null,
+ * generated: import('@profullstack/openprofile').OpenProfileDoc }} input
+ * @returns {{ overrides: Record, public?: boolean, error?: string }}
+ */
+export function overridesFromBody({ contentType, text, existing, generated }) {
+ if (/markdown|text\/plain/i.test(contentType)) {
+ if (!text.trim()) return { overrides: existing ?? {}, error: 'empty document' };
+ return { overrides: overridesFromDocument(text, generated, true) };
+ }
+
+ let body;
+ try {
+ body = JSON.parse(text || '{}');
+ } catch {
+ return { overrides: existing ?? {}, error: 'bad JSON' };
+ }
+ if (!body || typeof body !== 'object' || Array.isArray(body)) {
+ return { overrides: existing ?? {}, error: 'expected an object' };
+ }
+
+ if (typeof body.markdown === 'string') {
+ return {
+ overrides: overridesFromDocument(body.markdown, generated, true),
+ ...(typeof body.public === 'boolean' ? { public: body.public } : {}),
+ };
+ }
+
+ const patch = {};
+ if (body.name !== undefined) patch.name = body.name == null ? null : String(body.name);
+ if (body.headline !== undefined) patch.headline = body.headline == null ? null : String(body.headline);
+ if (body.prose !== undefined) patch.prose = body.prose == null ? null : String(body.prose);
+ if (body.identity && typeof body.identity === 'object') {
+ patch.identity = {};
+ for (const [k, v] of Object.entries(body.identity)) patch.identity[k] = v == null ? null : String(v);
+ }
+ if (body.sections && typeof body.sections === 'object') {
+ patch.sections = {};
+ for (const [k, v] of Object.entries(body.sections)) patch.sections[k] = v == null ? 'none' : String(v);
+ }
+
+ return {
+ overrides: mergeOverrides(existing ?? {}, patch),
+ ...(typeof body.public === 'boolean' ? { public: body.public } : {}),
+ };
+}
+
+/**
+ * The overlay the web form produces. Every field is the whole value of that
+ * part of the file, so an emptied field removes what it held.
+ *
+ * @param {FormData} form
+ * @returns {{ overrides: Record, public: boolean }}
+ */
+export function overridesFromForm(form) {
+ const str = (name) => String(form.get(name) ?? '').trim();
+ const identity = {};
+ for (const line of str('identity').split(/\r?\n/)) {
+ const m = /^\s*-?\s*\*{0,2}([^:*]+?)\*{0,2}\s*:\s*(.*)$/.exec(line);
+ if (m) identity[m[1].trim()] = m[2].trim() || null;
+ }
+ const sections = {};
+ for (const name of ['accounts', 'topics', 'broadcast', 'guest', 'about', 'links', 'reshare']) {
+ const value = str(`section_${name}`);
+ if (form.has(`section_${name}`)) sections[name] = value || 'none';
+ }
+ return {
+ overrides: {
+ ...(str('name') ? { name: str('name') } : {}),
+ headline: str('headline') || null,
+ identity,
+ sections,
+ },
+ public: form.get('public') != null,
+ };
+}
+
+/**
+ * Where an author's profile lives.
+ *
+ * @param {string} base
+ * @param {string} slug
+ * @returns {string}
+ */
+export function profileUrl(base, slug) {
+ return `${base}/authors/${encodeURIComponent(slug)}/openprofile.md`;
+}
diff --git a/apps/web/src/lib/profileAuth.js b/apps/web/src/lib/profileAuth.js
new file mode 100644
index 0000000..06e93cf
--- /dev/null
+++ b/apps/web/src/lib/profileAuth.js
@@ -0,0 +1,271 @@
+import { apikeys, profiles } from '@rssamplifier/db';
+import { hashToken, looksLikeApiKey } from '@rssamplifier/auth';
+
+import { db } from './db.js';
+import { SCOPE_PROFILE_EDIT, bearerToken, principalFromToken } from './openaccess.js';
+
+/**
+ * The session reader, loaded on first use: it lives on next/headers, which
+ * only works inside a request, and this module is also imported by tests and
+ * by the MCP tools, which hand in their own.
+ *
+ * @returns {Promise}
+ */
+async function sessionUser() {
+ const { currentUser } = await import('./auth.js');
+ return currentUser();
+}
+
+/**
+ * Who is asking to edit or claim a profile, and whether they may.
+ *
+ * Three credentials reach the same answer: the site's own session cookie, one
+ * of the site's API keys (`rsa_...`, which stands for the account that minted
+ * it), or an OpenAccess token with the `openprofile:edit` scope. The first
+ * two identify an account; the third identifies a principal at the hub, and
+ * optionally the email the hub knows for them.
+ *
+ * "May edit" is one rule: the profile has been claimed, and the caller is the
+ * account or the principal that claimed it. An unclaimed profile is edited by
+ * nobody; it is claimed first (see `claimVerdict`), which is where the
+ * verification lives.
+ */
+
+/**
+ * @typedef {{
+ * kind: 'session'|'apikey'|'openaccess'|null,
+ * userId: string|null,
+ * email: string|null,
+ * principal: string|null,
+ * scopes: string[],
+ * }} Caller
+ */
+
+/**
+ * Identify the caller from a request. Never throws; an unknown or bad
+ * credential is an anonymous caller.
+ *
+ * @param {Request} req
+ * @param {{ verify?: (token: string) => Promise, user?: () => Promise }} [deps]
+ * @returns {Promise}
+ */
+export async function callerOf(req, deps = {}) {
+ const none = { kind: null, userId: null, email: null, principal: null, scopes: [] };
+
+ const token = bearerToken(req);
+ if (token) {
+ if (looksLikeApiKey(token)) {
+ const key = await apikeys.keyByHash(db(), hashToken(token));
+ if (!key || key.revoked_at) return none;
+ const user = await userById(String(key.user_id));
+ return {
+ kind: 'apikey',
+ userId: String(key.user_id),
+ email: user?.email ? String(user.email).toLowerCase() : null,
+ principal: null,
+ scopes: [],
+ };
+ }
+ const principal = await principalFromToken(token, deps);
+ if (!principal) return none;
+ return {
+ kind: 'openaccess',
+ userId: null,
+ email: principal.email,
+ principal: principal.sub,
+ scopes: principal.scopes,
+ };
+ }
+
+ const user = await (deps.user ?? sessionUser)();
+ if (!user) return none;
+ return {
+ kind: 'session',
+ userId: String(user.id),
+ email: user.email ? String(user.email).toLowerCase() : null,
+ principal: null,
+ scopes: [],
+ };
+}
+
+/**
+ * @param {string} id
+ * @returns {Promise<{ id: string, email: string }|null>}
+ */
+async function userById(id) {
+ const { rows } = await db().execute({
+ sql: 'select id, email from users where id = ? limit 1',
+ args: [id],
+ });
+ return rows[0] ? /** @type {any} */ (rows[0]) : null;
+}
+
+/**
+ * Is this caller the owner of this profile?
+ *
+ * @param {Caller} caller
+ * @param {import('@rssamplifier/db').profiles.AuthorProfile|null} profile
+ * @returns {boolean}
+ */
+export function isOwner(caller, profile) {
+ if (!profile || !profile.claimed_at) return false;
+ if (caller.kind === 'openaccess') {
+ if (!caller.scopes.includes(SCOPE_PROFILE_EDIT)) return false;
+ return Boolean(caller.principal && caller.principal === profile.owner_principal);
+ }
+ if (!caller.userId) return false;
+ return caller.userId === profile.owner_user_id;
+}
+
+/**
+ * Whether this caller may claim this author, and how the claim is verified.
+ *
+ * Two proofs, either sufficient, both automatic:
+ *
+ * - `email`: the caller's verified address is the one the author published
+ * about themselves (a rel="me" mailto, an h-card email, the feed's own
+ * author element). The directory dropped role mailboxes at extraction, so a
+ * match here is a person matching a person.
+ * - `linkback`: the author's own site points at this profile, with
+ * `rel="openprofile"` or `rel="me"` to the profile URL or the author page.
+ * That is the verification rule OpenProfile.md itself names, and it works
+ * for an author who never published an address.
+ *
+ * An admin may claim on anybody's behalf, which is the fallback for the
+ * case neither proof covers.
+ *
+ * @param {{
+ * caller: Caller,
+ * person: { id: string, slug: string, email?: string|null, site_url?: string|null },
+ * profile: import('@rssamplifier/db').profiles.AuthorProfile|null,
+ * profileUrl: string,
+ * pageUrl: string,
+ * admins?: string[],
+ * fetchText?: (url: string) => Promise,
+ * }} input
+ * @returns {Promise<{ ok: true, method: string } | { ok: false, status: number, error: string }>}
+ */
+export async function claimVerdict({ caller, person, profile, profileUrl, pageUrl, admins = [], fetchText }) {
+ if (!caller.kind) return { ok: false, status: 401, error: 'sign-in-required' };
+ if (caller.kind === 'openaccess' && !caller.scopes.includes(SCOPE_PROFILE_EDIT)) {
+ return { ok: false, status: 403, error: `the grant lacks ${SCOPE_PROFILE_EDIT}` };
+ }
+
+ if (profile?.claimed_at) {
+ if (isOwner(caller, profile)) return { ok: true, method: String(profile.claim_method ?? 'email') };
+ // A second person may still attach a second credential to a profile they
+ // already own another way, which the email match below settles; anybody
+ // else is refused.
+ }
+
+ if (caller.email && admins.map((a) => a.toLowerCase()).includes(caller.email)) {
+ return { ok: true, method: 'admin' };
+ }
+
+ const published = person.email ? String(person.email).trim().toLowerCase() : null;
+ if (published && caller.email && published === caller.email) {
+ if (profile?.claimed_at && !ownedByEmail(profile, caller)) {
+ return { ok: false, status: 409, error: 'already-claimed' };
+ }
+ return { ok: true, method: 'email' };
+ }
+
+ if (profile?.claimed_at) return { ok: false, status: 409, error: 'already-claimed' };
+
+ if (person.site_url && fetchText) {
+ const html = await fetchText(String(person.site_url)).catch(() => null);
+ if (html && linksBack(html, [profileUrl, pageUrl])) return { ok: true, method: 'linkback' };
+ }
+
+ return {
+ ok: false,
+ status: 403,
+ error: published
+ ? `sign in as ${maskEmail(published)}, or add a rel="openprofile" link to ${profileUrl} on ${person.site_url ?? 'your site'}`
+ : `add to ${person.site_url ?? 'your site'} and claim again`,
+ };
+}
+
+/**
+ * @param {import('@rssamplifier/db').profiles.AuthorProfile} profile
+ * @param {Caller} caller
+ */
+function ownedByEmail(profile, caller) {
+ return Boolean(caller.userId && caller.userId === profile.owner_user_id) ||
+ Boolean(caller.principal && caller.principal === profile.owner_principal);
+}
+
+/**
+ * Does this HTML point at one of these URLs with rel="openprofile" or rel="me"?
+ *
+ * A plain regex over the markup, because the question is "is this exact URL
+ * in a link with this rel", which needs no parser and must not need a
+ * browser. Attribute order is not assumed.
+ *
+ * @param {string} html
+ * @param {string[]} urls
+ * @returns {boolean}
+ */
+export function linksBack(html, urls) {
+ const wanted = new Set(urls.map((u) => u.replace(/\/+$/, '').toLowerCase()));
+ const tags = html.match(/<(?:a|link)\b[^>]*>/gi) ?? [];
+ for (const tag of tags) {
+ const rel = /\brel\s*=\s*["']([^"']*)["']/i.exec(tag)?.[1]?.toLowerCase() ?? '';
+ if (!/\b(openprofile|me)\b/.test(rel)) continue;
+ const href = /\bhref\s*=\s*["']([^"']*)["']/i.exec(tag)?.[1] ?? '';
+ if (wanted.has(href.trim().replace(/\/+$/, '').toLowerCase())) return true;
+ }
+ return false;
+}
+
+/**
+ * `a***@example.com`, enough to recognise and not enough to harvest.
+ *
+ * @param {string} email
+ */
+function maskEmail(email) {
+ const [local, domain] = email.split('@');
+ return `${local.slice(0, 1)}***@${domain}`;
+}
+
+/**
+ * The addresses that may claim any profile: the site's admins.
+ *
+ * @returns {string[]}
+ */
+export function adminEmails() {
+ const env = process.env;
+ return String(env['ADMIN_EMAILS'] ?? 'anthony@profullstack.com')
+ .split(',')
+ .map((s) => s.trim().toLowerCase())
+ .filter(Boolean);
+}
+
+/**
+ * A small fetch for the linkback check: one page, a short timeout, a bounded
+ * body. The site being checked is the author's own, which is exactly the
+ * page the crawler already reads for rel="me".
+ *
+ * @param {string} url
+ * @returns {Promise}
+ */
+export async function fetchPage(url) {
+ const controller = new AbortController();
+ const timer = setTimeout(() => controller.abort(), 8000);
+ try {
+ const res = await fetch(url, {
+ signal: controller.signal,
+ redirect: 'follow',
+ headers: { 'user-agent': 'rssamplifier-openprofile/1 (+https://rssamplifier.com/about)', accept: 'text/html' },
+ });
+ if (!res.ok) return null;
+ const text = await res.text();
+ return text.slice(0, 512 * 1024);
+ } catch {
+ return null;
+ } finally {
+ clearTimeout(timer);
+ }
+}
+
+export { profiles };
diff --git a/apps/web/test/mcp.test.js b/apps/web/test/mcp.test.js
index c8f9b3e..8684e32 100644
--- a/apps/web/test/mcp.test.js
+++ b/apps/web/test/mcp.test.js
@@ -238,12 +238,18 @@ test('every tool is described well enough for a model to choose it', () => {
}
});
-test('exactly one tool writes, and it says so', () => {
+test('the tools that write say so, and they are the ones a person acts through', () => {
const writers = TOOLS.filter((t) => t.annotations.readOnlyHint === false);
assert.deepEqual(
writers.map((t) => t.name),
- ['submit_feed'],
+ // submit_feed adds to the directory; the two profile tools change only the
+ // caller's own claimed profile, behind a credential in the Authorization
+ // header, which their descriptions say.
+ ['update_openprofile', 'claim_openprofile', 'submit_feed'],
);
+ for (const t of writers.filter((w) => w.name.endsWith('_openprofile'))) {
+ assert.match(t.description, /Authorization header/);
+ }
});
test('markup comes out as prose, with the paragraphs still in it', () => {
diff --git a/apps/web/test/openprofile.test.js b/apps/web/test/openprofile.test.js
new file mode 100644
index 0000000..d38e09d
--- /dev/null
+++ b/apps/web/test/openprofile.test.js
@@ -0,0 +1,241 @@
+import assert from 'node:assert/strict';
+import { test } from 'node:test';
+
+import { broadcasts, parseOpenProfile, topics as topicsOf } from '@profullstack/openprofile';
+
+import {
+ authorProfile,
+ generateAuthorProfile,
+ overridesFromBody,
+ overridesFromForm,
+ profileUrl,
+ yearMonth,
+} from '../src/lib/openprofile.js';
+import { claimVerdict, isOwner, linksBack } from '../src/lib/profileAuth.js';
+import { principalFromToken } from '../src/lib/openaccess.js';
+
+const BASE = 'https://rssamplifier.test';
+
+/** An author with a podcast they own and a blog they write in. */
+const ADA = {
+ id: 'a1',
+ slug: 'ada-lovelace',
+ name: 'Ada Lovelace',
+ bio: 'Wrote the first program.\nMore about that later.',
+ avatar_url: 'https://ada.example/ada.jpg',
+ site_url: 'https://ada.example',
+ email: 'ada@example.com',
+ links: [
+ { network: 'bluesky', url: 'https://bsky.app/profile/ada.example', handle: '@ada.example' },
+ { network: 'github', url: 'https://github.com/ada', handle: 'ada' },
+ { network: 'email', url: 'mailto:ada@example.com', handle: null },
+ { network: 'website', url: 'https://ada.example', handle: null },
+ ],
+};
+const FEEDS = [
+ {
+ id: 'f1',
+ slug: 'analytical-engine',
+ title: 'The Analytical Engine',
+ kind: 'podcast',
+ role: 'owner',
+ feed_url: 'https://ada.example/podcast/feed.xml',
+ site_url: 'https://ada.example/podcast',
+ language: 'en',
+ },
+ { id: 'f2', slug: 'group-blog', title: 'Group Blog', kind: 'blog', role: 'author', feed_url: 'https://g.example/feed' },
+ {
+ id: 'f3',
+ slug: 'other-show',
+ title: 'Somebody Elses Show',
+ kind: 'podcast',
+ role: 'author',
+ feed_url: 'https://o.example/feed',
+ },
+];
+const TOPICS = new Map([
+ ['f1', ['history', 'mathematics']],
+ ['f2', ['mathematics', 'writing']],
+]);
+const FIRST = new Map([['f1', '2024-11-15T00:00:00.000Z']]);
+
+test('the generated file says what the author published, and nothing they did not', () => {
+ const doc = generateAuthorProfile({ person: ADA, feeds: FEEDS, topicsByFeed: TOPICS, firstByFeed: FIRST, base: BASE });
+
+ assert.equal(doc.name, 'Ada Lovelace');
+ const identity = Object.fromEntries(doc.identity.map((e) => [e.key, e.value]));
+ assert.deepEqual(identity, {
+ Kind: 'person',
+ Handle: 'ada.example',
+ Web: 'https://ada.example',
+ Avatar: 'https://ada.example/ada.jpg',
+ });
+ assert.equal('Email' in identity, false, 'the published address is not carried into the file');
+ assert.equal(doc.headline, 'Wrote the first program.', 'first line of the bio');
+
+ const accounts = doc.sections.find((s) => s.name === 'accounts');
+ assert.equal(accounts.body, '- [Bluesky](https://bsky.app/profile/ada.example)\n- [GitHub](https://github.com/ada)\n- https://ada.example');
+
+ assert.deepEqual(topicsOf(doc), ['history', 'mathematics', 'writing'], 'the union, strongest feed first');
+
+ const shows = broadcasts(doc);
+ assert.equal(shows.length, 1, "only the show they own; somebody else's podcast is not theirs to describe");
+ assert.deepEqual(shows[0], {
+ Show: 'The Analytical Engine',
+ Kind: 'podcast',
+ Language: 'en',
+ Since: '2024-11',
+ Feed: 'https://ada.example/podcast/feed.xml',
+ Listen: 'https://ada.example/podcast',
+ Topics: 'history, mathematics',
+ });
+ assert.equal('Seeking' in shows[0], false);
+ assert.equal(doc.sections.some((s) => s.name === 'guest'), false, 'no Guest section unless the person wrote one');
+
+ const links = doc.sections.find((s) => s.name === 'links');
+ assert.match(links.body, /^- \[The Analytical Engine\]\(https:\/\/rssamplifier\.test\/analytical-engine\)/, 'owned feeds first');
+});
+
+test('two owned shows become ### groups in one Broadcast section', () => {
+ const feeds = [FEEDS[0], { ...FEEDS[2], role: 'owner' }];
+ const doc = generateAuthorProfile({ person: ADA, feeds, topicsByFeed: TOPICS, firstByFeed: FIRST, base: BASE });
+ const shows = broadcasts(doc);
+ assert.deepEqual(shows.map((s) => s.Show), ['The Analytical Engine', 'Somebody Elses Show']);
+ assert.equal(shows[1].Listen, `${BASE}/other-show`, 'a show with no site listens on its directory page');
+});
+
+test('the overlay wins per part, the rest stays generated, and the file round-trips', () => {
+ const overrides = {
+ headline: 'Countess, programmer.',
+ identity: { Email: 'ada@example.com', Location: 'London', Avatar: null },
+ sections: { guest: '- **Available**: yes\n- **Expertise**: early computing', links: 'none' },
+ };
+ const { markdown, doc } = authorProfile({ person: ADA, feeds: FEEDS, topicsByFeed: TOPICS, firstByFeed: FIRST, base: BASE, overrides });
+ assert.equal(doc.headline, 'Countess, programmer.');
+ const identity = Object.fromEntries(doc.identity.map((e) => [e.key, e.value]));
+ assert.equal(identity.Email, 'ada@example.com', 'the owner chose to publish it');
+ assert.equal(identity.Location, 'London');
+ assert.equal('Avatar' in identity, false);
+ assert.deepEqual(doc.sections.map((s) => s.name), ['accounts', 'topics', 'broadcast', 'guest']);
+ assert.equal(broadcasts(doc)[0].Show, 'The Analytical Engine', 'untouched sections are still generated');
+
+ const back = parseOpenProfile(markdown);
+ assert.equal(back.name, 'Ada Lovelace');
+ assert.equal(back.sections.find((s) => s.name === 'guest').body, '- **Available**: yes\n- **Expertise**: early computing');
+});
+
+test('a PUT body as Markdown becomes the whole overlay; as JSON it is a patch', () => {
+ const generated = generateAuthorProfile({ person: ADA, feeds: FEEDS, topicsByFeed: TOPICS, firstByFeed: FIRST, base: BASE });
+
+ const edited = '# Ada Lovelace\n\n- Kind: person\n- Web: https://ada.example\n\nCountess.\n\n## Guest\n\n- **Available**: yes\n';
+ const fromMd = overridesFromBody({ contentType: 'text/markdown; charset=utf-8', text: edited, existing: null, generated });
+ assert.equal(fromMd.error, undefined);
+ assert.equal(fromMd.overrides.headline, 'Countess.');
+ assert.equal(fromMd.overrides.identity.Avatar, null, 'a key the file dropped is removed');
+ assert.equal(fromMd.overrides.sections.accounts, 'none', 'a section the file dropped is dropped');
+ assert.equal(fromMd.overrides.sections.guest, '- **Available**: yes');
+
+ const fromJson = overridesFromBody({
+ contentType: 'application/json',
+ text: JSON.stringify({ sections: { guest: null }, identity: { Location: 'London' }, public: false }),
+ existing: fromMd.overrides,
+ generated,
+ });
+ assert.equal(fromJson.public, false);
+ assert.equal(fromJson.overrides.sections.guest, 'none');
+ assert.equal(fromJson.overrides.sections.accounts, 'none', 'the stored overlay is kept');
+ assert.equal(fromJson.overrides.identity.Location, 'London');
+ assert.equal(fromJson.overrides.headline, 'Countess.');
+
+ assert.equal(overridesFromBody({ contentType: 'application/json', text: '{nope', existing: null, generated }).error, 'bad JSON');
+ assert.equal(overridesFromBody({ contentType: 'text/markdown', text: ' ', existing: null, generated }).error, 'empty document');
+});
+
+test('the web form is one box per part; an emptied box drops the section', () => {
+ const form = new FormData();
+ form.set('name', 'Ada');
+ form.set('headline', '');
+ form.set('identity', 'Kind: person\n- **Web**: https://ada.example\nEmail:');
+ form.set('section_topics', '- engines');
+ form.set('section_guest', '');
+ const { overrides, public: isPublic } = overridesFromForm(form);
+ assert.equal(overrides.name, 'Ada');
+ assert.equal(overrides.headline, null);
+ assert.deepEqual(overrides.identity, { Kind: 'person', Web: 'https://ada.example', Email: null });
+ assert.equal(overrides.sections.topics, '- engines');
+ assert.equal(overrides.sections.guest, 'none');
+ assert.equal('accounts' in overrides.sections, false, 'a box the form did not have is not touched');
+ assert.equal(isPublic, false, 'an unticked checkbox is absent from the form');
+});
+
+test('isOwner: only the claimant, and an OpenAccess principal only with the scope', () => {
+ const claimed = { claimed_at: '2026-09-13T00:00:00Z', owner_user_id: 'u1', owner_principal: 'oa:1' };
+ assert.equal(isOwner({ kind: 'session', userId: 'u1', scopes: [] }, claimed), true);
+ assert.equal(isOwner({ kind: 'apikey', userId: 'u2', scopes: [] }, claimed), false);
+ assert.equal(isOwner({ kind: 'openaccess', principal: 'oa:1', scopes: ['openprofile:edit'] }, claimed), true);
+ assert.equal(isOwner({ kind: 'openaccess', principal: 'oa:1', scopes: ['podcasts:submit'] }, claimed), false);
+ assert.equal(isOwner({ kind: 'session', userId: 'u1', scopes: [] }, null), false, 'unclaimed is nobody\'s');
+ assert.equal(isOwner({ kind: 'session', userId: 'u1', scopes: [] }, { ...claimed, claimed_at: null }), false);
+});
+
+test('a claim is verified by the published address, or by the site linking back', async () => {
+ const person = { id: 'a1', slug: 'ada-lovelace', email: 'Ada@Example.com', site_url: 'https://ada.example' };
+ const urls = { profileUrl: `${BASE}/authors/ada-lovelace/openprofile.md`, pageUrl: `${BASE}/authors/ada-lovelace` };
+
+ assert.deepEqual(await claimVerdict({ caller: { kind: null, scopes: [] }, person, profile: null, ...urls }), {
+ ok: false,
+ status: 401,
+ error: 'sign-in-required',
+ });
+
+ const byEmail = await claimVerdict({ caller: { kind: 'session', userId: 'u1', email: 'ada@example.com', scopes: [] }, person, profile: null, ...urls });
+ assert.deepEqual(byEmail, { ok: true, method: 'email' });
+
+ const stranger = { kind: 'session', userId: 'u9', email: 'bob@example.com', scopes: [] };
+ const noLink = await claimVerdict({ caller: stranger, person, profile: null, ...urls, fetchText: async () => 'x' });
+ assert.equal(noLink.ok, false);
+ assert.equal(noLink.status, 403);
+ assert.match(noLink.error, /sign in as a\*\*\*@example\.com/);
+
+ const byLink = await claimVerdict({
+ caller: stranger,
+ person,
+ profile: null,
+ ...urls,
+ fetchText: async () => ``,
+ });
+ assert.deepEqual(byLink, { ok: true, method: 'linkback' });
+
+ const taken = { claimed_at: '2026-09-13T00:00:00Z', owner_user_id: 'u1', owner_principal: null, claim_method: 'email' };
+ const second = await claimVerdict({ caller: stranger, person, profile: taken, ...urls, fetchText: async () => '' });
+ assert.deepEqual(second, { ok: false, status: 409, error: 'already-claimed' });
+ const owner = await claimVerdict({ caller: { kind: 'session', userId: 'u1', email: 'ada@example.com', scopes: [] }, person, profile: taken, ...urls });
+ assert.deepEqual(owner, { ok: true, method: 'email' });
+
+ const admin = await claimVerdict({ caller: { kind: 'session', userId: 'u7', email: 'root@rssamplifier.com', scopes: [] }, person, profile: null, ...urls, admins: ['root@rssamplifier.com'] });
+ assert.deepEqual(admin, { ok: true, method: 'admin' });
+
+ const noScope = await claimVerdict({ caller: { kind: 'openaccess', principal: 'oa:1', email: 'ada@example.com', scopes: [] }, person, profile: null, ...urls });
+ assert.equal(noScope.status, 403);
+});
+
+test('linksBack reads rel="openprofile" and rel="me" in either attribute order', () => {
+ const target = 'https://rssamplifier.test/authors/ada/openprofile.md';
+ assert.equal(linksBack(`me`, [target]), true);
+ assert.equal(linksBack(``, [target]), true);
+ assert.equal(linksBack(`no rel`, [target]), false);
+ assert.equal(linksBack(`x`, [target]), false);
+});
+
+test('an OpenAccess token yields a principal with scopes and an email when the hub gave one', async () => {
+ const p = await principalFromToken('t', { verify: async () => ({ sub: 'oa:1', scope: 'openprofile:edit x', email: 'Ada@Example.com' }) });
+ assert.deepEqual(p, { sub: 'oa:1', scopes: ['openprofile:edit', 'x'], email: 'ada@example.com' });
+ assert.equal(await principalFromToken('t', { verify: async () => { throw new Error('bad'); } }), null);
+ assert.equal(await principalFromToken(null), null);
+});
+
+test('small helpers', () => {
+ assert.equal(yearMonth('2024-11-15T00:00:00Z'), '2024-11');
+ assert.equal(yearMonth(null), null);
+ assert.equal(profileUrl(BASE, 'ada lovelace'), `${BASE}/authors/ada%20lovelace/openprofile.md`);
+});
diff --git a/packages/db/index.js b/packages/db/index.js
index 3e85441..b180b59 100644
--- a/packages/db/index.js
+++ b/packages/db/index.js
@@ -17,6 +17,7 @@ export * as discovery from './src/discovery.js';
export * as extracts from './src/extracts.js';
export * as apikeys from './src/apikeys.js';
export * as authors from './src/authors.js';
+export * as profiles from './src/profiles.js';
export * as alerts from './src/alerts.js';
export * as social from './src/social.js';
export * as dataset from './src/dataset.js';
diff --git a/packages/db/migrations/20260913042000_author_profiles.sql b/packages/db/migrations/20260913042000_author_profiles.sql
new file mode 100644
index 0000000..10ca456
--- /dev/null
+++ b/packages/db/migrations/20260913042000_author_profiles.sql
@@ -0,0 +1,28 @@
+-- The author's own word over the directory's reading of them.
+--
+-- Everything on an author page was read off markup the author published, and
+-- the OpenProfile.md served beside it (logicsrc.com/openprofile) is generated
+-- from the same rows. This table is where the person corrects it: an overlay
+-- of identity keys, a headline and sections that win over the generated file
+-- (@profullstack/openprofile applyOverrides), and who claimed it, so the
+-- directory knows whose word it is.
+--
+-- One row per author, created on first claim or first edit. No row means the
+-- generated file stands as it is, which is the state every author starts in.
+create table if not exists author_profiles (
+ author_id text primary key references authors (id) on delete cascade,
+ -- JSON: { name?, headline?, prose?, identity?: {key: value|null}, sections?: {name: body} }
+ overrides text not null default '{}',
+ -- 0 hides the file (404) without removing the author page; the owner's switch.
+ public integer not null default 1,
+ -- Who may edit: a directory account, an OpenAccess principal, or both.
+ owner_user_id text references users (id) on delete set null,
+ owner_principal text,
+ claimed_at text,
+ -- 'email' (the account's address matched the author's published one),
+ -- 'linkback' (their site pointed at this profile), 'admin'.
+ claim_method text,
+ updated_at text not null
+);
+
+create index if not exists author_profiles_owner_idx on author_profiles (owner_user_id);
diff --git a/packages/db/src/authors.js b/packages/db/src/authors.js
index 049cc09..be9a1c7 100644
--- a/packages/db/src/authors.js
+++ b/packages/db/src/authors.js
@@ -445,8 +445,13 @@ export async function authorBySlug(db, slug) {
// `f.id` rides along so the caller can ask what these feeds published
// without a second lookup -- see `postsByAuthor`, which is bounded by
// exactly these ids rather than searching feed_items for an author.
- sql: `select f.id, f.slug, f.title, f.site_url, f.image_url, f.kind, f.description,
- fa.role, f.item_count
+ // `category` is what a feed is (blog, podcast, music...), re-derived on
+ // every crawl; `kind` is the older column of the same name that nothing
+ // writes any more and that says 'blog' for every podcast. Callers read
+ // `kind`, so they get the live answer under the name they already use.
+ sql: `select f.id, f.slug, f.title, f.site_url, f.feed_url, f.image_url,
+ coalesce(nullif(f.category, ''), f.kind) as kind, f.language,
+ f.description, fa.role, f.item_count
from feed_authors fa
join feeds f on f.id = fa.feed_id
where fa.author_id = ?
diff --git a/packages/db/src/profiles.js b/packages/db/src/profiles.js
new file mode 100644
index 0000000..74a73c7
--- /dev/null
+++ b/packages/db/src/profiles.js
@@ -0,0 +1,197 @@
+import { nowIso } from './client.js';
+
+/**
+ * The author's own corrections to their generated OpenProfile.md, and the
+ * claim that makes them theirs.
+ *
+ * The generated file is built from `authors`, `author_links`, `feed_authors`
+ * and the feeds themselves (see apps/web/src/lib/openprofile.js). This table
+ * holds only what a person changed on top of that and who they are, so a
+ * re-crawl that finds a new link still reaches the file, and a correction the
+ * person made still wins over it.
+ *
+ * @typedef {import('@libsql/client').Client} Client
+ * @typedef {{
+ * author_id: string,
+ * overrides: Record,
+ * public: boolean,
+ * owner_user_id: string|null,
+ * owner_principal: string|null,
+ * claimed_at: string|null,
+ * claim_method: string|null,
+ * updated_at: string,
+ * }} AuthorProfile
+ */
+
+const COLUMNS = `author_id, overrides, public, owner_user_id, owner_principal,
+ claimed_at, claim_method, updated_at`;
+
+/**
+ * @param {any} row
+ * @returns {AuthorProfile}
+ */
+function shape(row) {
+ let overrides = {};
+ try {
+ overrides = JSON.parse(String(row.overrides ?? '{}')) ?? {};
+ } catch {
+ overrides = {};
+ }
+ return {
+ author_id: String(row.author_id),
+ overrides,
+ public: Number(row.public ?? 1) === 1,
+ owner_user_id: row.owner_user_id == null ? null : String(row.owner_user_id),
+ owner_principal: row.owner_principal == null ? null : String(row.owner_principal),
+ claimed_at: row.claimed_at == null ? null : String(row.claimed_at),
+ claim_method: row.claim_method == null ? null : String(row.claim_method),
+ updated_at: String(row.updated_at),
+ };
+}
+
+/**
+ * The profile row for an author, or null when nobody has touched it.
+ *
+ * @param {Client} db
+ * @param {string} authorId
+ * @returns {Promise}
+ */
+export async function profileForAuthor(db, authorId) {
+ const { rows } = await db.execute({
+ sql: `select ${COLUMNS} from author_profiles where author_id = ? limit 1`,
+ args: [authorId],
+ });
+ return rows[0] ? shape(rows[0]) : null;
+}
+
+/**
+ * Write the overlay and the public switch. Creates the row when there is none.
+ *
+ * The caller merges: this stores what it is handed, so an edit over the API,
+ * the CLI, the MCP tool and the web form all go through one merge in one
+ * place (the route) and one write here.
+ *
+ * @param {Client} db
+ * @param {string} authorId
+ * @param {{ overrides?: Record, public?: boolean }} patch
+ * @returns {Promise}
+ */
+export async function saveProfile(db, authorId, patch) {
+ const now = nowIso();
+ const existing = await profileForAuthor(db, authorId);
+ const overrides = patch.overrides ?? existing?.overrides ?? {};
+ const isPublic = patch.public ?? existing?.public ?? true;
+
+ await db.execute({
+ sql: `insert into author_profiles (author_id, overrides, public, updated_at)
+ values (?, ?, ?, ?)
+ on conflict (author_id) do update set
+ overrides = excluded.overrides,
+ public = excluded.public,
+ updated_at = excluded.updated_at`,
+ args: [authorId, JSON.stringify(overrides), isPublic ? 1 : 0, now],
+ });
+
+ return /** @type {AuthorProfile} */ (await profileForAuthor(db, authorId));
+}
+
+/**
+ * Record who this profile belongs to.
+ *
+ * A second claim by the same person is a no-op; a claim by somebody else on a
+ * profile that already has an owner is refused by the caller, not here, because
+ * the caller is the one that verified the claim and knows why it is allowed
+ * (an admin taking a profile over, for instance).
+ *
+ * @param {Client} db
+ * @param {string} authorId
+ * @param {{ userId?: string|null, principal?: string|null, method: string }} claim
+ * @returns {Promise}
+ */
+export async function claimProfile(db, authorId, claim) {
+ const now = nowIso();
+ await db.execute({
+ sql: `insert into author_profiles (author_id, overrides, public, owner_user_id, owner_principal, claimed_at, claim_method, updated_at)
+ values (?, '{}', 1, ?, ?, ?, ?, ?)
+ on conflict (author_id) do update set
+ owner_user_id = coalesce(excluded.owner_user_id, author_profiles.owner_user_id),
+ owner_principal = coalesce(excluded.owner_principal, author_profiles.owner_principal),
+ claimed_at = coalesce(author_profiles.claimed_at, excluded.claimed_at),
+ claim_method = coalesce(author_profiles.claim_method, excluded.claim_method),
+ updated_at = excluded.updated_at`,
+ args: [authorId, claim.userId ?? null, claim.principal ?? null, now, claim.method, now],
+ });
+ return /** @type {AuthorProfile} */ (await profileForAuthor(db, authorId));
+}
+
+/**
+ * The profiles an account owns, for the account page.
+ *
+ * @param {Client} db
+ * @param {string} userId
+ * @returns {Promise>}
+ */
+export async function profilesForUser(db, userId) {
+ const { rows } = await db.execute({
+ sql: `select p.author_id, a.slug, a.name, p.claimed_at
+ from author_profiles p
+ join authors a on a.id = p.author_id
+ where p.owner_user_id = ?
+ order by p.claimed_at desc`,
+ args: [userId],
+ });
+ return /** @type {any} */ (rows);
+}
+
+/**
+ * When each feed first published, as `feed_id -> ISO date`, for the `Since`
+ * key of a Broadcast section. Feeds with no dated item are absent.
+ *
+ * @param {Client} db
+ * @param {string[]} feedIds
+ * @returns {Promise