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
2 changes: 1 addition & 1 deletion .github/workflows/deploy-dev.yml
Original file line number Diff line number Diff line change
Expand Up @@ -87,7 +87,7 @@ jobs:
REPO=${REGION}-docker.pkg.dev/${PROJECT_ID}/${AR_REPO}/commonly-frontend
docker build frontend \
--build-arg REACT_APP_API_URL=https://api.commonly.me \
--build-arg REACT_APP_SHOWCASE_POD_ID=6a4394e5dd52c8ec8425ad69 \
--build-arg REACT_APP_SHOWCASE_POD_ID=6a507c9b792f1ed2cbfec648 \
--build-arg REACT_APP_COMMUNITY_POD_ID=6a5fe677306155f677c26abf \
--build-arg REACT_APP_COMMUNITY_INVITE_TOKEN=7b91255f18ae3c0ae3721707a6613731 \
--build-arg "REACT_APP_VERSION=$TAG" \
Expand Down
80 changes: 80 additions & 0 deletions backend/__tests__/unit/models/pg/message.retentionExempt.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
/**
* Message.deleteOlderThan — retention exemption (PG_RETENTION_EXEMPT_POD_IDS).
*
* Why this exists: the delete used to be unconditional, and on 2026-08-05 it
* was discovered to have silently emptied the public showcase pod the landing
* page points at — "Watch a live room" led to "No messages yet" for however
* long nobody checked. The exemption is the guard; these tests are the reason
* a future simplification of the query has to argue with the incident.
*
* Tested at the SQL boundary (mocked pool) because the retention SERVICE test
* mocks deleteOlderThan away entirely — without this file, the exemption has
* no test anywhere.
*/

jest.mock('../../../../config/db-pg', () => ({
pool: { query: jest.fn() },
}));

const { pool } = require('../../../../config/db-pg');
const Message = require('../../../../models/pg/Message');

const SHOWCASE = '6a507c9b792f1ed2cbfec648';
const HQ = '6a5fe677306155f677c26abf';

