fix(schema): tighten tenant boundaries on pages/refs

Three security-review findings on migration 002:
- pages.space_id and refs.space_id changed to NOT NULL + ON DELETE CASCADE
  (was nullable + SET NULL, which allowed orphan rows surviving space deletion).
- pages.parent_id wrapped in composite FK (parent_id, space_id) -> pages(id, space_id)
  to prevent cross-space parent linkage (same pattern as tasks.project_id in 001).
- idx_refs_external promoted to UNIQUE on (space_id, source_kind, external_id);
  upsertByExternal now requires space_id and dedups per-space, not globally.

Added 3 regression tests covering composite FK rejection, CASCADE-on-space-delete,
and per-space dedup independence.
This commit is contained in:
root
2026-05-31 02:21:47 +10:00
parent c8649d753f
commit 9dd944226d
3 changed files with 74 additions and 11 deletions

View File

@@ -30,13 +30,13 @@ export async function getById(id) {
}
export async function upsertByExternal(input, actor) {
const { source_kind, external_id } = input;
if (!source_kind || !external_id) {
throw new Error('upsertByExternal requires source_kind + external_id');
const { space_id, source_kind, external_id } = input;
if (!space_id || !source_kind || !external_id) {
throw new Error('upsertByExternal requires space_id + source_kind + external_id');
}
const { rows: [existing] } = await pool.query(
`SELECT * FROM refs WHERE source_kind=$1 AND external_id=$2`,
[source_kind, external_id]
`SELECT * FROM refs WHERE space_id=$1 AND source_kind=$2 AND external_id=$3`,
[space_id, source_kind, external_id]
);
if (existing) {
return update(existing.id, input, actor);