Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 \
Expand All @@ -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 `<link rel="openprofile">` 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
Expand Down
19 changes: 15 additions & 4 deletions apps/cli/package.json
Original file line number Diff line number Diff line change
@@ -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"
}
122 changes: 121 additions & 1 deletion apps/cli/src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -103,6 +103,21 @@ export const COMMANDS = [
options: ['--json'],
examples: ['rssamp show technotim-live'],
},
{
name: 'profile',
usage: 'profile <slug> | profile edit <slug> [--file f.md] | profile claim <slug>',
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 <slug>` prints it. `profile claim <slug>` says the author is you, verified on the spot by the address you published or by your site linking back. `profile edit <slug>` 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 <path>', '--token <key>', '--feed <url>', '--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 <url|file.opml> …',
Expand Down Expand Up @@ -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<string|null>}
*/
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
Expand Down Expand Up @@ -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 <url>');
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');
Expand Down
144 changes: 144 additions & 0 deletions apps/cli/test/profile.test.js
Original file line number Diff line number Diff line change
@@ -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<Response>} handler
* @param {() => Promise<void>} 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 <slug>/);
assert.ok(cmd.options.includes('--token <key>'));
});

test('profile <slug> 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/);
},
);
});
2 changes: 2 additions & 0 deletions apps/web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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:*",
Expand Down
4 changes: 3 additions & 1 deletion apps/web/public/.well-known/openaccess.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
],
Expand Down
Loading
Loading