feat: harden plugin api for separate repos, signed updates and generic external login

This commit is contained in:
LukeGus committed 2026-09-29 18:11:59 -05:00
1 parent fb9c168b6b
commit 80944301dd
310 files changed
+6577 -4927

No files matched your search

+1 -1
View File
@@ -11,7 +11,7 @@ export interface PublicLoginMethod {
kind: "redirect" | "form";
labelKey: string;
icon?: string;
instances: Array<{ id: string; label: string }>;
instances: Array<{ id: string; label: string; autoStart?: boolean }>;
}
export interface SecondFactorRef {
+1 -1
View File
@@ -12,7 +12,7 @@ export interface UptimeInfo {
export interface RecentActivityItem {
id: number;
userId: string;
/** Core records file_manager and tunnel; plugin tabs record their own. */
/** The activity type; each plugin records its own. */
type: string;
hostId: number;
hostName: string;
+36
View File
@@ -0,0 +1,36 @@
import { authApi, handleApiError } from "@/main-axios";
// EXTERNAL ACCOUNT LINKING
// ============================================================================
export async function linkExternalToPasswordAccount(
externalUserId: string,
targetUsername: string,
): Promise<{ success: boolean; message: string }> {
try {
const response = await authApi.post("/users/link-external-to-password", {
externalUserId,
targetUsername,
});
return response.data;
} catch (error) {
throw handleApiError(error, "link external account to password account");
}
}
export async function unlinkExternalFromPasswordAccount(
userId: string,
): Promise<{ success: boolean; message: string }> {
try {
const response = await authApi.post(
"/users/unlink-external-from-password",
{ userId },
);
return response.data;
} catch (error) {
throw handleApiError(
error,
"unlink external sign-in from password account",
);
}
}
-32
View File
@@ -1,32 +0,0 @@
import { authApi, handleApiError } from "@/main-axios";
// OIDC ACCOUNT LINKING
// ============================================================================
export async function linkOIDCToPasswordAccount(
oidcUserId: string,
targetUsername: string,
): Promise<{ success: boolean; message: string }> {
try {
const response = await authApi.post("/users/link-oidc-to-password", {
oidcUserId,
targetUsername,
});
return response.data;
} catch (error) {
throw handleApiError(error, "link OIDC account to password account");
}
}
export async function unlinkOIDCFromPasswordAccount(
userId: string,
): Promise<{ success: boolean; message: string }> {
try {
const response = await authApi.post("/users/unlink-oidc-from-password", {
userId,
});
return response.data;
} catch (error) {
throw handleApiError(error, "unlink OIDC from password account");
}
}
+3 -20
View File
@@ -1,9 +1,6 @@
import { authApi } from "@/main-axios";
import { createTtlRequestCache } from "@/lib/ttl-request-cache";
import type { CustomKeybinding } from "@/types/keybindings";
import { invokeAction, isActionRegistered } from "@/shell/action-registry";
const SHARED_WITH_ME_ACTION = "sessions.sharedWithMe";
// OPEN TABS API
// ============================================================================
@@ -45,10 +42,6 @@ export interface ActiveSessionInfo {
tabInstanceId: string | null;
isConnected: boolean;
createdAt: number;
isOwnSession: boolean;
sharedByUsername: string | null;
permissionLevel: string | null;
shareId: string | null;
}
// Negative IDs identify remote-only shared hosts, absent from the local DB.
@@ -86,21 +79,11 @@ export async function addOpenTab(tab: OpenTabUpsertPayload): Promise<void> {
await authApi.post("/open-tabs", tab);
}
/**
* The caller's live sessions, plus sessions other users shared with them,
* which the session sharing plugin answers through an action (none while it
* is off).
*/
/** The caller's own live sessions. */
export async function getActiveSessions(): Promise<ActiveSessionInfo[]> {
return activeSessionsCache.get(async () => {
const [response, shared] = await Promise.all([
authApi.get("/open-tabs/active-sessions"),
isActionRegistered(SHARED_WITH_ME_ACTION)
? invokeAction(SHARED_WITH_ME_ACTION).catch(() => [])
: Promise.resolve([]),
]);
const own = Array.isArray(response.data) ? response.data : [];
return Array.isArray(shared) ? [...own, ...shared] : own;
const response = await authApi.get("/open-tabs/active-sessions");
return Array.isArray(response.data) ? response.data : [];
});
}
+4 -13
View File
@@ -78,19 +78,10 @@ export interface PluginContributions {
uiPresets?: PluginUiPresets;
}
/** A secret as it arrives from the server. The value never leaves the server. */
export interface RedactedSecret {
set: boolean;
}
export function isRedactedSecret(value: unknown): value is RedactedSecret {
return (
typeof value === "object" &&
value !== null &&
!Array.isArray(value) &&
typeof (value as RedactedSecret).set === "boolean"
);
}
export {
isRedactedSecret,
type RedactedSecret,
} from "@termix/plugin-sdk/settings";
export type PluginSettingsValues = Record<string, unknown>;
+1 -1
View File
@@ -76,7 +76,7 @@ export async function updateAnalyticsEnabled(
}
// ============================================================================
// TERMINAL IMAGE STORAGE SETTINGS
// BRANDING SETTINGS
// ============================================================================
export interface BrandingSettings {
-46
View File
@@ -1,46 +0,0 @@
export interface ServerSentEvent {
event: string;
data: string;
}
export async function streamServerSentEvents(
url: string,
init: RequestInit,
onEvent: (event: ServerSentEvent) => void,
): Promise<void> {
const response = await fetch(url, init);
if (!response.ok) {
throw new Error(`SSE request failed with status ${response.status}`);
}
if (!response.body) throw new Error("SSE response has no body");
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = "";
let eventName = "message";
let data: string[] = [];
const dispatch = () => {
if (data.length > 0) onEvent({ event: eventName, data: data.join("\n") });
eventName = "message";
data = [];
};
while (true) {
const { done, value } = await reader.read();
buffer += decoder.decode(value, { stream: !done });
const lines = buffer.split(/\r?\n/);
buffer = done ? "" : (lines.pop() ?? "");
for (const line of lines) {
if (line === "") dispatch();
else if (line.startsWith("event:")) eventName = line.slice(6).trimStart();
else if (line.startsWith("data:")) data.push(line.slice(5).trimStart());
}
if (done) {
dispatch();
return;
}
}
}
+6 -29
View File
@@ -221,20 +221,22 @@ export async function updateRegistrationAllowed(
}
}
export async function getOidcAutoProvision(): Promise<{ enabled: boolean }> {
export async function getExternalAutoProvision(): Promise<{
enabled: boolean;
}> {
try {
const response = await authApi.get("/users/oidc-auto-provision");
const response = await authApi.get("/users/external-auto-provision");
return response.data;
} catch (error) {
handleApiError(error, "check OIDC auto-provision status");
}
}
export async function updateOidcAutoProvision(
export async function updateExternalAutoProvision(
enabled: boolean,
): Promise<Record<string, unknown>> {
try {
const response = await authApi.patch("/users/oidc-auto-provision", {
const response = await authApi.patch("/users/external-auto-provision", {
enabled,
});
return response.data;
@@ -270,31 +272,6 @@ export async function updateSecondFactorAfterExternalLogin(
}
}
export async function getOidcSilentLoginDefault(): Promise<{
enabled: boolean;
locked?: boolean;
}> {
try {
const response = await authApi.get("/users/oidc-silent-login-default");
return response.data;
} catch (error) {
handleApiError(error, "get OIDC silent login default");
}
}
export async function updateOidcSilentLoginDefault(
enabled: boolean,
): Promise<{ enabled: boolean }> {
try {
const response = await authApi.patch("/users/oidc-silent-login-default", {
enabled,
});
return response.data;
} catch (error) {
handleApiError(error, "update OIDC silent login default");
}
}
export async function updatePasswordLoginAllowed(
allowed: boolean,
): Promise<{ allowed: boolean }> {