mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-30 23:25:24 +00:00
feat: move termix identity into a plugin (C6)
This commit is contained in:
1 parent
6e56c90aac
commit
4dbce08379
157 files changed
+20743
-5766
No files matched your search
@@ -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;
|
||||
},
|
||||
};
|
||||
}
|
||||
Reference in new issue
Block a user