Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 5 additions & 13 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -51,19 +51,11 @@ jobs:
# next declares sharp ^0.34.5 (optional) and miniflare pins sharp
# 0.34.5 exactly, so the fix is only reachable by forcing a major
# override against both parents. Drop the ignore once either admits
# sharp >= 0.35.0. Tracked in PYZ-345. (fast-uri + postcss are fixed
# via package.json overrides; see the //overrides note there.)
#
# GHSA-mh99-v99m-4gvg: brace-expansion DoS, vulnerable range <= 5.0.7,
# spanning every major. Reached only through dev dependencies, via
# minimatch under eslint, eslint-plugin-import, typescript-eslint, and
# @opennextjs/cloudflare > glob. No runtime path: `bun audit --prod`
# does not report it. An override was rejected because 5.0.8 is
# the only patched release, so a single range drags the consumers
# pinned to 1.1.16 and 2.1.2 across two majors. Drop the ignore once
# upstream publishes patched 1.x and 2.x lines, or once the tree
# consolidates on brace-expansion >= 5.0.8. Tracked in PYZ-345.
run: bun audit --audit-level=high --ignore=GHSA-f88m-g3jw-g9cj --ignore=GHSA-mh99-v99m-4gvg
# sharp >= 0.35.0. Tracked in PYZ-345. (fast-uri, ip-address, postcss,
# and undici are fixed via package.json overrides; brace-expansion is
# fixed per-major in bun.lock, patched lines now exist for every
# major. See the //overrides note in package.json.)
run: bun audit --audit-level=high --ignore=GHSA-f88m-g3jw-g9cj
- name: Validate PR title
if: github.event_name == 'pull_request'
env:
Expand Down
18 changes: 10 additions & 8 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 4 additions & 0 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,10 @@ services:
db:
image: postgres:18
restart: unless-stopped
# pg_stat_statements collects nothing unless preloaded; CREATE EXTENSION
# alone (docker/extensions.sql) only creates the SQL objects. Applies on
# the next `docker compose up -d`.
command: ["postgres", "-c", "shared_preload_libraries=pg_stat_statements"]
environment:
POSTGRES_USER: piyaz
# Superuser: exempt from FORCE ROW LEVEL SECURITY, so this credential
Expand Down
21 changes: 21 additions & 0 deletions docker/extensions.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
-- =============================================================================
-- Extensions every Piyaz database carries. Owner-only: CREATE EXTENSION needs
-- the database owner on Neon, never the least-privilege migration role, so
-- this is NOT a Drizzle migration. Applied by scripts/apply-owner-rls.ts
-- (db:rls:owner, hosted), the db:rls psql chain (self-host), and
-- tests/setup/migrate.ts (testcontainer). scripts/verify-rls.ts derives its
-- extension contract from this file, so a forgotten owner apply fails the
-- deploy. Idempotent.
--
-- pg_stat_statements: CREATE EXTENSION succeeds without preloading, but
-- collecting/querying stats requires shared_preload_libraries — set for Neon
-- by the platform and for self-host by the postgres command in
-- docker-compose.yml. The read path is scripts/db-stats.ts (owner-only).
--
-- Extensions live in their own schema, never public: the public schema is
-- owned by Drizzle, and `drizzle-kit push` (throwaway test DB) tries to drop
-- any non-Drizzle objects it finds there.
-- =============================================================================

CREATE SCHEMA IF NOT EXISTS extensions;
CREATE EXTENSION IF NOT EXISTS pg_stat_statements WITH SCHEMA extensions;
22 changes: 0 additions & 22 deletions docker/init-pg-cron.sql

This file was deleted.

119 changes: 119 additions & 0 deletions docker/rls-functions.sql
Original file line number Diff line number Diff line change
Expand Up @@ -1317,3 +1317,122 @@ CREATE TRIGGER task_edges_touch_project_delete
REFERENCING OLD TABLE AS changed_edges
FOR EACH STATEMENT
EXECUTE FUNCTION public.touch_projects_for_changed_task_edges();

