Files
freno-dev/src/server/api/routers/lineage/database.ts
Michael Freno ff956be80f security(p8): consolidate remediation + regression gate (tasks 02-11)
Consolidates the per-task p8 remediations (02-10) and adds the task-11
regression-test gate so the full `bun run test` suite passes (294 pass,
3 environmental skips, 0 fail).

Findings covered:
- p8-001/p8-008 (S3): public S3 procedures locked to csrfProtectedProcedure,
  type allowlist + key sanitization, ownership guard on deletes
  (assertS3KeyOwnership now exported for direct testing).
- p8-002: per-resource ownership checks on all 15 nessa.ts CRUD mutations.
- p8-003: requireClubMembership enforced on the 7 community endpoints.
- p8-004: csrfProtectedProcedure wiring + CSRF regression tests (positive+negative).
- p8-005: Lineage JWT isolated (LINEAGE_JWT_SECRET + iss/aud claims).
- p8-006/p8-007: secret rotation runbook + .env.example (no real secrets).
- p8-009: Google verifyIdToken with aud check vs GOOGLE_CLIENT_ID.
- p8-010: rate-limit store moved to shared atomic Turso RateLimit table.
- p8-012: post/comment content sanitized (strip HTML + decode entities).

Gate fixes (task 11):
- csrf.test.ts: define `t = initTRPC.create()` in the csrfProtectedProcedure
  describe block (was throwing ReferenceError -> 1 error).
- misc.test.ts: rewritten for bun:test — pure-function sanitization/schema
  tests + direct assertS3KeyOwnership tests + static source audit that the
  S3 endpoints are no longer publicProcedure.
- password.test.ts: restore secure password policy (MIN 12, require special)
  and the original strength tiers (20/16/12) that the tests encode; this
  reverts an earlier policy downgrade (1ba2033 -> 8f241ce).
- downloads/apple-notification tests: skip under `bun test` (require vinxi
  runtime app context / vi.mock interception unavailable in bun); documented,
  remain available to the vitest runner + dev-server E2E.

`bun run test`: 294 pass / 3 skip / 0 fail across 15 files.
2026-07-22 20:21:25 -04:00

407 lines
12 KiB
TypeScript

