feat: move session sharing and collab rooms into a plugin (B12)

This commit is contained in:
LukeGus committed 2026-09-23 23:42:46 -05:00
1 parent 791d8656a1
commit a276f6898c
207 files changed
+35771 -11072

No files matched your search

+17 -2
View File
@@ -179,8 +179,23 @@ export interface PluginRegistry {
}
export interface PluginServices {
provide: <T extends object>(service: string, implementation: T) => void;
get: <T extends object>(service: string, options?: { userId?: string }) => T;
/**
* `name` registers one of several providers of the same service, for a
* service keyed by something the caller picks (sessions.live by session
* type). It must be listed in the manifest's `provides[].names`.
*/
provide: <T extends object>(
service: string,
implementation: T,
options?: { name?: string },
) => void;
/** `provider` picks a named provider; omitted, the unnamed one. */
get: <T extends object>(
service: string,
options?: { userId?: string; provider?: string },
) => T;
/** Names of the providers running now ("" for an unnamed one). */
providers: (service: string) => string[];
}
export interface PluginSecrets {
+1 -17
View File
@@ -96,7 +96,7 @@ export interface ShellApi {
export interface TabsApi extends Pick<
ShellApi,
"openTab" | "openSingletonTab" | "closeTab"
"openTab" | "openSingletonTab" | "closeTab" | "openRailView"
> {
/** The current tabs and split layout, or null before the shell mounts. */
getLayout: () => ShellLayout | null;
@@ -187,16 +187,6 @@ export interface TabHandle {
paste?: (text: string) => void;
refresh?: () => void;
notifyResize?: () => void;
/** A tab that shows its own share dialog. */
canShare?: () => boolean;
openShareModal?: () => void;
/** A tab whose session the shell's share dialog can share. */
getShareTarget?: () => {
hostId: number;
sessionId: string;
protocol: "ssh";
tabInstanceId?: string;
} | null;
[key: string]: unknown;
}
@@ -232,12 +222,6 @@ export interface TabOptions {
singleton?: boolean;
/** A live session: asks before closing and can be refreshed. */
session?: boolean;
/**
* Whether a session tab offers "Share session". Default true. A plugin
* whose ref does not implement session sharing (a local device, not a
* remote connection another user could join) sets this false.
*/
shareable?: boolean;
/** Restorable without a host. */
hostless?: boolean;
/** Decides whether a saved tab for this host may be restored. */
+45 -1
View File
@@ -145,6 +145,12 @@ export interface PluginServiceProvide {
version: string;
/** The role permission a calling user needs. */
permission: string;
/**
* Named providers this plugin registers, for a service several plugins
* provide side by side (sessions.live, keyed by session type). Omitted, the
* plugin provides it once, unnamed.
*/
names?: string[];
}
export interface PluginServiceRequire {
@@ -256,6 +262,11 @@ export interface PluginContributions {
* collab links), where it is activated with app.guest set.
*/
guest?: boolean;
/**
* `?view=` names an anonymous guest page may open. Each must be served by a
* tab's `standalone` component. Needs `guest: true`.
*/
guestViews?: string[];
actions?: PluginActionContribution[];
actionSlots?: PluginActionSlot[];
permissions?: PluginPermissionContribution[];
@@ -348,6 +359,7 @@ const ALLOWED_CONTRIBUTES = new Set([
"panels",
"dashboardCards",
"guest",
"guestViews",
"actions",
"actionSlots",
"permissions",
@@ -598,7 +610,12 @@ function validateProvides(provides: unknown, errors: string[]): void {
errors.push(`${where} must be an object`);
return;
}
rejectUnknown(raw, ["service", "version", "permission"], where, errors);
rejectUnknown(
raw,
["service", "version", "permission", "names"],
where,
errors,
);
if (typeof raw.service !== "string" || !SERVICE_PATTERN.test(raw.service)) {
errors.push(`${where}.service must be a dotted lowercase name`);
@@ -613,9 +630,30 @@ function validateProvides(provides: unknown, errors: string[]): void {
errors.push(`${where}.version must be valid semver`);
}
requireString(raw.permission, `${where}.permission`, errors);
if (raw.names !== undefined) {
validateNameList(raw.names, `${where}.names`, errors);
}
});
}
const PROVIDER_NAME_PATTERN = /^[a-z][a-z0-9-]*$/;
function validateNameList(value: unknown, where: string, errors: string[]) {
if (!Array.isArray(value) || value.length === 0) {
errors.push(`${where} must be a non-empty array`);
return;
}
const seen = new Set<string>();
for (const name of value) {
if (typeof name !== "string" || !PROVIDER_NAME_PATTERN.test(name)) {
errors.push(`${where} entries must be lowercase names`);
continue;
}
if (seen.has(name)) errors.push(`${where} duplicates "${name}"`);
seen.add(name);
}
}
function validateRequires(requires: unknown, errors: string[]): void {
if (requires === undefined) return;
if (!Array.isArray(requires)) {
@@ -721,6 +759,12 @@ function validateContributes(
) {
errors.push('Field "contributes.guest" must be a boolean');
}
if (contributes.guestViews !== undefined) {
validateNameList(contributes.guestViews, "contributes.guestViews", errors);
if (contributes.guest !== true) {
errors.push('Field "contributes.guestViews" needs "contributes.guest"');
}
}
validatePermissions(contributes.permissions, pluginId, errors);
validateActions(contributes.actions, errors);
validateActionSlots(contributes.actionSlots, errors);
+6 -4
View File
@@ -104,15 +104,17 @@ export function buildTable(
*/
export function buildRefTable(
name: string,
columns: Record<string, "text" | "integer">,
columns: Record<string, "text" | "integer" | "boolean">,
): unknown {
const built: Record<string, unknown> = {};
for (const [property, type] of Object.entries(columns)) {
const columnSqlName = columnName(property);
built[property] =
type === "integer"
? sqliteInteger(columnSqlName)
: sqliteText(columnSqlName);
type === "boolean"
? sqliteInteger(columnSqlName, { mode: "boolean" })
: type === "integer"
? sqliteInteger(columnSqlName)
: sqliteText(columnSqlName);
}
return sqliteTable(name, built as never);
}
+36 -15
View File
@@ -57,6 +57,11 @@ export interface FakeContextOptions {
* when omitted every check passes, so tests that do not care stay simple.
*/
permissions?: string[];
/**
* Services other plugins provide, for testing a consumer. Keyed by service
* name, or "<service>#<provider>" for a named provider.
*/
services?: Record<string, object>;
/** Hosts ctx.hosts.list/get/checkAccess answer with. */
hosts?: PluginHostSummary[];
/** Users and roles ctx.hosts.listUsers/listRoles answer with. */
@@ -132,7 +137,7 @@ export interface FakePluginContext {
}>;
/** Changes the acting user, as core's request middleware would. */
setActor: (userId: string | undefined) => void;
/** Implementations provided through ctx.services.provide, by service name. */
/** Provided or seeded services, by name or "<service>#<provider>". */
services: Map<string, object>;
}
@@ -210,7 +215,11 @@ export function createFakeContext(
);
const hostRecordsById = new Map<number, PluginHostRecord>();
let nextHostId = Math.max(0, ...(options.hosts ?? []).map((h) => h.id)) + 1;
const services = new Map<string, object>();
const services = new Map<string, object>(
Object.entries(options.services ?? {}),
);
const serviceKey = (service: string, name?: string) =>
name ? `${service}#${name}` : service;
const registryProviders = new Map<string, unknown>();
const auth: FakeAuthRegistrations = {
sshAuthProviders: [],
@@ -349,18 +358,23 @@ export function createFakeContext(
},
services: {
provide: (service, implementation) => {
services.set(service, implementation);
},
// Only services this same context provided. A test of a consumer
// should provide a stub first.
get: (service) => {
const implementation = services.get(service);
if (!implementation) {
throw new Error(`No service "${service}" was provided in this test`);
}
return implementation as never;
provide: (service, implementation, provideOptions) => {
services.set(serviceKey(service, provideOptions?.name), implementation);
},
// What this context provided or the test seeded through `services`.
// Like the real handle, a missing provider is an empty object, which a
// consumer's `"method" in handle` probe reads as absent.
get: (service, getOptions) =>
(services.get(serviceKey(service, getOptions?.provider)) ??
{}) as never,
providers: (service) =>
[...services.keys()].flatMap((key) =>
key === service
? [""]
: key.startsWith(`${service}#`)
? [key.slice(service.length + 1)]
: [],
),
},
secrets: {
@@ -680,6 +694,8 @@ export interface MockContextOptions {
permissions?: string[];
/** Hosts ctx.hosts.list/get/checkAccess serve. See FakeContextOptions. */
hosts?: PluginHostSummary[];
/** Other plugins' services. See FakeContextOptions. */
services?: Record<string, object>;
/** Hosts ctx.ssh.resolveHost serves. See FakeContextOptions. */
sshHosts?: PluginSshHost[];
}
@@ -717,6 +733,7 @@ export function createMockCtx(
permissions: options.permissions,
hosts: options.hosts,
sshHosts: options.sshHosts,
services: options.services,
manifest: {
capabilities: options.capabilities ?? [],
...options.manifest,
@@ -1018,7 +1035,7 @@ export async function createTestDb(
const sqlite = new Database(":memory:") as TestSqlite;
sqlite.exec("PRAGMA foreign_keys = ON");
sqlite.exec(
"CREATE TABLE users (id TEXT PRIMARY KEY, username TEXT NOT NULL DEFAULT '')",
"CREATE TABLE users (id TEXT PRIMARY KEY, username TEXT NOT NULL DEFAULT '', is_admin INTEGER NOT NULL DEFAULT 0)",
);
sqlite.exec("CREATE TABLE ssh_data (id INTEGER PRIMARY KEY AUTOINCREMENT)");
sqlite.exec(
@@ -1059,7 +1076,11 @@ export async function createTestDb(
if (!options.skipMigrations) await migrate();
const refs = {
users: builder.buildRefTable("users", { id: "text", username: "text" }),
users: builder.buildRefTable("users", {
id: "text",
username: "text",
isAdmin: "boolean",
}),
hosts: builder.buildRefTable("ssh_data", { id: "integer" }),
roles: builder.buildRefTable("roles", {
id: "integer",