-- ---------------------------------------------------------------------------
-- Nightly housekeeping sweep: bounded, idempotent deletion of expired auth
-- artifacts and stale invite codes. Called by the Cloudflare cron in
-- worker-cf.ts through service_role (JS caller:
-- lib/db/raw/purge-expired-rows.ts). SECURITY DEFINER because service_role
-- lacks DELETE on piyaz_auth."session" and all rights on
-- piyaz_auth."verification", and auth_role's 15s statement_timeout is unfit
-- for bulk deletes.
--
-- The CONSTANT declarations are the retention matrix's single source of
-- truth. Dry runs and live runs share the same victim selection (the
-- data-modifying CTE always executes; NOT p_dry_run turns it into a no-op),
-- and the per-table LIMIT bounds one run — leftovers roll to the next.
-- Retained by policy: legal_acceptances, activity_events, note_revisions,
-- oauthConsent, jwks, piyaz_auth.invitation, account.
-- ---------------------------------------------------------------------------
CREATE OR REPLACE FUNCTION public.purge_expired_rows(p_dry_run boolean, p_batch_limit integer)
RETURNS TABLE (table_name text, row_count integer)
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = pg_catalog, pg_temp
AS $$
DECLARE
c_oauth_grace CONSTANT interval := interval '24 hours';
c_session_grace CONSTANT interval := interval '7 days';
c_verification_grace CONSTANT interval := interval '7 days';
c_invite_grace CONSTANT interval := interval '30 days';
BEGIN
IF p_batch_limit IS NULL OR p_batch_limit < 1 OR p_batch_limit > 50000 THEN
RAISE EXCEPTION 'p_batch_limit out of range: %', p_batch_limit;
END IF;

WITH victims AS (
SELECT ctid FROM piyaz_auth."oauthAccessToken"
WHERE "expiresAt" < now() - c_oauth_grace
LIMIT p_batch_limit
), deleted AS (
DELETE FROM piyaz_auth."oauthAccessToken" t
USING victims v
WHERE t.ctid = v.ctid AND NOT p_dry_run
RETURNING 1
)
SELECT CASE WHEN p_dry_run THEN (SELECT count(*) FROM victims)
ELSE (SELECT count(*) FROM deleted) END::integer
INTO row_count;
table_name := 'oauthAccessToken';
RETURN NEXT;

WITH victims AS (
SELECT ctid FROM piyaz_auth."oauthRefreshToken"
WHERE revoked < now() - c_oauth_grace
OR "expiresAt" < now() - c_oauth_grace
LIMIT p_batch_limit
), deleted AS (
DELETE FROM piyaz_auth."oauthRefreshToken" t
USING victims v
WHERE t.ctid = v.ctid AND NOT p_dry_run
RETURNING 1
)
SELECT CASE WHEN p_dry_run THEN (SELECT count(*) FROM victims)
ELSE (SELECT count(*) FROM deleted) END::integer
INTO row_count;
table_name := 'oauthRefreshToken';
RETURN NEXT;

WITH victims AS (
SELECT ctid FROM piyaz_auth."session"
WHERE "expiresAt" < now() - c_session_grace
LIMIT p_batch_limit
), deleted AS (
DELETE FROM piyaz_auth."session" t
USING victims v
WHERE t.ctid = v.ctid AND NOT p_dry_run
RETURNING 1
)
SELECT CASE WHEN p_dry_run THEN (SELECT count(*) FROM victims)
ELSE (SELECT count(*) FROM deleted) END::integer
INTO row_count;
table_name := 'session';
RETURN NEXT;

WITH victims AS (
SELECT ctid FROM piyaz_auth."verification"
WHERE "expiresAt" < now() - c_verification_grace
LIMIT p_batch_limit
), deleted AS (
DELETE FROM piyaz_auth."verification" t
USING victims v
WHERE t.ctid = v.ctid AND NOT p_dry_run
RETURNING 1
)
SELECT CASE WHEN p_dry_run THEN (SELECT count(*) FROM victims)
ELSE (SELECT count(*) FROM deleted) END::integer
INTO row_count;
table_name := 'verification';
RETURN NEXT;

