import { createHash, randomBytes } from "crypto"; import { and, eq, isNull } from "drizzle-orm"; import type { ApiAuthContext } from "@/lib/api-auth"; import { db } from "@/lib/db"; import { getEntityType, getRegistry } from "./registry"; import { shareLinks } from "./schema"; import { ensureEntityShareAuthorized } from "./share-authorization"; import type { ShareLinkCapabilities } from "./share"; export type CreateShareLinkResult = { url: string; token: string; expiresAt: Date | null; }; export type EntityShareLink = { id: string; entityType: string; entityId: string; createdAt: string; expiresAt: string | null; capabilities: ShareLinkCapabilities; }; function hashToken(raw: string): string { return createHash("sha256").update(raw).digest("hex"); } function buildUrl(token: string): string { const base = process.env["NEXT_PUBLIC_APP_URL"] ?? process.env["AUTH_URL"] ?? "http://localhost:3000"; return `${base}/s/${token}`; } export function listShareableEntityTypes() { return getRegistry() .entityTypes.filter((entity) => entity.share?.canShare) .map((entity) => ({ type: entity.type, label: entity.label.singular, defaultCapabilities: entity.share?.defaultCapabilities ?? ["read"], })); } export async function createShareLinkForScope( scope: ApiAuthContext, entityType: string, entityId: string, opts: { expiresAt?: Date | null; capabilities?: Partial; } = {}, ): Promise { const registration = getEntityType(entityType); if (!registration?.share?.canShare) { throw new Error(`Entity type "${entityType}" is not shareable`); } if (!scope.userId) { throw new Error("Share links require an authenticated user session"); } await ensureEntityShareAuthorized(registration, entityId, { householdId: scope.householdId, userId: scope.userId, }); const rawToken = randomBytes(32).toString("base64url"); const tokenHash = hashToken(rawToken); const capabilities: ShareLinkCapabilities = { read: opts.capabilities?.read ?? true, write: opts.capabilities?.write ?? false, }; await db.insert(shareLinks).values({ householdId: scope.householdId, entityType, entityId, token: tokenHash, capabilities, createdBy: scope.userId, expiresAt: opts.expiresAt ?? null, }); return { url: buildUrl(rawToken), token: rawToken, expiresAt: opts.expiresAt ?? null }; } export async function revokeShareLinkForScope(scope: ApiAuthContext, id: string): Promise { const [row] = await db .select({ id: shareLinks.id }) .from(shareLinks) .where(and(eq(shareLinks.id, id), eq(shareLinks.householdId, scope.householdId))) .limit(1); if (!row) throw new Error("Share link not found"); await db.update(shareLinks).set({ revokedAt: new Date() }).where(eq(shareLinks.id, id)); } export async function listShareLinksForScope( scope: ApiAuthContext, filters?: { entityType?: string; entityId?: string }, ): Promise { const now = new Date(); const conditions = [eq(shareLinks.householdId, scope.householdId), isNull(shareLinks.revokedAt)]; if (filters?.entityType) conditions.push(eq(shareLinks.entityType, filters.entityType)); if (filters?.entityId) conditions.push(eq(shareLinks.entityId, filters.entityId)); const rows = await db .select({ id: shareLinks.id, entityType: shareLinks.entityType, entityId: shareLinks.entityId, createdAt: shareLinks.createdAt, expiresAt: shareLinks.expiresAt, capabilities: shareLinks.capabilities, }) .from(shareLinks) .where(and(...conditions)) .orderBy(shareLinks.createdAt); return rows .filter((row) => !row.expiresAt || row.expiresAt > now) .map((row) => ({ id: row.id, entityType: row.entityType, entityId: row.entityId, createdAt: row.createdAt.toISOString(), expiresAt: row.expiresAt?.toISOString() ?? null, capabilities: row.capabilities, })); }