feat: move termix identity into a plugin (C6)

This commit is contained in:
LukeGus committed 2026-09-24 20:50:54 -05:00
1 parent 6e56c90aac
commit 4dbce08379
157 files changed
+20743 -5766

No files matched your search

@@ -0,0 +1,145 @@
import crypto from "crypto";
// Minimal OpenSSH ed25519 user-certificate signer + per-user CA generation.
// Implemented in pure Node so we never shell out or move private keys to disk.
// Format reference: PROTOCOL.certkeys (ssh-ed25519-cert-v01@openssh.com).
const ED25519 = "ssh-ed25519";
const CERT_TYPE = "ssh-ed25519-cert-v01@openssh.com";
const SSH2_CERT_TYPE_USER = 1;
// Standard login extensions OpenSSH grants by default; must be name-sorted.
const DEFAULT_EXTENSIONS = [
"permit-X11-forwarding",
"permit-agent-forwarding",
"permit-port-forwarding",
"permit-pty",
"permit-user-rc",
];
// --- SSH wire-format primitives -------------------------------------------
function sshString(value: Buffer | string): Buffer {
const buf = typeof value === "string" ? Buffer.from(value, "utf8") : value;
const len = Buffer.alloc(4);
len.writeUInt32BE(buf.length, 0);
return Buffer.concat([len, buf]);
}
function sshUint32(n: number): Buffer {
const b = Buffer.alloc(4);
b.writeUInt32BE(n >>> 0, 0);
return b;
}
function sshUint64(n: bigint): Buffer {
const b = Buffer.alloc(8);
b.writeBigUInt64BE(n, 0);
return b;
}
function ed25519PublicBlob(raw32: Buffer): Buffer {
return Buffer.concat([sshString(ED25519), sshString(raw32)]);
}
/** Extract the 32-byte raw ed25519 key from an `ssh-ed25519 <base64>` line. */
export function ed25519RawFromLine(line: string): Buffer | null {
const parts = line.trim().split(/\s+/);
if (parts[0] !== ED25519 || !parts[1]) return null;
let blob: Buffer;
try {
blob = Buffer.from(parts[1], "base64");
} catch {
return null;
}
try {
let off = 0;
const typeLen = blob.readUInt32BE(off);
off += 4;
if (blob.toString("utf8", off, off + typeLen) !== ED25519) return null;
off += typeLen;
const keyLen = blob.readUInt32BE(off);
off += 4;
const pk = blob.subarray(off, off + keyLen);
return pk.length === 32 ? pk : null;
} catch {
return null;
}
}
// --- CA generation ---------------------------------------------------------
export interface GeneratedCa {
publicKeyLine: string; // "ssh-ed25519 <base64>"
privateKeyPem: string; // PKCS#8 PEM (to be stored encrypted)
}
export function generateCa(): GeneratedCa {
const { publicKey, privateKey } = crypto.generateKeyPairSync("ed25519");
const jwk = publicKey.export({ format: "jwk" }) as { x: string };
const raw = Buffer.from(jwk.x, "base64url");
const publicKeyLine = `${ED25519} ${ed25519PublicBlob(raw).toString("base64")}`;
const privateKeyPem = privateKey
.export({ format: "pem", type: "pkcs8" })
.toString();
return { publicKeyLine, privateKeyPem };
}
// --- Certificate signing ---------------------------------------------------
export interface SignCertOptions {
userPublicKeyLine: string; // ed25519 public key to certify
caPrivateKeyPem: string;
caPublicKeyLine: string;
keyId: string;
principals: string[]; // empty = valid for all usernames
validAfter: number; // unix seconds
validBefore: number; // unix seconds
serial?: bigint;
}
/**
* Sign an ed25519 user public key into an OpenSSH user certificate.
* Returns the certificate line, or null if the inputs aren't ed25519.
*/
export function signUserCertificate(opts: SignCertOptions): string | null {
const pk = ed25519RawFromLine(opts.userPublicKeyLine);
const caRaw = ed25519RawFromLine(opts.caPublicKeyLine);
if (!pk || !caRaw) return null;
const signatureKey = ed25519PublicBlob(caRaw);
const principals = Buffer.concat(opts.principals.map((p) => sshString(p)));
const extensions = Buffer.concat(
[...DEFAULT_EXTENSIONS]
.sort()
.map((name) => Buffer.concat([sshString(name), sshString("")])),
);
// Everything up to (not including) the signature: this is what gets signed.
const body = Buffer.concat([
sshString(CERT_TYPE),
sshString(crypto.randomBytes(32)), // nonce
sshString(pk),
sshUint64(opts.serial ?? 0n),
sshUint32(SSH2_CERT_TYPE_USER),
sshString(opts.keyId),
sshString(principals),
sshUint64(BigInt(opts.validAfter)),
sshUint64(BigInt(opts.validBefore)),
sshString(Buffer.alloc(0)), // critical options (none)
sshString(extensions),
sshString(Buffer.alloc(0)), // reserved
sshString(signatureKey),
]);
const caKey = crypto.createPrivateKey({
key: opts.caPrivateKeyPem,
format: "pem",
type: "pkcs8",
});
const rawSig = crypto.sign(null, body, caKey); // ed25519 -> 64 bytes
const signature = Buffer.concat([sshString(ED25519), sshString(rawSig)]);
const cert = Buffer.concat([body, sshString(signature)]);
return `${CERT_TYPE} ${cert.toString("base64")} ${opts.keyId}`;
}
@@ -0,0 +1,69 @@
/** A GitHub-like public namespace: lowercase, starts alphanumeric, 1-39 chars. */
export const HANDLE_REGEX = /^[a-z0-9][a-z0-9_-]{0,38}$/;
const RESERVED_HANDLES = new Set(["u", "me", "keys", "check", "admin", "api"]);
const MAX_DESCRIPTION_LENGTH = 500;
export function isValidHandle(handle: string): boolean {
return HANDLE_REGEX.test(handle) && !RESERVED_HANDLES.has(handle);
}
export function cleanDescription(value: unknown): string | null {
if (typeof value !== "string") return null;
return value.trim().slice(0, MAX_DESCRIPTION_LENGTH) || null;
}
export function cleanLabel(value: unknown): string | null {
return typeof value === "string" ? value.trim() || null : null;
}
/** Validity in days: a positive whole number, at most ten years. */
export function clampValidityDays(value: unknown, fallback: number): number {
const days = Number(value);
if (!Number.isFinite(days) || days <= 0) return fallback;
return Math.min(Math.floor(days), 3650);
}
interface DbError {
code?: string;
errno?: number;
message?: string;
cause?: unknown;
}
function errorChain(error: unknown): DbError[] {
const chain: DbError[] = [];
let current = error as DbError | undefined;
while (current && typeof current === "object" && chain.length < 5) {
chain.push(current);
current = current.cause as DbError | undefined;
}
return chain;
}
/** A UNIQUE violation on sqlite, Postgres or MySQL. */
export function isUniqueViolation(error: unknown): boolean {
return errorChain(error).some(
(entry) =>
entry.code === "SQLITE_CONSTRAINT_UNIQUE" ||
entry.code === "23505" ||
entry.code === "ER_DUP_ENTRY" ||
entry.errno === 1062 ||
(typeof entry.message === "string" &&
entry.message.includes("UNIQUE constraint failed")),
);
}
/** Whether a UNIQUE violation was on user_id (one handle per user) rather than the handle. */
export function isUserIdViolation(error: unknown): boolean {
return errorChain(error).some(
(entry) =>
typeof entry.message === "string" &&
(entry.message.includes("user_id") || entry.message.includes("userId")),
);
}
export function asError(error: unknown): Error {
return error instanceof Error ? error : new Error(String(error));
}
@@ -0,0 +1,20 @@
import type { Router } from "express";
import type { PluginContext } from "@termix/plugin-sdk/backend";
import { ca, identities, keys } from "./tables.js";
import { createStore } from "./store.js";
import { PUBLIC_PATHS } from "./resolver.js";
import { registerRoutes } from "./routes.js";
export async function activate(ctx: PluginContext) {
const store = createStore(ctx, {
identities: await ctx.db.define(identities),
keys: await ctx.db.define(keys),
ca: await ctx.db.define(ca),
});
registerRoutes(ctx, ctx.http.router<Router>({ public: PUBLIC_PATHS }), store);
}
export async function deactivate() {
// Everything above was registered through ctx and is disposed by core.
}
@@ -0,0 +1,89 @@
// Pure, dependency-free helpers for SSH public-key parsing/classification used
// by the Termix Identity plugin.
// Upper bound on an accepted public-key line. Public keys are tiny (an RSA-4096
// line is ~720 chars); this caps the value the unauthenticated resolver later
// streams, preventing a multi-MB blob being stored and amplified.
export const MAX_PUBLIC_KEY_LENGTH = 8192;
// Normalized algorithm groups, used both for storage and for the `/<ALGO>`
// resolver filter (mirrors sshid.io's RSA/ED25519/ECDSA suffixes).
export const ALGO_GROUPS: Record<string, string> = {
"ssh-rsa": "RSA",
"rsa-sha2-256": "RSA",
"rsa-sha2-512": "RSA",
"ssh-dss": "DSA",
"ssh-ed25519": "ED25519",
"sk-ssh-ed25519@openssh.com": "ED25519-SK",
"ecdsa-sha2-nistp256": "ECDSA",
"ecdsa-sha2-nistp384": "ECDSA",
"ecdsa-sha2-nistp521": "ECDSA",
"sk-ecdsa-sha2-nistp256@openssh.com": "ECDSA-SK",
};
export function classifyAlgo(type: string): string {
if (ALGO_GROUPS[type]) return ALGO_GROUPS[type];
if (type.startsWith("ecdsa-")) return "ECDSA";
if (type.includes("ed25519")) return "ED25519";
if (type.includes("rsa")) return "RSA";
if (type.includes("dss") || type.includes("dsa")) return "DSA";
return type.toUpperCase();
}
export interface ParsedPublicKey {
type: string;
algorithm: string;
comment: string;
normalized: string; // "<type> <base64blob>"
}
/**
* Parse and validate a single OpenSSH public key line. Returns null when the
* input is not a well-formed public key.
*/
export function parsePublicKey(
raw: string | null | undefined,
): ParsedPublicKey | null {
if (typeof raw !== "string") return null;
if (raw.length > MAX_PUBLIC_KEY_LENGTH) return null;
const line = raw.trim().replace(/\s+/g, " ");
if (!line) return null;
const parts = line.split(" ");
if (parts.length < 2) return null;
const [type, blob, ...rest] = parts;
if (!/^[A-Za-z0-9@.-]+$/.test(type)) return null;
if (!/^[A-Za-z0-9+/]+={0,2}$/.test(blob)) return null;
const decoded = Buffer.from(blob, "base64");
// The blob is an SSH wire-format string whose first field repeats the type.
if (decoded.length < 8) return null;
try {
const len = decoded.readUInt32BE(0);
if (len <= 0 || len > 64 || 4 + len > decoded.length) return null;
const embeddedType = decoded.toString("utf8", 4, 4 + len);
if (embeddedType !== type) return null;
} catch {
return null;
}
return {
type,
algorithm: classifyAlgo(type),
comment: rest.join(" "),
normalized: `${type} ${blob}`,
};
}
/**
* Whether a key's normalized algorithm group matches a `/<ALGO>` resolver
* filter. Exact match only: `ED25519` must NOT also return `ED25519-SK`.
*/
export function matchesAlgoFilter(
algorithm: string,
filter: string | null,
): boolean {
if (!filter) return true;
return algorithm.toUpperCase() === filter.toUpperCase();
}
@@ -0,0 +1,173 @@
import type { Request, Response } from "express";
import type { PluginContext } from "@termix/plugin-sdk/backend";
import { HANDLE_REGEX, asError } from "./handles.js";
import { matchesAlgoFilter } from "./keys.js";
import type { KeyRow, Store } from "./store.js";
/**
* The resolver needs no login: servers fetch it from scripts. Paths are
* relative to /plugin-api/termix-identity/.
*/
export const PUBLIC_PATHS = ["/u/:handle", "/u/:handle/:algo"];
/** The public resolver path for a handle, below the base URL. */
export function resolverPath(handle: string): string {
return `/plugin-api/termix-identity/u/${handle}`;
}
// A disabled or removed key must not keep being served by a cache, and the
// feed stays out of search indexes. Set on every response, 404s included.
function setResolverHeaders(res: Response) {
res.setHeader("Cache-Control", "no-store, max-age=0");
res.setHeader("X-Robots-Tag", "noindex, nofollow");
}
function notFound(res: Response, body = "Not found\n") {
return res.status(404).type("text/plain").send(body);
}
export function escapeHtml(value: string): string {
return value
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;");
}
export function renderHtml(
handle: string,
keys: Array<Pick<KeyRow, "algorithm" | "publicKey">>,
resolverUrl: string,
): string {
const keyRows = keys.length
? keys
.map(
(key) =>
`<div class="key"><span class="algo">${escapeHtml(
key.algorithm,
)}</span><code>${escapeHtml(key.publicKey)}</code></div>`,
)
.join("")
: `<p class="empty">No public keys published yet.</p>`;
// A standalone page, not the React app, so the dark theme is inlined.
return `<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="robots" content="noindex, nofollow" />
<meta name="color-scheme" content="dark" />
<title>Termix ID @${escapeHtml(handle)}</title>
<style>
:root {
--bg: #0c0d0b; --panel: #141614; --panel-2: #181a17; --border: rgba(255,255,255,.12);
--fg: #f8f8f6; --muted: #8f9189; --accent: #f59145;
}
* { box-sizing: border-box; }
body { margin: 0; background: var(--bg); color: var(--fg); line-height: 1.5;
font-family: ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, sans-serif; }
.wrap { max-width: 820px; margin: 0 auto; padding: 32px 16px 48px; }
.card { background: var(--panel); border: 1px solid var(--border); }
header.card { padding: 16px 18px; display: flex; align-items: center; gap: 10px; }
h1 { font-size: 1.05rem; font-weight: 700; margin: 0; letter-spacing: .02em; color: var(--fg); }
h1 .handle { color: var(--accent); }
.label { font-size: 10px; font-weight: 700; letter-spacing: .18em; text-transform: uppercase;
color: var(--muted); margin: 22px 0 8px; }
pre.cmd { margin: 0; background: var(--panel-2); border: 1px solid var(--border); color: var(--muted);
padding: 12px 14px; overflow-x: auto; font-size: 12px;
font-family: ui-monospace, SFMono-Regular, Menlo, monospace; }
.keys { display: flex; flex-direction: column; gap: 8px; }
.key { background: var(--panel); border: 1px solid var(--border); padding: 10px 12px;
display: flex; align-items: center; gap: 10px; overflow-x: auto; }
.algo { flex: 0 0 auto; font-size: 10px; font-weight: 700; letter-spacing: .06em; text-transform: uppercase;
color: var(--accent); border: 1px solid rgba(245,145,69,.4); padding: 1px 5px; line-height: 1.4; }
code { white-space: pre; word-break: break-all; font-size: 12px; color: var(--muted);
font-family: ui-monospace, SFMono-Regular, Menlo, monospace; }
.empty { color: var(--muted); font-size: 13px; }
footer { margin-top: 28px; color: var(--muted); font-size: 11px; letter-spacing: .04em; }
</style>
</head>
<body>
<div class="wrap">
<header class="card">
<h1>Termix ID <span class="handle">@${escapeHtml(handle)}</span></h1>
</header>
<div class="label">Provision a server</div>
<pre class="cmd">curl -fsSL ${escapeHtml(resolverUrl)} &gt;&gt; ~/.ssh/authorized_keys</pre>
<div class="label">Published keys</div>
<div class="keys">${keyRows}</div>
<footer>Served by Termix</footer>
</div>
</body>
</html>`;
}
export function createResolver(ctx: PluginContext, store: Store) {
const handleOf = (req: Request) =>
String(req.params.handle || "").toLowerCase();
/** authorized_keys as text, or a small viewer for browsers. */
async function keys(req: Request, res: Response) {
setResolverHeaders(res);
const handle = handleOf(req);
if (!HANDLE_REGEX.test(handle)) return notFound(res);
const algoFilter = req.params.algo
? String(req.params.algo).toUpperCase()
: null;
try {
const identity = await store.identityByHandle(handle);
if (!identity) return notFound(res);
const published = (await store.enabledKeys(identity.id)).filter((key) =>
matchesAlgoFilter(key.algorithm, algoFilter),
);
if ((req.headers.accept || "").includes("text/html")) {
res.setHeader("Content-Type", "text/html; charset=utf-8");
return res.send(
renderHtml(
handle,
published,
`${ctx.http.baseUrl(req)}${resolverPath(handle)}`,
),
);
}
const body = published
.map((key) => `${key.publicKey} #termix-id @${handle}`)
.join("\n");
res.setHeader("Content-Type", "text/plain; charset=utf-8");
return res.send(body ? `${body}\n` : "");
} catch (error) {
ctx.log.error("Termix ID resolve failed", asError(error));
return res.status(500).type("text/plain").send("Internal Server Error\n");
}
}
/** The CA public key, for TrustedUserCAKeys or an @cert-authority line. */
async function caKey(req: Request, res: Response) {
setResolverHeaders(res);
const handle = handleOf(req);
if (!HANDLE_REGEX.test(handle)) return notFound(res);
try {
const identity = await store.identityByHandle(handle);
if (!identity) return notFound(res);
const ca = await store.caForIdentity(identity.id);
if (!ca) return notFound(res, "No CA configured\n");
res.setHeader("Content-Type", "text/plain; charset=utf-8");
return res.send(`${ca.publicKey} termix-id-ca@${handle}\n`);
} catch (error) {
ctx.log.error("Termix ID CA resolve failed", asError(error));
return res.status(500).type("text/plain").send("Internal Server Error\n");
}
}
return { keys, caKey };
}
@@ -0,0 +1,987 @@
import type { Request, Response, Router } from "express";
// ssh2 is CommonJS and its `utils` named export is not visible to ESM, so it
// is read off the default import.
import ssh2 from "ssh2";
import type { PluginContext } from "@termix/plugin-sdk/backend";
import { parsePublicKey } from "./keys.js";
import {
ed25519RawFromLine,
generateCa,
signUserCertificate,
} from "./certificate.js";
import {
asError,
clampValidityDays,
cleanDescription,
cleanLabel,
isUniqueViolation,
isUserIdViolation,
isValidHandle,
} from "./handles.js";
import { createResolver, resolverPath } from "./resolver.js";
import type { IdentityRow, Store } from "./store.js";
const ALREADY_HAVE_ONE =
"You already have a Termix ID. Use update to rename it.";
function parseId(value: unknown): number | null {
const id = Number.parseInt(String(value), 10);
return Number.isInteger(id) && id > 0 ? id : null;
}
export function registerRoutes(
ctx: PluginContext,
router: Router,
store: Store,
): void {
const resolver = createResolver(ctx, store);
const userId = () => {
const actor = ctx.currentActor();
if (!actor) throw new Error("No acting user");
return actor;
};
const withUrls = (req: Request, handle: string, suffix = "") => {
const path = `${resolverPath(handle)}${suffix}`;
return {
resolverPath: path,
resolverUrl: `${ctx.http.baseUrl(req)}${path}`,
};
};
const fail = (res: Response, message: string, error: unknown) => {
ctx.log.error(message, asError(error));
res.status(500).json({ error: message });
};
/**
* @openapi
* /plugin-api/termix-identity/u/{handle}/ca:
* get:
* summary: Public CA key for a handle
* description: Public. The certificate authority's public key, for TrustedUserCAKeys or an @cert-authority line. The 2.8 URL /termix-id/u/{handle}/ca redirects here.
* tags: [Termix ID]
* parameters:
* - in: path
* name: handle
* required: true
* schema:
* type: string
* responses:
* 200:
* description: The CA public key as text.
* 404:
* description: No such handle, or it has no CA.
*/
router.get("/u/:handle/ca", resolver.caKey);
/**
* @openapi
* /plugin-api/termix-identity/u/{handle}:
* get:
* summary: Published keys for a handle
* description: Public. authorized_keys lines for every enabled key, or an HTML viewer when the client accepts text/html. Never cached. The 2.8 URL /termix-id/u/{handle} redirects here.
* tags: [Termix ID]
* parameters:
* - in: path
* name: handle
* required: true
* schema:
* type: string
* responses:
* 200:
* description: The keys as text or HTML.
* 404:
* description: No such handle.
*/
router.get("/u/:handle", resolver.keys);
/**
* @openapi
* /plugin-api/termix-identity/u/{handle}/{algo}:
* get:
* summary: Published keys of one algorithm for a handle
* description: Public. Like /u/{handle}, keeping only keys whose algorithm group matches (RSA, ED25519, ECDSA and so on). The 2.8 URL /termix-id/u/{handle}/{algo} redirects here.
* tags: [Termix ID]
* parameters:
* - in: path
* name: handle
* required: true
* schema:
* type: string
* - in: path
* name: algo
* required: true
* schema:
* type: string
* responses:
* 200:
* description: The matching keys as text or HTML.
* 404:
* description: No such handle.
*/
router.get("/u/:handle/:algo", resolver.keys);
// Everything below needs a signed-in user holding termix-identity.use.
router.use(ctx.rbac.require("use") as never);
/**
* @openapi
* /plugin-api/termix-identity/me:
* get:
* summary: Get the current user's Termix ID and keys
* tags: [Termix ID]
* security:
* - bearerAuth: []
* responses:
* 200:
* description: Identity and keys (identity may be null)
*/
router.get("/me", async (req, res) => {
try {
const identity = await store.identityForUser(userId());
if (!identity) return res.json({ identity: null, keys: [] });
res.json({
identity: { ...identity, ...withUrls(req, identity.handle) },
keys: await store.keysForIdentity(identity.id),
});
} catch (error) {
fail(res, "Failed to fetch Termix ID", error);
}
});
/**
* @openapi
* /plugin-api/termix-identity/check/{handle}:
* get:
* summary: Check if a handle is available
* tags: [Termix ID]
* security:
* - bearerAuth: []
* parameters:
* - in: path
* name: handle
* required: true
* schema:
* type: string
* responses:
* 200:
* description: Availability and validity status
*/
router.get("/check/:handle", async (req, res) => {
const handle = String(req.params.handle || "").toLowerCase();
if (!isValidHandle(handle)) {
return res.json({ available: false, valid: false });
}
try {
res.json({
available: !(await store.isHandleTaken(handle)),
valid: true,
});
} catch (error) {
fail(res, "Failed to check handle", error);
}
});
/**
* @openapi
* /plugin-api/termix-identity:
* post:
* summary: Create a Termix ID
* tags: [Termix ID]
* security:
* - bearerAuth: []
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* required: [handle]
* properties:
* handle:
* type: string
* description:
* type: string
* responses:
* 201:
* description: Created identity
* 400:
* description: Invalid handle
* 409:
* description: Handle taken or user already has an identity
*/
router.post("/", async (req, res) => {
const handle = String(req.body?.handle || "").toLowerCase();
if (!isValidHandle(handle)) {
return res.status(400).json({ error: "Invalid handle" });
}
try {
const owner = userId();
if (await store.identityForUser(owner)) {
return res.status(409).json({ error: ALREADY_HAVE_ONE });
}
if (await store.isHandleTaken(handle)) {
return res.status(409).json({ error: "Handle already taken" });
}
const identity = await store.createIdentity({
userId: owner,
handle,
description: cleanDescription(req.body?.description),
});
await ctx.audit.record({
action: "create_termix_id",
resourceType: "termix_id",
resourceId: String(identity.id),
resourceName: handle,
success: true,
});
res.status(201).json(identity);
} catch (error) {
// The checks above race a double submit; the UNIQUE constraints on
// handle and user_id are the real guard.
if (isUniqueViolation(error)) {
return res.status(409).json({
error: isUserIdViolation(error)
? ALREADY_HAVE_ONE
: "Handle already taken",
});
}
fail(res, "Failed to create Termix ID", error);
}
});
/**
* @openapi
* /plugin-api/termix-identity:
* put:
* summary: Update Termix ID handle or description
* tags: [Termix ID]
* security:
* - bearerAuth: []
* requestBody:
* content:
* application/json:
* schema:
* type: object
* properties:
* handle:
* type: string
* description:
* type: string
* responses:
* 200:
* description: Updated identity
* 404:
* description: No Termix ID
* 409:
* description: Handle taken
*/
router.put("/", async (req, res) => {
try {
const owner = userId();
const identity = await store.identityForUser(owner);
if (!identity)
return res.status(404).json({ error: "No Termix ID found" });
const update: { handle?: string; description?: string | null } = {};
if (req.body?.handle !== undefined) {
const handle = String(req.body.handle || "").toLowerCase();
if (!isValidHandle(handle)) {
return res.status(400).json({ error: "Invalid handle" });
}
if (handle !== identity.handle && (await store.isHandleTaken(handle))) {
return res.status(409).json({ error: "Handle already taken" });
}
update.handle = handle;
}
if (req.body?.description !== undefined) {
update.description = cleanDescription(req.body.description);
}
const updated = await store.updateIdentity(owner, update);
await ctx.audit.record({
action: "update_termix_id",
resourceType: "termix_id",
resourceId: String(identity.id),
resourceName: updated?.handle ?? identity.handle,
details:
update.handle && update.handle !== identity.handle
? `renamed ${identity.handle} -> ${update.handle}`
: undefined,
success: true,
});
res.json(updated);
} catch (error) {
if (isUniqueViolation(error)) {
return res.status(409).json({ error: "Handle already taken" });
}
fail(res, "Failed to update Termix ID", error);
}
});
/**
* @openapi
* /plugin-api/termix-identity:
* delete:
* summary: Delete Termix ID with its keys and CA
* tags: [Termix ID]
* security:
* - bearerAuth: []
* responses:
* 200:
* description: Deleted
* 404:
* description: No Termix ID
*/
router.delete("/", async (_req, res) => {
try {
const identity = await store.identityForUser(userId());
if (!identity)
return res.status(404).json({ error: "No Termix ID found" });
await store.deleteIdentity(identity);
await ctx.audit.record({
action: "delete_termix_id",
resourceType: "termix_id",
resourceId: String(identity.id),
resourceName: identity.handle,
success: true,
});
res.json({ success: true });
} catch (error) {
fail(res, "Failed to delete Termix ID", error);
}
});
const needIdentity = async (res: Response): Promise<IdentityRow | null> => {
const identity = await store.identityForUser(userId());
if (!identity) {
res
.status(400)
.json({ error: "Create a Termix ID handle before adding keys" });
}
return identity;
};
/**
* @openapi
* /plugin-api/termix-identity/keys:
* post:
* summary: Publish a public key
* description: Publishes a pasted key, or the public half of one of the user's saved SSH key credentials.
* tags: [Termix ID]
* security:
* - bearerAuth: []
* requestBody:
* content:
* application/json:
* schema:
* type: object
* properties:
* publicKey:
* type: string
* credentialId:
* type: integer
* label:
* type: string
* responses:
* 201:
* description: Published key
* 400:
* description: No identity, or the key is invalid
* 404:
* description: Credential not found
* 409:
* description: Already published
*/
router.post("/keys", async (req, res) => {
const credentialId =
req.body?.credentialId != null ? parseId(req.body.credentialId) : null;
try {
const identity = await needIdentity(res);
if (!identity) return;
let rawPublicKey: string | null = null;
let source = "manual";
let label = cleanLabel(req.body?.label);
if (credentialId) {
const credential = (await ctx.credentials.listSshKeys()).find(
(key) => key.id === credentialId,
);
if (!credential) {
return res.status(404).json({ error: "Credential not found" });
}
if (!credential.publicKey) {
return res.status(400).json({
error:
"This credential has no public key and one could not be derived. Paste the public key manually.",
});
}
source = "credential";
label = label || credential.name || null;
rawPublicKey = credential.publicKey;
} else if (typeof req.body?.publicKey === "string") {
rawPublicKey = req.body.publicKey;
}
const parsed = parsePublicKey(rawPublicKey);
if (!parsed) {
return res.status(400).json({ error: "Invalid SSH public key" });
}
const existing = await store.keysForIdentity(identity.id);
if (existing.some((key) => key.publicKey === parsed.normalized)) {
return res.status(409).json({ error: "This key is already published" });
}
const key = await store.createKey({
identityId: identity.id,
userId: identity.userId,
publicKey: parsed.normalized,
keyType: parsed.type,
algorithm: parsed.algorithm,
label,
comment: parsed.comment || null,
source,
credentialId: credentialId || null,
});
await ctx.audit.record({
action: "add_termix_id_key",
resourceType: "termix_id_key",
resourceId: String(key.id),
resourceName: identity.handle,
details: `${parsed.algorithm} (${source})`,
success: true,
});
res.status(201).json(key);
} catch (error) {
fail(res, "Failed to add key", error);
}
});
/**
* @openapi
* /plugin-api/termix-identity/keys/generate:
* post:
* summary: Generate a new key pair and publish the public key
* description: The private key is returned once. With saveCredential (the default) the pair is also saved as an SSH key credential.
* tags: [Termix ID]
* security:
* - bearerAuth: []
* requestBody:
* content:
* application/json:
* schema:
* type: object
* properties:
* type:
* type: string
* enum: [ed25519, rsa]
* label:
* type: string
* saveCredential:
* type: boolean
* username:
* type: string
* responses:
* 201:
* description: Generated key info with the private key (shown once)
*/
router.post("/keys/generate", async (req, res) => {
const keyType = req.body?.type === "rsa" ? "rsa" : "ed25519";
const saveCredential = req.body?.saveCredential !== false;
const username =
typeof req.body?.username === "string" ? req.body.username.trim() : null;
try {
const identity = await needIdentity(res);
if (!identity) return;
const comment = `termix-${identity.handle}`;
const pair =
keyType === "rsa"
? ssh2.utils.generateKeyPairSync("rsa", { bits: 4096, comment })
: ssh2.utils.generateKeyPairSync("ed25519", { comment });
const parsed = parsePublicKey(pair.public);
if (!parsed) {
return res.status(500).json({ error: "Key generation failed" });
}
// The published row only ever holds the public key; the pair goes to
// the user's encrypted credentials. Publish first so a failed save can
// be undone here, since the plugin cannot delete credentials.
const key = await store.createKey({
identityId: identity.id,
userId: identity.userId,
publicKey: parsed.normalized,
keyType: parsed.type,
algorithm: parsed.algorithm,
label: cleanLabel(req.body?.label) || `Generated ${parsed.algorithm}`,
comment: parsed.comment || null,
source: "generated",
credentialId: null,
});
let credentialId: number | null = null;
if (saveCredential) {
try {
({ id: credentialId } = await ctx.credentials.createSshKey({
name: `Termix ID @${identity.handle} (${parsed.algorithm})`,
description: "Auto-generated by Termix ID",
username,
privateKey: pair.private,
publicKey: parsed.normalized,
keyType: parsed.type,
}));
await store.updateKey(identity.userId, key.id, { credentialId });
} catch (error) {
await store.deleteKey(identity.userId, key.id);
throw error;
}
}
await ctx.audit.record({
action: "generate_termix_id_key",
resourceType: "termix_id_key",
resourceId: String(key.id),
resourceName: identity.handle,
details: `${parsed.algorithm}${credentialId !== null ? " (saved to credentials)" : ""}`,
success: true,
});
res.status(201).json({
key: { ...key, credentialId },
privateKey: pair.private,
publicKey: parsed.normalized,
credentialId,
});
} catch (error) {
fail(res, "Failed to generate key", error);
}
});
/**
* @openapi
* /plugin-api/termix-identity/keys/{id}:
* patch:
* summary: Update key metadata (enabled state or label)
* tags: [Termix ID]
* security:
* - bearerAuth: []
* parameters:
* - in: path
* name: id
* required: true
* schema:
* type: integer
* requestBody:
* content:
* application/json:
* schema:
* type: object
* properties:
* enabled:
* type: boolean
* label:
* type: string
* responses:
* 200:
* description: Updated key
* 404:
* description: Key not found
*/
router.patch("/keys/:id", async (req, res) => {
const id = parseId(req.params.id);
if (!id) return res.status(400).json({ error: "Invalid key id" });
try {
const update: { enabled?: boolean; label?: string | null } = {};
if (req.body?.enabled !== undefined) update.enabled = !!req.body.enabled;
if (req.body?.label !== undefined)
update.label = cleanLabel(req.body.label);
const key = await store.updateKey(userId(), id, update);
if (!key) return res.status(404).json({ error: "Key not found" });
await ctx.audit.record({
action: "update_termix_id_key",
resourceType: "termix_id_key",
resourceId: String(id),
resourceName: key.label ?? key.algorithm,
details:
update.enabled !== undefined
? `enabled: ${update.enabled}`
: "label updated",
success: true,
});
res.json(key);
} catch (error) {
fail(res, "Failed to update key", error);
}
});
/**
* @openapi
* /plugin-api/termix-identity/keys/{id}:
* delete:
* summary: Revoke and delete a published key
* tags: [Termix ID]
* security:
* - bearerAuth: []
* parameters:
* - in: path
* name: id
* required: true
* schema:
* type: integer
* responses:
* 200:
* description: Deleted
* 404:
* description: Key not found
*/
router.delete("/keys/:id", async (req, res) => {
const id = parseId(req.params.id);
if (!id) return res.status(400).json({ error: "Invalid key id" });
try {
const deleted = await store.deleteKey(userId(), id);
if (!deleted) return res.status(404).json({ error: "Key not found" });
await ctx.audit.record({
action: "delete_termix_id_key",
resourceType: "termix_id_key",
resourceId: String(id),
resourceName: deleted.algorithm,
success: true,
});
res.json({ success: true });
} catch (error) {
fail(res, "Failed to delete key", error);
}
});
/**
* @openapi
* /plugin-api/termix-identity/ca:
* get:
* summary: Get the current user's certificate authority
* tags: [Termix ID]
* security:
* - bearerAuth: []
* responses:
* 200:
* description: CA info or null
*/
router.get("/ca", async (req, res) => {
try {
const identity = await store.identityForUser(userId());
if (!identity) return res.json({ ca: null });
const ca = await store.caForIdentity(identity.id);
if (!ca) return res.json({ ca: null });
res.json({
ca: {
publicKey: ca.publicKey,
validityDays: ca.validityDays,
...withUrls(req, identity.handle, "/ca"),
},
});
} catch (error) {
fail(res, "Failed to fetch CA", error);
}
});
/**
* @openapi
* /plugin-api/termix-identity/ca:
* post:
* summary: Create a certificate authority
* tags: [Termix ID]
* security:
* - bearerAuth: []
* requestBody:
* content:
* application/json:
* schema:
* type: object
* properties:
* validityDays:
* type: integer
* responses:
* 201:
* description: Created CA
* 409:
* description: CA already exists
*/
router.post("/ca", async (req, res) => {
try {
const identity = await store.identityForUser(userId());
if (!identity) {
return res
.status(400)
.json({ error: "Create a Termix ID handle first" });
}
if (await store.caForIdentity(identity.id)) {
return res.status(409).json({ error: "CA already exists" });
}
const validityDays = clampValidityDays(req.body?.validityDays, 90);
const generated = generateCa();
await store.createCa({
identityId: identity.id,
userId: identity.userId,
publicKey: generated.publicKeyLine,
privateKey: await ctx.secrets.seal(generated.privateKeyPem),
validityDays,
});
await ctx.audit.record({
action: "create_termix_id_ca",
resourceType: "termix_id_ca",
resourceName: identity.handle,
success: true,
});
res.status(201).json({
publicKey: generated.publicKeyLine,
validityDays,
...withUrls(req, identity.handle, "/ca"),
});
} catch (error) {
if (isUniqueViolation(error)) {
return res.status(409).json({ error: "CA already exists" });
}
fail(res, "Failed to create CA", error);
}
});
/**
* @openapi
* /plugin-api/termix-identity/ca/rotate:
* post:
* summary: Rotate the certificate authority (revokes all issued certificates)
* tags: [Termix ID]
* security:
* - bearerAuth: []
* requestBody:
* content:
* application/json:
* schema:
* type: object
* properties:
* validityDays:
* type: integer
* responses:
* 200:
* description: New CA public key
* 404:
* description: No Termix ID or no CA
*/
router.post("/ca/rotate", async (req, res) => {
try {
const identity = await store.identityForUser(userId());
if (!identity)
return res.status(404).json({ error: "No Termix ID found" });
const existing = await store.caForIdentity(identity.id);
if (!existing) return res.status(404).json({ error: "No CA to rotate" });
const validityDays = clampValidityDays(
req.body?.validityDays,
existing.validityDays,
);
// A new key invalidates every certificate the old one signed: this is
// the revocation mechanism.
const generated = generateCa();
await store.replaceCa(identity.id, {
publicKey: generated.publicKeyLine,
privateKey: await ctx.secrets.seal(generated.privateKeyPem),
validityDays,
});
await ctx.audit.record({
action: "rotate_termix_id_ca",
resourceType: "termix_id_ca",
resourceName: identity.handle,
success: true,
});
res.json({
publicKey: generated.publicKeyLine,
validityDays,
...withUrls(req, identity.handle, "/ca"),
});
} catch (error) {
fail(res, "Failed to rotate CA", error);
}
});
/**
* @openapi
* /plugin-api/termix-identity/ca:
* delete:
* summary: Delete the certificate authority
* tags: [Termix ID]
* security:
* - bearerAuth: []
* responses:
* 200:
* description: Deleted
* 404:
* description: No Termix ID or no CA
*/
router.delete("/ca", async (_req, res) => {
try {
const identity = await store.identityForUser(userId());
if (!identity)
return res.status(404).json({ error: "No Termix ID found" });
if (!(await store.deleteCa(identity.id))) {
return res.status(404).json({ error: "No CA to delete" });
}
await ctx.audit.record({
action: "delete_termix_id_ca",
resourceType: "termix_id_ca",
resourceName: identity.handle,
success: true,
});
res.json({ success: true });
} catch (error) {
fail(res, "Failed to delete CA", error);
}
});
/**
* @openapi
* /plugin-api/termix-identity/keys/{id}/certificate:
* post:
* summary: Issue an SSH certificate for a key (Ed25519 only)
* tags: [Termix ID]
* security:
* - bearerAuth: []
* parameters:
* - in: path
* name: id
* required: true
* schema:
* type: integer
* requestBody:
* content:
* application/json:
* schema:
* type: object
* properties:
* validityDays:
* type: integer
* principals:
* type: array
* items:
* type: string
* responses:
* 200:
* description: Issued certificate
* 400:
* description: Not an Ed25519 key, or no CA
* 404:
* description: Key not found
*/
router.post("/keys/:id/certificate", async (req, res) => {
const id = parseId(req.params.id);
if (!id) return res.status(400).json({ error: "Invalid key id" });
try {
const owner = userId();
const key = await store.keyForUser(owner, id);
if (!key) return res.status(404).json({ error: "Key not found" });
if (!ed25519RawFromLine(key.publicKey)) {
return res
.status(400)
.json({ error: "Certificates are only supported for Ed25519 keys" });
}
const identity = await store.identityForUser(owner);
if (!identity)
return res.status(404).json({ error: "No Termix ID found" });
const ca = await store.caForIdentity(identity.id);
const caPrivateKey = ca ? await ctx.secrets.unseal(ca.privateKey) : null;
if (!ca || !caPrivateKey) {
return res
.status(400)
.json({ error: "Enable a certificate authority first" });
}
const validityDays = clampValidityDays(
req.body?.validityDays,
ca.validityDays,
);
const principals: string[] = Array.isArray(req.body?.principals)
? req.body.principals
.filter((p: unknown) => typeof p === "string" && p.trim())
.map((p: string) => p.trim())
.slice(0, 32)
: [];
const now = Math.floor(Date.now() / 1000);
const validBefore = now + validityDays * 86400;
const keyId = `termix:@${identity.handle}:${id}`;
const certificate = signUserCertificate({
userPublicKeyLine: key.publicKey,
caPrivateKeyPem: caPrivateKey,
caPublicKeyLine: ca.publicKey,
keyId,
principals,
validAfter: now - 60,
validBefore,
});
if (!certificate) {
return res.status(500).json({ error: "Failed to sign certificate" });
}
await ctx.audit.record({
action: "issue_termix_id_certificate",
resourceType: "termix_id_key",
resourceId: String(id),
resourceName: identity.handle,
details: `validity ${validityDays}d${principals.length ? `, principals: ${principals.join(",")}` : ""}`,
success: true,
});
res.json({ certificate, keyId, validBefore, principals, validityDays });
} catch (error) {
fail(res, "Failed to issue certificate", error);
}
});
/**
* @openapi
* /plugin-api/termix-identity/linked-credentials:
* get:
* summary: Credential ids with at least one enabled published key
* tags: [Termix ID]
* security:
* - bearerAuth: []
* responses:
* 200:
* description: List of credential ids
*/
router.get("/linked-credentials", async (_req, res) => {
try {
const identity = await store.identityForUser(userId());
if (!identity) return res.json({ credentialIds: [] });
res.json({
credentialIds: await store.linkedCredentialIds(identity.id),
});
} catch (error) {
fail(res, "Failed to fetch linked credentials", error);
}
});
/**
* @openapi
* /plugin-api/termix-identity/credentials:
* get:
* summary: The user's SSH key credentials that can be published
* description: Id and name of each saved SSH key credential, for the import picker. No key material.
* tags: [Termix ID]
* security:
* - bearerAuth: []
* responses:
* 200:
* description: List of key credentials
*/
router.get("/credentials", async (_req, res) => {
try {
const keys = await ctx.credentials.listSshKeys();
res.json({
credentials: keys.map((key) => ({ id: key.id, name: key.name })),
});
} catch (error) {
fail(res, "Failed to fetch credentials", error);
}
});
}
@@ -0,0 +1,296 @@
import { and, asc, eq, isNotNull } from "drizzle-orm";
import type { PluginContext } from "@termix/plugin-sdk/backend";
/* eslint-disable @typescript-eslint/no-explicit-any */
// ctx.db.define hands the tables back untyped and the drizzle handle is the
// server's own, so both are typed at this module's edge.
type Table = any;
type Drizzle = any;
/* eslint-enable @typescript-eslint/no-explicit-any */
export interface IdentityRow {
id: number;
userId: string;
handle: string;
description: string | null;
createdAt: string;
updatedAt: string;
}
export interface KeyRow {
id: number;
identityId: number;
userId: string;
publicKey: string;
keyType: string;
algorithm: string;
label: string | null;
comment: string | null;
source: string;
credentialId: number | null;
enabled: boolean;
createdAt: string;
}
export interface CaRow {
id: number;
identityId: number;
userId: string;
publicKey: string;
/** Sealed with ctx.secrets.seal. */
privateKey: string;
validityDays: number;
createdAt: string;
updatedAt: string;
}
export type NewKey = Omit<KeyRow, "id" | "enabled" | "createdAt">;
export interface Tables {
identities: Table;
keys: Table;
ca: Table;
}
function toKey(row: Record<string, unknown>): KeyRow {
return {
...(row as unknown as KeyRow),
enabled: row.enabled === true || row.enabled === 1 || row.enabled === "1",
};
}
export type Store = ReturnType<typeof createStore>;
export function createStore(ctx: PluginContext, tables: Tables) {
const { identities, keys, ca } = tables;
const client = () => ctx.db.client<Drizzle>();
async function first<T>(query: Promise<T[]>): Promise<T | null> {
const rows = await query;
return rows[0] ?? null;
}
const identityForUser = async (userId: string) =>
first<IdentityRow>(
(await client())
.select()
.from(identities)
.where(eq(identities.userId, userId))
.limit(1),
);
const identityByHandle = async (handle: string) =>
first<IdentityRow>(
(await client())
.select()
.from(identities)
.where(eq(identities.handle, handle))
.limit(1),
);
const keysForIdentity = async (identityId: number): Promise<KeyRow[]> =>
(
await (
await client()
)
.select()
.from(keys)
.where(eq(keys.identityId, identityId))
.orderBy(asc(keys.id))
).map(toKey);
const keyForUser = async (userId: string, id: number) => {
const row = await first<Record<string, unknown>>(
(await client())
.select()
.from(keys)
.where(and(eq(keys.id, id), eq(keys.userId, userId)))
.limit(1),
);
return row ? toKey(row) : null;
};
const caForIdentity = async (identityId: number) =>
first<CaRow>(
(await client())
.select()
.from(ca)
.where(eq(ca.identityId, identityId))
.limit(1),
);
return {
identityForUser,
identityByHandle,
async isHandleTaken(handle: string): Promise<boolean> {
return (await identityByHandle(handle)) !== null;
},
async createIdentity(input: {
userId: string;
handle: string;
description: string | null;
}): Promise<IdentityRow> {
const now = new Date().toISOString();
await (
await client()
)
.insert(identities)
.values({ ...input, createdAt: now, updatedAt: now });
await ctx.db.persist();
return (await identityForUser(input.userId)) as IdentityRow;
},
async updateIdentity(
userId: string,
update: { handle?: string; description?: string | null },
): Promise<IdentityRow | null> {
await (
await client()
)
.update(identities)
.set({ ...update, updatedAt: new Date().toISOString() })
.where(eq(identities.userId, userId));
await ctx.db.persist();
return identityForUser(userId);
},
/** Deletes the identity with its keys and CA. */
async deleteIdentity(identity: IdentityRow): Promise<void> {
const db = await client();
await db.delete(keys).where(eq(keys.identityId, identity.id));
await db.delete(ca).where(eq(ca.identityId, identity.id));
await db.delete(identities).where(eq(identities.id, identity.id));
await ctx.db.persist();
},
keysForIdentity,
keyForUser,
async enabledKeys(identityId: number): Promise<KeyRow[]> {
return (await keysForIdentity(identityId)).filter((key) => key.enabled);
},
async createKey(input: NewKey): Promise<KeyRow> {
await (
await client()
)
.insert(keys)
.values({
...input,
enabled: true,
createdAt: new Date().toISOString(),
});
await ctx.db.persist();
// A public key appears once per identity, so it finds the new row.
const row = await first<Record<string, unknown>>(
(await client())
.select()
.from(keys)
.where(
and(
eq(keys.identityId, input.identityId),
eq(keys.publicKey, input.publicKey),
),
)
.limit(1),
);
return toKey(row as Record<string, unknown>);
},
async updateKey(
userId: string,
id: number,
update: {
enabled?: boolean;
label?: string | null;
credentialId?: number | null;
},
): Promise<KeyRow | null> {
if (!(await keyForUser(userId, id))) return null;
if (Object.keys(update).length > 0) {
await (
await client()
)
.update(keys)
.set(update)
.where(and(eq(keys.id, id), eq(keys.userId, userId)));
await ctx.db.persist();
}
return keyForUser(userId, id);
},
async deleteKey(userId: string, id: number): Promise<KeyRow | null> {
const existing = await keyForUser(userId, id);
if (!existing) return null;
await (
await client()
)
.delete(keys)
.where(and(eq(keys.id, id), eq(keys.userId, userId)));
await ctx.db.persist();
return existing;
},
async linkedCredentialIds(identityId: number): Promise<number[]> {
const rows: Array<{ credentialId: number | null }> = await (
await client()
)
.select({ credentialId: keys.credentialId })
.from(keys)
.where(
and(
eq(keys.identityId, identityId),
eq(keys.enabled, true),
isNotNull(keys.credentialId),
),
);
return [
...new Set(
rows
.map((row) => Number(row.credentialId))
.filter((id) => Number.isInteger(id) && id > 0),
),
];
},
caForIdentity,
async createCa(input: {
identityId: number;
userId: string;
publicKey: string;
privateKey: string;
validityDays: number;
}): Promise<void> {
const now = new Date().toISOString();
await (
await client()
)
.insert(ca)
.values({ ...input, createdAt: now, updatedAt: now });
await ctx.db.persist();
},
async replaceCa(
identityId: number,
update: { publicKey: string; privateKey: string; validityDays: number },
): Promise<void> {
await (
await client()
)
.update(ca)
.set({ ...update, updatedAt: new Date().toISOString() })
.where(eq(ca.identityId, identityId));
await ctx.db.persist();
},
async deleteCa(identityId: number): Promise<boolean> {
if (!(await caForIdentity(identityId))) return false;
await (await client()).delete(ca).where(eq(ca.identityId, identityId));
await ctx.db.persist();
return true;
},
};
}
@@ -0,0 +1,78 @@
import {
adoptLegacyTable,
boolean,
defineTable,
id,
integer,
refUser,
text,
varchar,
} from "@termix/plugin-sdk/db";
/** One public handle per user. Adopted from core's termix_identities. */
export const identities = adoptLegacyTable(
"termix_identities",
defineTable("identities", {
id: id(),
userId: refUser().unique(),
handle: varchar().notNull().unique(),
description: text(),
createdAt: text().notNull().defaultNow(),
updatedAt: text().notNull().defaultNow(),
}),
);
/**
* Published public keys, plaintext because the resolver serves them without
* auth. Adopted from core's termix_identity_keys. The migrations keep the
* legacy links to identities (cascade) and ssh_credentials (set null) by hand.
*/
export const keys = adoptLegacyTable(
"termix_identity_keys",
defineTable(
"keys",
{
id: id(),
identityId: integer().notNull(),
userId: refUser(),
publicKey: text().notNull(),
keyType: text().notNull(),
algorithm: text().notNull(),
label: text(),
comment: text(),
source: text().notNull().default("manual"),
credentialId: integer(),
enabled: boolean().notNull().default(true),
createdAt: text().notNull().defaultNow(),
},
{
indexes: [
{
name: "idx_termix_identity_keys_identity",
columns: ["identityId"],
},
],
},
),
);
/**
* One certificate authority per identity. The private key is sealed with
* ctx.secrets.seal; 2.8 rows are resealed by core's
* termix-identity-ca-migration. Adopted from core's termix_identity_ca.
*/
export const ca = adoptLegacyTable(
"termix_identity_ca",
defineTable("ca", {
id: id(),
identityId: integer().notNull().unique(),
userId: refUser(),
publicKey: text().notNull(),
privateKey: text().notNull(),
validityDays: integer().notNull().default(90),
createdAt: text().notNull().defaultNow(),
updatedAt: text().notNull().defaultNow(),
}),
);
export const tables = [identities, keys, ca];
@@ -0,0 +1,953 @@
import { useEffect, useState, useCallback } from "react";
import {
Check,
Copy,
Fingerprint,
KeyRound,
Loader2,
Plus,
RefreshCw,
ScrollText,
ShieldCheck,
Sparkles,
Trash2,
} from "lucide-react";
import { toast } from "sonner";
import { useTranslation } from "@termix/plugin-sdk/frontend";
import {
Button,
FakeSwitch,
Input,
SectionCard,
Select2,
SettingRow,
Textarea,
} from "@termix/plugin-sdk/ui";
import type { TermixIdApi } from "./api";
import type { LinkedStore } from "./linked-store";
import {
errorMessage,
type CredentialOption,
type TermixIdCa,
type TermixIdentity,
type TermixIdentityKey,
} from "./types";
const DOCS_URL = "https://docs.termix.site/features/authentication/termix-id";
const accentBtn =
"border-accent-brand/40 text-accent-brand hover:bg-accent-brand/20 hover:text-accent-brand";
function CopyRow({ label, value }: { label: string; value: string }) {
const { t } = useTranslation();
const [copied, setCopied] = useState(false);
async function copy() {
try {
await navigator.clipboard.writeText(value);
setCopied(true);
setTimeout(() => setCopied(false), 1500);
} catch {
toast.error(t("termixId.copyFailed"));
}
}
return (
<div className="flex flex-col gap-1.5 py-2.5 border-b border-border last:border-0">
<span className="text-[10px] font-semibold uppercase tracking-widest text-muted-foreground">
{label}
</span>
<div className="flex items-stretch gap-2">
<code className="flex-1 min-w-0 text-[11px] font-mono bg-muted/30 border border-border px-2 py-1.5 overflow-x-auto whitespace-nowrap text-muted-foreground">
{value}
</code>
<button
onClick={copy}
className="shrink-0 w-7 flex items-center justify-center border border-border bg-background hover:bg-muted text-muted-foreground hover:text-foreground transition-colors"
>
{copied ? (
<Check className="size-3.5 text-accent-brand" />
) : (
<Copy className="size-3.5" />
)}
</button>
</div>
</div>
);
}
export function TermixIdPanel({
api,
linked,
}: {
api: TermixIdApi;
linked: LinkedStore;
}) {
const { t } = useTranslation();
const [loading, setLoading] = useState(true);
const [identity, setIdentity] = useState<TermixIdentity | null>(null);
const [keys, setKeys] = useState<TermixIdentityKey[]>([]);
const [ca, setCa] = useState<TermixIdCa | null>(null);
const [linkedCredentialIds, setLinkedCredentialIds] = useState<Set<number>>(
new Set(),
);
const refresh = useCallback(async () => {
try {
const [data, caData, linkedData] = await Promise.all([
api.me(),
api.ca().catch(() => ({ ca: null })),
api.linkedCredentialIds().catch(() => ({ credentialIds: [] })),
]);
setIdentity(data.identity);
setKeys(data.keys);
setCa(data.identity ? caData.ca : null);
setLinkedCredentialIds(new Set(linkedData.credentialIds));
} catch {
toast.error(t("termixId.loadFailed"));
} finally {
setLoading(false);
}
}, [api, t]);
// Credential badges elsewhere follow what this panel changes.
const changed = useCallback(async () => {
await refresh();
void linked.refresh();
}, [refresh, linked]);
useEffect(() => {
refresh();
}, [refresh]);
if (loading) {
return (
<div className="flex items-center justify-center h-40 text-muted-foreground">
<Loader2 className="animate-spin size-4" />
</div>
);
}
return (
<div className="flex-1 min-h-0 overflow-y-auto scrollbar-none">
<div className="flex flex-col gap-3 p-3">
{!identity ? (
<ClaimHandle api={api} onCreated={changed} />
) : (
<>
<IdentityCard api={api} identity={identity} onChanged={changed} />
<AddKey
api={api}
handle={identity.handle}
linkedCredentialIds={linkedCredentialIds}
onAdded={changed}
/>
<KeyList
api={api}
keys={keys}
handle={identity.handle}
caEnabled={!!ca}
onChanged={changed}
/>
<CaCard
api={api}
handle={identity.handle}
ca={ca}
onChanged={changed}
/>
</>
)}
</div>
</div>
);
}
function ClaimHandle({
api,
onCreated,
}: {
api: TermixIdApi;
onCreated: () => void;
}) {
const { t } = useTranslation();
const [handle, setHandle] = useState("");
const [description, setDescription] = useState("");
const [checking, setChecking] = useState(false);
const [status, setStatus] = useState<
"idle" | "available" | "taken" | "invalid"
>("idle");
const [submitting, setSubmitting] = useState(false);
useEffect(() => {
const h = handle.trim().toLowerCase();
if (!h) {
setStatus("idle");
return;
}
// Guard against an out-of-order response: if the input changed (effect
// cleanup ran) before this request resolved, ignore its result.
let cancelled = false;
setChecking(true);
const timer = setTimeout(async () => {
try {
const res = await api.checkHandle(h);
if (cancelled) return;
setStatus(
!res.valid ? "invalid" : res.available ? "available" : "taken",
);
} catch {
if (!cancelled) setStatus("idle");
} finally {
if (!cancelled) setChecking(false);
}
}, 350);
return () => {
cancelled = true;
clearTimeout(timer);
};
}, [api, handle]);
async function submit() {
setSubmitting(true);
try {
await api.create(
handle.trim().toLowerCase(),
description.trim() || undefined,
);
toast.success(t("termixId.created"));
onCreated();
} catch (e) {
toast.error(errorMessage(e, t("termixId.createFailed")));
} finally {
setSubmitting(false);
}
}
return (
<SectionCard
title={t("termixId.title")}
icon={<Fingerprint className="size-3.5" />}
action={
<a
href={DOCS_URL}
target="_blank"
rel="noreferrer"
className="text-[10px] text-accent-brand hover:underline"
>
{t("termixId.docsLink")}
</a>
}
>
<div className="flex flex-col gap-3 py-3">
<p className="text-xs text-muted-foreground leading-snug">
{t("termixId.claimIntro")}
</p>
<div className="flex flex-col gap-1.5">
<span className="text-[10px] font-semibold uppercase tracking-widest text-muted-foreground">
{t("termixId.handleLabel")}
</span>
<div className="relative flex items-center">
<span className="absolute left-2 text-muted-foreground text-xs pointer-events-none select-none">
@
</span>
<Input
value={handle}
placeholder={t("termixId.handlePlaceholder")}
autoCapitalize="none"
spellCheck={false}
className="h-8 text-xs pl-5"
onChange={(e) => setHandle(e.target.value)}
/>
</div>
<span className="text-[11px] h-4 leading-4">
{checking ? (
<span className="text-muted-foreground">
{t("termixId.checking")}
</span>
) : status === "available" ? (
<span className="text-accent-brand">
{t("termixId.available")}
</span>
) : status === "taken" ? (
<span className="text-destructive">{t("termixId.taken")}</span>
) : status === "invalid" ? (
<span className="text-destructive">
{t("termixId.invalidHandle")}
</span>
) : null}
</span>
</div>
<div className="flex flex-col gap-1.5">
<span className="text-[10px] font-semibold uppercase tracking-widest text-muted-foreground">
{t("termixId.descriptionLabel")}
</span>
<Input
value={description}
placeholder={t("termixId.descriptionPlaceholder")}
className="h-8 text-xs"
onChange={(e) => setDescription(e.target.value)}
/>
</div>
<Button
variant="outline"
size="sm"
className={`self-start ${accentBtn}`}
disabled={status !== "available" || submitting}
onClick={submit}
>
{submitting ? (
<Loader2 className="animate-spin size-3.5" />
) : (
t("termixId.create")
)}
</Button>
</div>
</SectionCard>
);
}
function IdentityCard({
api,
identity,
onChanged,
}: {
api: TermixIdApi;
identity: TermixIdentity;
onChanged: () => void;
}) {
const { t } = useTranslation();
const [confirming, setConfirming] = useState(false);
const [busy, setBusy] = useState(false);
const url =
identity.resolverUrl ??
`${window.location.origin}${identity.resolverPath ?? ""}`;
const curl = `curl -fsSL ${url} >> ~/.ssh/authorized_keys`;
async function remove() {
setBusy(true);
try {
await api.remove();
toast.success(t("termixId.deleted"));
onChanged();
} catch (e) {
toast.error(errorMessage(e, t("termixId.deleteFailed")));
} finally {
setBusy(false);
setConfirming(false);
}
}
return (
<SectionCard
title={t("termixId.title")}
icon={<Fingerprint className="size-3.5" />}
action={
confirming ? (
<div className="flex items-center gap-1">
<Button
variant="outline"
size="sm"
className="border-destructive/40 text-destructive hover:bg-destructive/10 hover:text-destructive"
onClick={remove}
disabled={busy}
>
{busy ? (
<Loader2 className="animate-spin size-3.5" />
) : (
t("common.confirm")
)}
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => setConfirming(false)}
>
{t("common.cancel")}
</Button>
</div>
) : (
<div className="flex items-center gap-1">
<a
href={DOCS_URL}
target="_blank"
rel="noreferrer"
className="text-[10px] text-accent-brand hover:underline px-1"
>
{t("termixId.docsLink")}
</a>
<Button
variant="ghost"
size="icon-sm"
onClick={() => setConfirming(true)}
className="hover:text-destructive"
>
<Trash2 className="size-3.5" />
</Button>
</div>
)
}
>
{confirming && (
<div className="py-2 border-b border-border">
<p className="text-xs text-muted-foreground">
{t("termixId.deleteConfirm")}
</p>
</div>
)}
<div className="flex items-center gap-2 py-2.5 border-b border-border">
<span className="text-sm font-semibold text-accent-brand">
@{identity.handle}
</span>
</div>
<CopyRow label={t("termixId.resolverUrlLabel")} value={url} />
<CopyRow label={t("termixId.provisionLabel")} value={curl} />
</SectionCard>
);
}
function downloadText(filename: string, text: string) {
// octet-stream so the browser/OS keeps the given extension instead of
// appending .txt to a text/plain download.
const blob = new Blob([text], { type: "application/octet-stream" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
setTimeout(() => URL.revokeObjectURL(url), 1000);
}
function CredentialImportRow({
credentials,
linkedCredentialIds,
submitting,
onImport,
}: {
credentials: CredentialOption[];
linkedCredentialIds: Set<number>;
submitting: boolean;
onImport: (id: number) => void;
}) {
const { t } = useTranslation();
const [selected, setSelected] = useState<string>("");
const selectedId = selected ? Number(selected) : null;
const alreadyLinked =
selectedId !== null && linkedCredentialIds.has(selectedId);
return (
<div className="flex items-center gap-2">
<Select2
value={selected}
onChange={(e) => setSelected(e.target.value)}
disabled={submitting}
className="flex-1 h-8 min-w-0 border border-border bg-background px-2 text-xs outline-none focus:ring-1 focus:ring-ring disabled:opacity-50 disabled:cursor-not-allowed"
>
<option value="" disabled>
{t("termixId.selectCredential")}
</option>
{credentials.map((c) => (
<option key={c.id} value={String(c.id)}>
{linkedCredentialIds.has(c.id) ? `✓ ${c.name}` : c.name}
</option>
))}
</Select2>
<Button
variant="outline"
size="default"
className={`h-8 shrink-0 ${alreadyLinked ? "border-accent-brand/20 text-accent-brand/50" : accentBtn}`}
disabled={!selectedId || submitting || alreadyLinked}
onClick={() => selectedId !== null && onImport(selectedId)}
>
{alreadyLinked ? <Check className="size-3.5" /> : t("termixId.import")}
</Button>
</div>
);
}
function AddKey({
api,
handle,
linkedCredentialIds,
onAdded,
}: {
api: TermixIdApi;
handle: string;
linkedCredentialIds: Set<number>;
onAdded: () => void;
}) {
const { t } = useTranslation();
const [publicKey, setPublicKey] = useState("");
const [label, setLabel] = useState("");
const [submitting, setSubmitting] = useState(false);
const [generating, setGenerating] = useState(false);
const [saveToVault, setSaveToVault] = useState(true);
const [credentials, setCredentials] = useState<CredentialOption[]>([]);
const loadCredentials = useCallback(async () => {
try {
setCredentials((await api.credentials()).credentials);
} catch {
// Pasting a key still works without the list.
}
}, [api]);
useEffect(() => {
loadCredentials();
}, [loadCredentials]);
async function addManual() {
if (!publicKey.trim()) return;
setSubmitting(true);
try {
await api.addKey({
publicKey: publicKey.trim(),
label: label.trim() || undefined,
});
toast.success(t("termixId.keyPublished"));
setPublicKey("");
setLabel("");
onAdded();
} catch (e) {
toast.error(errorMessage(e, t("termixId.addKeyFailed")));
} finally {
setSubmitting(false);
}
}
async function generate() {
setGenerating(true);
try {
const result = await api.generateKey("ed25519", saveToVault);
downloadText(`termix-${handle}-ed25519.key`, result.privateKey);
toast.success(
saveToVault
? t("termixId.generatedSaved")
: t("termixId.generatedOnly"),
);
if (saveToVault) loadCredentials();
onAdded();
} catch (e) {
toast.error(errorMessage(e, t("termixId.generateFailed")));
} finally {
setGenerating(false);
}
}
async function importCredential(id: number) {
setSubmitting(true);
try {
await api.addKey({ credentialId: id });
toast.success(t("termixId.imported"));
onAdded();
} catch (e) {
toast.error(errorMessage(e, t("termixId.importFailed")));
} finally {
setSubmitting(false);
}
}
return (
<SectionCard
title={t("termixId.publishTitle")}
icon={<Plus className="size-3.5" />}
action={
<Button
variant="outline"
size="sm"
className={accentBtn}
onClick={generate}
disabled={generating}
title={t("termixId.generateTooltip")}
>
{generating ? (
<Loader2 className="animate-spin size-3.5" />
) : (
<>
<Sparkles className="size-3.5" />
{t("termixId.generate")}
</>
)}
</Button>
}
>
<div className="flex flex-col gap-3 py-3">
<SettingRow label={t("termixId.saveToVault")}>
<FakeSwitch checked={saveToVault} onChange={setSaveToVault} />
</SettingRow>
<Textarea
value={publicKey}
onChange={(e) => setPublicKey(e.target.value)}
placeholder={t("termixId.keyPlaceholder")}
rows={3}
className="rounded-none font-mono text-[11px]"
spellCheck={false}
/>
<div className="flex items-center gap-2">
<Input
value={label}
onChange={(e) => setLabel(e.target.value)}
placeholder={t("termixId.labelPlaceholder")}
className="h-8 text-xs flex-1"
/>
<Button
variant="outline"
size="default"
className={`h-8 ${accentBtn}`}
onClick={addManual}
disabled={!publicKey.trim() || submitting}
>
{submitting ? (
<Loader2 className="animate-spin size-3.5" />
) : (
t("termixId.add")
)}
</Button>
</div>
{credentials.length > 0 && (
<div className="flex flex-col gap-1.5 border-t border-border pt-3">
<span className="text-[10px] font-semibold uppercase tracking-widest text-muted-foreground">
{t("termixId.importFromCredential")}
</span>
<CredentialImportRow
credentials={credentials}
linkedCredentialIds={linkedCredentialIds}
submitting={submitting}
onImport={importCredential}
/>
</div>
)}
</div>
</SectionCard>
);
}
function KeyList({
api,
keys,
handle,
caEnabled,
onChanged,
}: {
api: TermixIdApi;
keys: TermixIdentityKey[];
handle: string;
caEnabled: boolean;
onChanged: () => void;
}) {
const { t } = useTranslation();
const [issuingId, setIssuingId] = useState<number | null>(null);
const [principalsByKey, setPrincipalsByKey] = useState<
Record<number, string>
>({});
async function toggle(k: TermixIdentityKey) {
try {
await api.setKeyEnabled(k.id, !k.enabled);
onChanged();
} catch (e) {
toast.error(errorMessage(e, t("termixId.updateKeyFailed")));
}
}
async function remove(k: TermixIdentityKey) {
try {
await api.removeKey(k.id);
toast.success(t("termixId.keyRemoved"));
onChanged();
} catch (e) {
toast.error(errorMessage(e, t("termixId.removeKeyFailed")));
}
}
async function issueCert(k: TermixIdentityKey) {
setIssuingId(k.id);
try {
const res = await api.issueCertificate(k.id, {
principals: (principalsByKey[k.id] ?? "")
.split(",")
.map((principal) => principal.trim())
.filter(Boolean),
});
downloadText(`termix-${handle}-${k.id}-cert.pub`, res.certificate + "\n");
toast.success(t("termixId.certIssued"));
} catch (e) {
toast.error(errorMessage(e, t("termixId.certIssueFailed")));
} finally {
setIssuingId(null);
}
}
return (
<div className="flex flex-col border border-border bg-card">
<div className="flex items-center gap-2 px-4 py-2.5 border-b border-border shrink-0">
<span className="text-muted-foreground">
<KeyRound className="size-3.5" />
</span>
<span className="text-xs font-semibold uppercase tracking-widest text-muted-foreground flex-1">
{t("termixId.keysTitle")}
</span>
</div>
{keys.length === 0 ? (
<p className="text-xs text-muted-foreground px-4 py-3">
{t("termixId.noKeys")}
</p>
) : (
keys.map((k) => {
const canCert = caEnabled && k.algorithm.toUpperCase() === "ED25519";
return (
<div
key={k.id}
className="flex flex-col px-3 py-2 gap-1 border-b border-border last:border-0"
>
{/* row 1: label + badges */}
<div className="flex items-center gap-1.5 min-w-0">
<span className="text-[10px] font-bold uppercase tracking-wide text-accent-brand border border-accent-brand/40 px-1 py-px shrink-0 leading-none">
{k.algorithm}
</span>
{k.credentialId && (
<span className="text-[9px] font-semibold uppercase tracking-widest text-accent-brand/60 border border-accent-brand/20 px-1 py-px shrink-0 leading-none">
{t("termixId.fromVault")}
</span>
)}
<span className="text-xs font-medium truncate min-w-0">
{k.label || k.comment || k.keyType}
</span>
</div>
{/* row 2: public key */}
<code className="text-[10px] font-mono text-muted-foreground truncate">
{k.publicKey}
</code>
{/* row 3: actions */}
<div className="flex items-center gap-1 pt-0.5">
<FakeSwitch checked={k.enabled} onChange={() => toggle(k)} />
{canCert && (
<>
<Input
value={principalsByKey[k.id] ?? ""}
onChange={(e) =>
setPrincipalsByKey((current) => ({
...current,
[k.id]: e.target.value,
}))
}
placeholder={t("termixId.principalsPlaceholder")}
aria-label={t("termixId.principalsLabel")}
className="h-7 min-w-0 flex-1 text-xs"
spellCheck={false}
/>
<Button
variant="outline"
size="icon-sm"
onClick={() => issueCert(k)}
disabled={
issuingId === k.id ||
!(principalsByKey[k.id] ?? "").trim()
}
title={t("termixId.issueCertTooltip")}
>
{issuingId === k.id ? (
<Loader2 className="animate-spin size-3.5" />
) : (
<ScrollText className="size-3.5" />
)}
</Button>
</>
)}
<Button
variant="ghost"
size="icon-sm"
onClick={() => remove(k)}
className="hover:text-destructive"
>
<Trash2 className="size-3.5" />
</Button>
</div>
</div>
);
})
)}
</div>
);
}
function CaCard({
api,
handle,
ca,
onChanged,
}: {
api: TermixIdApi;
handle: string;
ca: TermixIdCa | null;
onChanged: () => void;
}) {
const { t } = useTranslation();
const [busy, setBusy] = useState(false);
const [confirmingRotate, setConfirmingRotate] = useState(false);
const [confirmingDelete, setConfirmingDelete] = useState(false);
const caUrl = ca?.resolverUrl ?? "";
const trustCmd = `curl -fsSL ${caUrl} | sudo tee /etc/ssh/${handle}-ca.pub && echo "TrustedUserCAKeys /etc/ssh/${handle}-ca.pub" | sudo tee -a /etc/ssh/sshd_config && sudo systemctl reload sshd`;
async function enable() {
setBusy(true);
try {
await api.createCa();
toast.success(t("termixId.caEnabled"));
onChanged();
} catch (e) {
toast.error(errorMessage(e, t("termixId.caCreateFailed")));
} finally {
setBusy(false);
}
}
async function rotate() {
setBusy(true);
try {
await api.rotateCa();
toast.success(t("termixId.caRotated"));
onChanged();
} catch (e) {
toast.error(errorMessage(e, t("termixId.caRotateFailed")));
} finally {
setBusy(false);
setConfirmingRotate(false);
}
}
async function remove() {
setBusy(true);
try {
await api.removeCa();
toast.success(t("termixId.caDeleted"));
onChanged();
} catch (e) {
toast.error(errorMessage(e, t("termixId.caDeleteFailed")));
} finally {
setBusy(false);
setConfirmingDelete(false);
}
}
return (
<SectionCard
title={t("termixId.caTitle")}
icon={<ShieldCheck className="size-3.5" />}
action={
!ca ? (
<Button
variant="outline"
size="sm"
className={accentBtn}
onClick={enable}
disabled={busy}
>
{busy ? (
<Loader2 className="animate-spin size-3.5" />
) : (
t("termixId.caEnable")
)}
</Button>
) : confirmingRotate ? (
<div className="flex items-center gap-1">
<Button
variant="outline"
size="sm"
className="border-destructive/40 text-destructive hover:bg-destructive/10 hover:text-destructive"
onClick={rotate}
disabled={busy}
>
{busy ? (
<Loader2 className="animate-spin size-3.5" />
) : (
t("termixId.caRotate")
)}
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => setConfirmingRotate(false)}
>
{t("common.cancel")}
</Button>
</div>
) : confirmingDelete ? (
<div className="flex items-center gap-1">
<Button
variant="outline"
size="sm"
className="border-destructive/40 text-destructive hover:bg-destructive/10 hover:text-destructive"
onClick={remove}
disabled={busy}
>
{busy ? (
<Loader2 className="animate-spin size-3.5" />
) : (
t("termixId.caDelete")
)}
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => setConfirmingDelete(false)}
>
{t("common.cancel")}
</Button>
</div>
) : (
<div className="flex items-center gap-1">
<Button
variant="outline"
size="sm"
onClick={() => setConfirmingRotate(true)}
disabled={busy}
>
<RefreshCw className="size-3.5" />
{t("termixId.caRotate")}
</Button>
<Button
variant="ghost"
size="icon-sm"
onClick={() => setConfirmingDelete(true)}
disabled={busy}
className="hover:text-destructive"
>
<Trash2 className="size-3.5" />
</Button>
</div>
)
}
>
<div className="py-2.5 border-b border-border">
<p className="text-xs text-muted-foreground leading-snug">
{t("termixId.caIntro")}
</p>
</div>
{confirmingRotate && (
<div className="py-2.5 border-b border-border">
<p className="text-xs text-destructive/80 leading-snug">
{t("termixId.caRotateConfirm")}
</p>
</div>
)}
{confirmingDelete && (
<div className="py-2.5 border-b border-border">
<p className="text-xs text-destructive/80 leading-snug">
{t("termixId.caDeleteConfirm")}
</p>
</div>
)}
{ca && (
<>
<CopyRow label={t("termixId.caTrustLabel")} value={trustCmd} />
<CopyRow
label={t("termixId.caPublicKeyLabel")}
value={ca.publicKey}
/>
</>
)}
</SectionCard>
);
}
@@ -0,0 +1,57 @@
import type { PluginApiClient } from "@termix/plugin-sdk/frontend";
import type {
CredentialOption,
GeneratedKey,
IssuedCertificate,
TermixIdCa,
TermixIdentity,
TermixIdentityKey,
TermixIdMe,
} from "./types";
/** The Termix ID routes, relative to /plugin-api/termix-identity/. */
export function createTermixIdApi(api: PluginApiClient) {
const data = async <T>(request: Promise<{ data: T }>) => (await request).data;
return {
me: () => data(api.get<TermixIdMe>("/me")),
checkHandle: (handle: string) =>
data(
api.get<{ available: boolean; valid: boolean }>(
`/check/${encodeURIComponent(handle)}`,
),
),
create: (handle: string, description?: string) =>
data(api.post<TermixIdentity>("/", { handle, description })),
update: (body: { handle?: string; description?: string }) =>
data(api.put<TermixIdentity>("/", body)),
remove: () => data(api.delete<{ success: boolean }>("/")),
addKey: (body: {
publicKey?: string;
credentialId?: number;
label?: string;
}) => data(api.post<TermixIdentityKey>("/keys", body)),
generateKey: (type: "ed25519" | "rsa", saveCredential: boolean) =>
data(api.post<GeneratedKey>("/keys/generate", { type, saveCredential })),
setKeyEnabled: (id: number, enabled: boolean) =>
data(api.patch<TermixIdentityKey>(`/keys/${id}`, { enabled })),
removeKey: (id: number) =>
data(api.delete<{ success: boolean }>(`/keys/${id}`)),
ca: () => data(api.get<{ ca: TermixIdCa | null }>("/ca")),
createCa: (validityDays?: number) =>
data(api.post<TermixIdCa>("/ca", { validityDays })),
rotateCa: (validityDays?: number) =>
data(api.post<TermixIdCa>("/ca/rotate", { validityDays })),
removeCa: () => data(api.delete<{ success: boolean }>("/ca")),
issueCertificate: (
keyId: number,
body: { principals?: string[]; validityDays?: number } = {},
) => data(api.post<IssuedCertificate>(`/keys/${keyId}/certificate`, body)),
linkedCredentialIds: () =>
data(api.get<{ credentialIds: number[] }>("/linked-credentials")),
credentials: () =>
data(api.get<{ credentials: CredentialOption[] }>("/credentials")),
};
}
export type TermixIdApi = ReturnType<typeof createTermixIdApi>;
@@ -0,0 +1,101 @@
import { useEffect, useReducer, type ComponentType } from "react";
import { Fingerprint } from "lucide-react";
import {
useTranslation,
type Disposer,
type TermixApp,
} from "@termix/plugin-sdk/frontend";
import { TermixIdPanel } from "./TermixIdPanel";
import { createTermixIdApi } from "./api";
import { createLinkedStore, type LinkedStore } from "./linked-store";
const VIEW = "termix-id";
function createCredentialBadge(linked: LinkedStore) {
/** The "ID" badge on a saved credential whose key is published. */
return function CredentialBadge({ credentialId }: { credentialId?: number }) {
const { t } = useTranslation();
const [, rerender] = useReducer((n: number) => n + 1, 0);
useEffect(() => {
linked.ensure();
return linked.subscribe(rerender);
}, []);
if (credentialId === undefined || !linked.has(Number(credentialId))) {
return null;
}
return (
<span className="text-[9px] px-1 py-px font-bold border leading-none shrink-0 border-accent-brand/30 text-accent-brand/70">
{t("credentials.idBadge")}
</span>
);
};
}
export function activate(app: TermixApp): void {
const api = createTermixIdApi(app.api);
const linked = createLinkedStore(api, () => app.hasPermission("use"));
const Panel = () => (
<div className="flex flex-col flex-1 min-h-0">
<TermixIdPanel api={api} linked={linked} />
</div>
);
// Publishing keys under a public handle means nothing to a standalone
// desktop install, so the rail item waits for a connected remote server.
let removeRailItem: Disposer | null = null;
let hidden: boolean | null = null;
const showRailItem = (hide: boolean) => {
if (hide === hidden) return;
hidden = hide;
removeRailItem?.();
removeRailItem = app.registerRailItem({
id: VIEW,
icon: Fingerprint,
titleKey: "nav.termixId",
after: "credentials",
promotable: true,
separatorAfter: true,
permission: "use",
hidden: hide,
});
};
if (app.desktop.available) {
let alive = true;
const check = () =>
void app.desktop.remoteServerUrl().then((url) => {
if (alive) showRailItem(!url);
});
showRailItem(true);
check();
app.desktop.onRemoteServerChange(check);
app.onDispose(() => {
alive = false;
});
} else {
showRailItem(false);
}
app.registerPanel(VIEW, Panel);
app.registerTab(VIEW, Panel, {
icon: Fingerprint,
titleKey: "nav.termixId",
hostless: true,
singleton: true,
panelFrame: true,
});
app.registerSlotContribution("credentials.badges", {
actionId: "termix-identity.credentialBadge",
titleKey: "credentials.idBadge",
kind: "component",
component: createCredentialBadge(linked) as unknown as ComponentType<
Record<string, unknown>
>,
});
}
export function deactivate(): void {
// Registrations through app are disposed by core.
}
@@ -0,0 +1,48 @@
import type { TermixIdApi } from "./api";
/**
* Which credentials have a published key, shared by every credential badge
* so the list fetches once instead of once per row. The panel calls
* refresh() after it publishes or removes a key.
*/
export function createLinkedStore(
api: TermixIdApi,
allowed: () => Promise<boolean>,
) {
let ids = new Set<number>();
let loading: Promise<void> | null = null;
let loaded = false;
const listeners = new Set<() => void>();
const refresh = (): Promise<void> => {
loading = (async () => {
try {
ids = (await allowed())
? new Set((await api.linkedCredentialIds()).credentialIds)
: new Set();
} catch {
// Badges are a hint; a failed lookup just shows none.
}
loaded = true;
listeners.forEach((listener) => listener());
})();
return loading;
};
return {
has: (id: number) => ids.has(id),
ids: () => ids,
ensure: () => {
if (!loaded && !loading) void refresh();
},
refresh,
subscribe: (listener: () => void) => {
listeners.add(listener);
return () => {
listeners.delete(listener);
};
},
};
}
export type LinkedStore = ReturnType<typeof createLinkedStore>;
@@ -0,0 +1,64 @@
export interface TermixIdentity {
id: number;
userId: string;
handle: string;
description: string | null;
resolverPath?: string;
resolverUrl?: string;
createdAt: string;
updatedAt: string;
}
export interface TermixIdentityKey {
id: number;
identityId: number;
userId: string;
publicKey: string;
keyType: string;
algorithm: string;
label: string | null;
comment: string | null;
source: string;
credentialId: number | null;
enabled: boolean;
createdAt: string;
}
export interface TermixIdMe {
identity: TermixIdentity | null;
keys: TermixIdentityKey[];
}
export interface GeneratedKey {
key: TermixIdentityKey;
privateKey: string;
publicKey: string;
credentialId: number | null;
}
export interface TermixIdCa {
publicKey: string;
validityDays: number;
resolverPath: string;
resolverUrl: string;
}
export interface IssuedCertificate {
certificate: string;
keyId: string;
validBefore: number;
principals: string[];
validityDays: number;
}
export interface CredentialOption {
id: number;
name: string;
}
/** The server's error message when it sent one, else the fallback. */
export function errorMessage(error: unknown, fallback: string): string {
const data = (error as { response?: { data?: { error?: unknown } } })
?.response?.data;
return typeof data?.error === "string" ? data.error : fallback;
}