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; export interface Tables { identities: Table; keys: Table; ca: Table; } function toKey(row: Record): KeyRow { return { ...(row as unknown as KeyRow), enabled: row.enabled === true || row.enabled === 1 || row.enabled === "1", }; } export type Store = ReturnType; export function createStore(ctx: PluginContext, tables: Tables) { const { identities, keys, ca } = tables; const client = () => ctx.db.client(); async function first(query: Promise): Promise { const rows = await query; return rows[0] ?? null; } const identityForUser = async (userId: string) => first( (await client()) .select() .from(identities) .where(eq(identities.userId, userId)) .limit(1), ); const identityByHandle = async (handle: string) => first( (await client()) .select() .from(identities) .where(eq(identities.handle, handle)) .limit(1), ); const keysForIdentity = async (identityId: number): Promise => ( 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>( (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( (await client()) .select() .from(ca) .where(eq(ca.identityId, identityId)) .limit(1), ); return { identityForUser, identityByHandle, async isHandleTaken(handle: string): Promise { return (await identityByHandle(handle)) !== null; }, async createIdentity(input: { userId: string; handle: string; description: string | null; }): Promise { 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 { 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 { 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 { return (await keysForIdentity(identityId)).filter((key) => key.enabled); }, async createKey(input: NewKey): Promise { 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>( (await client()) .select() .from(keys) .where( and( eq(keys.identityId, input.identityId), eq(keys.publicKey, input.publicKey), ), ) .limit(1), ); return toKey(row as Record); }, async updateKey( userId: string, id: number, update: { enabled?: boolean; label?: string | null; credentialId?: number | null; }, ): Promise { 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 { 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 { 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 { 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 { 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 { if (!(await caForIdentity(identityId))) return false; await (await client()).delete(ca).where(eq(ca.identityId, identityId)); await ctx.db.persist(); return true; }, }; }