mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-30 07:05:24 +00:00
feat: move session sharing and collab rooms into a plugin (B12)
This commit is contained in:
1 parent
791d8656a1
commit
a276f6898c
207 files changed
+35771
-11072
No files matched your search
@@ -367,6 +367,14 @@ Anonymous shared-session and collab pages have no session. They load only
|
||||
plugins whose manifest sets `contributes.guest: true`, from the public
|
||||
`GET /plugins/public`, and `app.guest` is true there.
|
||||
|
||||
**B12** made those pages generic. A guest plugin lists the `?view=` names it
|
||||
serves anonymously in `contributes.guestViews` (which needs `guest: true`),
|
||||
and `/plugins/public` returns them. `src/main.tsx` asks for that list when a
|
||||
URL carries `?view=`: a guest view starts the guest runtime and renders the
|
||||
tab type whose `standalone`/`standaloneViews` claims it, anything else goes
|
||||
through the signed-in full-screen gate as before. The shell no longer knows
|
||||
`?view=shared` or `?view=collab-guest`; session-sharing declares both.
|
||||
|
||||
The login screen runs a smaller pass before anyone signs in: it activates only
|
||||
enabled plugins that contribute `loginMethods` or `secondFactors`, listed by
|
||||
the public `GET /plugins/public-manifest`. They stay active after login, and
|
||||
@@ -426,7 +434,7 @@ boundary and Suspense.
|
||||
| `registerLoginMethod`, `registerSecondFactorUI` | Login screen UI for a method and a second factor challenge (plus an optional `enrollment` section) |
|
||||
| `api`, `wsUrl(path)` | axios on `/plugin-api/<id>/` and the plugin's WebSocket URL |
|
||||
| `t`, `hasPermission` | The plugin's strings and a permission check, for code outside a component (a toast from `activate`) |
|
||||
| `tabs.open`, `getLayout`, `applyLayout`, `onChange` | Tab control, used by workspaces |
|
||||
| `tabs.open`, `getLayout`, `applyLayout`, `onChange` | Tab control, used by workspaces; `tabs.openRailView` (**B12**) opens a rail view |
|
||||
| `guest`, `info`, `onDispose` | Guest mode flag, plugin info, extra cleanup |
|
||||
|
||||
Hooks: `useTranslation` (the plugin's namespace), `usePermission` (a short
|
||||
@@ -445,7 +453,14 @@ Slots core owns: `terminal.toolbar`, `terminal.toolbarStatus`,
|
||||
renamed `terminal.dock` to `terminal.sidePanel` and added the status slot,
|
||||
where host-metrics puts its CPU, memory and disk bars), `onboarding.steps`, `onboarding.features`,
|
||||
`onboarding.workflow`, `hosts.importMenu`, `hosts.panel`, `proxmox.hostEditor`
|
||||
and `session.remoteDisplay`. `host-metrics.managers` (component slot,
|
||||
and `shell.overlay`. **B12** added `shell.overlay`, a component slot the
|
||||
shell renders once at its root for always-mounted plugin UI: session-sharing
|
||||
keeps its share dialog and its room-invite watcher there.
|
||||
`session.remoteDisplay` moved to session-sharing, which declares it and draws
|
||||
whatever remote desktop contributes to it in rooms and share links.
|
||||
`remote-desktop.toolbar` (button slot, **B12**) is remote desktop's own,
|
||||
invoked with `{ hostId, sessionId, protocol, tabInstanceId }`.
|
||||
`host-metrics.managers` (component slot,
|
||||
**B3**) is host-metrics's own: tailscale contributes its manager card there
|
||||
instead of host-metrics knowing tailscale exists, and host-metrics works with
|
||||
or without tailscale enabled. Cross-plugin frontend calls go through actions:
|
||||
@@ -474,7 +489,7 @@ tools act on the last one focused; `PanelProps.targetTab` hands a panel that
|
||||
tab), `ownBackground` (the shell leaves the frame transparent) and
|
||||
`multiInstance` (each open is a new tab). `TabHandle` documents what a session
|
||||
tab puts on `handleRef`: focus, reconnect, disconnect and, for sharing,
|
||||
`getShareTarget()`, which the shell's own share dialog uses. These replaced
|
||||
`getShareTarget()` for sharing. These replaced
|
||||
every check of the `terminal` and `local-terminal` tab types in the shell.
|
||||
|
||||
**B2** added a live-terminal-session surface, for a plugin that needs to push
|
||||
@@ -598,13 +613,35 @@ who has no user, cannot make one. Guest link resolution is therefore
|
||||
published on `ctx.registry` as `sessions.sharing.guests` instead: the token is
|
||||
the authority there.
|
||||
|
||||
**Core consuming a plugin service.** Session sharing, collab and the open
|
||||
tabs route are still core and reach live terminal sessions through
|
||||
`getServiceImplementation(service, range)` in `service-registry.ts`
|
||||
**Core consuming a plugin service.** The open tabs route is still core and
|
||||
lists the caller's own terminal sessions through
|
||||
`getServiceImplementation(service, range, name?)` in `service-registry.ts`
|
||||
(`hosts/live-terminal-sessions.ts` wraps it). Core already authorized the
|
||||
request, so this skips the per-call check and returns the same object
|
||||
plugins get, or undefined while no compatible provider runs.
|
||||
|
||||
**Named providers (B12).** A service several plugins provide side by side is
|
||||
keyed by a provider name. The manifest lists the names in
|
||||
`provides[].names`, `ctx.services.provide(service, impl, { name })` refuses
|
||||
any other, `ctx.services.get(service, { provider })` reaches one, and
|
||||
`ctx.services.providers(service)` lists the running ones. A requirement is
|
||||
satisfied by any compatible provider. `sessions.live` is the first: keyed by
|
||||
session type, ssh-terminal provides `ssh` and remote desktop provides `rdp`,
|
||||
`vnc` and `telnet` (B14), with `createViewerToken(sessionId, readOnly)` for
|
||||
a viewer. A consumer with no actor, such as a public guest route, calls it
|
||||
through `ctx.asUser(<share owner>)`.
|
||||
|
||||
**Session sharing (B12)** is the session-sharing plugin. It provides
|
||||
`sessions.sharing` (member joins, room events) and `sessions.sharing.guests`,
|
||||
adopts `session_shares`, `session_share_participants`, `collab_rooms` and
|
||||
`collab_room_members`, and reaches sessions only through `sessions.live`. Its
|
||||
two public routes, `/resolve/:linkToken` and `/guest/:token`, check the token
|
||||
themselves and are rate limited per IP. The share button is a contribution to
|
||||
`terminal.toolbar` and `remote-desktop.toolbar`; the tab bar's share entry and
|
||||
the `shareable`, `canShare`, `openShareModal` and `getShareTarget` members of
|
||||
`TabOptions`/`TabHandle` are gone. Core's active connections list gets the
|
||||
sessions shared with a user from the `sessions.sharedWithMe` action.
|
||||
|
||||
### 10. Lifecycle
|
||||
|
||||
`activate(ctx)` creates all state. Everything registered through `ctx` or `app`
|
||||
@@ -638,7 +675,7 @@ not care about the gates, `createTestDb(pluginDir)` for a real database, and
|
||||
`renderWithApp(plugin, options)` for frontend tests.
|
||||
|
||||
`createTestDb(pluginDir, { before })` opens an in-memory SQLite with stub
|
||||
`users`, `ssh_data`, `roles` and `user_roles` tables and foreign keys on, runs
|
||||
`users` (with `is_admin`, **B12**), `ssh_data`, `roles` and `user_roles` tables and foreign keys on, runs
|
||||
`before` (seed a legacy table there to test an adoption), then applies the
|
||||
plugin's own `migrations/sqlite` with the runner's splitter. `ctx.db.refs()`
|
||||
against this database returns the same four, built by `buildRefTable`, which
|
||||
@@ -653,7 +690,10 @@ against the real schema's property names works unchanged in a test. Pass its
|
||||
and `hosts` (`PluginHostSummary[]`, what `ctx.hosts.list/get/checkAccess`
|
||||
serve - **B8** added this option, since it existed on `createFakeContext`
|
||||
already but was not forwarded), and returns `setActor(userId)` for a test
|
||||
middleware and `services` for what the plugin provided. **B8** also gave
|
||||
middleware and `services` for what the plugin provided. **B12** added a
|
||||
`services` option seeding other plugins' services for a consumer's test,
|
||||
keyed `<service>` or `<service>#<provider>`, and the doubles now answer a
|
||||
missing service with an empty handle, like the runtime, instead of throwing. **B8** also gave
|
||||
`ctx.registry` on both doubles a real in-memory backing (it was a no-op stub
|
||||
before), so `provide`/`consume`/`revoke` round-trip the way the real registry
|
||||
does. `plugins/workspaces/tests/backend/helpers.ts` is the worked example.
|
||||
@@ -1068,7 +1108,10 @@ Cross-field rules the JSON schema cannot express, checked by `parseManifest`:
|
||||
`contributes.panels` and `contributes.dashboardCards` (each `{ id, titleKey,
|
||||
icon? }`) declare the views a plugin owns besides its tabs; the app object
|
||||
refuses to register a view the manifest does not declare. `contributes.guest`
|
||||
opts a plugin into anonymous guest pages. Action contributions and slots
|
||||
opts a plugin into anonymous guest pages, and `contributes.guestViews` names
|
||||
the `?view=` pages it serves there. `provides[].names` lists the named
|
||||
providers a plugin registers for a service keyed by provider (see Cross-plugin
|
||||
use). Action contributions and slots
|
||||
accept the kinds `button` and `component`.
|
||||
|
||||
`contributes.auth` lists the ids a plugin registers through `ctx.auth`. A
|
||||
@@ -1148,7 +1191,7 @@ Built per plugin in `src/backend/plugins/ctx.ts` and passed to `activate`.
|
||||
| `ctx.events.emit` / `.on` | `events:core` for core topics | **A1** |
|
||||
| `ctx.kv.get/set/delete/list` | `kv:own` | **A1** |
|
||||
| `ctx.registry.*` | none | **A1** |
|
||||
| `ctx.services.provide` / `.get` | per-service RBAC | **A1** |
|
||||
| `ctx.services.provide` / `.get` / `.providers` | per-service RBAC | **A1**, named **B12** |
|
||||
| `ctx.secrets.offer` / `.withdraw` / `.getShared` | per-secret RBAC | **A1** |
|
||||
| `ctx.disposables.add` | none | **A1** |
|
||||
| `ctx.asUser(userId, fn)` | none, always audited | **A1** |
|
||||
|
||||
@@ -144,21 +144,6 @@ build`'s esbuild step has no static-asset-copy pipeline the way core's Vite
|
||||
to ask Electron's main process for something, extend
|
||||
`BACKEND_REQUEST_HANDLERS` there rather than building a parallel channel.
|
||||
No owner needed unless a second caller appears.
|
||||
- **B9 (ssh-terminal):** session sharing has no provider yet. The terminal
|
||||
consumes `sessions.sharing` v1 (member joins through `authorizeJoin` and
|
||||
`recordJoin`, collab room events through `subscribeRoom`/`unsubscribeRoom`)
|
||||
and reads guest link resolution from `ctx.registry` as
|
||||
`sessions.sharing.guests` (`resolve`, `recordJoin`), all typed in
|
||||
`plugins/ssh-terminal/src/backend/services.ts`. Until something provides
|
||||
them, share-link and room guests are refused and in-app joins fail. Core's
|
||||
session-sharing, collab and open-tabs code reach the terminal through
|
||||
`sessions.live` already. Owner: B12, which provides both from the
|
||||
session-sharing plugin (the lookups are the old code in
|
||||
`hosts/session-sharing` and `hosts/collab`: share repo, room repo, guest
|
||||
rate limit, `canJoinRoomStageShare`, `session_sharing_globally_enabled`,
|
||||
host `allowSessionSharing`, `getAuditUsername` for labels, `collabRoomHub`).
|
||||
B12 also moves `eventsWsPath` in `collab/routes.ts` and the share dialog the
|
||||
shell opens from a tab's `getShareTarget()`.
|
||||
- **B9 (ssh-terminal), for D2:** the terminal socket is a public route with
|
||||
`optionalAuth`, because share-link and room guests authenticate inside the
|
||||
handler with their token. Guest auth itself is unchanged; review it with the
|
||||
@@ -270,6 +255,34 @@ build`'s esbuild step has no static-asset-copy pipeline the way core's Vite
|
||||
section. Owner: whoever designs the 3.0.0 install flow (prebuild-per-platform
|
||||
packaging, or a documented "avoid native code" rule for community plugins).
|
||||
|
||||
- **B12 (session-sharing):** the remote desktop side of sharing has no
|
||||
provider yet. Session sharing reaches rdp, vnc and telnet sessions through
|
||||
`sessions.live` named providers `rdp`, `vnc` and `telnet`, each with
|
||||
`getSession(sessionId)` (id, userId as the session's owner, hostId,
|
||||
hostName, isConnected, createdAt, tabInstanceId) and
|
||||
`createViewerToken(sessionId, readOnly)`, typed in
|
||||
`plugins/session-sharing/src/backend/live.ts`. Until remote-desktop provides
|
||||
them, sharing or presenting an RDP/VNC/Telnet session is refused and those
|
||||
links resolve 404. B14 also drops `createJoinToken`/`getSessionInfo` from
|
||||
its `remote-desktop.sessions` registry entry, which core no longer reads.
|
||||
Owner: B14.
|
||||
- **B12 (session-sharing):** `allow_session_sharing` is still a live `ssh_data`
|
||||
column. The copy into the plugin's host setting shipped
|
||||
(`session-sharing-settings-migration.ts`) and nothing in core reads or
|
||||
writes it any more; drop it in lockstep (`schema.ts`, `db/index.ts`, a
|
||||
drizzle migration per dialect, `schema:generate`). Owner: D0.
|
||||
- **B12 (session-sharing):** the legacy `session_sharing_globally_enabled`
|
||||
settings row is left in place for a release; nothing reads it. Delete it in
|
||||
3.0.0. Owner: D0 to decide.
|
||||
- **B12 (session-sharing):** room audit lines go through `ctx.audit.record`,
|
||||
which records the actor but not the request's IP address and user agent the
|
||||
old `logAudit` calls carried. Adding those to `ctx.audit.record` would
|
||||
restore them. Owner: D1.
|
||||
- **B12 (session-sharing):** the share button lives in the terminal and remote
|
||||
desktop toolbars now; the tab bar's share button is gone, so a host with the
|
||||
terminal toolbar switched off has no share button. A generic tab-menu slot
|
||||
would bring it back if that matters. Owner: D1, if wanted.
|
||||
|
||||
## Manual checks after 2.9.0
|
||||
|
||||
- SSH terminal: password auth, key auth (and an encrypted key's passphrase
|
||||
@@ -278,3 +291,7 @@ build`'s esbuild step has no static-asset-copy pipeline the way core's Vite
|
||||
page reload (session reattach), and the local terminal in the desktop app.
|
||||
- Serial: the Electron backend path against a real device path/COM port, and
|
||||
the Web Serial browser picker in Chrome or Edge.
|
||||
- Session sharing: a link share (read-only and read-write) opened in a private
|
||||
window, a user share joined from Active Connections, revoke and "end for
|
||||
everyone", and a collab room with two members, control hand-off, a guest
|
||||
link, rotate and end.
|
||||
@@ -162,6 +162,12 @@
|
||||
"permission": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"names": {
|
||||
"type": "array",
|
||||
"minItems": 1,
|
||||
"uniqueItems": true,
|
||||
"items": { "type": "string", "pattern": "^[a-z][a-z0-9-]*$" }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -265,6 +271,12 @@
|
||||
}
|
||||
},
|
||||
"guest": { "type": "boolean" },
|
||||
"guestViews": {
|
||||
"type": "array",
|
||||
"minItems": 1,
|
||||
"uniqueItems": true,
|
||||
"items": { "type": "string", "pattern": "^[a-z][a-z0-9-]*$" }
|
||||
},
|
||||
"auth": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
Reference in new issue
Block a user