describe('Message.deleteOlderThan retention exemption', () => {
const ORIGINAL_ENV = process.env;

beforeEach(() => {
pool.query.mockReset();
pool.query.mockResolvedValue({ rowCount: 3, rows: [] });
process.env = { ...ORIGINAL_ENV };
delete process.env.PG_RETENTION_EXEMPT_POD_IDS;
});

afterAll(() => {
process.env = ORIGINAL_ENV;
});

it('unset env keeps the original unconditional delete', async () => {
const res = await Message.deleteOlderThan(30);
expect(res).toEqual({ deleted: 3 });
const [sql, params] = pool.query.mock.calls[0];
expect(sql).not.toMatch(/pod_id/);
expect(params).toEqual(['30 days']);
});

it('exempt pods are excluded from the delete', async () => {
process.env.PG_RETENTION_EXEMPT_POD_IDS = `${SHOWCASE},${HQ}`;
await Message.deleteOlderThan(30);
const [sql, params] = pool.query.mock.calls[0];
expect(sql).toMatch(/pod_id != ALL\(\$2\)/);
expect(params).toEqual(['30 days', [SHOWCASE, HQ]]);
});

it('whitespace and empty entries in the list are tolerated', async () => {
process.env.PG_RETENTION_EXEMPT_POD_IDS = ` ${SHOWCASE} , , ${HQ} ,`;
await Message.deleteOlderThan(30);
const [, params] = pool.query.mock.calls[0];
expect(params[1]).toEqual([SHOWCASE, HQ]);
});

it('an env of only separators falls back to the unconditional delete', async () => {
// An empty ALL(ARRAY[]) is harmless in Postgres, but the unconditional
// path is what ran for months — keep it byte-identical when the list is
// effectively empty, so this change is provably a no-op for every
// deployment that does not set the var.
process.env.PG_RETENTION_EXEMPT_POD_IDS = ' , ,, ';
await Message.deleteOlderThan(30);
const [sql, params] = pool.query.mock.calls[0];
expect(sql).not.toMatch(/pod_id/);
expect(params).toEqual(['30 days']);
});

it('invalid day counts still refuse to run at all', async () => {
process.env.PG_RETENTION_EXEMPT_POD_IDS = SHOWCASE;
expect(await Message.deleteOlderThan(0)).toEqual({ deleted: 0 });
expect(await Message.deleteOlderThan(NaN)).toEqual({ deleted: 0 });
expect(pool.query).not.toHaveBeenCalled();
});
});
27 changes: 25 additions & 2 deletions backend/models/pg/Message.ts
Original file line number Diff line number Diff line change
Expand Up @@ -199,13 +199,36 @@ class Message {
* Bulk-delete messages older than `days` days. Used by the retention cron
* (pgRetentionService) to enforce the 30-day message retention window on
* the PostgreSQL chat store.
*
* Pods named in PG_RETENTION_EXEMPT_POD_IDS (comma-separated) are skipped.
* The exemption exists because the delete used to be unconditional and, on
* 2026-08-05, was discovered to have silently emptied the public showcase
* pod the landing page points at — the room the product uses to prove
* itself was erased by the product's own retention policy, and nothing
* anywhere said so. A showroom, or any publicly-linked pod, must not be on
* a rolling 30-day self-destruct.
*
* Env-var rather than a pod flag, deliberately, for now: the paid tier
* being designed makes retention a per-account entitlement, and THAT
* mechanism should own per-pod retention when it lands. An env list is the
* smallest honest stopgap that cannot drift into being a second tier
* system — it names specific operator-owned pods, nothing more.
*/
static async deleteOlderThan(days: number): Promise<{ deleted: number }> {
if (!Number.isFinite(days) || days <= 0) {
return { deleted: 0 };
}
const query = `DELETE FROM messages WHERE created_at < NOW() - $1::interval RETURNING id`;
const result = await (pool as PgPool).query(query, [`${Math.trunc(days)} days`]);
const exempt = String(process.env.PG_RETENTION_EXEMPT_POD_IDS || '')
.split(',')
.map((s) => s.trim())
.filter(Boolean);
const query = exempt.length > 0
? `DELETE FROM messages WHERE created_at < NOW() - $1::interval AND pod_id != ALL($2) RETURNING id`
: `DELETE FROM messages WHERE created_at < NOW() - $1::interval RETURNING id`;
const params: unknown[] = exempt.length > 0
? [`${Math.trunc(days)} days`, exempt]
: [`${Math.trunc(days)} days`];
const result = await (pool as PgPool).query(query, params);
const deleted = typeof result.rowCount === 'number'
? result.rowCount
: (Array.isArray(result.rows) ? result.rows.length : 0);
Expand Down
8 changes: 8 additions & 0 deletions k8s/helm/commonly/templates/core/backend-deployment.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -150,6 +150,14 @@ spec:
- name: COMMUNITY_POD_ID
value: {{ .Values.backend.env.communityPodId | quote }}
{{- end }}
{{- if .Values.backend.env.retentionExemptPodIds }}
# Pods the 30-day PG message-retention cron must never touch —
# publicly-linked rooms (showcase, community HQ) whose history IS the
# product surface. The cron silently emptied the landing-page showroom
# once (2026-08-05); this is the guard.
- name: PG_RETENTION_EXEMPT_POD_IDS
value: {{ .Values.backend.env.retentionExemptPodIds | quote }}
{{- end }}

# Email Configuration (SMTP2GO)
- name: SMTP2GO_API_KEY
Expand Down
5 changes: 5 additions & 0 deletions k8s/helm/commonly/values-dev.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,11 @@ backend:
frontendUrl: "https://commonly.me"
backendUrl: "https://api.commonly.me"
communityPodId: "6a5fe677306155f677c26abf"
# Retention-exempt: the landing-page showroom (Eng Milestone) and HQ.
# Both are publicly readable; the 30-day cron emptied the previous
# showroom silently (2026-08-05). Replace with per-account retention
# entitlements when the paid tier lands.
retentionExemptPodIds: "6a507c9b792f1ed2cbfec648,6a5fe677306155f677c26abf"
# Must match the callback URL registered on the GitHub/Google OAuth apps.
oauthCallbackBaseUrl: "https://api.commonly.me"
commonlyApiUrl: ""
Expand Down
3 changes: 3 additions & 0 deletions k8s/helm/commonly/values.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,9 @@ backend:
# Optional instance community Pod. Empty keeps self-hosted registration
# independent of community wiring.
communityPodId: ""
# Comma-separated pod ids the PG message-retention cron skips (publicly
# linked rooms whose history is a product surface). Empty = no exemptions.
retentionExemptPodIds: ""
# Base URL presented to OAuth providers as the redirect_uri origin.
# Empty = derive from the request host (fine when the API has one host).
oauthCallbackBaseUrl: ""
Expand Down
Loading