the hono app is built, not assembled once
first piece of the plugin system: the platform can now be rebuilt with a
different set of plugins mounted, at runtime, without restarting.
hono cannot do this the obvious way. its default SmartRouter throws "Can not add
a route since the matcher is already built" the moment a route is added after
serving begins, RegExpRouter does the same, and hono has no api to REMOVE a
route at all — so uninstall was impossible even with TrieRouter, which does
allow adding. tested all four.
so nothing is added to a live app. buildHonoApp(plugins) constructs a fresh one
and honoServer is reassigned, which keeps the default fast router and makes
uninstall expressible. server.tsx now serves it through a closure rather than
the bound honoServer.fetch — that one line is the whole mechanism, since the
bound method would capture whichever app existed at serve() and every rebuild
would silently do nothing.
buildHonoApp is pure: everything it needs arrives as an argument, so an app for
a hypothetical plugin set can be built without a database, a filesystem or a
running server.
alongside it, discovery. plugins live at platform/plugins/<app-name>/ — inside
the repo, because bun links the workspace packages into the root node_modules
and that is what lets a plugin author write `import { useClient } from
'hooks/useClient'` with no publishing and no version negotiation. verified with
Bun.resolveSync from a directory there.
discovery is by convention and presence is the declaration: api/router.ts,
db/schema.ts, sidecar/index.ts, web/Router.tsx. the app name comes from the
directory, so it cannot disagree with where the code sits, and the sidecar
runtime comes from the extension — .mjs is node, .ts is bun — which is already
the rule here and cannot contradict the file it describes.
a broken plugin is collected, never thrown: one unreadable manifest must not
stop the boot or hide the nine beside it that are fine.
verified by booting the refactored server on a spare port — /api answers 200,
protected routes still 401. full suite: 719 pass, and the same 10 failures as
before this change (8 in capabilities, plus cliamp and pty), stash-verified
earlier as pre-existing.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,77 @@
|
||||
import { describe, expect, it } from 'bun:test';
|
||||
import { manifestProblems, mountPrefix, type PluginManifest } from './manifest';
|
||||
|
||||
const valid = (over: Partial<PluginManifest> = {}): PluginManifest => ({
|
||||
publisher: 'officerdev',
|
||||
version: '1.0.0',
|
||||
platform: '>=1.0.0 <2.0.0',
|
||||
label: 'Offscale',
|
||||
summary: 'Your tailnet',
|
||||
icon: 'Network',
|
||||
color: '#818cf8',
|
||||
permissions: [{ key: 'offscale', label: 'Offscale', description: 'The tailnet', ownerOnly: true }],
|
||||
...over,
|
||||
});
|
||||
|
||||
describe('mountPrefix', () => {
|
||||
it('puts first-party plugins at the root', () => {
|
||||
expect(mountPrefix({ appName: 'offscale', manifest: { publisher: 'officerdev' } })).toBe('/offscale');
|
||||
});
|
||||
|
||||
it('puts everyone else under /p/<publisher>/', () => {
|
||||
expect(mountPrefix({ appName: 'notes', manifest: { publisher: 'alice' } })).toBe('/p/alice/notes');
|
||||
});
|
||||
|
||||
// The property the segment exists for: a third party cannot reach a core route's namespace, whatever
|
||||
// they call their plugin. Without it, publishing `notes` at /api/notes would mean the platform could
|
||||
// never add /api/notes itself.
|
||||
it('cannot shadow a core route, whatever the plugin is called', () => {
|
||||
for (const core of ['chat', 'users', 'terminal', 'auth', 'files']) {
|
||||
expect(mountPrefix({ appName: core, manifest: { publisher: 'alice' } })).toBe(`/p/alice/${core}`);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('manifestProblems', () => {
|
||||
it('accepts a good manifest', () => {
|
||||
expect(manifestProblems('offscale', valid())).toEqual([]);
|
||||
});
|
||||
|
||||
it('rejects a missing manifest with a reason rather than a crash', () => {
|
||||
expect(manifestProblems('offscale', null)).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('reports every problem at once, not the first', () => {
|
||||
// An install fixed one field per attempt is an install nobody finishes.
|
||||
const problems = manifestProblems('offscale', { publisher: 'officerdev' } as Partial<PluginManifest>);
|
||||
expect(problems.length).toBeGreaterThan(3);
|
||||
});
|
||||
|
||||
// The app name is a URL segment, a SQL identifier prefix and a directory name simultaneously. Anything
|
||||
// that is not safe in all three has to be refused at the door.
|
||||
it.each([
|
||||
['Offscale', 'uppercase'],
|
||||
['1offscale', 'leading digit'],
|
||||
['off scale', 'space'],
|
||||
['off_scale', 'underscore'],
|
||||
['off/scale', 'slash'],
|
||||
['../escape', 'traversal'],
|
||||
['', 'empty'],
|
||||
])('refuses the directory name %p (%s)', (appName) => {
|
||||
expect(manifestProblems(appName, valid()).length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('refuses a publisher that is not a safe path segment', () => {
|
||||
expect(manifestProblems('notes', valid({ publisher: '../evil' })).length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('requires permissions to be an array, so [] is how a plugin says it gates nothing', () => {
|
||||
expect(manifestProblems('notes', valid({ permissions: [] }))).toEqual([]);
|
||||
expect(manifestProblems('notes', valid({ permissions: undefined })).length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('names the permission that is malformed, by index', () => {
|
||||
const problems = manifestProblems('notes', valid({ permissions: [{ label: 'x' }] as never }));
|
||||
expect(problems.some((p) => p.includes('permissions[0].key'))).toBe(true);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user