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
32 changes: 29 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,28 @@ A TypeScript bulletin board. Forums, topics, replies, moderation, private
messages, avatars, signatures, search, feeds — and a plugin system that ships
with the board rather than being bolted on later.

If you have run phpBB, SMF or vBulletin, you already know what this is. The
difference is what it is made of: TypeScript that runs unbuilt, one SQLite file
by default, no client-side JavaScript, and a terminal client.
If you have run phpBB, SMF or vBulletin, you already know what this is. What is
different is everything around it. One board answers as pages, as an installable
app, over a REST API, from a shell, through MCP and in a terminal, all of them
resolving the same permissions. Plugins are directories you drop in, because
there is no build step to stop you. A board fetches its own updates. Agent-ok
and human-ok are the same board, not two products.

| Front door | |
|---|---|
| **Pages** | Server-rendered HTML, no client-side JavaScript, three skins. |
| **App** | An installable PWA: manifest, service worker, offline page. [Docs](docs/PWA.md) |
| **API** | `/api/v1`, permission-checked, with an OpenAPI description. [Docs](docs/API.md) |
| **CLI** | `tsbb`, with `--json` on every command. [Docs](docs/CLI.md) |
| **MCP** | `/api/mcp` over HTTP, `tsbb-mcp` over stdio. [Docs](docs/MCP.md) |
| **Terminal** | `tsbb-tui`, a real client over SSH. [Docs](docs/SKINS.md) |
| **Machines** | llms.txt, skill.md, JSON-LD, sitemap index, OPML. [Docs](docs/AGENTS.md) |
| **Plugins** | A directory in `plugins/`. No build, no registry. [Docs](docs/PLUGINS.md) |
| **Updates** | A board installs new releases itself. [Docs](docs/UPDATES.md) |

**Not yet: peer to peer.** Boards connecting to other boards, and syncing topics
between nodes, is the direction and is not in the code. Everything else on this
page is.

