mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-28 14:15:24 +00:00
feat: convert secret-sources into a plugin (B20)
This commit is contained in:
1 parent
2ac7779eda
commit
f4f26a9b8b
72 files changed
+18843
-933
No files matched your search
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"hint": "You can paste a 1Password reference (op://vault/item/field) instead of the secret. It is fetched from your secret source when connecting.",
|
||||
"manage": "Manage secret sources",
|
||||
"title": "Secret sources",
|
||||
"description": "External password managers Termix reads secrets from at connect time. Currently 1Password Connect (self-hosted). The access token is stored encrypted with your data key.",
|
||||
"new": "New source",
|
||||
"nameLabel": "Friendly name",
|
||||
"urlLabel": "Connect server URL",
|
||||
"tokenLabel": "Connect access token",
|
||||
"tokenKeep": "Leave empty to keep the current token",
|
||||
"sharedLabel": "Share with all users (admins only, resolves while you are signed in)",
|
||||
"shared": "shared",
|
||||
"test": "Test",
|
||||
"testOk": "Connected, {{count}} vault(s) visible",
|
||||
"testFailed": "Connection test failed",
|
||||
"required": "Name, server URL and token are required",
|
||||
"saved": "Secret source saved",
|
||||
"deleted": "Secret source deleted",
|
||||
"settings": {
|
||||
"admin": {
|
||||
"privateEndpoints": {
|
||||
"label": "Private endpoint allowlist",
|
||||
"description": "Private or loopback hosts a secret source may reach, one per line. A self-hosted 1Password Connect server usually needs its own address listed here."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
{
|
||||
"id": "secret-sources",
|
||||
"name": "Secret Sources",
|
||||
"version": "1.0.0",
|
||||
"description": "Pull host secrets from an external password manager at connect time instead of storing them, using op:// references.",
|
||||
"author": {
|
||||
"name": "Termix"
|
||||
},
|
||||
"license": "MIT",
|
||||
"repository": "https://github.com/Termix-SSH/Termix",
|
||||
"category": "Access & Security",
|
||||
"icon": "KeyRound",
|
||||
"engine": {
|
||||
"termix": ">=2.9.0",
|
||||
"api": "1"
|
||||
},
|
||||
"capabilities": [
|
||||
"db:own",
|
||||
"secrets:own",
|
||||
"network:outbound",
|
||||
"network:serve",
|
||||
"auth:provide",
|
||||
"ui:surface"
|
||||
],
|
||||
"contributes": {
|
||||
"auth": {
|
||||
"secretSchemes": ["op"]
|
||||
},
|
||||
"settings": {
|
||||
"admin": [
|
||||
{
|
||||
"key": "privateEndpoints",
|
||||
"type": "textarea",
|
||||
"labelKey": "settings.admin.privateEndpoints.label",
|
||||
"descriptionKey": "settings.admin.privateEndpoints.description",
|
||||
"default": "localhost\n127.0.0.1\n::1\nhost.docker.internal"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"locales": "locales",
|
||||
"platforms": ["linux", "win32", "darwin"]
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
-- secret-sources 0001: adopt_secret_sources
|
||||
-- Generated by termix-plugin migrations. Review before committing.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `secret_sources` (
|
||||
`id` varchar(255) PRIMARY KEY NOT NULL,
|
||||
`user_id` varchar(255) NOT NULL,
|
||||
`name` text NOT NULL,
|
||||
`kind` text NOT NULL DEFAULT ('onepassword-connect'),
|
||||
`base_url` text NOT NULL,
|
||||
`shared` boolean NOT NULL DEFAULT false,
|
||||
`created_at` text NOT NULL DEFAULT (CURRENT_TIMESTAMP),
|
||||
`updated_at` text NOT NULL DEFAULT (CURRENT_TIMESTAMP),
|
||||
FOREIGN KEY (`user_id`) REFERENCES `users` (`id`) ON DELETE CASCADE
|
||||
);
|
||||
RENAME TABLE `secret_sources` TO `p_secret_sources_sources`;
|
||||
@@ -0,0 +1,16 @@
|
||||
-- secret-sources 0001: adopt_secret_sources
|
||||
-- Generated by termix-plugin migrations. Review before committing.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS "secret_sources" (
|
||||
"id" varchar(255) PRIMARY KEY NOT NULL,
|
||||
"user_id" varchar(255) NOT NULL,
|
||||
"name" text NOT NULL,
|
||||
"kind" text NOT NULL DEFAULT 'onepassword-connect',
|
||||
"base_url" text NOT NULL,
|
||||
"shared" boolean NOT NULL DEFAULT false,
|
||||
"created_at" text NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" text NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY ("user_id") REFERENCES "users" ("id") ON DELETE CASCADE
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS "idx_secret_sources_user" ON "secret_sources" ("user_id");
|
||||
ALTER TABLE "secret_sources" RENAME TO "p_secret_sources_sources";
|
||||
@@ -0,0 +1,55 @@
|
||||
{
|
||||
"version": 1,
|
||||
"tables": {
|
||||
"sources": {
|
||||
"columns": {
|
||||
"id": {
|
||||
"type": "varchar",
|
||||
"length": 255,
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"userId": {
|
||||
"type": "refUser",
|
||||
"length": 255,
|
||||
"notNull": true
|
||||
},
|
||||
"name": {
|
||||
"type": "text",
|
||||
"notNull": true
|
||||
},
|
||||
"kind": {
|
||||
"type": "text",
|
||||
"notNull": true,
|
||||
"defaultValue": "onepassword-connect"
|
||||
},
|
||||
"baseUrl": {
|
||||
"type": "text",
|
||||
"notNull": true
|
||||
},
|
||||
"shared": {
|
||||
"type": "boolean",
|
||||
"notNull": true,
|
||||
"defaultValue": false
|
||||
},
|
||||
"createdAt": {
|
||||
"type": "timestamp",
|
||||
"notNull": true,
|
||||
"defaultNow": true
|
||||
},
|
||||
"updatedAt": {
|
||||
"type": "timestamp",
|
||||
"notNull": true,
|
||||
"defaultNow": true
|
||||
}
|
||||
},
|
||||
"indexes": [
|
||||
{
|
||||
"name": "idx_secret_sources_user",
|
||||
"columns": ["userId"]
|
||||
}
|
||||
],
|
||||
"adopts": "secret_sources"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
-- secret-sources 0001: adopt_secret_sources
|
||||
-- Generated by termix-plugin migrations. Review before committing.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS "secret_sources" (
|
||||
"id" text PRIMARY KEY NOT NULL,
|
||||
"user_id" text NOT NULL,
|
||||
"name" text NOT NULL,
|
||||
"kind" text NOT NULL DEFAULT 'onepassword-connect',
|
||||
"base_url" text NOT NULL,
|
||||
"shared" integer NOT NULL DEFAULT 0,
|
||||
"created_at" text NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" text NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY ("user_id") REFERENCES "users" ("id") ON DELETE CASCADE
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS "idx_secret_sources_user" ON "secret_sources" ("user_id");
|
||||
ALTER TABLE "secret_sources" RENAME TO "p_secret_sources_sources";
|
||||
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"name": "@termix-plugin/secret-sources",
|
||||
"private": true,
|
||||
"version": "1.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"build": "termix-plugin build",
|
||||
"test": "termix-plugin test",
|
||||
"typecheck": "tsc -p tsconfig.json",
|
||||
"validate": "termix-plugin validate"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@termix/plugin-sdk": "*"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
/**
|
||||
* Where a secret source (a self-hosted 1Password Connect server) may be
|
||||
* reached. Every request goes through ctx.fetch, which refuses private and
|
||||
* loopback addresses unless the exact host is listed here, admin-only and
|
||||
* install-wide.
|
||||
*/
|
||||
|
||||
export const DEFAULT_PRIVATE_ALLOWLIST = [
|
||||
"localhost",
|
||||
"127.0.0.1",
|
||||
"::1",
|
||||
"host.docker.internal",
|
||||
];
|
||||
|
||||
/** The admin setting is one host per line. A line that is not a bare host is ignored. */
|
||||
const HOST_PATTERN = /^[a-z0-9._:-]+$/;
|
||||
|
||||
export function parseAllowlist(raw: unknown): string[] {
|
||||
if (typeof raw !== "string") return [...DEFAULT_PRIVATE_ALLOWLIST];
|
||||
const hosts: string[] = [];
|
||||
for (const line of raw.split(/[\r\n,]+/)) {
|
||||
const host = line.trim().toLowerCase();
|
||||
if (host && HOST_PATTERN.test(host) && !hosts.includes(host)) {
|
||||
hosts.push(host);
|
||||
}
|
||||
}
|
||||
return hosts;
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import type { Router } from "express";
|
||||
import type { PluginContext } from "@termix/plugin-sdk/backend";
|
||||
import { sources } from "./tables.js";
|
||||
import { createSecretSourceRepository } from "./repository.js";
|
||||
import { createTokenStore } from "./token-store.js";
|
||||
import { registerSecretSourceRoutes } from "./routes.js";
|
||||
import { registerOnePasswordResolver } from "./resolver.js";
|
||||
|
||||
export async function activate(ctx: PluginContext) {
|
||||
const table = await ctx.db.define(sources);
|
||||
const repository = createSecretSourceRepository(ctx.db, table);
|
||||
const tokenStore = createTokenStore(ctx);
|
||||
|
||||
registerSecretSourceRoutes(
|
||||
ctx.http.router<Router>(),
|
||||
ctx,
|
||||
repository,
|
||||
tokenStore,
|
||||
);
|
||||
registerOnePasswordResolver(ctx, repository, tokenStore);
|
||||
|
||||
ctx.log.info("Secret sources routes mounted at /plugin-api/secret-sources");
|
||||
}
|
||||
|
||||
export async function deactivate() {
|
||||
// Everything above was registered through ctx and is disposed by core.
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
import type { PluginFetch } from "@termix/plugin-sdk/backend";
|
||||
|
||||
/**
|
||||
* The slice of the 1Password Connect REST API needed to resolve a secret
|
||||
* reference: find the vault, find the item, read one field.
|
||||
* https://developer.1password.com/docs/connect/api-reference
|
||||
*/
|
||||
|
||||
export interface SecretReference {
|
||||
vault: string;
|
||||
item: string;
|
||||
field: string;
|
||||
}
|
||||
|
||||
const FETCH_TIMEOUT_MS = 10_000;
|
||||
|
||||
/** op://<vault>/<item>/<field> (a trailing ?query, as in ?ssh-format=openssh, is ignored) */
|
||||
export function parseSecretReference(raw: string): SecretReference | null {
|
||||
const match = /^op:\/\/([^/]+)\/([^/]+)\/([^/?]+)(?:\?.*)?$/.exec(raw.trim());
|
||||
if (!match) return null;
|
||||
const [, vault, item, field] = match.map((part) => decodeURIComponent(part));
|
||||
return { vault, item, field };
|
||||
}
|
||||
|
||||
export interface ConnectSource {
|
||||
baseUrl: string;
|
||||
token: string;
|
||||
allowedPrivateHosts: readonly string[];
|
||||
}
|
||||
|
||||
async function connectGet<T>(
|
||||
fetch: PluginFetch,
|
||||
source: ConnectSource,
|
||||
path: string,
|
||||
): Promise<T> {
|
||||
const base = source.baseUrl.replace(/\/+$/, "");
|
||||
const response = await fetch(`${base}${path}`, {
|
||||
method: "GET",
|
||||
headers: { Authorization: `Bearer ${source.token}` },
|
||||
timeoutMs: FETCH_TIMEOUT_MS,
|
||||
allowPrivateHosts: source.allowedPrivateHosts,
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(
|
||||
`1Password Connect ${path} failed: HTTP ${response.status}`,
|
||||
);
|
||||
}
|
||||
return (await response.json()) as T;
|
||||
}
|
||||
|
||||
function eqFilter(value: string): string {
|
||||
return encodeURIComponent(`title eq "${value.replace(/"/g, '\\"')}"`);
|
||||
}
|
||||
|
||||
interface ConnectVault {
|
||||
id: string;
|
||||
name: string;
|
||||
}
|
||||
interface ConnectItemSummary {
|
||||
id: string;
|
||||
title: string;
|
||||
}
|
||||
interface ConnectField {
|
||||
id: string;
|
||||
label?: string;
|
||||
purpose?: string;
|
||||
type?: string;
|
||||
value?: string;
|
||||
}
|
||||
|
||||
/** Reachability + token validity: the vault list needs a valid token. */
|
||||
export async function testConnectSource(
|
||||
fetch: PluginFetch,
|
||||
source: ConnectSource,
|
||||
): Promise<number> {
|
||||
const vaults = await connectGet<ConnectVault[]>(fetch, source, "/v1/vaults");
|
||||
return vaults.length;
|
||||
}
|
||||
|
||||
const byIdOrName =
|
||||
(name: string) =>
|
||||
(candidate: { id: string; name?: string; title?: string }) =>
|
||||
candidate.id === name ||
|
||||
(candidate.name ?? candidate.title ?? "").toLowerCase() ===
|
||||
name.toLowerCase();
|
||||
|
||||
export async function resolveConnectReference(
|
||||
fetch: PluginFetch,
|
||||
source: ConnectSource,
|
||||
ref: SecretReference,
|
||||
): Promise<string> {
|
||||
const vaults = await connectGet<ConnectVault[]>(
|
||||
fetch,
|
||||
source,
|
||||
`/v1/vaults?filter=${eqFilter(ref.vault)}`,
|
||||
);
|
||||
const vault =
|
||||
vaults.find(byIdOrName(ref.vault)) ??
|
||||
(await connectGet<ConnectVault[]>(fetch, source, "/v1/vaults")).find(
|
||||
byIdOrName(ref.vault),
|
||||
);
|
||||
if (!vault) throw new Error(`1Password vault "${ref.vault}" not found`);
|
||||
|
||||
const items = await connectGet<ConnectItemSummary[]>(
|
||||
fetch,
|
||||
source,
|
||||
`/v1/vaults/${vault.id}/items?filter=${eqFilter(ref.item)}`,
|
||||
);
|
||||
const summary =
|
||||
items.find(byIdOrName(ref.item)) ??
|
||||
(ref.item.length >= 26 ? { id: ref.item, title: ref.item } : undefined);
|
||||
if (!summary) {
|
||||
throw new Error(`1Password item "${ref.item}" not found in "${ref.vault}"`);
|
||||
}
|
||||
|
||||
const item = await connectGet<{ fields?: ConnectField[] }>(
|
||||
fetch,
|
||||
source,
|
||||
`/v1/vaults/${vault.id}/items/${summary.id}`,
|
||||
);
|
||||
const wanted = ref.field.toLowerCase();
|
||||
const field = (item.fields ?? []).find(
|
||||
(f) =>
|
||||
f.id === ref.field ||
|
||||
f.label?.toLowerCase() === wanted ||
|
||||
f.purpose?.toLowerCase() === wanted ||
|
||||
(wanted === "private key" && f.id === "private_key") ||
|
||||
(wanted === "private_key" && f.label?.toLowerCase() === "private key"),
|
||||
);
|
||||
if (!field || field.value === undefined) {
|
||||
throw new Error(
|
||||
`1Password field "${ref.field}" not found on "${ref.item}"`,
|
||||
);
|
||||
}
|
||||
return field.value;
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
import { eq, or } from "drizzle-orm";
|
||||
import type { PluginDatabase } from "@termix/plugin-sdk/backend";
|
||||
|
||||
export type SecretSourceKind = "onepassword-connect";
|
||||
|
||||
export interface SecretSourceRecord {
|
||||
id: string;
|
||||
userId: string;
|
||||
name: string;
|
||||
kind: SecretSourceKind;
|
||||
baseUrl: string;
|
||||
shared: boolean;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface SecretSourceCreateInput {
|
||||
id: string;
|
||||
userId: string;
|
||||
name: string;
|
||||
kind: SecretSourceKind;
|
||||
baseUrl: string;
|
||||
shared: boolean;
|
||||
}
|
||||
|
||||
export interface SecretSourceUpdateInput {
|
||||
name?: string;
|
||||
baseUrl?: string;
|
||||
shared?: boolean;
|
||||
}
|
||||
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
// The table comes from ctx.db.define, which the SDK hands back untyped, and
|
||||
// the drizzle handle is the server's own. Typed at this module's edge instead.
|
||||
type Table = any;
|
||||
type Drizzle = any;
|
||||
/* eslint-enable @typescript-eslint/no-explicit-any */
|
||||
|
||||
export type SecretSourceRepository = ReturnType<
|
||||
typeof createSecretSourceRepository
|
||||
>;
|
||||
|
||||
/**
|
||||
* Per-user secret source storage. The token is never in this table - it
|
||||
* lives in ctx.secrets under "source:<id>". A row with an id the caller
|
||||
* chose is written without RETURNING so it runs the same on all three
|
||||
* engines.
|
||||
*/
|
||||
export function createSecretSourceRepository(db: PluginDatabase, table: Table) {
|
||||
const client = () => db.client<Drizzle>();
|
||||
|
||||
async function findById(id: string): Promise<SecretSourceRecord | null> {
|
||||
const drizzle = await client();
|
||||
const rows = await drizzle
|
||||
.select()
|
||||
.from(table)
|
||||
.where(eq(table.id, id))
|
||||
.limit(1);
|
||||
return (rows[0] as SecretSourceRecord) ?? null;
|
||||
}
|
||||
|
||||
return {
|
||||
findById,
|
||||
|
||||
async listVisibleToUser(userId: string): Promise<SecretSourceRecord[]> {
|
||||
const drizzle = await client();
|
||||
return drizzle
|
||||
.select()
|
||||
.from(table)
|
||||
.where(or(eq(table.userId, userId), eq(table.shared, true)));
|
||||
},
|
||||
|
||||
async create(
|
||||
input: SecretSourceCreateInput,
|
||||
now = new Date().toISOString(),
|
||||
): Promise<SecretSourceRecord> {
|
||||
const drizzle = await client();
|
||||
await drizzle.insert(table).values({
|
||||
id: input.id,
|
||||
userId: input.userId,
|
||||
name: input.name,
|
||||
kind: input.kind,
|
||||
baseUrl: input.baseUrl,
|
||||
shared: input.shared,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
});
|
||||
await db.persist();
|
||||
return (await findById(input.id)) as SecretSourceRecord;
|
||||
},
|
||||
|
||||
async update(
|
||||
row: SecretSourceRecord,
|
||||
input: SecretSourceUpdateInput,
|
||||
now = new Date().toISOString(),
|
||||
): Promise<void> {
|
||||
const drizzle = await client();
|
||||
await drizzle
|
||||
.update(table)
|
||||
.set({
|
||||
...(input.name !== undefined ? { name: input.name } : {}),
|
||||
...(input.baseUrl !== undefined ? { baseUrl: input.baseUrl } : {}),
|
||||
...(input.shared !== undefined ? { shared: input.shared } : {}),
|
||||
updatedAt: now,
|
||||
})
|
||||
.where(eq(table.id, row.id));
|
||||
await db.persist();
|
||||
},
|
||||
|
||||
async deleteById(id: string): Promise<void> {
|
||||
const drizzle = await client();
|
||||
await drizzle.delete(table).where(eq(table.id, id));
|
||||
await db.persist();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import type { PluginContext } from "@termix/plugin-sdk/backend";
|
||||
import type { SecretSourceRepository } from "./repository.js";
|
||||
import type { TokenStore } from "./token-store.js";
|
||||
import { parseAllowlist } from "./egress.js";
|
||||
import {
|
||||
parseSecretReference,
|
||||
resolveConnectReference,
|
||||
} from "./onepassword-connect.js";
|
||||
|
||||
/** The user's own source first, else a shared one. */
|
||||
async function pickSecretSource(
|
||||
repository: SecretSourceRepository,
|
||||
userId: string,
|
||||
) {
|
||||
const sources = await repository.listVisibleToUser(userId);
|
||||
return (
|
||||
sources.find((source) => source.userId === userId) ?? sources[0] ?? null
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The "op" scheme resolver: turns "op://vault/item/field" into the actual
|
||||
* secret, using whichever source the acting user can see (their own first,
|
||||
* else a shared one).
|
||||
*/
|
||||
export function registerOnePasswordResolver(
|
||||
ctx: PluginContext,
|
||||
repository: SecretSourceRepository,
|
||||
tokenStore: TokenStore,
|
||||
): void {
|
||||
ctx.credentials.registerSecretResolver("op", async (userId, reference) => {
|
||||
const ref = parseSecretReference(reference);
|
||||
if (!ref) throw new Error(`Invalid secret reference: ${reference}`);
|
||||
|
||||
const source = await pickSecretSource(repository, userId);
|
||||
if (!source) {
|
||||
throw new Error(
|
||||
"This host uses a secret reference but no secret source is configured",
|
||||
);
|
||||
}
|
||||
|
||||
const token = await tokenStore.getForOwner(source.id, source.userId);
|
||||
if (!token) {
|
||||
throw new Error(
|
||||
"The secret source owner's data is locked; they need to sign in first",
|
||||
);
|
||||
}
|
||||
|
||||
return resolveConnectReference(
|
||||
ctx.fetch,
|
||||
{
|
||||
baseUrl: source.baseUrl,
|
||||
token,
|
||||
allowedPrivateHosts: parseAllowlist(
|
||||
await ctx.settings.get<string>("privateEndpoints"),
|
||||
),
|
||||
},
|
||||
ref,
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,255 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import type { Request, Response, Router } from "express";
|
||||
import type { PluginContext } from "@termix/plugin-sdk/backend";
|
||||
import type { SecretSourceRepository } from "./repository.js";
|
||||
import type { TokenStore } from "./token-store.js";
|
||||
import { testConnectSource } from "./onepassword-connect.js";
|
||||
import { parseAllowlist } from "./egress.js";
|
||||
|
||||
const KINDS = ["onepassword-connect"] as const;
|
||||
|
||||
function isNonEmptyString(value: unknown): value is string {
|
||||
return typeof value === "string" && value.trim().length > 0;
|
||||
}
|
||||
|
||||
function validBaseUrl(raw: unknown): string | null {
|
||||
if (!isNonEmptyString(raw)) return null;
|
||||
try {
|
||||
const url = new URL(raw.trim());
|
||||
if (!["http:", "https:"].includes(url.protocol)) return null;
|
||||
return url.toString().replace(/\/+$/, "");
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function errorMessage(error: unknown): string {
|
||||
return error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
|
||||
export function registerSecretSourceRoutes(
|
||||
router: Router,
|
||||
ctx: PluginContext,
|
||||
repository: SecretSourceRepository,
|
||||
tokenStore: TokenStore,
|
||||
): void {
|
||||
const isAdmin = () => ctx.rbac.has("admin.plugins.manage");
|
||||
const allowedPrivateHosts = async () =>
|
||||
parseAllowlist(await ctx.settings.get<string>("privateEndpoints"));
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /plugin-api/secret-sources:
|
||||
* get:
|
||||
* summary: List secret sources visible to the caller (own + shared)
|
||||
* tags:
|
||||
* - Secret Sources
|
||||
*/
|
||||
router.get(
|
||||
"/",
|
||||
ctx.rbac.require("credentials.view") as never,
|
||||
async (req: Request, res: Response) => {
|
||||
const userId = ctx.currentActor()!;
|
||||
try {
|
||||
const rows = await repository.listVisibleToUser(userId);
|
||||
res.json({
|
||||
sources: rows.map((row) => ({
|
||||
...row,
|
||||
owned: row.userId === userId,
|
||||
})),
|
||||
});
|
||||
} catch (error) {
|
||||
ctx.log.error("Failed to list secret sources", error);
|
||||
res.status(500).json({ error: "Failed to list secret sources" });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /plugin-api/secret-sources:
|
||||
* post:
|
||||
* summary: Create a secret source (1Password Connect)
|
||||
* description: Sharing a source with every user requires admin. The token is encrypted with the owner's data key.
|
||||
* tags:
|
||||
* - Secret Sources
|
||||
*/
|
||||
router.post(
|
||||
"/",
|
||||
ctx.rbac.require("credentials.create") as never,
|
||||
async (req: Request, res: Response) => {
|
||||
const userId = ctx.currentActor()!;
|
||||
const {
|
||||
name,
|
||||
kind = "onepassword-connect",
|
||||
baseUrl,
|
||||
token,
|
||||
shared,
|
||||
} = req.body ?? {};
|
||||
const url = validBaseUrl(baseUrl);
|
||||
if (!isNonEmptyString(name) || !url || !isNonEmptyString(token)) {
|
||||
return res.status(400).json({
|
||||
error: "name, a valid http(s) baseUrl and token are required",
|
||||
});
|
||||
}
|
||||
if (!(KINDS as readonly string[]).includes(kind)) {
|
||||
return res
|
||||
.status(400)
|
||||
.json({ error: "Unsupported secret source kind" });
|
||||
}
|
||||
if (shared === true && !(await isAdmin())) {
|
||||
return res
|
||||
.status(403)
|
||||
.json({ error: "Only admins can share a secret source" });
|
||||
}
|
||||
try {
|
||||
const id = randomUUID();
|
||||
const created = await repository.create({
|
||||
id,
|
||||
userId,
|
||||
name: name.trim(),
|
||||
kind,
|
||||
baseUrl: url,
|
||||
shared: shared === true,
|
||||
});
|
||||
await tokenStore.set(id, token.trim());
|
||||
await ctx.audit.record({
|
||||
action: "secret_source_create",
|
||||
resourceType: "secret_source",
|
||||
resourceId: created.id,
|
||||
resourceName: created.name,
|
||||
success: true,
|
||||
});
|
||||
res.json({ source: { ...created, owned: true } });
|
||||
} catch (error) {
|
||||
ctx.log.error("Failed to create secret source", error);
|
||||
res.status(500).json({ error: "Failed to create secret source" });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /plugin-api/secret-sources/{id}:
|
||||
* put:
|
||||
* summary: Update a secret source (owner only; omit token to keep it)
|
||||
* tags:
|
||||
* - Secret Sources
|
||||
*/
|
||||
router.put(
|
||||
"/:id",
|
||||
ctx.rbac.require("credentials.edit") as never,
|
||||
async (req: Request, res: Response) => {
|
||||
const userId = ctx.currentActor()!;
|
||||
const { name, baseUrl, token, shared } = req.body ?? {};
|
||||
try {
|
||||
const source = await repository.findById(String(req.params.id));
|
||||
if (!source) {
|
||||
return res.status(404).json({ error: "Secret source not found" });
|
||||
}
|
||||
if (source.userId !== userId) {
|
||||
return res
|
||||
.status(403)
|
||||
.json({ error: "Only the owner can change this source" });
|
||||
}
|
||||
const url = baseUrl === undefined ? undefined : validBaseUrl(baseUrl);
|
||||
if (baseUrl !== undefined && !url) {
|
||||
return res.status(400).json({ error: "baseUrl must be http(s)" });
|
||||
}
|
||||
if (shared === true && !source.shared && !(await isAdmin())) {
|
||||
return res
|
||||
.status(403)
|
||||
.json({ error: "Only admins can share a secret source" });
|
||||
}
|
||||
await repository.update(source, {
|
||||
...(isNonEmptyString(name) ? { name: name.trim() } : {}),
|
||||
...(url ? { baseUrl: url } : {}),
|
||||
...(typeof shared === "boolean" ? { shared } : {}),
|
||||
});
|
||||
if (isNonEmptyString(token)) {
|
||||
await tokenStore.set(source.id, token.trim());
|
||||
}
|
||||
res.json({ success: true });
|
||||
} catch (error) {
|
||||
ctx.log.error("Failed to update secret source", error);
|
||||
res.status(500).json({ error: "Failed to update secret source" });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /plugin-api/secret-sources/{id}:
|
||||
* delete:
|
||||
* summary: Delete a secret source (owner only)
|
||||
* tags:
|
||||
* - Secret Sources
|
||||
*/
|
||||
router.delete(
|
||||
"/:id",
|
||||
ctx.rbac.require("credentials.delete") as never,
|
||||
async (req: Request, res: Response) => {
|
||||
const userId = ctx.currentActor()!;
|
||||
try {
|
||||
const source = await repository.findById(String(req.params.id));
|
||||
if (!source) {
|
||||
return res.status(404).json({ error: "Secret source not found" });
|
||||
}
|
||||
if (source.userId !== userId) {
|
||||
return res
|
||||
.status(403)
|
||||
.json({ error: "Only the owner can change this source" });
|
||||
}
|
||||
await repository.deleteById(source.id);
|
||||
await tokenStore.clear(source.id);
|
||||
await ctx.audit.record({
|
||||
action: "secret_source_delete",
|
||||
resourceType: "secret_source",
|
||||
resourceId: source.id,
|
||||
resourceName: source.name,
|
||||
success: true,
|
||||
});
|
||||
res.json({ success: true });
|
||||
} catch (error) {
|
||||
ctx.log.error("Failed to delete secret source", error);
|
||||
res.status(500).json({ error: "Failed to delete secret source" });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /plugin-api/secret-sources/{id}/test:
|
||||
* post:
|
||||
* summary: Check that the source is reachable and the token is accepted
|
||||
* tags:
|
||||
* - Secret Sources
|
||||
*/
|
||||
router.post(
|
||||
"/:id/test",
|
||||
ctx.rbac.require("credentials.view") as never,
|
||||
async (req: Request, res: Response) => {
|
||||
const userId = ctx.currentActor()!;
|
||||
try {
|
||||
const source = await repository.findById(String(req.params.id));
|
||||
if (!source || (source.userId !== userId && !source.shared)) {
|
||||
return res.status(404).json({ error: "Secret source not found" });
|
||||
}
|
||||
const token = await tokenStore.getForOwner(source.id, source.userId);
|
||||
if (!token) {
|
||||
return res
|
||||
.status(200)
|
||||
.json({ ok: false, error: "No token stored for this source" });
|
||||
}
|
||||
const vaults = await testConnectSource(ctx.fetch, {
|
||||
baseUrl: source.baseUrl,
|
||||
token,
|
||||
allowedPrivateHosts: await allowedPrivateHosts(),
|
||||
});
|
||||
res.json({ ok: true, vaults });
|
||||
} catch (error) {
|
||||
res.status(200).json({ ok: false, error: errorMessage(error) });
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import {
|
||||
adoptLegacyTable,
|
||||
boolean,
|
||||
defineTable,
|
||||
refUser,
|
||||
text,
|
||||
timestamp,
|
||||
varchar,
|
||||
} from "@termix/plugin-sdk/db";
|
||||
|
||||
/**
|
||||
* Adopted from core's secret_sources, so column and index names are the
|
||||
* legacy ones and the rename carries every row across.
|
||||
*
|
||||
* The access token is not a column here: it lives in ctx.secrets under
|
||||
* "source:<id>", the same move the ai plugin made for provider API keys. A
|
||||
* 2.8 database still has a token column, which the boot migration empties
|
||||
* after moving the value.
|
||||
*/
|
||||
export const sources = adoptLegacyTable(
|
||||
"secret_sources",
|
||||
defineTable(
|
||||
"sources",
|
||||
{
|
||||
id: varchar().primaryKey(),
|
||||
userId: refUser(),
|
||||
name: text().notNull(),
|
||||
// "onepassword-connect" for now; the reference syntax is per kind.
|
||||
kind: text().notNull().default("onepassword-connect"),
|
||||
baseUrl: text().notNull(),
|
||||
// Visible to every user; the token still decrypts with the owner's key.
|
||||
shared: boolean().notNull().default(false),
|
||||
createdAt: timestamp().notNull().defaultNow(),
|
||||
updatedAt: timestamp().notNull().defaultNow(),
|
||||
},
|
||||
{
|
||||
indexes: [{ name: "idx_secret_sources_user", columns: ["userId"] }],
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
export const tables = [sources];
|
||||
@@ -0,0 +1,37 @@
|
||||
import type { PluginContext } from "@termix/plugin-sdk/backend";
|
||||
|
||||
/**
|
||||
* The access token for one source, kept out of the sources table and in
|
||||
* ctx.secrets instead, the same move the ai plugin made for provider API
|
||||
* keys: encrypted with the installation key rather than the owner's data
|
||||
* key, so it is readable whenever the plugin runs rather than only while the
|
||||
* owner is signed in. A shared source's token is still only ever handed to
|
||||
* its owner's part of the resolve, so a read for any other acting user runs
|
||||
* as the owner through ctx.asUser.
|
||||
*/
|
||||
|
||||
function tokenKey(sourceId: string): string {
|
||||
return `source:${sourceId}`;
|
||||
}
|
||||
|
||||
export function createTokenStore(ctx: PluginContext) {
|
||||
return {
|
||||
async set(sourceId: string, token: string): Promise<void> {
|
||||
await ctx.secrets.set(tokenKey(sourceId), token);
|
||||
},
|
||||
|
||||
async clear(sourceId: string): Promise<void> {
|
||||
await ctx.secrets.delete(tokenKey(sourceId));
|
||||
},
|
||||
|
||||
/** Reads the token as the source's owner, whoever is currently acting. */
|
||||
async getForOwner(
|
||||
sourceId: string,
|
||||
ownerId: string,
|
||||
): Promise<string | null> {
|
||||
return ctx.asUser(ownerId, () => ctx.secrets.get(tokenKey(sourceId)));
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export type TokenStore = ReturnType<typeof createTokenStore>;
|
||||
@@ -0,0 +1,18 @@
|
||||
import { useTranslation } from "@termix/plugin-sdk/frontend";
|
||||
|
||||
/** One line under a secret field: references are allowed, here is where to set them up. */
|
||||
export function SecretReferenceHint({ onManage }: { onManage: () => void }) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<p className="text-[10px] text-muted-foreground">
|
||||
{t("hint")}{" "}
|
||||
<button
|
||||
type="button"
|
||||
className="text-accent-brand hover:underline"
|
||||
onClick={onManage}
|
||||
>
|
||||
{t("manage")}
|
||||
</button>
|
||||
</p>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,277 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { KeyRound, Pencil, Plus, Trash2, X } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { Button, Input, PasswordInput } from "@termix/plugin-sdk/ui";
|
||||
import { usePluginApi, useTranslation } from "@termix/plugin-sdk/frontend";
|
||||
import {
|
||||
createSecretSourcesApi,
|
||||
type SecretSource,
|
||||
} from "./secret-sources-api";
|
||||
|
||||
function errorMessage(error: unknown): string {
|
||||
if (error && typeof error === "object" && "message" in error) {
|
||||
return String((error as { message?: unknown }).message ?? "");
|
||||
}
|
||||
return String(error);
|
||||
}
|
||||
|
||||
type FormState = {
|
||||
id?: string;
|
||||
name: string;
|
||||
baseUrl: string;
|
||||
token: string;
|
||||
shared: boolean;
|
||||
};
|
||||
|
||||
const emptyForm: FormState = {
|
||||
name: "",
|
||||
baseUrl: "",
|
||||
token: "",
|
||||
shared: false,
|
||||
};
|
||||
|
||||
export function SecretSourceManager({ onClose }: { onClose: () => void }) {
|
||||
const { t } = useTranslation();
|
||||
const pluginApi = usePluginApi();
|
||||
const api = useMemo(() => createSecretSourcesApi(pluginApi), [pluginApi]);
|
||||
const [sources, setSources] = useState<SecretSource[]>([]);
|
||||
const [form, setForm] = useState<FormState | null>(null);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [testing, setTesting] = useState<string | null>(null);
|
||||
|
||||
const reload = useCallback(async () => {
|
||||
try {
|
||||
setSources(await api.list());
|
||||
} catch (e) {
|
||||
toast.error(errorMessage(e));
|
||||
}
|
||||
}, [api]);
|
||||
|
||||
useEffect(() => {
|
||||
void reload();
|
||||
}, [reload]);
|
||||
|
||||
const setField = <K extends keyof FormState>(key: K, value: FormState[K]) =>
|
||||
setForm((prev) => (prev ? { ...prev, [key]: value } : prev));
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!form) return;
|
||||
if (
|
||||
!form.name.trim() ||
|
||||
!form.baseUrl.trim() ||
|
||||
(!form.id && !form.token)
|
||||
) {
|
||||
toast.error(t("required"));
|
||||
return;
|
||||
}
|
||||
setSaving(true);
|
||||
try {
|
||||
if (form.id) {
|
||||
await api.update(form.id, {
|
||||
name: form.name,
|
||||
baseUrl: form.baseUrl,
|
||||
shared: form.shared,
|
||||
...(form.token ? { token: form.token } : {}),
|
||||
});
|
||||
} else {
|
||||
await api.create({
|
||||
name: form.name,
|
||||
baseUrl: form.baseUrl,
|
||||
token: form.token,
|
||||
shared: form.shared,
|
||||
});
|
||||
}
|
||||
toast.success(t("saved"));
|
||||
setForm(null);
|
||||
await reload();
|
||||
} catch (e) {
|
||||
toast.error(errorMessage(e));
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (source: SecretSource) => {
|
||||
try {
|
||||
await api.remove(source.id);
|
||||
toast.success(t("deleted"));
|
||||
await reload();
|
||||
} catch (e) {
|
||||
toast.error(errorMessage(e));
|
||||
}
|
||||
};
|
||||
|
||||
const handleTest = async (source: SecretSource) => {
|
||||
setTesting(source.id);
|
||||
try {
|
||||
const result = await api.test(source.id);
|
||||
if (result.ok) {
|
||||
toast.success(t("testOk", { count: result.vaults ?? 0 }));
|
||||
} else {
|
||||
toast.error(result.error ?? t("testFailed"));
|
||||
}
|
||||
} catch (e) {
|
||||
toast.error(errorMessage(e));
|
||||
} finally {
|
||||
setTesting(null);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3 col-span-2 border border-border bg-muted/20 p-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
|
||||
{t("title")}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<X className="size-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
<p className="text-[10px] text-muted-foreground">{t("description")}</p>
|
||||
|
||||
{!form && (
|
||||
<>
|
||||
{sources.map((source) => (
|
||||
<div
|
||||
key={source.id}
|
||||
className="flex items-center gap-2 border border-border bg-background px-2 py-1.5 text-xs"
|
||||
>
|
||||
<KeyRound className="size-3.5 text-muted-foreground shrink-0" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="truncate">
|
||||
{source.name}
|
||||
{source.shared && (
|
||||
<span className="ml-1 text-[9px] uppercase text-muted-foreground">
|
||||
{t("shared")}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="truncate text-[10px] text-muted-foreground">
|
||||
{source.baseUrl}
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 text-[10px]"
|
||||
disabled={testing === source.id}
|
||||
onClick={() => void handleTest(source)}
|
||||
>
|
||||
{t("test")}
|
||||
</Button>
|
||||
{source.owned && (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
className="text-muted-foreground hover:text-foreground"
|
||||
onClick={() =>
|
||||
setForm({
|
||||
id: source.id,
|
||||
name: source.name,
|
||||
baseUrl: source.baseUrl,
|
||||
token: "",
|
||||
shared: source.shared,
|
||||
})
|
||||
}
|
||||
>
|
||||
<Pencil className="size-3.5" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="text-muted-foreground hover:text-destructive"
|
||||
onClick={() => void handleDelete(source)}
|
||||
>
|
||||
<Trash2 className="size-3.5" />
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="self-start border-accent-brand/40 text-accent-brand"
|
||||
onClick={() => setForm(emptyForm)}
|
||||
>
|
||||
<Plus className="size-3 mr-1" /> {t("new")}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
|
||||
{form && (
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-2">
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-[9px] font-bold uppercase tracking-widest text-muted-foreground">
|
||||
{t("nameLabel")}
|
||||
</label>
|
||||
<Input
|
||||
className="h-8 text-xs"
|
||||
placeholder="Team 1Password"
|
||||
value={form.name}
|
||||
onChange={(e) => setField("name", e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-[9px] font-bold uppercase tracking-widest text-muted-foreground">
|
||||
{t("urlLabel")}
|
||||
</label>
|
||||
<Input
|
||||
className="h-8 text-xs"
|
||||
placeholder="https://connect.internal:8080"
|
||||
value={form.baseUrl}
|
||||
onChange={(e) => setField("baseUrl", e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-[9px] font-bold uppercase tracking-widest text-muted-foreground">
|
||||
{t("tokenLabel")}
|
||||
</label>
|
||||
<PasswordInput
|
||||
className="h-8 text-xs pr-8"
|
||||
placeholder={form.id ? t("tokenKeep") : "eyJhbGciOi..."}
|
||||
value={form.token}
|
||||
onChange={(e) => setField("token", e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<label className="flex items-center gap-2 text-xs text-foreground">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={form.shared}
|
||||
onChange={(e) => setField("shared", e.target.checked)}
|
||||
/>
|
||||
{t("sharedLabel")}
|
||||
</label>
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setForm(null)}
|
||||
disabled={saving}
|
||||
>
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="border-accent-brand/40 text-accent-brand"
|
||||
onClick={() => void handleSave()}
|
||||
disabled={saving}
|
||||
>
|
||||
{form.id ? t("common.save") : t("common.create")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import type { TermixApp } from "@termix/plugin-sdk/frontend";
|
||||
import { SecretSourceManager } from "./SecretSourceManager";
|
||||
import { SecretReferenceHint } from "./SecretReferenceHint";
|
||||
|
||||
export function activate(app: TermixApp): void {
|
||||
app.registerComponent("secret-sources.hint", SecretReferenceHint);
|
||||
app.registerComponent("secret-sources.manager", SecretSourceManager);
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import type { PluginApiClient } from "@termix/plugin-sdk/frontend";
|
||||
|
||||
export type SecretSourceKind = "onepassword-connect";
|
||||
|
||||
export interface SecretSource {
|
||||
id: string;
|
||||
name: string;
|
||||
kind: SecretSourceKind;
|
||||
baseUrl: string;
|
||||
shared: boolean;
|
||||
owned: boolean;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface SecretSourceInput {
|
||||
name: string;
|
||||
baseUrl: string;
|
||||
token: string;
|
||||
shared?: boolean;
|
||||
}
|
||||
|
||||
export interface SecretSourceUpdateInput {
|
||||
name?: string;
|
||||
baseUrl?: string;
|
||||
token?: string;
|
||||
shared?: boolean;
|
||||
}
|
||||
|
||||
/** The secret-sources routes, through the plugin's own client. */
|
||||
export function createSecretSourcesApi(api: PluginApiClient) {
|
||||
const data = async <T>(request: Promise<{ data: T }>) => (await request).data;
|
||||
|
||||
return {
|
||||
list: () =>
|
||||
data(api.get<{ sources: SecretSource[] }>("/")).then((r) => r.sources),
|
||||
create: (body: SecretSourceInput) =>
|
||||
data(api.post<{ source: SecretSource }>("/", body)).then((r) => r.source),
|
||||
update: (id: string, body: SecretSourceUpdateInput) =>
|
||||
data(api.put<{ success: boolean }>(`/${id}`, body)),
|
||||
remove: (id: string) => data(api.delete<{ success: boolean }>(`/${id}`)),
|
||||
test: (id: string) =>
|
||||
data(
|
||||
api.post<{ ok: boolean; vaults?: number; error?: string }>(
|
||||
`/${id}/test`,
|
||||
),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
export type SecretSourcesApi = ReturnType<typeof createSecretSourcesApi>;
|
||||
@@ -0,0 +1,103 @@
|
||||
import http from "node:http";
|
||||
import type { AddressInfo } from "node:net";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import express, { type Router } from "express";
|
||||
import {
|
||||
createMockCtx,
|
||||
createTestDb,
|
||||
type MockPluginContext,
|
||||
type TestDb,
|
||||
} from "@termix/plugin-sdk/testing";
|
||||
import type { PluginManifest } from "@termix/plugin-sdk/manifest";
|
||||
import manifestJson from "../../manifest.json";
|
||||
|
||||
export const pluginDir = fileURLToPath(new URL("../..", import.meta.url));
|
||||
export const manifest = manifestJson as unknown as PluginManifest;
|
||||
|
||||
export const ALL_PERMISSIONS = [
|
||||
"credentials.view",
|
||||
"credentials.create",
|
||||
"credentials.edit",
|
||||
"credentials.delete",
|
||||
];
|
||||
|
||||
export interface TestServer {
|
||||
db: TestDb;
|
||||
mock: MockPluginContext;
|
||||
request: (
|
||||
method: string,
|
||||
path: string,
|
||||
options?: { user?: string; body?: unknown },
|
||||
// Route bodies vary per test; asserting on them is the point.
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
) => Promise<{ status: number; body: any }>;
|
||||
close: () => Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* The plugin activated against a real in-memory database, its router mounted
|
||||
* on an express app the way core mounts it: JSON parsed, the acting user set
|
||||
* from the request, everything else left to the plugin.
|
||||
*/
|
||||
export async function startServer(
|
||||
options: {
|
||||
permissions?: string[];
|
||||
users?: string[];
|
||||
fetch?: (url: string, init?: unknown) => Promise<Response>;
|
||||
} = {},
|
||||
): Promise<TestServer> {
|
||||
const db = await createTestDb(pluginDir);
|
||||
for (const user of options.users ?? ["user-1", "user-2"]) {
|
||||
db.sqlite
|
||||
.prepare("INSERT INTO users (id, username) VALUES (?, ?)")
|
||||
.run(user, user);
|
||||
}
|
||||
|
||||
let router: Router | null = null;
|
||||
const mock = createMockCtx({
|
||||
pluginId: manifest.id,
|
||||
manifest,
|
||||
capabilities: manifest.capabilities,
|
||||
db: db.database,
|
||||
router: () => (router = express.Router()),
|
||||
permissions: options.permissions ?? ALL_PERMISSIONS,
|
||||
fetch: options.fetch as never,
|
||||
});
|
||||
|
||||
const { activate } = await import("../../src/backend/index.js");
|
||||
await activate(mock.ctx);
|
||||
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use((req, _res, next) => {
|
||||
mock.setActor(req.header("x-test-user") ?? undefined);
|
||||
next();
|
||||
});
|
||||
app.use((req, res, next) => router!(req, res, next));
|
||||
|
||||
const server = http.createServer(app);
|
||||
await new Promise<void>((resolve) => server.listen(0, resolve));
|
||||
const { port } = server.address() as AddressInfo;
|
||||
|
||||
return {
|
||||
db,
|
||||
mock,
|
||||
async request(method, path, { user = "user-1", body: given } = {}) {
|
||||
const body = method === "GET" ? undefined : given;
|
||||
const response = await fetch(`http://127.0.0.1:${port}${path}`, {
|
||||
method,
|
||||
headers: {
|
||||
"x-test-user": user,
|
||||
...(body !== undefined ? { "content-type": "application/json" } : {}),
|
||||
},
|
||||
body: body !== undefined ? JSON.stringify(body) : undefined,
|
||||
});
|
||||
const text = await response.text();
|
||||
return { status: response.status, body: text ? JSON.parse(text) : null };
|
||||
},
|
||||
async close() {
|
||||
await new Promise<void>((resolve) => server.close(() => resolve()));
|
||||
db.close();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
parseSecretReference,
|
||||
resolveConnectReference,
|
||||
testConnectSource,
|
||||
} from "../../src/backend/onepassword-connect.js";
|
||||
import type { PluginFetch } from "@termix/plugin-sdk/backend";
|
||||
|
||||
function jsonResponse(body: unknown, ok = true, status = 200): Response {
|
||||
return {
|
||||
ok,
|
||||
status,
|
||||
json: async () => body,
|
||||
} as unknown as Response;
|
||||
}
|
||||
|
||||
describe("1Password secret references", () => {
|
||||
it("parses op://vault/item/field, ignoring a query suffix", () => {
|
||||
expect(parseSecretReference("op://Infra/prod-db/password")).toEqual({
|
||||
vault: "Infra",
|
||||
item: "prod-db",
|
||||
field: "password",
|
||||
});
|
||||
expect(
|
||||
parseSecretReference(
|
||||
"op://Infra/deploy key/private key?ssh-format=openssh",
|
||||
),
|
||||
).toEqual({ vault: "Infra", item: "deploy key", field: "private key" });
|
||||
expect(parseSecretReference("op://Infra/only-two")).toBeNull();
|
||||
expect(parseSecretReference("https://x")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("testConnectSource", () => {
|
||||
it("returns the vault count when the token is valid", async () => {
|
||||
const fetch = vi.fn().mockResolvedValue(
|
||||
jsonResponse([
|
||||
{ id: "v1", name: "Infra" },
|
||||
{ id: "v2", name: "Dev" },
|
||||
]),
|
||||
) as unknown as PluginFetch;
|
||||
|
||||
const count = await testConnectSource(fetch, {
|
||||
baseUrl: "https://connect.internal",
|
||||
token: "tok",
|
||||
allowedPrivateHosts: ["connect.internal"],
|
||||
});
|
||||
expect(count).toBe(2);
|
||||
expect(fetch).toHaveBeenCalledWith(
|
||||
"https://connect.internal/v1/vaults",
|
||||
expect.objectContaining({
|
||||
headers: { Authorization: "Bearer tok" },
|
||||
allowPrivateHosts: ["connect.internal"],
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("throws when the server answers with a non-2xx status", async () => {
|
||||
const fetch = vi
|
||||
.fn()
|
||||
.mockResolvedValue(
|
||||
jsonResponse([], false, 401),
|
||||
) as unknown as PluginFetch;
|
||||
|
||||
await expect(
|
||||
testConnectSource(fetch, {
|
||||
baseUrl: "https://connect.internal",
|
||||
token: "bad",
|
||||
allowedPrivateHosts: [],
|
||||
}),
|
||||
).rejects.toThrow(/HTTP 401/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveConnectReference", () => {
|
||||
it("resolves a field by label across vault, item and field lookups", async () => {
|
||||
const fetch = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce(jsonResponse([{ id: "vid", name: "Infra" }]))
|
||||
.mockResolvedValueOnce(jsonResponse([{ id: "iid", title: "prod-db" }]))
|
||||
.mockResolvedValueOnce(
|
||||
jsonResponse({
|
||||
fields: [{ id: "f1", label: "password", value: "s3cret" }],
|
||||
}),
|
||||
) as unknown as PluginFetch;
|
||||
|
||||
const value = await resolveConnectReference(
|
||||
fetch,
|
||||
{
|
||||
baseUrl: "https://connect.internal",
|
||||
token: "tok",
|
||||
allowedPrivateHosts: [],
|
||||
},
|
||||
{ vault: "Infra", item: "prod-db", field: "password" },
|
||||
);
|
||||
expect(value).toBe("s3cret");
|
||||
});
|
||||
|
||||
it("fails clearly when the vault is not found", async () => {
|
||||
const fetch = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce(jsonResponse([]))
|
||||
.mockResolvedValueOnce(jsonResponse([])) as unknown as PluginFetch;
|
||||
|
||||
await expect(
|
||||
resolveConnectReference(
|
||||
fetch,
|
||||
{
|
||||
baseUrl: "https://connect.internal",
|
||||
token: "tok",
|
||||
allowedPrivateHosts: [],
|
||||
},
|
||||
{ vault: "Missing", item: "x", field: "y" },
|
||||
),
|
||||
).rejects.toThrow(/vault "Missing" not found/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,93 @@
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { createMockCtx, createTestDb } from "@termix/plugin-sdk/testing";
|
||||
import { createSecretSourceRepository } from "../../src/backend/repository.js";
|
||||
import { createTokenStore } from "../../src/backend/token-store.js";
|
||||
import { registerOnePasswordResolver } from "../../src/backend/resolver.js";
|
||||
import { sources } from "../../src/backend/tables.js";
|
||||
import manifestJson from "../../manifest.json";
|
||||
import type { PluginManifest } from "@termix/plugin-sdk/manifest";
|
||||
|
||||
const manifest = manifestJson as unknown as PluginManifest;
|
||||
const pluginDir = fileURLToPath(new URL("../..", import.meta.url));
|
||||
|
||||
function jsonResponse(body: unknown): Response {
|
||||
return {
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => body,
|
||||
} as unknown as Response;
|
||||
}
|
||||
|
||||
async function setUp(fetchMock: ReturnType<typeof vi.fn>) {
|
||||
const db = await createTestDb(pluginDir);
|
||||
db.sqlite
|
||||
.prepare("INSERT INTO users (id, username) VALUES (?, ?)")
|
||||
.run("owner-1", "owner-1");
|
||||
|
||||
const mock = createMockCtx({
|
||||
pluginId: manifest.id,
|
||||
manifest,
|
||||
capabilities: manifest.capabilities,
|
||||
db: db.database,
|
||||
actor: "owner-1",
|
||||
fetch: fetchMock as never,
|
||||
});
|
||||
|
||||
const table = await mock.ctx.db.define(sources);
|
||||
const repository = createSecretSourceRepository(mock.ctx.db, table as never);
|
||||
const tokenStore = createTokenStore(mock.ctx);
|
||||
registerOnePasswordResolver(mock.ctx, repository, tokenStore);
|
||||
|
||||
const resolve = mock.auth.secretResolvers.get("op");
|
||||
if (!resolve) throw new Error("resolver was not registered");
|
||||
|
||||
return { db, repository, tokenStore, resolve };
|
||||
}
|
||||
|
||||
describe("op:// secret resolver", () => {
|
||||
it("registers under the declared scheme", async () => {
|
||||
const { resolve } = await setUp(vi.fn());
|
||||
expect(typeof resolve).toBe("function");
|
||||
});
|
||||
|
||||
it("resolves through the owner's source", async () => {
|
||||
const fetchMock = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce(jsonResponse([{ id: "vid", name: "Infra" }]))
|
||||
.mockResolvedValueOnce(jsonResponse([{ id: "iid", title: "prod-db" }]))
|
||||
.mockResolvedValueOnce(
|
||||
jsonResponse({
|
||||
fields: [{ id: "f1", label: "password", value: "s3cret" }],
|
||||
}),
|
||||
);
|
||||
const { repository, tokenStore, resolve } = await setUp(fetchMock);
|
||||
|
||||
const source = await repository.create({
|
||||
id: "src-1",
|
||||
userId: "owner-1",
|
||||
name: "n",
|
||||
kind: "onepassword-connect",
|
||||
baseUrl: "https://connect.internal",
|
||||
shared: false,
|
||||
});
|
||||
await tokenStore.set(source.id, "tok");
|
||||
|
||||
const value = await resolve("owner-1", "op://Infra/prod-db/password");
|
||||
expect(value).toBe("s3cret");
|
||||
});
|
||||
|
||||
it("fails clearly with no configured source", async () => {
|
||||
const { resolve } = await setUp(vi.fn());
|
||||
await expect(
|
||||
resolve("nobody", "op://Infra/prod-db/password"),
|
||||
).rejects.toThrow(/no secret source is configured/);
|
||||
});
|
||||
|
||||
it("rejects a malformed reference", async () => {
|
||||
const { resolve } = await setUp(vi.fn());
|
||||
await expect(resolve("owner-1", "op://only-two")).rejects.toThrow(
|
||||
/Invalid secret reference/,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,177 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { startServer, type TestServer } from "./helpers.js";
|
||||
|
||||
function jsonResponse(body: unknown, ok = true, status = 200): Response {
|
||||
return { ok, status, json: async () => body } as unknown as Response;
|
||||
}
|
||||
|
||||
let server: TestServer;
|
||||
|
||||
afterEach(async () => {
|
||||
await server?.close();
|
||||
});
|
||||
|
||||
describe("secret-sources routes", () => {
|
||||
beforeEach(async () => {
|
||||
server = await startServer();
|
||||
});
|
||||
|
||||
it("refuses a request without the matching core permission", async () => {
|
||||
await server.close();
|
||||
server = await startServer({ permissions: [] });
|
||||
const res = await server.request("GET", "/");
|
||||
expect(res.status).toBe(403);
|
||||
});
|
||||
|
||||
it("creates, lists and hides the token from the owner's own list", async () => {
|
||||
const create = await server.request("POST", "/", {
|
||||
body: {
|
||||
name: "Team 1Password",
|
||||
baseUrl: "https://connect.internal",
|
||||
token: "s3cret-token",
|
||||
},
|
||||
});
|
||||
expect(create.status).toBe(200);
|
||||
expect(create.body.source).toMatchObject({
|
||||
name: "Team 1Password",
|
||||
baseUrl: "https://connect.internal",
|
||||
owned: true,
|
||||
shared: false,
|
||||
});
|
||||
expect(create.body.source.token).toBeUndefined();
|
||||
|
||||
const list = await server.request("GET", "/");
|
||||
expect(list.body.sources).toHaveLength(1);
|
||||
expect(list.body.sources[0].owned).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects a create missing a required field", async () => {
|
||||
const res = await server.request("POST", "/", {
|
||||
body: { name: "", baseUrl: "https://x", token: "t" },
|
||||
});
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it("rejects an http(s)-only baseUrl", async () => {
|
||||
const res = await server.request("POST", "/", {
|
||||
body: { name: "n", baseUrl: "ftp://x", token: "t" },
|
||||
});
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it("refuses shared: true for a non-admin", async () => {
|
||||
const res = await server.request("POST", "/", {
|
||||
body: {
|
||||
name: "n",
|
||||
baseUrl: "https://connect.internal",
|
||||
token: "t",
|
||||
shared: true,
|
||||
},
|
||||
});
|
||||
expect(res.status).toBe(403);
|
||||
});
|
||||
|
||||
it("allows shared: true for an admin", async () => {
|
||||
await server.close();
|
||||
server = await startServer({
|
||||
permissions: [
|
||||
"credentials.view",
|
||||
"credentials.create",
|
||||
"credentials.edit",
|
||||
"credentials.delete",
|
||||
"admin.plugins.manage",
|
||||
],
|
||||
});
|
||||
const res = await server.request("POST", "/", {
|
||||
body: {
|
||||
name: "n",
|
||||
baseUrl: "https://connect.internal",
|
||||
token: "t",
|
||||
shared: true,
|
||||
},
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.source.shared).toBe(true);
|
||||
});
|
||||
|
||||
it("shows a shared source to another user, unowned", async () => {
|
||||
await server.close();
|
||||
server = await startServer({
|
||||
permissions: [
|
||||
"credentials.view",
|
||||
"credentials.create",
|
||||
"credentials.edit",
|
||||
"credentials.delete",
|
||||
"admin.plugins.manage",
|
||||
],
|
||||
});
|
||||
await server.request("POST", "/", {
|
||||
body: { name: "n", baseUrl: "https://x", token: "t", shared: true },
|
||||
});
|
||||
const list = await server.request("GET", "/", { user: "user-2" });
|
||||
expect(list.body.sources).toHaveLength(1);
|
||||
expect(list.body.sources[0].owned).toBe(false);
|
||||
});
|
||||
|
||||
it("refuses an update or delete from a non-owner", async () => {
|
||||
const created = await server.request("POST", "/", {
|
||||
body: { name: "n", baseUrl: "https://x", token: "t" },
|
||||
});
|
||||
const id = created.body.source.id;
|
||||
|
||||
const update = await server.request("PUT", `/${id}`, {
|
||||
user: "user-2",
|
||||
body: { name: "hijacked" },
|
||||
});
|
||||
expect(update.status).toBe(403);
|
||||
|
||||
const del = await server.request("DELETE", `/${id}`, { user: "user-2" });
|
||||
expect(del.status).toBe(403);
|
||||
});
|
||||
|
||||
it("updates without a token keeping the existing one, and deletes cleanly", async () => {
|
||||
const created = await server.request("POST", "/", {
|
||||
body: { name: "n", baseUrl: "https://x", token: "orig-token" },
|
||||
});
|
||||
const id = created.body.source.id;
|
||||
|
||||
const update = await server.request("PUT", `/${id}`, {
|
||||
body: { name: "renamed" },
|
||||
});
|
||||
expect(update.status).toBe(200);
|
||||
|
||||
const test = await server.request("POST", `/${id}/test`);
|
||||
// Reaches out through ctx.fetch, unstubbed here, so it fails softly.
|
||||
expect(test.status).toBe(200);
|
||||
expect(test.body.ok).toBe(false);
|
||||
|
||||
const del = await server.request("DELETE", `/${id}`);
|
||||
expect(del.status).toBe(200);
|
||||
|
||||
const list = await server.request("GET", "/");
|
||||
expect(list.body.sources).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("tests a source through ctx.fetch and reports the vault count", async () => {
|
||||
await server.close();
|
||||
const fetchMock = vi
|
||||
.fn()
|
||||
.mockResolvedValue(jsonResponse([{ id: "v1", name: "Infra" }]));
|
||||
server = await startServer({ fetch: fetchMock });
|
||||
|
||||
const created = await server.request("POST", "/", {
|
||||
body: { name: "n", baseUrl: "https://connect.internal", token: "tok" },
|
||||
});
|
||||
const id = created.body.source.id;
|
||||
|
||||
const test = await server.request("POST", `/${id}/test`);
|
||||
expect(test.status).toBe(200);
|
||||
expect(test.body).toEqual({ ok: true, vaults: 1 });
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
"https://connect.internal/v1/vaults",
|
||||
expect.objectContaining({
|
||||
headers: { Authorization: "Bearer tok" },
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,46 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { createMockCtx } from "@termix/plugin-sdk/testing";
|
||||
import { createTokenStore } from "../../src/backend/token-store.js";
|
||||
import manifestJson from "../../manifest.json";
|
||||
import type { PluginManifest } from "@termix/plugin-sdk/manifest";
|
||||
|
||||
const manifest = manifestJson as unknown as PluginManifest;
|
||||
|
||||
function mockCtx(actor?: string) {
|
||||
return createMockCtx({
|
||||
pluginId: manifest.id,
|
||||
manifest,
|
||||
capabilities: manifest.capabilities,
|
||||
actor,
|
||||
});
|
||||
}
|
||||
|
||||
describe("token store", () => {
|
||||
it("stores and reads back a token for the acting user", async () => {
|
||||
const { ctx } = mockCtx("owner-1");
|
||||
const store = createTokenStore(ctx);
|
||||
|
||||
await store.set("src-1", "s3cret");
|
||||
await expect(store.getForOwner("src-1", "owner-1")).resolves.toBe("s3cret");
|
||||
});
|
||||
|
||||
it("reads the owner's token for a caller currently acting as someone else", async () => {
|
||||
const { ctx, setActor } = mockCtx("owner-1");
|
||||
const store = createTokenStore(ctx);
|
||||
await store.set("src-1", "s3cret");
|
||||
|
||||
// A shared source's test/resolve runs as the recipient viewing it.
|
||||
setActor("recipient-1");
|
||||
await expect(store.getForOwner("src-1", "owner-1")).resolves.toBe("s3cret");
|
||||
// ctx.asUser restores the previous actor once it returns.
|
||||
expect(ctx.currentActor()).toBe("recipient-1");
|
||||
});
|
||||
|
||||
it("clears a token", async () => {
|
||||
const { ctx } = mockCtx("owner-1");
|
||||
const store = createTokenStore(ctx);
|
||||
await store.set("src-1", "s3cret");
|
||||
await store.clear("src-1");
|
||||
await expect(store.getForOwner("src-1", "owner-1")).resolves.toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,56 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import {
|
||||
renderWithApp,
|
||||
type RenderedPluginApp,
|
||||
} from "@termix/plugin-sdk/testing";
|
||||
import { PluginComponent } from "@termix/plugin-sdk/ui";
|
||||
import type { PluginManifest } from "@termix/plugin-sdk/manifest";
|
||||
import * as plugin from "../../src/frontend/index";
|
||||
import manifestJson from "../../manifest.json";
|
||||
import locales from "../../locales/en.json";
|
||||
|
||||
const manifest = manifestJson as unknown as PluginManifest;
|
||||
|
||||
let rendered: RenderedPluginApp | null = null;
|
||||
|
||||
afterEach(async () => {
|
||||
await rendered?.deactivate();
|
||||
rendered = null;
|
||||
});
|
||||
|
||||
describe(`${manifest.id} activate`, () => {
|
||||
it("registers the hint and manager components", async () => {
|
||||
rendered = await renderWithApp(plugin, { manifest, locales });
|
||||
render(
|
||||
<PluginComponent
|
||||
id="secret-sources.hint"
|
||||
onManage={vi.fn()}
|
||||
fallback={<span>fallback</span>}
|
||||
/>,
|
||||
);
|
||||
expect(await screen.findByText(locales.hint)).toBeTruthy();
|
||||
});
|
||||
|
||||
it("renders the manager and can be closed", async () => {
|
||||
rendered = await renderWithApp(plugin, {
|
||||
manifest,
|
||||
locales,
|
||||
api: {
|
||||
get: vi.fn(async () => ({ data: { sources: [] } })),
|
||||
} as never,
|
||||
});
|
||||
const onClose = vi.fn();
|
||||
render(<PluginComponent id="secret-sources.manager" onClose={onClose} />);
|
||||
expect(await screen.findByText(locales.title)).toBeTruthy();
|
||||
});
|
||||
|
||||
it("removes both components on deactivate", async () => {
|
||||
const app = await renderWithApp(plugin, { manifest, locales });
|
||||
await app.deactivate();
|
||||
render(
|
||||
<PluginComponent id="secret-sources.hint" fallback={<span>gone</span>} />,
|
||||
);
|
||||
expect(await screen.findByText("gone")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"extends": "@termix/plugin-sdk/tsconfig.plugin.json",
|
||||
"include": ["src/**/*.ts", "src/**/*.tsx", "tests/**/*.ts", "tests/**/*.tsx"]
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
import { pluginVitestConfig } from "@termix/plugin-sdk/vitest-preset";
|
||||
|
||||
export default pluginVitestConfig(import.meta.url);
|
||||
Reference in new issue
Block a user