diff --git a/plugins/workspaces/backend/index.mjs b/plugins/workspaces/backend/index.mjs new file mode 100644 index 000000000..63ab2a5b7 --- /dev/null +++ b/plugins/workspaces/backend/index.mjs @@ -0,0 +1,58 @@ +/** + * Workspaces - first-party, in-process plugin. + * + * Unlike the other first-party plugins (see src/backend/plugins/first-party.ts), + * this one does not own a transport or touch the shared SSH pool -- its routes + * are plain per-user JSON CRUD against a single repository. It runs in-process + * anyway to keep its existing /workspaces URL and its existing dedicated table + * (user_workspaces, keyed by userId) rather than moving onto + * /plugin-api/workspaces/* and the generic key-value ctx.storage the worker + * tier would require, matching how network-topology was migrated in place + * rather than reworked around the worker ctx boundary. + * + * Like network-topology, this plugin does not get its own port -- it + * registers its Express router with the always-mounted /workspaces + * dispatcher in src/backend/database/routes/workspace-dispatch.ts. + * + * routes.ts is what used to be src/backend/database/routes/workspaces.ts, + * moved here and rewritten from `export default router` into + * startWorkspacesService()/stopWorkspacesService(), called from + * activate()/deactivate() below. The user_workspaces table and its + * repository stay in core (src/backend/database/db/schema.ts, + * src/backend/database/repositories/workspace-repository.ts) -- this + * plugin system has no owned-table migration mechanism of its own, and the + * table carries an FK into core's users table. + */ + +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +/** + * Loads a module by its path relative to this file's own directory. + * + * The plugin ships beside dist/backend, so in a built server the compiled + * .js for routes.js sits right next to this .mjs. Under vitest/dev nothing + * imports this plugin directly (the loader loads in-process plugins with a + * plain dynamic import, no tsx loader involved), so the only layout that + * needs to resolve here is the built one. + */ +async function loadRelative(relativePath) { + const here = path.dirname(fileURLToPath(import.meta.url)); + const target = path.join(here, `${relativePath}.js`); + return import(new URL(`file://${target.replace(/\\/g, "/")}`).href); +} + +let routesModule = null; + +export async function activate(ctx) { + routesModule = await loadRelative("routes"); + routesModule.startWorkspacesService(); + ctx.log.info("Workspaces router registered at /workspaces"); +} + +export async function deactivate() { + if (routesModule) { + routesModule.stopWorkspacesService(); + routesModule = null; + } +} diff --git a/src/backend/database/routes/workspaces.ts b/plugins/workspaces/backend/routes.ts similarity index 95% rename from src/backend/database/routes/workspaces.ts rename to plugins/workspaces/backend/routes.ts index fcf166aad..2b26e511e 100644 --- a/src/backend/database/routes/workspaces.ts +++ b/plugins/workspaces/backend/routes.ts @@ -1,11 +1,15 @@ -import type { AuthenticatedRequest } from "../../../types/index.js"; +import type { AuthenticatedRequest } from "../../../src/types/index.js"; import express, { type Request, type Response } from "express"; -import { databaseLogger } from "../../utils/logger.js"; -import { AuthManager } from "../../utils/auth-manager.js"; -import { createCurrentWorkspaceRepository } from "../repositories/factory.js"; -import type { WorkspaceRecord } from "../repositories/workspace-repository.js"; +import { databaseLogger } from "../../../src/backend/utils/logger.js"; +import { AuthManager } from "../../../src/backend/utils/auth-manager.js"; +import { createCurrentWorkspaceRepository } from "../../../src/backend/database/repositories/factory.js"; +import type { WorkspaceRecord } from "../../../src/backend/database/repositories/workspace-repository.js"; +import { + registerWorkspacesRouter, + unregisterWorkspacesRouter, +} from "../../../src/backend/database/routes/workspace-dispatch.js"; -const router = express.Router(); +export const router = express.Router(); const authManager = AuthManager.getInstance(); const authenticateJWT = authManager.createAuthMiddleware(); @@ -635,4 +639,10 @@ router.delete( }, ); -export default router; +export function startWorkspacesService(): void { + registerWorkspacesRouter(router); +} + +export function stopWorkspacesService(): void { + unregisterWorkspacesRouter(); +} diff --git a/src/ui/sidebar/WorkspacesPanel.tsx b/plugins/workspaces/frontend/WorkspacesPanel.tsx similarity index 99% rename from src/ui/sidebar/WorkspacesPanel.tsx rename to plugins/workspaces/frontend/WorkspacesPanel.tsx index 72db0246d..435152877 100644 --- a/src/ui/sidebar/WorkspacesPanel.tsx +++ b/plugins/workspaces/frontend/WorkspacesPanel.tsx @@ -1,4 +1,4 @@ -import { getErrorMessage } from "../lib/error-message.js"; +import { getErrorMessage } from "@/lib/error-message.js"; import { useState, useEffect, useCallback, useRef } from "react"; import { useTranslation } from "react-i18next"; import { toast } from "sonner"; @@ -33,7 +33,7 @@ import { duplicateWorkspace, setDefaultWorkspace, unsetDefaultWorkspace, -} from "@/api/workspaces-api"; +} from "./workspaces-api"; import type { Workspace, WorkspacePayload } from "@/types/ui-types"; function timeAgo(isoStr: string): string { diff --git a/plugins/workspaces/frontend/index.mjs b/plugins/workspaces/frontend/index.mjs new file mode 100644 index 000000000..7bc89d5a4 --- /dev/null +++ b/plugins/workspaces/frontend/index.mjs @@ -0,0 +1,34 @@ +/** + * Frontend half of the workspaces plugin. + * + * WorkspacesPanel is a left-sidebar railView panel (opened from the rail, + * not a Tab), the same shape as FleetsPanel/HostsPanel/CredentialsPanel. + * None of those go through registerRailItem/registerTabComponent either -- + * per src/ui/shell/pluginLoader.ts's own comment, a rail-view panel like + * this is rendered directly by AppShell.tsx rather than registered at + * runtime. WorkspacesPanel is reached today through a direct relative + * import in AppShell.tsx (../../plugins/workspaces/frontend/WorkspacesPanel), + * not through register() below. + * + * register()/unregister() still follow the same shape every other plugin's + * frontend entry does, so this is ready the moment a real frontend plugin + * loader exists -- see plugins/ssh-terminal/frontend/index.mjs and + * src/ui/tests/sidebar/plugin-extension-seam.test.tsx, which says outright + * "there is no plugin loader yet". + */ + +export const id = "workspaces"; +export const railViewId = "workspaces"; + +export async function register({ registerRailItem, icons }) { + registerRailItem?.({ + id: railViewId, + icon: icons.LayoutTemplate, + labelKey: "nav.workspaces", + separatorAfter: true, + }); +} + +export async function unregister({ unregisterRailItem }) { + unregisterRailItem?.(railViewId); +} diff --git a/src/ui/shell/workspaceUtils.ts b/plugins/workspaces/frontend/workspaceUtils.ts similarity index 100% rename from src/ui/shell/workspaceUtils.ts rename to plugins/workspaces/frontend/workspaceUtils.ts diff --git a/src/ui/api/workspaces-api.ts b/plugins/workspaces/frontend/workspaces-api.ts similarity index 100% rename from src/ui/api/workspaces-api.ts rename to plugins/workspaces/frontend/workspaces-api.ts diff --git a/plugins/workspaces/manifest.json b/plugins/workspaces/manifest.json new file mode 100644 index 000000000..0409d97e8 --- /dev/null +++ b/plugins/workspaces/manifest.json @@ -0,0 +1,20 @@ +{ + "id": "workspaces", + "name": "Workspaces", + "version": "1.0.0", + "description": "Save and restore named tab/split layouts per user.", + "author": { "name": "Termix" }, + "license": "MIT", + "repository": "https://github.com/Termix-SSH/Termix", + "category": "Productivity", + "icon": "LayoutTemplate", + "engine": { "termix": ">=2.9.0", "api": "1" }, + "capabilities": { + "backend": true, + "frontend": true, + "electron": true, + "platforms": ["linux", "win32", "darwin"] + }, + "permissions": ["process:transport-owner"], + "sidecars": [] +} diff --git a/src/backend/database/database.ts b/src/backend/database/database.ts index a1002d4da..44cb4ee97 100644 --- a/src/backend/database/database.ts +++ b/src/backend/database/database.ts @@ -11,7 +11,7 @@ import alertRoutes from "./routes/alerts.js"; import credentialsRoutes from "./routes/credentials.js"; import snippetsRoutes from "./routes/snippets.js"; import fleetsDispatch from "./routes/fleet-dispatch.js"; -import workspaceRoutes from "./routes/workspaces.js"; +import workspaceDispatch from "./routes/workspace-dispatch.js"; import c2sTunnelPresetRoutes from "./routes/c2s-tunnel-presets.js"; import terminalRoutes from "./routes/terminal.js"; import sessionLogRoutes from "./routes/session-log-routes.js"; @@ -1754,7 +1754,7 @@ app.use("/alerts", alertRoutes); app.use("/credentials", credentialsRoutes); app.use("/snippets", snippetsRoutes); app.use("/fleets", fleetsDispatch); -app.use("/workspaces", workspaceRoutes); +app.use("/workspaces", workspaceDispatch); app.use("/c2s-tunnel-presets", c2sTunnelPresetRoutes); app.use("/terminal", terminalRoutes); app.use("/session_logs", sessionLogRoutes); diff --git a/src/backend/database/routes/workspace-dispatch.ts b/src/backend/database/routes/workspace-dispatch.ts new file mode 100644 index 000000000..59b2a86c1 --- /dev/null +++ b/src/backend/database/routes/workspace-dispatch.ts @@ -0,0 +1,47 @@ +// Mounted at /workspaces. Workspaces ships as a first-party in-process +// plugin (plugins/workspaces) rather than a route file imported directly +// here, so this module exists to keep /workspaces/* working at its original +// path without handing the plugin a reference to the shared app instance. +// The plugin runtime registers its router via registerWorkspacesRouter when +// it activates, and clears it on deactivate, so a disabled plugin falls back +// to the 404 below. +// +// Unlike /plugin-api, this dispatcher applies no auth of its own: it forwards +// to exactly one first-party router (the Workspaces plugin's own), and every +// route in that router already applies authenticateJWT itself, the same as +// every other router mounted alongside it here (/users, /host, /snippets, +// ...). /plugin-api gates at the mount point because it forwards to arbitrary +// third-party plugin code that cannot be assumed to gate itself; that +// reasoning does not apply to a single first-party router that already does. + +import type { Request, Response, NextFunction, Router } from "express"; +import { databaseLogger } from "../../utils/logger.js"; + +let activeRouter: Router | null = null; + +export function registerWorkspacesRouter(router: Router): void { + activeRouter = router; + databaseLogger.info("Registered Workspaces plugin router", { + operation: "workspaces_dispatch_register", + }); +} + +export function unregisterWorkspacesRouter(): void { + if (!activeRouter) return; + activeRouter = null; + databaseLogger.info("Unregistered Workspaces plugin router", { + operation: "workspaces_dispatch_register", + }); +} + +export default function workspaceDispatch( + req: Request, + res: Response, + next: NextFunction, +): void { + if (!activeRouter) { + res.status(404).json({ error: "Workspaces is not available" }); + return; + } + activeRouter(req, res, next); +} diff --git a/src/backend/plugins/first-party.ts b/src/backend/plugins/first-party.ts index a51840800..7c7f61938 100644 --- a/src/backend/plugins/first-party.ts +++ b/src/backend/plugins/first-party.ts @@ -98,6 +98,14 @@ * The worker ctx has no fetch primitive at all (ctx.fetch is notImplemented * in SDK v1, see worker-bootstrap.ts), so there is currently no way for a * worker-tier plugin to make an outbound HTTP request. + * + * workspaces is here for the same reason network-topology is: its routes are + * plain per-user JSON CRUD against a single repository, and would fit the + * worker ctx fine on their own. It stays in-process to keep its existing + * /workspaces URL and its existing dedicated table (user_workspaces, keyed + * by userId) rather than moving onto /plugin-api/workspaces/* and the + * generic key-value ctx.storage the worker tier would require -- a URL and + * storage-model change, not a capability gap. */ export const FIRST_PARTY_PLUGIN_IDS: ReadonlySet = new Set([ "ssh-terminal", @@ -110,6 +118,7 @@ export const FIRST_PARTY_PLUGIN_IDS: ReadonlySet = new Set([ "automations", "network-topology", "tailscale", + "workspaces", ]); export const TRANSPORT_OWNER_CAPABILITY = "process:transport-owner"; diff --git a/src/backend/starter.ts b/src/backend/starter.ts index f4c6f783a..def925235 100644 --- a/src/backend/starter.ts +++ b/src/backend/starter.ts @@ -295,16 +295,16 @@ async function provisionLocalDesktopUserIfNeeded(): Promise { // ssh-terminal, docker and host-metrics plugins start their own servers, // so disabling any of them stops its WS/HTTP server. See // plugins/ssh-terminal, plugins/docker and plugins/host-metrics. - // AI, Proxmox, Remote Desktop, Fleets, Automations and Network Topology - // are also absent for the same reason: each plugin registers its router - // with its own dispatcher in database.ts on activate (/ai, /proxmox, - // /guacamole, /fleets, /automations, /network-topology), so disabling - // any of them makes its routes 404 instead of leaving a dead import - // here. Automations' scheduler (due schedules, dwell rechecks, - // docker-event polling, history pruning) also starts from its own - // activate() rather than here. See plugins/ai, plugins/proxmox, - // plugins/remote-desktop, plugins/fleets, plugins/automations and - // plugins/network-topology. + // AI, Proxmox, Remote Desktop, Fleets, Automations, Network Topology and + // Workspaces are also absent for the same reason: each plugin registers + // its router with its own dispatcher in database.ts on activate (/ai, + // /proxmox, /guacamole, /fleets, /automations, /network-topology, + // /workspaces), so disabling any of them makes its routes 404 instead of + // leaving a dead import here. Automations' scheduler (due schedules, + // dwell rechecks, docker-event polling, history pruning) also starts + // from its own activate() rather than here. See plugins/ai, + // plugins/proxmox, plugins/remote-desktop, plugins/fleets, + // plugins/automations, plugins/network-topology and plugins/workspaces. await import("./hosts/tunnel/index.js"); await import("./hosts/file-manager/index.js"); await import("./hosts/tmux/index.js"); diff --git a/src/backend/tests/plugins/bundled-plugin.test.ts b/src/backend/tests/plugins/bundled-plugin.test.ts index dd9404ccb..2d09d059b 100644 --- a/src/backend/tests/plugins/bundled-plugin.test.ts +++ b/src/backend/tests/plugins/bundled-plugin.test.ts @@ -374,3 +374,44 @@ describe("bundled network-topology plugin", () => { expect(source).toMatch(/export async function deactivate/); }); }); + +describe("bundled workspaces plugin", () => { + it("ships a directory with a manifest and both entry points", () => { + const dir = path.join(getBundledPluginsDir(), "workspaces"); + + expect(fs.existsSync(path.join(dir, "manifest.json"))).toBe(true); + expect(fs.existsSync(path.join(dir, "backend", "index.mjs"))).toBe(true); + expect(fs.existsSync(path.join(dir, "frontend", "index.mjs"))).toBe(true); + }); + + it("has a manifest that passes the real validator", () => { + const { manifest, errors } = parseManifest( + readBundledManifest("workspaces"), + ); + + expect(errors).toEqual([]); + expect(manifest?.id).toBe("workspaces"); + expect(manifest?.category).toBe("Productivity"); + }); + + it("declares the transport-owner capability and qualifies for the tier", () => { + const { manifest } = parseManifest(readBundledManifest("workspaces")); + + expect(manifest?.permissions).toContain(TRANSPORT_OWNER_CAPABILITY); + expect(isFirstParty("workspaces")).toBe(true); + expect(runsInProcess("workspaces", manifest!.permissions)).toBe(true); + }); + + it("exports activate and deactivate from its backend entry", () => { + const entry = path.join( + getBundledPluginsDir(), + "workspaces", + "backend", + "index.mjs", + ); + const source = fs.readFileSync(entry, "utf8"); + + expect(source).toMatch(/export async function activate/); + expect(source).toMatch(/export async function deactivate/); + }); +}); diff --git a/src/backend/tests/plugins/first-party.test.ts b/src/backend/tests/plugins/first-party.test.ts index 88700b765..b5d36c3d8 100644 --- a/src/backend/tests/plugins/first-party.test.ts +++ b/src/backend/tests/plugins/first-party.test.ts @@ -39,7 +39,7 @@ function manifest(overrides: Record = {}) { } describe("first-party allowlist", () => { - it("contains only ssh-terminal, docker, host-metrics, ai, proxmox, remote-desktop, fleets, automations, network-topology and tailscale", () => { + it("contains only ssh-terminal, docker, host-metrics, ai, proxmox, remote-desktop, fleets, automations, network-topology, tailscale and workspaces", () => { expect([...FIRST_PARTY_PLUGIN_IDS].sort()).toEqual([ "ai", "automations", @@ -51,10 +51,11 @@ describe("first-party allowlist", () => { "remote-desktop", "ssh-terminal", "tailscale", + "workspaces", ]); }); - it("recognises ssh-terminal, docker, host-metrics, ai, proxmox, remote-desktop, fleets, automations, network-topology and tailscale and nothing else", () => { + it("recognises ssh-terminal, docker, host-metrics, ai, proxmox, remote-desktop, fleets, automations, network-topology, tailscale and workspaces and nothing else", () => { expect(isFirstParty("ssh-terminal")).toBe(true); expect(isFirstParty("docker")).toBe(true); expect(isFirstParty("host-metrics")).toBe(true); @@ -65,6 +66,7 @@ describe("first-party allowlist", () => { expect(isFirstParty("automations")).toBe(true); expect(isFirstParty("network-topology")).toBe(true); expect(isFirstParty("tailscale")).toBe(true); + expect(isFirstParty("workspaces")).toBe(true); expect(isFirstParty("ssh-terminal-pro")).toBe(false); expect(isFirstParty("community-plugin")).toBe(false); expect(isFirstParty("")).toBe(false); @@ -93,6 +95,9 @@ describe("runsInProcess", () => { runsInProcess("network-topology", [TRANSPORT_OWNER_CAPABILITY]), ).toBe(true); expect(runsInProcess("tailscale", [TRANSPORT_OWNER_CAPABILITY])).toBe(true); + expect(runsInProcess("workspaces", [TRANSPORT_OWNER_CAPABILITY])).toBe( + true, + ); // On the list but not asking for it: stays in a worker, so the manifest // remains an honest description of what the plugin does. @@ -106,6 +111,7 @@ describe("runsInProcess", () => { expect(runsInProcess("automations", ["hosts.read"])).toBe(false); expect(runsInProcess("network-topology", ["hosts.read"])).toBe(false); expect(runsInProcess("tailscale", ["hosts.read"])).toBe(false); + expect(runsInProcess("workspaces", ["hosts.read"])).toBe(false); // Asking for it but not on the list. expect( @@ -233,6 +239,18 @@ describe("manifest gate on the reserved capability", () => { expect(parsed?.id).toBe("tailscale"); }); + it("allows it for workspaces", () => { + const { manifest: parsed, errors } = parseManifest( + manifest({ + id: "workspaces", + permissions: [TRANSPORT_OWNER_CAPABILITY], + }), + ); + + expect(errors).toEqual([]); + expect(parsed?.id).toBe("workspaces"); + }); + it("still accepts an ordinary manifest that never mentions it", () => { const { manifest: parsed, errors } = parseManifest( manifest({ permissions: ["hosts.read"] }), diff --git a/src/backend/tests/database/routes/workspaces.test.ts b/src/backend/tests/plugins/workspaces/routes.test.ts similarity index 98% rename from src/backend/tests/database/routes/workspaces.test.ts rename to src/backend/tests/plugins/workspaces/routes.test.ts index 81c3ceb65..dacd2c629 100644 --- a/src/backend/tests/database/routes/workspaces.test.ts +++ b/src/backend/tests/plugins/workspaces/routes.test.ts @@ -161,8 +161,13 @@ vi.mock("../../../database/repositories/factory.js", () => ({ }), })); -const { default: router } = - await import("../../../database/routes/workspaces.js"); +vi.mock("../../../database/routes/workspace-dispatch.js", () => ({ + registerWorkspacesRouter: vi.fn(), + unregisterWorkspacesRouter: vi.fn(), +})); + +const { router } = + await import("../../../../../plugins/workspaces/backend/routes.js"); function findLayer(method: string, path: string) { const stack = (router as unknown as Router).stack as Array<{ diff --git a/src/ui/AppShell.tsx b/src/ui/AppShell.tsx index b7404af7f..90a2bc460 100644 --- a/src/ui/AppShell.tsx +++ b/src/ui/AppShell.tsx @@ -102,7 +102,7 @@ const TailscaleDevicesPanel = lazy(() => ), ); const WorkspacesPanel = lazy(() => - import("@/sidebar/WorkspacesPanel").then((m) => ({ + import("../../plugins/workspaces/frontend/WorkspacesPanel").then((m) => ({ default: m.WorkspacesPanel, })), ); @@ -197,12 +197,12 @@ import { listWorkspaces, applyWorkspaceServer, saveLastSessionWorkspace, -} from "@/api/workspaces-api"; +} from "../../plugins/workspaces/frontend/workspaces-api"; import { buildWorkspacePayload as buildWorkspacePayloadUtil, remapSlotIds, resolveWorkspaceTabTarget, -} from "@/shell/workspaceUtils"; +} from "../../plugins/workspaces/frontend/workspaceUtils"; import { DonationReminderModal } from "@/user/DonationReminderModal.tsx"; import { RemoteSyncBanner } from "@/components/RemoteSyncBanner.tsx"; import { MigrationNoticeDialog } from "@/components/MigrationNoticeDialog.tsx"; diff --git a/src/ui/tests/shell/workspaceUtils.test.ts b/src/ui/tests/plugins/workspaces/workspaceUtils.test.ts similarity index 99% rename from src/ui/tests/shell/workspaceUtils.test.ts rename to src/ui/tests/plugins/workspaces/workspaceUtils.test.ts index e322a10da..00b8c8afc 100644 --- a/src/ui/tests/shell/workspaceUtils.test.ts +++ b/src/ui/tests/plugins/workspaces/workspaceUtils.test.ts @@ -4,7 +4,7 @@ import { buildWorkspaceTabSnapshots, remapSlotIds, resolveWorkspaceTabTarget, -} from "../../shell/workspaceUtils"; +} from "../../../../../plugins/workspaces/frontend/workspaceUtils"; import type { Host, Tab, WorkspaceTabSnapshot } from "@/types/ui-types"; afterEach(() => vi.unstubAllGlobals()); diff --git a/src/ui/tests/api/workspaces-api.test.ts b/src/ui/tests/plugins/workspaces/workspaces-api.test.ts similarity index 98% rename from src/ui/tests/api/workspaces-api.test.ts rename to src/ui/tests/plugins/workspaces/workspaces-api.test.ts index bd80e1b3d..9be63eca2 100644 --- a/src/ui/tests/api/workspaces-api.test.ts +++ b/src/ui/tests/plugins/workspaces/workspaces-api.test.ts @@ -24,7 +24,7 @@ import { applyWorkspaceServer, getLastSessionWorkspace, saveLastSessionWorkspace, -} from "../../api/workspaces-api"; +} from "../../../../../plugins/workspaces/frontend/workspaces-api"; import type { WorkspacePayload } from "@/types/ui-types"; beforeEach(() => { diff --git a/src/ui/tests/sidebar/rail-items.test.ts b/src/ui/tests/sidebar/rail-items.test.ts index 0ef8d4761..ed626a2dc 100644 --- a/src/ui/tests/sidebar/rail-items.test.ts +++ b/src/ui/tests/sidebar/rail-items.test.ts @@ -8,7 +8,7 @@ import { railItemLabel, visibleRailItems, } from "@/sidebar/rail-items"; -import { WORKSPACE_CAPTURABLE_TYPES } from "@/shell/workspaceUtils"; +import { WORKSPACE_CAPTURABLE_TYPES } from "../../../../plugins/workspaces/frontend/workspaceUtils"; import type { TabType } from "@/types/ui-types"; import en from "@/locales/en.json"; diff --git a/tsconfig.plugins.json b/tsconfig.plugins.json index c1aa00d61..63d0186c9 100644 --- a/tsconfig.plugins.json +++ b/tsconfig.plugins.json @@ -34,7 +34,8 @@ "plugins/remote-desktop/backend/**/*.ts", "plugins/fleets/backend/**/*.ts", "plugins/automations/backend/**/*.ts", - "plugins/network-topology/backend/**/*.ts" + "plugins/network-topology/backend/**/*.ts", + "plugins/workspaces/backend/**/*.ts" ], "exclude": ["src/**/*.test.ts"] }