UNPKG

webpods

Version:

Append-only log service with OAuth authentication

322 lines 11 kB
/** * Pod operations domain logic */ import { isValidPodId, calculateRecordHash } from "../utils.js"; import { createLogger } from "../logger.js"; const logger = createLogger("webpods:domain:pods"); /** * Map database row to domain type */ function mapPodFromDb(row) { return { id: row.id, pod_id: row.pod_id, owner_id: "", // Will be populated from .meta/owner stream metadata: undefined, created_at: row.created_at, updated_at: row.created_at, }; } /** * Map stream database row to domain type */ function mapStreamFromDb(row) { return { id: row.id, pod_id: row.pod_id, stream_id: row.stream_id, creator_id: row.creator_id, access_permission: row.access_permission, metadata: undefined, created_at: row.created_at, updated_at: row.created_at, }; } /** * Create a new pod */ export async function createPod(db, userId, podId) { // Validate pod ID if (!isValidPodId(podId)) { return { success: false, error: { code: "INVALID_POD_ID", message: "Pod ID must be lowercase alphanumeric with hyphens", }, }; } try { return await db.tx(async (t) => { // Check if pod already exists const existing = await t.oneOrNone(`SELECT * FROM pod WHERE pod_id = $(podId)`, { podId }); if (existing) { return { success: false, error: { code: "POD_EXISTS", message: "Pod already exists", }, }; } // Create pod const pod = await t.one(`INSERT INTO pod (id, pod_id, created_at) VALUES (gen_random_uuid(), $(podId), NOW()) RETURNING *`, { podId }); // Create .meta/owner stream with initial owner record const ownerStream = await t.one(`INSERT INTO stream (id, pod_id, stream_id, creator_id, access_permission, created_at) VALUES (gen_random_uuid(), $(podId), '.meta/owner', $(userId), 'private', NOW()) RETURNING *`, { podId: pod.id, userId }); // Write initial owner record const ownerContent = { owner: userId }; const timestamp = new Date().toISOString(); const hash = calculateRecordHash(null, timestamp, ownerContent); await t.none(`INSERT INTO record (stream_id, index, content, content_type, name, hash, previous_hash, author_id, created_at) VALUES ($(streamId), 0, $(content), 'application/json', 'owner', $(hash), NULL, $(authorId), $(timestamp))`, { streamId: ownerStream.id, content: JSON.stringify(ownerContent), hash, authorId: userId, timestamp, }); logger.info("Pod created", { podId, userId }); const mappedPod = mapPodFromDb(pod); mappedPod.owner_id = userId; // Set owner from what we just wrote return { success: true, data: mappedPod }; }); } catch (error) { logger.error("Failed to create pod", { error, podId }); return { success: false, error: { code: "DATABASE_ERROR", message: "Failed to create pod", }, }; } } /** * Get pod by ID */ export async function getPod(db, podId) { try { const pod = await db.oneOrNone(`SELECT * FROM pod WHERE pod_id = $(podId)`, { podId }); if (!pod) { return { success: false, error: { code: "POD_NOT_FOUND", message: "Pod not found", }, }; } const mappedPod = mapPodFromDb(pod); // Get owner from .meta/owner stream const ownerResult = await getPodOwner(db, podId); if (ownerResult.success) { mappedPod.owner_id = ownerResult.data; } return { success: true, data: mappedPod }; } catch (error) { logger.error("Failed to get pod", { error, podId }); return { success: false, error: { code: "DATABASE_ERROR", message: "Failed to get pod", }, }; } } /** * Get pod owner from .meta/owner stream */ export async function getPodOwner(db, podId) { try { const record = await db.oneOrNone(`SELECT r.* FROM record r JOIN stream s ON s.id = r.stream_id JOIN pod p ON p.id = s.pod_id WHERE p.pod_id = $(podId) AND s.stream_id = '.meta/owner' ORDER BY r.created_at DESC LIMIT 1`, { podId }); if (!record) { return { success: false, error: { code: "OWNER_NOT_FOUND", message: "Pod owner not found", }, }; } const content = typeof record.content === "string" ? JSON.parse(record.content) : record.content; return { success: true, data: content.owner }; } catch (error) { logger.error("Failed to get pod owner", { error, podId }); return { success: false, error: { code: "DATABASE_ERROR", message: "Failed to get pod owner", }, }; } } /** * Transfer pod ownership */ export async function transferPodOwnership(db, podId, currentUserId, newOwnerId) { try { return await db.tx(async (t) => { // Check current ownership const ownerResult = await getPodOwner(t, podId); if (!ownerResult.success || ownerResult.data !== currentUserId) { return { success: false, error: { code: "FORBIDDEN", message: "Only pod owner can transfer ownership", }, }; } // Get .meta/owner stream const ownerStream = await t.oneOrNone(`SELECT s.* FROM stream s JOIN pod p ON p.id = s.pod_id WHERE p.pod_id = $(podId) AND s.stream_id = '.meta/owner'`, { podId }); if (!ownerStream) { return { success: false, error: { code: "STREAM_NOT_FOUND", message: ".meta/owner stream not found", }, }; } // Get last record for hash chain const lastRecord = await t.oneOrNone(`SELECT * FROM record WHERE stream_id = $(streamId) ORDER BY index DESC LIMIT 1`, { streamId: ownerStream.id }); // Write new owner record const ownerContent = { owner: newOwnerId }; const timestamp = new Date().toISOString(); const hash = calculateRecordHash(lastRecord?.hash || null, timestamp, ownerContent); await t.none(`INSERT INTO record (stream_id, index, content, content_type, name, hash, previous_hash, author_id, created_at) VALUES ($(streamId), $(index), $(content), 'application/json', $(name), $(hash), $(previousHash), $(authorId), $(timestamp))`, { streamId: ownerStream.id, index: (lastRecord?.index || 0) + 1, content: JSON.stringify(ownerContent), name: `owner-${(lastRecord?.index || 0) + 1}`, hash, previousHash: lastRecord?.hash || null, authorId: currentUserId, timestamp, }); logger.info("Pod ownership transferred", { podId, from: currentUserId, to: newOwnerId, }); return { success: true, data: undefined }; }); } catch (error) { logger.error("Failed to transfer pod ownership", { error, podId }); return { success: false, error: { code: "DATABASE_ERROR", message: "Failed to transfer ownership", }, }; } } /** * Delete pod and all its streams */ export async function deletePod(db, podId, userId) { try { return await db.tx(async (t) => { // Check ownership const ownerResult = await getPodOwner(t, podId); if (!ownerResult.success || ownerResult.data !== userId) { return { success: false, error: { code: "FORBIDDEN", message: "Only pod owner can delete pod", }, }; } // Get pod const pod = await t.oneOrNone(`SELECT * FROM pod WHERE pod_id = $(podId)`, { podId }); if (!pod) { return { success: false, error: { code: "POD_NOT_FOUND", message: "Pod not found", }, }; } // Delete custom domains await t.none(`DELETE FROM custom_domain WHERE pod_id = $(podId)`, { podId: pod.id, }); // Delete pod (cascades to streams and records) await t.none(`DELETE FROM pod WHERE id = $(podId)`, { podId: pod.id }); logger.info("Pod deleted", { podId, userId }); return { success: true, data: undefined }; }); } catch (error) { logger.error("Failed to delete pod", { error, podId }); return { success: false, error: { code: "DATABASE_ERROR", message: "Failed to delete pod", }, }; } } /** * List all streams in a pod */ export async function listPodStreams(db, podId) { try { const pod = await db.oneOrNone(`SELECT * FROM pod WHERE pod_id = $(podId)`, { podId }); if (!pod) { return { success: false, error: { code: "POD_NOT_FOUND", message: "Pod not found", }, }; } const streams = await db.manyOrNone(`SELECT * FROM stream WHERE pod_id = $(podId) ORDER BY created_at ASC`, { podId: pod.id }); return { success: true, data: streams.map(mapStreamFromDb) }; } catch (error) { logger.error("Failed to list pod streams", { error, podId }); return { success: false, error: { code: "DATABASE_ERROR", message: "Failed to list streams", }, }; } } //# sourceMappingURL=pods.js.map