```
pnpm install
Expand Down Expand Up @@ -37,6 +56,9 @@ Open <http://localhost:3000>, put in your email address, and click the link.
| **An API** | A permission-checked REST API with an OpenAPI description at `/api/v1/openapi.json`. |
| **A CLI** | `tsbb` reads and posts against any board from a shell, with `--json` on every command. |
| **An MCP server** | Served at `/api/mcp`, and as `tsbb-mcp` over stdio, so an assistant can use the board as a member. |
| **An app** | An installable PWA: a generated manifest, a service worker that is network-first for pages, and an offline page wearing the board's own chrome. |
| **A machine-readable board** | `llms.txt`, `llms-full.txt`, `skill.md`, a sitemap index, `security.txt`, JSON-LD on every page, OPML for the feeds. |
| **Self-updating** | A board checks for a new release a minute after boot and every five minutes, installs it and restarts itself. |

## Design decisions worth knowing before you read the code

Expand Down Expand Up @@ -87,6 +109,8 @@ else.
| `classic` | A 2000s bulletin board: boxy, dense, gradient title bars, Verdana. |
| `terminal` | Neutral surfaces, hairline rules, monospace chrome, window furniture on section headers. |

Full guide: **[docs/SKINS.md](docs/SKINS.md)**.

`classic` and `terminal` are **layers on top of** the modern sheet rather than
replacements, so a component's structure is defined in exactly one place and a
skin only argues about how it looks. Two full stylesheets drift apart within a
Expand Down Expand Up @@ -273,6 +297,8 @@ The worker runs inside the server by default, so email works from one command.

## Updates

Full guide: **[docs/UPDATES.md](docs/UPDATES.md)**.

A board keeps itself current. A minute after it starts, and every five minutes
after that, it asks GitHub for the newest release; when there is one it fetches
the tag, runs `pnpm install`, and restarts itself. The whole thing is the three
Expand Down
151 changes: 151 additions & 0 deletions apps/server/src/platform.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,151 @@
/**
* What tsbb is, said once.
*
* The front page, the About page, the docs index and llms.txt all describe the
* platform, and four hand-written copies of the same list drift apart within a
* release: one gains a feature, another keeps a claim that stopped being true.
* So the list lives here and every page renders it.
*
* Every entry marked `live` is backed by code in this repository today. An
* entry marked `planned` is on the roadmap and is rendered as such, never as
* a feature. Move it to `live` in the pull request that makes it true, not
* before.
*/
import { html } from 'hono/html';
import { Badge } from '@tsbb/ui';

export type FrontDoorStatus = 'live' | 'planned';

export interface FrontDoor {
/** A stable key, used for a class name and a JSON-LD feature id. */
key: string;
title: string;
/** One or two sentences. Plain text: it is rendered escaped everywhere. */
summary: string;
/** Where to read more. Absent for a planned entry: there is nothing to read yet. */
href?: string;
status: FrontDoorStatus;
}

export const PLATFORM_NAME = 'tsbb';
export const PLATFORM_TAGLINE = 'A TypeScript bulletin board';

/** The one-line positioning, used as a heading wherever the grid appears. */
export const PLATFORM_CLAIM = 'The new bulletin board platform';

/**
* The paragraph under that heading. Everything in it is true of the code; the
* only forward-looking clause is the last one, and it says so.
*/
export const PLATFORM_LEAD =
'One board, reachable every way people and programs read the web: as pages, as an installable app, ' +
'over a REST API, from a shell, through MCP, and from a terminal. Runtime plugins, no build step, ' +
'self-updating installs. Boards that connect to other boards are next.';

export const FRONT_DOORS: readonly FrontDoor[] = [
{
key: 'api',
title: 'REST API',
summary:
'Everything the pages show, at /api/v1, resolved through the same permission checks, and described by an OpenAPI file.',
href: '/docs/api',
status: 'live',
},
{
key: 'cli',
title: 'CLI',
summary:
'The tsbb command runs a board, and reads and posts against any board from a shell, with --json on every command.',
href: '/docs/cli',
status: 'live',
},
{
key: 'mcp',
title: 'MCP server',
summary:
'Served at /api/mcp over streamable HTTP, and as tsbb-mcp over stdio. An assistant reads, searches and posts as a member.',
href: '/docs/mcp',
status: 'live',
},
{
key: 'agents',
title: 'Agent-ok, human-ok',
summary:
'A browser, a script and a model all get the same content under the same permissions. llms.txt, skill.md and an explicit welcome in robots.txt.',
href: '/docs/agents',
status: 'live',
},
{
key: 'pwa',
title: 'Installable PWA',
summary:
'A manifest, a service worker and an offline page. Installs on a phone or a desktop and keeps what you have read when the connection goes.',
href: '/docs/pwa',
status: 'live',
},
{
key: 'plugins',
title: 'Plugins, no build step',
summary:
'A plugin is a directory. Filters, actions, slots, settings and routes, loaded at boot. Drop it in and restart.',
href: '/docs/plugins',
status: 'live',
},
{
key: 'updates',
title: 'Self-updating',
summary:
'A board checks GitHub releases a minute after boot and every five minutes, installs the new one and restarts itself.',
href: '/docs/updates',
status: 'live',
},
{
key: 'skins',
title: 'Terminal skin, terminal client',
summary:
'Three skins over one set of markup, one of them a terminal look. And tsbb-tui, for reading and posting over SSH.',
href: '/docs/skins',
status: 'live',
},
{
key: 'feeds',
title: 'Feeds both ways',
summary:
'RSS for the board, every forum, thread, member and search. And a forum can be filled from any RSS or Atom feed.',
href: '/feeds',
status: 'live',
},
{
key: 'p2p',
title: 'Peer to peer',
summary:
'Boards that connect to other boards and sync topics between nodes. On the roadmap, not in the code yet.',
status: 'planned',
},
];

export const LIVE_FRONT_DOORS: readonly FrontDoor[] = FRONT_DOORS.filter((door) => door.status === 'live');

/**
* The grid, as markup.
*
* Rendered on the front page, the About page and the docs index. A planned
* entry renders with a "planned" badge and no link, which is the whole reason
* status is data rather than prose: a page cannot accidentally present it as
* something that works today.
*/
export function PlatformGrid(doors: readonly FrontDoor[] = FRONT_DOORS) {
return html`<div class="platform-grid">
${doors.map(
(door) => html`<div class="platform-item platform-${door.key}">
<div class="platform-item-head">
${door.href
? html`<a class="platform-item-title" href="${door.href}">${door.title}</a>`
: html`<span class="platform-item-title">${door.title}</span>`}
${door.status === 'planned' ? Badge('Planned', 'outline') : ''}
</div>
<p class="platform-item-summary">${door.summary}</p>
</div>`,
)}
</div>`;
}
3 changes: 3 additions & 0 deletions apps/server/src/routes/admin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -215,6 +215,7 @@ export function adminRoutes(services: Services) {
'board.logoUrl',
'board.logoHref',
'board.faviconUrl',
'board.showPlatform',
],
},
{ title: 'Registration', keys: ['registration.mode', 'registration.minUsernameLength', 'registration.maxUsernameLength'] },
Expand Down Expand Up @@ -243,6 +244,8 @@ export function adminRoutes(services: Services) {
'board.logoHref':
'Where the header logo points. / is this board. An absolute URL is for a board that is one room in a larger site — the nav still leads back to the front page, so nobody is stranded.',
'board.faviconUrl': 'A URL to a browser-tab icon. Replaces the bundled tsbb icons.',
'board.showPlatform':
'A panel at the foot of the front page telling a visitor what the software under this board can do: the API, the CLI, MCP, the app, plugins. Guests only, never members. Turn it off for a board whose readers are not looking for one of their own.',
'signatures.minPosts':
'How many posts before a signature is shown. A new account with a link-filled signature is the shape of every piece of forum spam, so this is 10 by default.',
'posts.floodSeconds': 'Seconds between posts by the same account. 0 turns flood control off.',
Expand Down
29 changes: 29 additions & 0 deletions apps/server/src/routes/board.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ import {
} from '@tsbb/ui';
import type { Post, User, Viewer } from '@tsbb/plugin-api';
import { render, slot, type AppEnv, type Services } from '../context.ts';
import { PLATFORM_CLAIM, PLATFORM_LEAD, PlatformGrid } from '../platform.ts';

export function boardRoutes(services: Services) {
const app = new Hono<AppEnv>();
Expand Down Expand Up @@ -91,6 +92,7 @@ export function boardRoutes(services: Services) {
);
})()}
${boardStatsPanel(stats)}
${platformPanel(settings, viewer)}
${trusted(below)}`;