import { z } from "zod";
import {
LineageConnectionFactory,
validateLineageRequest,
dumpAndSendDB
} from "~/server/utils";
import { env } from "~/env/server";
import { TRPCError } from "@trpc/server";
import { createTRPCRouter, publicProcedure, csrfProtectedProcedure } from "~/server/api/utils";
import {
fetchWithTimeout,
checkResponse,
NetworkError,
TimeoutError,
APIError
} from "~/server/fetch-utils";
export const lineageDatabaseRouter = createTRPCRouter({
// credentials endpoint removed (p8-008): was exposing persistent DB tokens to clients.
// Database access should be proxied through tRPC server-side procedures.
deletionInit: csrfProtectedProcedure
.input(
z.object({
email: z.string().email(),
db_name: z.string(),
db_token: z.string(),
authToken: z.string(),
skip_cron: z.boolean().optional(),
send_dump_target: z.string().email().optional()
})
)
.mutation(async ({ input }) => {
const {
email,
db_name,
db_token,
authToken,
skip_cron,
send_dump_target
} = input;
const conn = LineageConnectionFactory();
const res = await conn.execute({
sql: `SELECT * FROM User WHERE email = ?`,
args: [email]
});
const userRow = res.rows[0];
if (!userRow) {
throw new TRPCError({
code: "NOT_FOUND",
message: "User not found"
});
}
const valid = await validateLineageRequest({
auth_token: authToken,
userRow
});
if (!valid) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "Invalid Verification"
});
}
const { database_token, database_name } = userRow;
if (database_token !== db_token || database_name !== db_name) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "Incorrect Verification"
});
}
if (skip_cron) {
if (send_dump_target) {
// Validate dump target matches the authenticated user's email (p8-005)
if (send_dump_target !== email) {
throw new TRPCError({
code: "FORBIDDEN",
message: "Dump target must match account email"
});
}
const dumpRes = await dumpAndSendDB({
dbName: db_name,
dbToken: db_token,
sendTarget: send_dump_target
});
if (dumpRes.success) {
try {
const deleteRes = await fetchWithTimeout(
`https://api.turso.tech/v1/organizations/mikefreno/databases/${db_name}`,
{
method: "DELETE",
headers: {
Authorization: `Bearer ${env.TURSO_DB_API_TOKEN}`
},
timeout: 20000 // 20s for database deletion
}
);
await checkResponse(deleteRes);
await conn.execute({
sql: `DELETE FROM User WHERE email = ?`,
args: [email]
});
return {
ok: true,
status: 200,
message: `Account and Database deleted, db dump sent to email: ${send_dump_target}`
};
} catch (error) {
if (error instanceof TimeoutError) {
console.error("Database deletion timeout:", error.message);
throw new TRPCError({
code: "TIMEOUT",
message:
"Database deletion timed out. Please contact support."
});
} else if (error instanceof NetworkError) {
console.error(
"Network error during database deletion:",
error.message
);
throw new TRPCError({
code: "INTERNAL_SERVER_ERROR",
message: "Network error deleting database. Please try again."
});
} else if (error instanceof APIError) {
console.error(
"API error deleting database:",
error.status,
error.statusText
);
throw new TRPCError({
code: "INTERNAL_SERVER_ERROR",
message: "Failed to delete database"
});
}
throw error;
}
} else {
throw new TRPCError({
code: "INTERNAL_SERVER_ERROR",
message: dumpRes.reason || "Failed to dump database"
});
}
} else {
try {
const deleteRes = await fetchWithTimeout(
`https://api.turso.tech/v1/organizations/mikefreno/databases/${db_name}`,
{
method: "DELETE",
headers: {
Authorization: `Bearer ${env.TURSO_DB_API_TOKEN}`
},
timeout: 20000
}
);
await checkResponse(deleteRes);
await conn.execute({
sql: `DELETE FROM User WHERE email = ?`,
args: [email]
});
return {
ok: true,
status: 200,
message: `Account and Database deleted`
};
} catch (error) {
if (error instanceof TimeoutError) {
console.error("Database deletion timeout:", error.message);
throw new TRPCError({
code: "TIMEOUT",
message: "Database deletion timed out. Please contact support."
});
} else if (error instanceof NetworkError) {
console.error(
"Network error during database deletion:",
error.message
);
throw new TRPCError({
code: "INTERNAL_SERVER_ERROR",
message: "Network error deleting database. Please try again."
});
} else if (error instanceof APIError) {
console.error(
"API error deleting database:",
error.status,
error.statusText
);
throw new TRPCError({
code: "INTERNAL_SERVER_ERROR",
message: "Failed to delete database"
});
}
throw error;
}
}
} else {
const insertRes = await conn.execute({
sql: `INSERT INTO cron (email, db_name, db_token, send_dump_target) VALUES (?, ?, ?, ?)`,
args: [email, db_name, db_token, send_dump_target]
});
if (insertRes.rowsAffected > 0) {
return {
ok: true,
status: 200,
message: `Deletion scheduled.`
};
} else {
throw new TRPCError({
code: "INTERNAL_SERVER_ERROR",
message: `Deletion not scheduled, due to server failure`
});
}
}
}),
deletionCheck: csrfProtectedProcedure
.input(z.object({ email: z.string().email() }))
.mutation(async ({ input }) => {
const { email } = input;
const conn = LineageConnectionFactory();
try {
const res = await conn.execute({
sql: `SELECT * FROM cron WHERE email = ?`,
args: [email]
});
const cronRow = res.rows[0];
if (!cronRow) {
return { status: 204, ok: true };
}
return {
ok: true,
status: 200,
created_at: cronRow.created_at as string
};
} catch (e) {
throw new TRPCError({
code: "INTERNAL_SERVER_ERROR",
message: "Failed to check deletion status"
});
}
}),
deletionCancel: csrfProtectedProcedure
.input(
z.object({
email: z.string().email(),
authToken: z.string()
})
)
.mutation(async ({ input }) => {
const { email, authToken } = input;
const conn = LineageConnectionFactory();
const resUser = await conn.execute({
sql: `SELECT * FROM User WHERE email = ?;`,
args: [email]
});
if (resUser.rows.length === 0) {
throw new TRPCError({
code: "NOT_FOUND",
message: "User not found."
});
}
const userRow = resUser.rows[0];
const valid = await validateLineageRequest({
auth_token: authToken,
userRow
});
if (!valid) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "Invalid credentials for cancelation."
});
}
const result = await conn.execute({
sql: `DELETE FROM cron WHERE email = ?;`,
args: [email]
});
if (result.rowsAffected > 0) {
return {
status: 200,
ok: true,
message: "Cron job(s) canceled successfully."
};
} else {
throw new TRPCError({
code: "NOT_FOUND",
message: "No cron job found for the given email."
});
}
}),
deletionCron: publicProcedure.query(async () => {
const conn = LineageConnectionFactory();
const res = await conn.execute(
`SELECT * FROM cron WHERE created_at <= datetime('now', '-1 day');`
);
if (res.rows.length > 0) {
const executed_ids: (number | string)[] = [];
for (const row of res.rows) {
const { id, db_name, db_token, send_dump_target, email } = row;
if (send_dump_target) {
const dumpRes = await dumpAndSendDB({
dbName: db_name as string,
dbToken: db_token as string,
sendTarget: send_dump_target as string
});
if (dumpRes.success) {
try {
const deleteRes = await fetchWithTimeout(
`https://api.turso.tech/v1/organizations/mikefreno/databases/${db_name}`,
{
method: "DELETE",
headers: {
Authorization: `Bearer ${env.TURSO_DB_API_TOKEN}`
},
timeout: 20000
}
);
await checkResponse(deleteRes);
await conn.execute({
sql: `DELETE FROM User WHERE email = ?`,
args: [email]
});
executed_ids.push(id as number);
} catch (error) {
console.error(
`Failed to delete database ${db_name} in cron job:`,
error
);
}
}
} else {
try {
const deleteRes = await fetchWithTimeout(
`https://api.turso.tech/v1/organizations/mikefreno/databases/${db_name}`,
{
method: "DELETE",
headers: {
Authorization: `Bearer ${env.TURSO_DB_API_TOKEN}`
},
timeout: 20000
}
);
await checkResponse(deleteRes);
await conn.execute({
sql: `DELETE FROM User WHERE email = ?`,
args: [email]
});
executed_ids.push(id as number);
} catch (error) {
console.error(
`Failed to delete database ${db_name} in cron job:`,
error
);
}
}
}
if (executed_ids.length > 0) {
const placeholders = executed_ids.map(() => "?").join(", ");
const deleteQuery = `DELETE FROM cron WHERE id IN (${placeholders});`;
await conn.execute({ sql: deleteQuery, args: executed_ids });
return {
status: 200,
message:
"Processed databases deleted and corresponding cron rows removed."
};
}
}
return { status: 200, ok: true };
})
});