webpods
Version:
Append-only log service with OAuth authentication
322 lines • 11 kB
JavaScript
/**
* 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