rspace-online/server/local-first/backup-routes.ts

129 lines
3.5 KiB
TypeScript

/**
* Backup API Routes — Hono router for encrypted backup operations.
*
* All endpoints require EncryptID JWT authentication.
* The server stores opaque ciphertext blobs it cannot decrypt.
*/
import { Hono } from "hono";
import type { Context, Next } from "hono";
import {
verifyEncryptIDToken,
extractToken,
} from "@encryptid/sdk/server";
import type { EncryptIDClaims } from "@encryptid/sdk/server";
import {
putBackup,
getBackup,
listBackups,
deleteBackup,
deleteAllBackups,
getUsage,
} from "./backup-store";
const MAX_BLOB_SIZE = 10 * 1024 * 1024; // 10 MB per blob
type BackupEnv = {
Variables: {
userId: string;
claims: EncryptIDClaims;
};
};
const backupRouter = new Hono<BackupEnv>();
/** Auth middleware — extracts and verifies JWT, sets userId. */
backupRouter.use("*", async (c: Context<BackupEnv>, next: Next) => {
const token = extractToken(c.req.raw.headers);
if (!token) {
return c.json({ error: "Authentication required" }, 401);
}
let claims: EncryptIDClaims;
try {
claims = await verifyEncryptIDToken(token);
} catch {
return c.json({ error: "Invalid or expired token" }, 401);
}
c.set("userId", claims.sub);
c.set("claims", claims);
await next();
});
/** PUT /api/backup/:space/:docId — upload encrypted blob */
backupRouter.put("/:space/:docId", async (c) => {
const userId = c.get("userId");
const space = c.req.param("space");
const docId = decodeURIComponent(c.req.param("docId"));
const blob = await c.req.arrayBuffer();
if (blob.byteLength > MAX_BLOB_SIZE) {
return c.json({ error: `Blob too large (max ${MAX_BLOB_SIZE} bytes)` }, 413);
}
if (blob.byteLength === 0) {
return c.json({ error: "Empty blob" }, 400);
}
await putBackup(userId, space, docId, new Uint8Array(blob));
return c.json({ ok: true, size: blob.byteLength });
});
/** GET /api/backup/:space/:docId — download encrypted blob */
backupRouter.get("/:space/:docId", async (c) => {
const userId = c.get("userId");
const space = c.req.param("space");
const docId = decodeURIComponent(c.req.param("docId"));
const blob = await getBackup(userId, space, docId);
if (!blob) {
return c.json({ error: "Not found" }, 404);
}
const body = new Uint8Array(blob).buffer as ArrayBuffer;
return new Response(body, {
headers: {
"Content-Type": "application/octet-stream",
"Content-Length": blob.byteLength.toString(),
},
});
});
/** GET /api/backup/:space — list manifest for a space */
backupRouter.get("/:space", async (c) => {
const userId = c.get("userId");
const space = c.req.param("space");
const manifest = await listBackups(userId, space);
return c.json(manifest);
});
/** DELETE /api/backup/:space/:docId — delete specific backup */
backupRouter.delete("/:space/:docId", async (c) => {
const userId = c.get("userId");
const space = c.req.param("space");
const docId = decodeURIComponent(c.req.param("docId"));
const ok = await deleteBackup(userId, space, docId);
if (!ok) {
return c.json({ error: "Not found or delete failed" }, 404);
}
return c.json({ ok: true });
});
/** DELETE /api/backup/:space — delete all backups for a space */
backupRouter.delete("/:space", async (c) => {
const userId = c.get("userId");
const space = c.req.param("space");
await deleteAllBackups(userId, space);
return c.json({ ok: true });
});
/** GET /api/backup/status — overall backup status for authenticated user */
backupRouter.get("/", async (c) => {
const userId = c.get("userId");
const usage = await getUsage(userId);
return c.json(usage);
});
export { backupRouter };