Closer/functions/dist/backup/cleanupRestoreRequests.js

159 lines
7.8 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"use strict";
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
var desc = Object.getOwnPropertyDescriptor(m, k);
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
desc = { enumerable: true, get: function() { return m[k]; } };
}
Object.defineProperty(o, k2, desc);
}) : (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
o[k2] = m[k];
}));
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
Object.defineProperty(o, "default", { enumerable: true, value: v });
}) : function(o, v) {
o["default"] = v;
});
var __importStar = (this && this.__importStar) || (function () {
var ownKeys = function(o) {
ownKeys = Object.getOwnPropertyNames || function (o) {
var ar = [];
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
return ar;
};
return ownKeys(o);
};
return function (mod) {
if (mod && mod.__esModule) return mod;
var result = {};
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
__setModuleDefault(result, mod);
return result;
};
})();
Object.defineProperty(exports, "__esModule", { value: true });
exports.cleanupExpiredRestoreRequests = exports.NOTIFY_WINDOW_MS = exports.FALLBACK_TTL_MS = exports.GRACE_MS = void 0;
exports.shouldReapRestoreRequest = shouldReapRestoreRequest;
exports.shouldNotifyExpiry = shouldNotifyExpiry;
exports.sweepExpiredRestoreRequests = sweepExpiredRestoreRequests;
const admin = __importStar(require("firebase-admin"));
const scheduler_1 = require("firebase-functions/v2/scheduler");
const queueAndPush_1 = require("../notifications/queueAndPush");
const log_1 = require("../log");
/**
* Cloud Function: cleanupExpiredRestoreRequests
*
* A `restore_requests` doc left behind (partner wrapped the couple key but the recipient never
* completed, or nobody ever answered) keeps its ECIES `keybox` — ciphertext sealed only to the
* recipient, useless to anyone else, but key material has no business lying around. Expiry is
* otherwise enforced only client-side (fulfil-time check, delete-before-re-request, delete-on-
* complete), so a request whose device disappeared lives forever.
*
* Hourly sweep: requests expire 30 minutes after creation, so this bounds a stranded keybox's life
* to ~1.5 h. Uses a collectionGroup query on purpose — it also reaps requests orphaned under
* already-deleted couple docs, which an iterate-the-couples sweep can never see (deleted parents
* don't list). Needs the COLLECTION_GROUP fieldOverride on `restore_requests.expiresAt` in
* `firestore.indexes.json`.
*
* Admin SDK bypasses the recipient-only delete rule, which is the point: nobody else CAN clean these.
*/
/** Never race a restore that is mid-completion: the client treats these as expired well before us. */
exports.GRACE_MS = 5 * 60 * 1000;
/** Reap docs with no usable `expiresAt` once they are unambiguously ancient. */
exports.FALLBACK_TTL_MS = 24 * 60 * 60 * 1000;
/**
* Only tell the requester about expiries that just happened (~2 sweep cycles). Without this bound,
* the first deploy would blast a notification for every ancient doc in the backlog at once.
*/
exports.NOTIFY_WINDOW_MS = 2 * 60 * 60 * 1000;
const asMillis = (v) => (typeof v === 'number' && v > 0 ? v : 0);
/**
* Pure reap decision, re-verified per doc even though the query already filtered — the query is an
* index scan, this is the contract. Deletes only on positive evidence of staleness:
* a real `expiresAt` past the grace window, or (defensively — the create rule doesn't validate field
* values) no usable `expiresAt` but a `createdAt` at least a day old. A doc with neither is left
* alone for a human to wonder about.
*/
function shouldReapRestoreRequest(data, nowMs) {
const expiresAt = asMillis(data.expiresAt);
if (expiresAt > 0)
return nowMs > expiresAt + exports.GRACE_MS;
const createdAt = asMillis(data.createdAt);
return createdAt > 0 && nowMs > createdAt + exports.FALLBACK_TTL_MS;
}
/**
* Pure notify decision: only for a request that expired *recently* while still waiting on someone
* (REQUESTED: partner never approved; READY: recipient never completed). Terminal states are debris —
* RESTORED already succeeded and DECLINED was answered; telling anyone again is noise.
*/
function shouldNotifyExpiry(data, nowMs) {
const expiresAt = asMillis(data.expiresAt);
if (expiresAt <= 0 || nowMs <= expiresAt + exports.GRACE_MS)
return false;
if (nowMs - expiresAt > exports.NOTIFY_WINDOW_MS)
return false;
return data.status === 'REQUESTED' || data.status === 'READY';
}
/**
* The sweep, with `db` injectable for tests. Per-doc failures are isolated (one bad doc can't stop
* the pass); the doc is deleted BEFORE the requester is notified so a notify failure costs a nudge,
* never a duplicate (the doc it would re-trigger on is already gone).
*/
async function sweepExpiredRestoreRequests(db, nowMs) {
const snap = await db
.collectionGroup('restore_requests')
.where('expiresAt', '<=', nowMs - exports.GRACE_MS)
.limit(200)
.get();
const counts = { scanned: snap.size, reaped: 0, notified: 0, skipped: 0, failed: 0 };
const results = await Promise.allSettled(snap.docs.map(async (doc) => {
var _a;
const data = ((_a = doc.data()) !== null && _a !== void 0 ? _a : {});
if (!shouldReapRestoreRequest(data, nowMs)) {
counts.skipped++;
log_1.logger.warn('[cleanupRestoreRequests] query hit failed re-verify; leaving doc', { path: doc.ref.path });
return;
}
const notify = shouldNotifyExpiry(data, nowMs);
await doc.ref.delete();
counts.reaped++;
if (!notify)
return;
// Best-effort: a missed nudge is fine, a failed delete is not — hence delete-first above.
// Skip when the couple itself is gone (an orphan reap has nobody meaningful to notify).
try {
const coupleRef = doc.ref.parent.parent;
const recipientUid = typeof data.recipientUid === 'string' && data.recipientUid ? data.recipientUid : doc.id;
if (!coupleRef)
return;
const coupleDoc = await coupleRef.get();
if (!coupleDoc.exists)
return;
await (0, queueAndPush_1.queueAndPush)(db, recipientUid, {
type: 'restore_request_expired',
title: 'Your restore request expired',
body: 'No worries — start a new one whenever youre ready.',
coupleId: coupleRef.id,
});
counts.notified++;
}
catch (e) {
log_1.logger.warn('[cleanupRestoreRequests] expiry notice failed', { path: doc.ref.path, error: String(e) });
}
}));
counts.failed = results.filter((r) => r.status === 'rejected').length;
return counts;
}
exports.cleanupExpiredRestoreRequests = (0, scheduler_1.onSchedule)({ schedule: 'every 1 hours', timeoutSeconds: 180 }, async () => {
try {
const counts = await sweepExpiredRestoreRequests(admin.firestore(), Date.now());
log_1.logger.log(`[cleanupRestoreRequests] scanned ${counts.scanned}; reaped ${counts.reaped}; ` +
`notified ${counts.notified}; skipped ${counts.skipped}; failed ${counts.failed}`);
}
catch (e) {
// Never rethrow: a scheduled-function failure retries in a storm, and the next hourly run IS the retry.
log_1.logger.error('[cleanupRestoreRequests] sweep failed', { error: String(e) });
}
});
//# sourceMappingURL=cleanupRestoreRequests.js.map