return render(c, services, {
Expand Down Expand Up @@ -313,6 +315,33 @@ function boardHero(settings: Settings, viewer: Viewer) {
</section>`;
}

/**
* What the software under this board is, for somebody who has never seen it.
*
* Guests only, and last on the page: a member came for the forum, and a visitor
* reads the forums first and the sales pitch second. The grid itself is data in
* platform.ts, so a claim made here is a claim made everywhere, and a feature
* that does not exist yet renders as planned rather than as a feature.
*/
function platformPanel(settings: Settings, viewer: Viewer) {
if (viewer.user || settings['board.showPlatform'] === false) return '';

return Card(html`
${CardHeader(PLATFORM_CLAIM, { description: 'This board runs on tsbb. So can yours.' })}
${CardContent(html`
<p class="platform-lead">${PLATFORM_LEAD}</p>
${PlatformGrid()}
<div class="row platform-actions">
${LinkButton('Read the docs', '/docs', { size: 'sm' })}
${LinkButton('About this board', '/about', { size: 'sm', variant: 'outline' })}
<!-- Off-site, so it carries rel="noopener" of its own rather than going
through LinkButton, which has no rel. -->
<a href="https://github.com/profullstack/tsbb" class="btn btn-ghost btn-sm" rel="noopener">Source on GitHub</a>
</div>
`)}
`);
}

function boardStatsPanel(stats: BoardStats) {
return Card(html`
${CardHeader('Board statistics')}
Expand Down
40 changes: 35 additions & 5 deletions apps/server/src/routes/discovery.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,10 @@ import { html } from 'hono/html';
import { forumTree, guestViewer, visibleForumIds, type Settings } from '@tsbb/core';
import { escapeHtml } from '@tsbb/markup';
import { all } from '@tsbb/db';
import { Card, CardContent, LinkButton } from '@tsbb/ui';
import { Card, CardContent, CardHeader, LinkButton } from '@tsbb/ui';
import { render, type AppEnv, type Services } from '../context.ts';
import { DOCS, docMarkdown } from './docs.ts';
import { DOCS, docMarkdown, docTitle } from './docs.ts';
import { FRONT_DOORS, LIVE_FRONT_DOORS, PLATFORM_CLAIM, PLATFORM_LEAD, PlatformGrid } from '../platform.ts';

/**
* The files a machine reads before it reads the board.
Expand Down Expand Up @@ -255,7 +256,21 @@ export function discoveryRoutes(services: Services) {
`${name} is a bulletin board: a tree of forums, each holding topics, each topic a thread of posts. ` +
'Every public page is complete server-rendered HTML with no client-side script, and every list ' +
'on the board is also an RSS feed. The same content is reachable through a REST API, a command ' +
'line client and an MCP server, all of which apply the permissions the pages do.',
'line client, an MCP server, an installable app and a terminal client, all of which apply the ' +
'permissions the pages do.',
'',
`It runs on tsbb, ${PLATFORM_CLAIM.toLowerCase()}. ${PLATFORM_LEAD}`,
'',
'## What this board can do',
'',
...LIVE_FRONT_DOORS.map((door) =>
door.href && door.href.startsWith('/')
? `- [${door.title}](${absolute(door.href)}): ${door.summary}`
: `- ${door.title}: ${door.summary}`,
),
...FRONT_DOORS.filter((door) => door.status === 'planned').map(
(door) => `- ${door.title} (planned, not built yet): ${door.summary}`,
),
'',
'## Read the board',
'',
Expand All @@ -274,7 +289,7 @@ export function discoveryRoutes(services: Services) {
'## Use the board from a program',
'',
link('Documentation', '/docs', 'The index of every guide below.'),
...DOCS.map((doc) => link(doc.blurb.split(':')[0]?.replace(/`/g, '') ?? doc.slug, `/docs/${doc.slug}`, doc.blurb.replace(/`/g, ''))),
...DOCS.map((doc) => link(docTitle(doc), `/docs/${doc.slug}`, doc.blurb.replace(/`/g, ''))),
link('OpenAPI description', '/api/v1/openapi.json', 'The REST API, machine-readable.'),
link('MCP endpoint', '/api/mcp', 'Streamable HTTP MCP server; a bearer token makes it act as a member.'),
link('Agent skill', '/skill.md', 'What an agent can do here and how to authenticate.'),
Expand Down Expand Up @@ -340,6 +355,10 @@ export function discoveryRoutes(services: Services) {
'',
`${name} is a forum. Use it to read topics, search posts, and — as a signed-in member — start topics and reply.`,
'',
'It runs on tsbb, which answers the same content four ways: pages, a REST API, a CLI and MCP.',
'Whichever one you use, the permissions are the ones the pages apply. Nothing here is an',
'agent-only view of the board, and nothing is withheld from a browser that a token can see.',
'',
'## Connect',
'',
`- MCP over streamable HTTP: \`${absolute('/api/mcp')}\``,
Expand Down Expand Up @@ -418,8 +437,15 @@ export function discoveryRoutes(services: Services) {
<li>A <a href="/docs/api">REST API</a>, described by an <a href="/api/v1/openapi.json">OpenAPI file</a>.</li>
<li>The <a href="/docs/cli"><code>tsbb</code> command line client</a>, with <code>--json</code> on every command.</li>
<li>An <a href="/docs/mcp">MCP server</a>, so an AI assistant can read and post as a member.</li>
<li>An <a href="/docs/pwa">app you can install</a>, and <code>tsbb-tui</code> in a terminal over SSH.</li>
</ul>
<p>They all apply exactly the permissions the pages do.</p>
<p>
They all apply exactly the permissions the pages do, and none of them is a second-class
copy of the site: see <a href="/docs/agents">what this board publishes for machines</a>.
</p>

<h2>What is it built on?</h2>
<p>${PLATFORM_LEAD}</p>

<h2>Who runs it?</h2>
<p>
Expand All @@ -430,6 +456,10 @@ export function discoveryRoutes(services: Services) {
</p>
</div>`),
)}
${Card(html`
${CardHeader(PLATFORM_CLAIM, { description: 'tsbb, the software this board runs.' })}
${CardContent(PlatformGrid())}
`)}
<div class="row about-actions">
${!viewer.user && mode !== 'closed' ? LinkButton('Join the board', '/signup', { size: 'sm' }) : ''}
${LinkButton('Read the docs', '/docs', { size: 'sm', variant: 'outline' })}
Expand Down
Loading
Loading