WITH victims AS (
SELECT ctid FROM public.team_invite_code
WHERE revoked_at < now() - c_invite_grace
OR expires_at < now() - c_invite_grace
LIMIT p_batch_limit
), deleted AS (
DELETE FROM public.team_invite_code t
USING victims v
WHERE t.ctid = v.ctid AND NOT p_dry_run
RETURNING 1
)
SELECT CASE WHEN p_dry_run THEN (SELECT count(*) FROM victims)
ELSE (SELECT count(*) FROM deleted) END::integer
INTO row_count;
table_name := 'team_invite_code';
RETURN NEXT;
END;
$$;
REVOKE EXECUTE ON FUNCTION public.purge_expired_rows(boolean, integer) FROM public;
REVOKE EXECUTE ON FUNCTION public.purge_expired_rows(boolean, integer) FROM app_user;
GRANT EXECUTE ON FUNCTION public.purge_expired_rows(boolean, integer) TO service_role;
2 changes: 1 addition & 1 deletion eslint.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -106,7 +106,7 @@ const eslintConfig = [
selector:
"CallExpression[callee.object.name='serviceRoleDb'][callee.property.name=/^(select|insert|update|delete)$/]",
message:
"serviceRoleDb.<verb> is BYPASSRLS. Allowed sites: lib/data/oauth-session.ts (oauth tables), lib/data/account.ts (clearOrgMembershipArtifacts, scrubLegalAcceptances, enumerateOwnedOrgsForDeletion), lib/data/membership.ts (admin lookups). Consider whether a SECURITY DEFINER function in docker/rls-functions.sql can replace this call site.",
"serviceRoleDb.<verb> is BYPASSRLS. Allowed sites: lib/data/oauth-session.ts (oauth tables), lib/data/account.ts (clearOrgMembershipArtifacts, scrubLegalAcceptances, enumerateOwnedOrgsForDeletion), lib/data/membership.ts (admin lookups), worker-cf.ts scheduled() (service-role handle from requestDbStore into lib/db/raw/purge-expired-rows.ts, which only EXECUTEs the SECURITY DEFINER public.purge_expired_rows). Consider whether a SECURITY DEFINER function in docker/rls-functions.sql can replace this call site.",
},
{
selector: "MemberExpression[object.name='db'][property.name='query']",
Expand Down
35 changes: 35 additions & 0 deletions lib/db/raw/purge-expired-rows.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import { sql } from "drizzle-orm";
import { executeRaw, type ServiceRoleConn } from "@/lib/db/raw";

/** Rows the nightly sweep may delete per table per run; leftovers roll to the next run. */
export const HOUSEKEEPING_BATCH_LIMIT = 5000;

/** One per-table result row from `public.purge_expired_rows`. */
export interface PurgeResultRow {
/** Table the row reports on (fixed order, one row per table per run). */
table_name: string;
/** Rows deleted (live run) or rows that would be deleted (dry run). */
row_count: number;
}

/**
* Run the housekeeping sweep via `public.purge_expired_rows` (SECURITY
* DEFINER, service_role-only; retention windows live in
* `docker/rls-functions.sql`). Dry runs count the same victims a live run
* would delete without mutating anything.
*
* @param conn - BYPASSRLS service-role client.
* @param dryRun - True to count would-be deletions without deleting.
* @param batchLimit - Per-table row cap for this run (1..50000).
* @returns One result row per swept table, fixed order.
*/
export async function purgeExpiredRows(
conn: ServiceRoleConn,
dryRun: boolean,
batchLimit: number,
): Promise<PurgeResultRow[]> {
return executeRaw<PurgeResultRow>(
conn,
sql`SELECT table_name, row_count FROM public.purge_expired_rows(${dryRun}, ${batchLimit})`,
);
}
Loading