offscale was missing from the permissions page because it declared ownerOnly, which mapped to kind admin, and admin capabilities are never offered for granting. correct by the old rule and wrong by the standard: every plugin follows the same platform-level permission model, appearing on the same page with the same read/write/none per role. so the field is gone rather than flipped. a plugin has no way to say owner-only, which makes the standard structural instead of remembered — the same move as the workspace rule. core, execution, confined and admin stay the platform's to assign and a plugin cannot name any of them, so the escalation question is removed rather than answered. this is the second draft of this decision to be deleted: first a full CapabilityKind with three of five values forbidden, then an ownerOnly boolean, now nothing. the doc records all three so the reasoning is visible rather than just the conclusion. finer visibility stays the plugin's job. offscale is the worked example of the gap that leaves and its manifest says so: it is grantable now, and its queries still scope by the caller, so a granted member would see their own empty server list rather than the owner's. closing that is a change inside the plugin. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
78 lines
3.1 KiB
TypeScript
78 lines
3.1 KiB
TypeScript
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' }],
|
|
...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);
|
|
});
|
|
});
|