From 15908746225a27560c47b7b9f87ab5a74f854f37 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 27 Apr 2026 16:50:01 +0800 Subject: [PATCH 01/26] feat(shared-validators): add dataIncidentDocSchema, extend breakglassEventDocSchema and agencyDocSchema for Phase 7 --- packages/shared-validators/src/agencies.ts | 1 + .../shared-validators/src/coordination.ts | 2 ++ .../src/incident-response.ts | 34 +++++++++++++++++++ packages/shared-validators/src/index.ts | 4 +-- 4 files changed, 39 insertions(+), 2 deletions(-) diff --git a/packages/shared-validators/src/agencies.ts b/packages/shared-validators/src/agencies.ts index d01a6718..32345b58 100644 --- a/packages/shared-validators/src/agencies.ts +++ b/packages/shared-validators/src/agencies.ts @@ -8,6 +8,7 @@ export const agencyDocSchema = z jurisdiction: z.enum(['provincial', 'municipal', 'national']), contactEmail: z.email().optional(), contactPhone: z.string().optional(), + mutualAidVisible: z.boolean().optional(), dispatchDefaults: z .object({ timeoutHighMs: z.number().int().positive(), diff --git a/packages/shared-validators/src/coordination.ts b/packages/shared-validators/src/coordination.ts index 96737d5c..27224b5f 100644 --- a/packages/shared-validators/src/coordination.ts +++ b/packages/shared-validators/src/coordination.ts @@ -123,6 +123,8 @@ export const breakglassEventDocSchema = z resourceRef: z.string().optional(), createdAt: z.number().int(), correlationId: z.string().min(1), + expiresAt: z.number().int().optional(), + sessionStartedAt: z.number().int().optional(), schemaVersion: z.number().int().positive(), }) .strict() diff --git a/packages/shared-validators/src/incident-response.ts b/packages/shared-validators/src/incident-response.ts index 5f870e0f..fa66d516 100644 --- a/packages/shared-validators/src/incident-response.ts +++ b/packages/shared-validators/src/incident-response.ts @@ -22,3 +22,37 @@ export const incidentResponseEventSchema = z .strict() export type IncidentResponseEvent = z.infer + +export const dataIncidentDocSchema = z + .object({ + incidentType: z.enum([ + 'unauthorized_access', + 'data_loss', + 'data_corruption', + 'system_breach', + 'accidental_disclosure', + ]), + severity: z.enum(['critical', 'high', 'medium', 'low']), + affectedCollections: z.array(z.string().min(1)), + affectedDataClasses: z.array(z.string().min(1)), + estimatedAffectedSubjects: z.number().int().optional(), + summary: z.string().min(1).max(2000), + status: z.enum([ + 'declared', + 'contained', + 'preserved', + 'assessed', + 'notified_npc', + 'notified_subjects', + 'post_report', + 'closed', + ]), + declaredAt: z.number().int(), + declaredBy: z.string().min(1), + closedAt: z.number().int().optional(), + retentionExempt: z.boolean(), + schemaVersion: z.number().int().positive(), + }) + .strict() + +export type DataIncidentDoc = z.infer diff --git a/packages/shared-validators/src/index.ts b/packages/shared-validators/src/index.ts index 99647220..1b038025 100644 --- a/packages/shared-validators/src/index.ts +++ b/packages/shared-validators/src/index.ts @@ -88,8 +88,8 @@ export type { } from './coordination.js' export { hazardZoneDocSchema, hazardZoneHistoryDocSchema, hazardSignalDocSchema } from './hazard.js' export type { HazardZoneDoc, HazardZoneHistoryDoc, HazardSignalDoc } from './hazard.js' -export { incidentResponseEventSchema } from './incident-response.js' -export type { IncidentResponseEvent } from './incident-response.js' +export { incidentResponseEventSchema, dataIncidentDocSchema } from './incident-response.js' +export type { IncidentResponseEvent, DataIncidentDoc } from './incident-response.js' export { moderationIncidentDocSchema } from './moderation.js' export type { ModerationIncidentDoc } from './moderation.js' export { rateLimitDocSchema } from './rate-limits.js' From 7f9a3d96f57469698df51ef9d356baf6cb08d4c6 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 27 Apr 2026 16:54:27 +0800 Subject: [PATCH 02/26] feat(functions): add requireMfaAuth() guard with tests for Phase 7 --- .../__tests__/callables/https-error.test.ts | 19 +++++++++++++++++++ functions/src/callables/https-error.ts | 9 +++++++++ 2 files changed, 28 insertions(+) diff --git a/functions/src/__tests__/callables/https-error.test.ts b/functions/src/__tests__/callables/https-error.test.ts index 4facc5b7..920e1908 100644 --- a/functions/src/__tests__/callables/https-error.test.ts +++ b/functions/src/__tests__/callables/https-error.test.ts @@ -4,6 +4,7 @@ import { BANTAYOG_TO_HTTPS_CODE, bantayogErrorToHttps, requireAuth, + requireMfaAuth, } from '../../callables/https-error.js' import { BantayogError, BantayogErrorCode } from '@bantayog/shared-validators' @@ -83,3 +84,21 @@ describe('requireAuth', () => { }) }) }) + +describe('requireMfaAuth', () => { + it('throws mfa_required when sign_in_second_factor is absent', () => { + expect(() => { + requireMfaAuth({ + auth: { uid: 'u1', token: { firebase: {} } }, + }) + }).toThrow('mfa_required') + }) + + it('passes when sign_in_second_factor is a string', () => { + expect(() => { + requireMfaAuth({ + auth: { uid: 'u1', token: { firebase: { sign_in_second_factor: 'totp' } } }, + }) + }).not.toThrow() + }) +}) diff --git a/functions/src/callables/https-error.ts b/functions/src/callables/https-error.ts index 1b133937..ed6f8b28 100644 --- a/functions/src/callables/https-error.ts +++ b/functions/src/callables/https-error.ts @@ -46,3 +46,12 @@ export function requireAuth( } return { uid: request.auth.uid, claims } } + +export function requireMfaAuth(request: { + auth?: { uid: string; token: Record } | null +}): void { + const firebase = request.auth?.token.firebase as Record | undefined + if (typeof firebase?.sign_in_second_factor !== 'string') { + throw new HttpsError('unauthenticated', 'mfa_required') + } +} From 4cb0e48b916e6eb4ba043472248f43bfb4fcd292 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 27 Apr 2026 16:57:25 +0800 Subject: [PATCH 03/26] feat(functions): add audit-stream.ts service with BigQuery streaming for Phase 7 --- functions/package.json | 1 + functions/src/services/audit-stream.ts | 30 ++++++ pnpm-lock.yaml | 123 +++++++++++++++---------- 3 files changed, 106 insertions(+), 48 deletions(-) create mode 100644 functions/src/services/audit-stream.ts diff --git a/functions/package.json b/functions/package.json index ae731c2f..65a15e47 100644 --- a/functions/package.json +++ b/functions/package.json @@ -29,6 +29,7 @@ }, "dependencies": { "@bantayog/shared-data": "workspace:*", + "@google-cloud/bigquery": "^7.9.2", "@bantayog/shared-sms-parser": "workspace:*", "@bantayog/shared-types": "workspace:*", "@bantayog/shared-validators": "workspace:*", diff --git a/functions/src/services/audit-stream.ts b/functions/src/services/audit-stream.ts new file mode 100644 index 00000000..9d7bdac4 --- /dev/null +++ b/functions/src/services/audit-stream.ts @@ -0,0 +1,30 @@ +/** + * audit-stream.ts + * + * Fire-and-forget audit event streaming to BigQuery. + * All PRE-7 callables call streamAuditEvent() — never throws. + * Requires infra: bantayog_audit.streaming_events table — see infra/bigquery/ + */ + +import { BigQuery } from '@google-cloud/bigquery' + +export interface AuditStreamEvent { + eventType: string + actorUid: string + sessionId?: string + targetCollection?: string + targetDocumentId?: string + metadata?: Record + occurredAt: number +} + +const bq = new BigQuery() +const table = bq.dataset('bantayog_audit').table('streaming_events') + +export async function streamAuditEvent(event: AuditStreamEvent): Promise { + try { + await table.insert([event]) + } catch (err) { + console.warn('[audit-stream] failed to stream event', event.eventType, err) + } +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index aa237b91..f5beb5fd 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -303,6 +303,9 @@ importers: '@bantayog/shared-validators': specifier: workspace:* version: link:../packages/shared-validators + '@google-cloud/bigquery': + specifier: ^7.9.2 + version: 7.9.4 '@turf/turf': specifier: ^7.3.5 version: 7.3.5 @@ -1087,6 +1090,14 @@ packages: '@firebase/webchannel-wrapper@1.0.5': resolution: {integrity: sha512-+uGNN7rkfn41HLO0vekTFhTxk61eKa8mTpRGLO0QSqlQdKvIoGAvLp3ppdVIWbTGYJWM6Kp0iN+PjMIOcnVqTw==} + '@google-cloud/bigquery@7.9.4': + resolution: {integrity: sha512-C7jeI+9lnCDYK3cRDujcBsPgiwshWKn/f0BiaJmClplfyosCLfWE83iGQ0eKH113UZzjR9c9q7aZQg0nU388sw==} + engines: {node: '>=14.0.0'} + + '@google-cloud/common@5.0.2': + resolution: {integrity: sha512-V7bmBKYQyu0eVG2BFejuUjlBt+zrya6vtsKdY+JxMM/dNntPF41vZ9+LhOshEUH01zOHEqBSvI7Dad7ZS6aUeA==} + engines: {node: '>=14.0.0'} + '@google-cloud/firestore@7.11.6': resolution: {integrity: sha512-EW/O8ktzwLfyWBOsNuhRoMi8lrC3clHM5LVFhGvO1HCsLozCOOXRAlHrYBoE6HL42Sc8yYMuCb2XqcnJ4OOEpw==} engines: {node: '>=14.0.0'} @@ -1095,6 +1106,10 @@ packages: resolution: {integrity: sha512-DJS3s0OVH4zFDB1PzjxAsHqJT6sKVbRwwML0ZBP9PbU7Yebtu/7SWMRzvO2J3nUi9pRNITCfu4LJeooM2w4pjg==} engines: {node: '>=14.0.0'} + '@google-cloud/precise-date@4.0.0': + resolution: {integrity: sha512-1TUx3KdaU3cN7nfCdNf+UVqA/PSX29Cjcox3fZZBtINlRrXVTmUkQnCKv2MbBUbCopbK4olAT1IHl76uZyCiVA==} + engines: {node: '>=14.0.0'} + '@google-cloud/projectify@4.0.0': resolution: {integrity: sha512-MmaX6HeSvyPbWGwFq7mXdo0uQZLGBYCwziiLIGq5JVX+/bdI3SAq6bP98trV5eTWfLuvsMcIC1YJOF2vfteLFA==} engines: {node: '>=14.0.0'} @@ -2645,6 +2660,9 @@ packages: resolution: {integrity: sha512-QxD8cf2eVqJOOz63z6JIN9BzvVs/dlySa5HGSBH5xtR8dPteIRQnBxxKqkNTiT6jbDTF6jAfrd4oMcND9RGbQg==} engines: {node: '>=0.6'} + big.js@6.2.2: + resolution: {integrity: sha512-y/ie+Faknx7sZA5MfGA2xKlu0GDv8RWrXGsmlteyJQ2lvoKv9GBK/fpRMc2qlSoBAgNxrixICFCBefIq8WCQpQ==} + bignumber.js@9.3.1: resolution: {integrity: sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ==} @@ -3722,6 +3740,10 @@ packages: resolution: {integrity: sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww==} engines: {node: '>=8'} + is@3.3.2: + resolution: {integrity: sha512-a2xr4E3s1PjDS8ORcGgXpWx6V+liNs+O3JRD2mb9aeugD7rtkkZ0zgLdYgw0tWsKhsdiezGYptSiMlVazCBTuQ==} + engines: {node: '>= 0.4'} + isarray@2.0.5: resolution: {integrity: sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==} @@ -6162,6 +6184,38 @@ snapshots: '@firebase/webchannel-wrapper@1.0.5': {} + '@google-cloud/bigquery@7.9.4': + dependencies: + '@google-cloud/common': 5.0.2 + '@google-cloud/paginator': 5.0.2 + '@google-cloud/precise-date': 4.0.0 + '@google-cloud/promisify': 4.0.0 + arrify: 2.0.1 + big.js: 6.2.2 + duplexify: 4.1.3 + extend: 3.0.2 + is: 3.3.2 + stream-events: 1.0.5 + uuid: 9.0.1 + transitivePeerDependencies: + - encoding + - supports-color + + '@google-cloud/common@5.0.2': + dependencies: + '@google-cloud/projectify': 4.0.0 + '@google-cloud/promisify': 4.0.0 + arrify: 2.0.1 + duplexify: 4.1.3 + extend: 3.0.2 + google-auth-library: 9.15.1 + html-entities: 2.6.0 + retry-request: 7.0.2 + teeny-request: 9.0.0 + transitivePeerDependencies: + - encoding + - supports-color + '@google-cloud/firestore@7.11.6': dependencies: '@opentelemetry/api': 1.9.1 @@ -6178,13 +6232,12 @@ snapshots: dependencies: arrify: 2.0.1 extend: 3.0.2 - optional: true - '@google-cloud/projectify@4.0.0': - optional: true + '@google-cloud/precise-date@4.0.0': {} - '@google-cloud/promisify@4.0.0': - optional: true + '@google-cloud/projectify@4.0.0': {} + + '@google-cloud/promisify@4.0.0': {} '@google-cloud/storage@7.19.0': dependencies: @@ -6891,8 +6944,7 @@ snapshots: '@tokenizer/token@0.3.0': {} - '@tootallnate/once@2.0.0': - optional: true + '@tootallnate/once@2.0.0': {} '@turbo/darwin-64@2.9.6': optional: true @@ -8081,8 +8133,7 @@ snapshots: '@types/connect': 3.4.38 '@types/node': 20.19.39 - '@types/caseless@0.12.5': - optional: true + '@types/caseless@0.12.5': {} '@types/chai@5.2.3': dependencies: @@ -8186,7 +8237,6 @@ snapshots: '@types/node': 20.19.39 '@types/tough-cookie': 4.0.5 form-data: 2.5.5 - optional: true '@types/send@0.17.6': dependencies: @@ -8213,8 +8263,7 @@ snapshots: '@types/statuses@2.0.6': {} - '@types/tough-cookie@4.0.5': - optional: true + '@types/tough-cookie@4.0.5': {} '@types/whatwg-mimetype@3.0.2': {} @@ -8473,7 +8522,6 @@ snapshots: debug: 4.4.3 transitivePeerDependencies: - supports-color - optional: true agent-base@7.1.4: {} @@ -8582,8 +8630,7 @@ snapshots: get-intrinsic: 1.3.0 is-array-buffer: 3.0.5 - arrify@2.0.1: - optional: true + arrify@2.0.1: {} assertion-error@2.0.1: {} @@ -8604,8 +8651,7 @@ snapshots: retry: 0.13.1 optional: true - asynckit@0.4.0: - optional: true + asynckit@0.4.0: {} at-least-node@1.0.0: {} @@ -8679,6 +8725,8 @@ snapshots: big-integer@1.6.52: {} + big.js@6.2.2: {} + bignumber.js@9.3.1: {} body-parser@1.20.4: @@ -8807,7 +8855,6 @@ snapshots: combined-stream@1.0.8: dependencies: delayed-stream: 1.0.0 - optional: true commander@12.1.0: {} @@ -8911,8 +8958,7 @@ snapshots: has-property-descriptors: 1.0.2 object-keys: 1.1.1 - delayed-stream@1.0.0: - optional: true + delayed-stream@1.0.0: {} depd@2.0.0: {} @@ -8944,7 +8990,6 @@ snapshots: inherits: 2.0.4 readable-stream: 3.6.2 stream-shift: 1.0.3 - optional: true earcut@2.2.4: {} @@ -8975,7 +9020,6 @@ snapshots: end-of-stream@1.4.5: dependencies: once: 1.4.0 - optional: true entities@4.5.0: {} @@ -9512,7 +9556,6 @@ snapshots: hasown: 2.0.2 mime-types: 2.1.35 safe-buffer: 5.2.1 - optional: true formdata-polyfill@4.0.10: dependencies: @@ -9569,7 +9612,6 @@ snapshots: transitivePeerDependencies: - encoding - supports-color - optional: true gaxios@7.1.4: dependencies: @@ -9587,7 +9629,6 @@ snapshots: transitivePeerDependencies: - encoding - supports-color - optional: true gcp-metadata@8.1.2: dependencies: @@ -9706,7 +9747,6 @@ snapshots: transitivePeerDependencies: - encoding - supports-color - optional: true google-gax@4.6.1: dependencies: @@ -9727,8 +9767,7 @@ snapshots: - supports-color optional: true - google-logging-utils@0.0.2: - optional: true + google-logging-utils@0.0.2: {} google-logging-utils@1.1.3: {} @@ -9745,7 +9784,6 @@ snapshots: transitivePeerDependencies: - encoding - supports-color - optional: true happy-dom@15.11.7: dependencies: @@ -9798,8 +9836,7 @@ snapshots: dependencies: hermes-estree: 0.25.1 - html-entities@2.6.0: - optional: true + html-entities@2.6.0: {} html-escaper@2.0.2: {} @@ -9820,7 +9857,6 @@ snapshots: debug: 4.4.3 transitivePeerDependencies: - supports-color - optional: true https-proxy-agent@5.0.1: dependencies: @@ -9828,7 +9864,6 @@ snapshots: debug: 4.4.3 transitivePeerDependencies: - supports-color - optional: true https-proxy-agent@7.0.6: dependencies: @@ -10012,6 +10047,8 @@ snapshots: dependencies: is-docker: 2.2.1 + is@3.3.2: {} + isarray@2.0.5: {} isexe@2.0.0: {} @@ -10779,7 +10816,6 @@ snapshots: node-fetch@2.7.0: dependencies: whatwg-url: 5.0.0 - optional: true node-fetch@3.3.2: dependencies: @@ -11168,7 +11204,6 @@ snapshots: transitivePeerDependencies: - encoding - supports-color - optional: true retry@0.13.1: optional: true @@ -11421,10 +11456,8 @@ snapshots: stream-events@1.0.5: dependencies: stubs: 3.0.0 - optional: true - stream-shift@1.0.3: - optional: true + stream-shift@1.0.3: {} strict-event-emitter@0.5.1: {} @@ -11537,8 +11570,7 @@ snapshots: dependencies: '@tokenizer/token': 0.3.0 - stubs@3.0.0: - optional: true + stubs@3.0.0: {} supports-color@7.2.0: dependencies: @@ -11578,7 +11610,6 @@ snapshots: transitivePeerDependencies: - encoding - supports-color - optional: true test-exclude@6.0.0: dependencies: @@ -11631,8 +11662,7 @@ snapshots: dependencies: tldts: 7.0.28 - tr46@0.0.3: - optional: true + tr46@0.0.3: {} tree-kill@1.2.2: {} @@ -11788,8 +11818,7 @@ snapshots: uuid@8.3.2: optional: true - uuid@9.0.1: - optional: true + uuid@9.0.1: {} v8-to-istanbul@9.3.0: dependencies: @@ -11925,8 +11954,7 @@ snapshots: web-vitals@4.2.4: {} - webidl-conversions@3.0.1: - optional: true + webidl-conversions@3.0.1: {} webidl-conversions@7.0.0: {} @@ -11944,7 +11972,6 @@ snapshots: dependencies: tr46: 0.0.3 webidl-conversions: 3.0.1 - optional: true which-boxed-primitive@1.1.1: dependencies: From a7a6081d0673c7ba8fb0ba609a4f9c2922923279 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 27 Apr 2026 17:03:41 +0800 Subject: [PATCH 04/26] feat(functions): add audit-export-batch and auditExportHealthCheck scheduled triggers for Phase 7 --- functions/package.json | 1 + functions/src/index.ts | 2 + functions/src/triggers/audit-export-batch.ts | 27 + .../src/triggers/audit-export-health-check.ts | 64 ++ pnpm-lock.yaml | 546 +++++++++++++++++- 5 files changed, 635 insertions(+), 5 deletions(-) create mode 100644 functions/src/triggers/audit-export-batch.ts create mode 100644 functions/src/triggers/audit-export-health-check.ts diff --git a/functions/package.json b/functions/package.json index 65a15e47..1ae6f520 100644 --- a/functions/package.json +++ b/functions/package.json @@ -30,6 +30,7 @@ "dependencies": { "@bantayog/shared-data": "workspace:*", "@google-cloud/bigquery": "^7.9.2", + "@google-cloud/logging": "^7.0.0", "@bantayog/shared-sms-parser": "workspace:*", "@bantayog/shared-types": "workspace:*", "@bantayog/shared-validators": "workspace:*", diff --git a/functions/src/index.ts b/functions/src/index.ts index a3b539e1..434a061c 100644 --- a/functions/src/index.ts +++ b/functions/src/index.ts @@ -138,3 +138,5 @@ export { smsDeliveryReport } from './http/sms-delivery-report.js' export { smsInboundWebhook } from './http/sms-inbound.js' export { smsInboundProcessor } from './firestore/sms-inbound-processor.js' export { analyticsSnapshotWriter } from './scheduled/analytics-snapshot-writer.js' +export { auditExportBatch } from './triggers/audit-export-batch.js' +export { auditExportHealthCheck } from './triggers/audit-export-health-check.js' diff --git a/functions/src/triggers/audit-export-batch.ts b/functions/src/triggers/audit-export-batch.ts new file mode 100644 index 00000000..3c38f243 --- /dev/null +++ b/functions/src/triggers/audit-export-batch.ts @@ -0,0 +1,27 @@ +import { onSchedule } from 'firebase-functions/v2/scheduler' +import { BigQuery } from '@google-cloud/bigquery' +import { Logging } from '@google-cloud/logging' + +const bq = new BigQuery() +const logging = new Logging() + +export const auditExportBatch = onSchedule( + { schedule: 'every 5 minutes', region: 'asia-southeast1', timeZone: 'UTC' }, + async () => { + const table = bq.dataset('bantayog_audit').table('batch_events') + const log = logging.log('cloudaudit.googleapis.com%2Factivity') + const [entries] = await log.getEntries({ pageSize: 500 }) + if (entries.length === 0) return + const rows = entries.map((e) => ({ + logName: e.metadata.logName, + resource: JSON.stringify(e.metadata.resource), + payload: JSON.stringify(e.data), + timestamp: e.metadata.timestamp, + })) + try { + await table.insert(rows) + } catch (err) { + console.warn('[audit-export-batch] insert failed', err) + } + }, +) diff --git a/functions/src/triggers/audit-export-health-check.ts b/functions/src/triggers/audit-export-health-check.ts new file mode 100644 index 00000000..25b18070 --- /dev/null +++ b/functions/src/triggers/audit-export-health-check.ts @@ -0,0 +1,64 @@ +import { onSchedule } from 'firebase-functions/v2/scheduler' +import { getFirestore, FieldValue } from 'firebase-admin/firestore' +import { getMessaging } from 'firebase-admin/messaging' +import { BigQuery } from '@google-cloud/bigquery' + +const bq = new BigQuery() + +interface LastAtRow { + lastAt: { value: string } | null +} + +function extractLastMs(rows: LastAtRow[]): number { + const row = rows[0] + if (!row?.lastAt?.value) return 0 + return Number(row.lastAt.value) +} + +function extractLastDateMs(rows: LastAtRow[]): number { + const row = rows[0] + if (!row?.lastAt?.value) return 0 + return new Date(row.lastAt.value).getTime() +} + +export const auditExportHealthCheck = onSchedule( + { schedule: 'every 10 minutes', region: 'asia-southeast1', timeZone: 'UTC' }, + async () => { + const db = getFirestore() + const now = Date.now() + + const [streamRows] = await bq.query( + 'SELECT MAX(occurredAt) as lastAt FROM bantayog_audit.streaming_events', + ) + const lastStreamMs = extractLastMs(streamRows as unknown as LastAtRow[]) + const streamingGapSeconds = Math.floor((now - lastStreamMs) / 1000) + + const [batchRows] = await bq.query( + 'SELECT MAX(timestamp) as lastAt FROM bantayog_audit.batch_events', + ) + const lastBatchMs = extractLastDateMs(batchRows as unknown as LastAtRow[]) + const batchGapSeconds = Math.floor((now - lastBatchMs) / 1000) + + const healthy = streamingGapSeconds < 60 && batchGapSeconds < 900 + await db.doc('system_health/latest').set({ + streamingGapSeconds, + batchGapSeconds, + healthy, + checkedAt: FieldValue.serverTimestamp(), + }) + + if (!healthy) { + try { + await getMessaging().send({ + topic: 'superadmin-alerts', + notification: { + title: 'Audit pipeline health alert', + body: `Streaming gap: ${String(streamingGapSeconds)}s · Batch gap: ${String(batchGapSeconds)}s`, + }, + }) + } catch { + /* non-critical */ + } + } + }, +) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f5beb5fd..cbd67583 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -306,6 +306,9 @@ importers: '@google-cloud/bigquery': specifier: ^7.9.2 version: 7.9.4 + '@google-cloud/logging': + specifier: ^7.0.0 + version: 7.3.0 '@turf/turf': specifier: ^7.3.5 version: 7.3.5 @@ -1094,6 +1097,10 @@ packages: resolution: {integrity: sha512-C7jeI+9lnCDYK3cRDujcBsPgiwshWKn/f0BiaJmClplfyosCLfWE83iGQ0eKH113UZzjR9c9q7aZQg0nU388sw==} engines: {node: '>=14.0.0'} + '@google-cloud/common@2.4.0': + resolution: {integrity: sha512-zWFjBS35eI9leAHhjfeOYlK5Plcuj/77EzstnrJIZbKgF/nkqjcQuGiMCpzCwOfPyUbz8ZaEOYgbHa759AKbjg==} + engines: {node: '>=8.10.0'} + '@google-cloud/common@5.0.2': resolution: {integrity: sha512-V7bmBKYQyu0eVG2BFejuUjlBt+zrya6vtsKdY+JxMM/dNntPF41vZ9+LhOshEUH01zOHEqBSvI7Dad7ZS6aUeA==} engines: {node: '>=14.0.0'} @@ -1102,6 +1109,14 @@ packages: resolution: {integrity: sha512-EW/O8ktzwLfyWBOsNuhRoMi8lrC3clHM5LVFhGvO1HCsLozCOOXRAlHrYBoE6HL42Sc8yYMuCb2XqcnJ4OOEpw==} engines: {node: '>=14.0.0'} + '@google-cloud/logging@7.3.0': + resolution: {integrity: sha512-xTW1V4MKpYC0mjSugyuiyUoZ9g6A42IhrrO3z7Tt3SmAb2IRj2Gf4RLoguKKncs340ooZFXrrVN/++t2Aj5zgg==} + engines: {node: '>=8.10.0'} + + '@google-cloud/paginator@2.0.3': + resolution: {integrity: sha512-kp/pkb2p/p0d8/SKUu4mOq8+HGwF8NPzHWkj+VKrIPQPyMRw8deZtrO/OcSiy9C/7bpfU5Txah5ltUNfPkgEXg==} + engines: {node: '>=8.10.0'} + '@google-cloud/paginator@5.0.2': resolution: {integrity: sha512-DJS3s0OVH4zFDB1PzjxAsHqJT6sKVbRwwML0ZBP9PbU7Yebtu/7SWMRzvO2J3nUi9pRNITCfu4LJeooM2w4pjg==} engines: {node: '>=14.0.0'} @@ -1110,10 +1125,18 @@ packages: resolution: {integrity: sha512-1TUx3KdaU3cN7nfCdNf+UVqA/PSX29Cjcox3fZZBtINlRrXVTmUkQnCKv2MbBUbCopbK4olAT1IHl76uZyCiVA==} engines: {node: '>=14.0.0'} + '@google-cloud/projectify@1.0.4': + resolution: {integrity: sha512-ZdzQUN02eRsmTKfBj9FDL0KNDIFNjBn/d6tHQmA/+FImH5DO6ZV8E7FzxMgAUiVAUq41RFAkb25p1oHOZ8psfg==} + engines: {node: '>=8.10.0'} + '@google-cloud/projectify@4.0.0': resolution: {integrity: sha512-MmaX6HeSvyPbWGwFq7mXdo0uQZLGBYCwziiLIGq5JVX+/bdI3SAq6bP98trV5eTWfLuvsMcIC1YJOF2vfteLFA==} engines: {node: '>=14.0.0'} + '@google-cloud/promisify@1.0.4': + resolution: {integrity: sha512-VccZDcOql77obTnFh0TbNED/6ZbbmHDf8UMNnzO1d5g9V0Htfm4k5cllY8P1tJsRKC3zWYGRLaViiupcgVjBoQ==} + engines: {node: '>=8.10.0'} + '@google-cloud/promisify@4.0.0': resolution: {integrity: sha512-Orxzlfb9c67A15cq2JQEyVc7wEsmFBmHjZWZYQMUyJ1qivXyMwdyNOs9odi79hze+2zqdTtu1E19IM/FtqZ10g==} engines: {node: '>=14'} @@ -1126,10 +1149,18 @@ packages: resolution: {integrity: sha512-Iq8QQQ/7X3Sac15oB6p0FmUg/klxQvXLeileoqrTRGJYLV+/9tubbr9ipz0GKHjmXVsgFPo/+W+2cA8eNcR+XA==} engines: {node: '>=12.10.0'} + '@grpc/grpc-js@1.3.8': + resolution: {integrity: sha512-4qJqqn+CU/nBydz9ePJP+oa8dz0U42Ut/GejlbyaQ1xTkynCc+ndNHHnISlNeHawDsv4MOAyP3mV/EnDNUw2zA==} + engines: {node: ^8.13.0 || >=10.10.0} + '@grpc/grpc-js@1.9.15': resolution: {integrity: sha512-nqE7Hc0AzI+euzUwDAy0aY5hCp10r734gMGRdU+qOPX0XSceI2ULrcXB5U2xSc5VkWwalCj4M7GzCAygZl2KoQ==} engines: {node: ^8.13.0 || >=10.10.0} + '@grpc/proto-loader@0.5.6': + resolution: {integrity: sha512-DT14xgw3PSzPxwS13auTEwxhMMOoz33DPUKNtmYK/QYbBSpLXJy78FGGs5yVoxVobEqPm4iW9MOIoz0A3bLTRQ==} + engines: {node: '>=6'} + '@grpc/proto-loader@0.7.15': resolution: {integrity: sha512-tMXdRCfYVixjuFK+Hk0Q1s38gV9zDiDJfWL3h1rv4Qc39oILCu1TRTDt7+fGUI8K4G1Fj125Hx/ru3azECWTyQ==} engines: {node: '>=6'} @@ -1505,6 +1536,13 @@ packages: '@open-draft/until@2.1.0': resolution: {integrity: sha512-U69T3ItWHvLwGg5eJ0n3I62nWuE6ilHlmz7zM0npLBRvPRd7e6NYmg54vvRtP5mZG7kZqZCFVdsTWo7BPtBujg==} + '@opencensus/core@0.0.20': + resolution: {integrity: sha512-vqOuTd2yuMpKohp8TNNGUAPjWEGjlnGfB9Rh5e3DKqeyR94YgierNs4LbMqxKtsnwB8Dm2yoEtRuUgoe5vD9DA==} + engines: {node: '>=8'} + + '@opencensus/propagation-stackdriver@0.0.20': + resolution: {integrity: sha512-P8yuHSLtce+yb+2EZjtTVqG7DQ48laC+IuOWi3X9q78s1Gni5F9+hmbmyP6Nb61jb5BEvXQX1s2rtRI6bayUWA==} + '@opentelemetry/api@1.9.1': resolution: {integrity: sha512-gLyJlPHPZYdAk1JENA9LeHejZe1Ti77/pTeFm/nMXmQH/HFZlcS/O2XJB+L8fkbrNSqhdtlvjBVjxwUYanNH5Q==} engines: {node: '>=8.0.0'} @@ -1716,6 +1754,10 @@ packages: '@tokenizer/token@0.3.0': resolution: {integrity: sha512-OvjF+z51L3ov0OyAU0duzsYuvO01PH7x4t6DJx+guahgTnBHkhJdG7soQeTSFLWN3efnHyibZ4Z8l2EuWwJN3A==} + '@tootallnate/once@1.1.2': + resolution: {integrity: sha512-RbzJvlNzmRq5c3O09UipeuXno4tA1FE6ikOjxZK0tuxVv3412l64l5t1W5pj4+rJq9vpkm/kwiR07aZXnsKPxw==} + engines: {node: '>= 6'} + '@tootallnate/once@2.0.0': resolution: {integrity: sha512-XCuKFP5PS55gnMVu3dty8KPatLqUoy/ZYzDzAGCQ8JNFCkLXzmI7vNHCR+XpbZaMWQK/vQubr7PkYq8g470J/A==} engines: {node: '>= 10'} @@ -2594,6 +2636,10 @@ packages: resolution: {integrity: sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA==} engines: {node: '>= 0.4'} + async-listener@0.6.10: + resolution: {integrity: sha512-gpuo6xOyF4D5DE5WvyqZdPA3NGhiT6Qf07l7DCB0wwDEsLvDIbCr6j9S5aj5Ch96dLace5tXVzWBZkxU/c5ohw==} + engines: {node: <=0.11.8 || >0.11.10} + async-retry@1.3.3: resolution: {integrity: sha512-wfr/jstw9xNi/0teMHrRW7dsz3Lt5ARhYNZ2ewpadnhaIp5mbALhOAP+EAdsC7t4Z6wqsDVv9+W6gm1Dk9mEyw==} @@ -2817,6 +2863,9 @@ packages: resolution: {integrity: sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==} engines: {node: '>= 0.6'} + continuation-local-storage@3.2.1: + resolution: {integrity: sha512-jx44cconVqkCEEyLSKWwkvUXwO561jXMa3LPjTPsm5QR22PA0/mhe33FT4Xb5y74JDvt/Cq+5lm8S8rskLv9ZA==} + convert-source-map@2.0.0: resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} @@ -2831,6 +2880,9 @@ packages: resolution: {integrity: sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==} engines: {node: '>=18'} + core-util-is@1.0.3: + resolution: {integrity: sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==} + cors@2.8.6: resolution: {integrity: sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==} engines: {node: '>= 0.10'} @@ -2854,6 +2906,9 @@ packages: d3-voronoi@1.1.2: resolution: {integrity: sha512-RhGS1u2vavcO7ay7ZNAPo4xeDh/VYeGof3x5ZLJBQgYhLegxr3s5IykvWmJ94FTU6mcbtp4sloqZ54mP6R4Utw==} + d64@1.0.0: + resolution: {integrity: sha512-5eNy3WZziVYnrogqgXhcdEmqcDB2IHurTqLcrgssJsfkMVCUoUaZpK6cJjxxvLV2dUm5SuJMNcYfVGoin9UIRw==} + damerau-levenshtein@1.0.8: resolution: {integrity: sha512-sdQSFB7+llfUcQHUQO3+B8ERRj0Oa4w9POWMI/puGtuf7gFywGmkaLCElnudfTiKZV+NvHqL0ifzdrI8Ro7ESA==} @@ -2951,10 +3006,17 @@ packages: dom-accessibility-api@0.6.3: resolution: {integrity: sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==} + dot-prop@5.3.0: + resolution: {integrity: sha512-QM8q3zDe58hqUqjraQOmzZ1LIH9SWQJTlEKCH4kJ2oQvLZk7RbQXvtDM2XEq3fwkV9CCvvH4LA0AV+ogFsBM2Q==} + engines: {node: '>=8'} + dunder-proto@1.0.1: resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} engines: {node: '>= 0.4'} + duplexify@3.7.1: + resolution: {integrity: sha512-07z8uv2wMyS51kKhD1KsdXJg5WQ6t93RneqRxUHnskXVtlYYkLqM0gqStQZ3pj073g687jPCHrqNfCzawLYh5g==} + duplexify@4.1.3: resolution: {integrity: sha512-M3BmBhwJRZsSx38lZyhE53Csddgzl5R7xGJNk7CVddZD6CcmwMCH8J+7AprIrQKH7TonKxaCjcv27Qmf+sQ+oA==} @@ -2977,6 +3039,9 @@ packages: resolution: {integrity: sha512-wkgGT6kugeQk/P6VZ/f4T+4HB41BVgNBq5CDIZVbQ02nvTVqAiVTbskxxu3eA/X96lMlfYOwnLQpN2v5E1zDEg==} engines: {node: '>= 0.4.0'} + emitter-listener@1.1.2: + resolution: {integrity: sha512-Bt1sBAGFHY9DKY+4/2cV6izcKJUf5T7/gkdmkxzX/qv9CcGH8xSwVRW5mtX03SWJtRTWSOpzCuWN9rBFYZepZQ==} + emittery@0.13.1: resolution: {integrity: sha512-DeWwawk6r5yR9jFgnDKYt4sLS0LmHJJi3ZOnb5/JdbYwj3nW+FxQnHIjhBKz8YLC7oRNPVM9NQ47I3CVx34eqQ==} engines: {node: '>=12'} @@ -2997,6 +3062,10 @@ packages: end-of-stream@1.4.5: resolution: {integrity: sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==} + ent@2.2.2: + resolution: {integrity: sha512-kKvD1tO6BM+oK9HzCPpUdRb4vKFQY/FPTFmurMvh6LlN68VMrdj77w8yp51/kDbpkFOS9J8w5W6zIzgM2H8/hw==} + engines: {node: '>= 0.4'} + entities@4.5.0: resolution: {integrity: sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==} engines: {node: '>=0.12'} @@ -3154,6 +3223,10 @@ packages: eventemitter3@5.0.4: resolution: {integrity: sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==} + eventid@1.0.0: + resolution: {integrity: sha512-4upSDsvpxhWPsmw4fsJCp0zj8S7I0qh1lCDTmZXP8V3TtryQKDI8CgQPN+e5JakbWwzaAX3lrdp2b3KSoMSUpw==} + engines: {node: '>=8'} + execa@5.1.1: resolution: {integrity: sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg==} engines: {node: '>=10'} @@ -3199,6 +3272,9 @@ packages: fast-string-width@3.0.2: resolution: {integrity: sha512-gX8LrtNEI5hq8DVUfRQMbr5lpaS4nMIWV+7XEbXk2b8kiQIizgnlr12B4dA3ZEx3308ze0O4Q1R+cHts8kyUJg==} + fast-text-encoding@1.0.6: + resolution: {integrity: sha512-VhXlQgj9ioXCqGstD37E/HBeqEGV/qOD/kmbVG8h5xKBYvM1L3lR1Zn4555cQ8GkYbJa8aJSipLPndE1k6zK2w==} + fast-wrap-ansi@0.2.0: resolution: {integrity: sha512-rLV8JHxTyhVmFYhBJuMujcrHqOT2cnO5Zxj37qROj23CP39GXubJRBUFF0z8KFK77Uc0SukZUf7JZhsVEQ6n8w==} @@ -3349,6 +3425,10 @@ packages: functions-have-names@1.2.3: resolution: {integrity: sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==} + gaxios@2.3.4: + resolution: {integrity: sha512-US8UMj8C5pRnao3Zykc4AAVr+cffoNKRTg9Rsf2GiuZCW69vgJj38VK2PzlPuQU73FZ/nTk9/Av6/JGcE1N9vA==} + engines: {node: '>=8.10.0'} + gaxios@6.7.1: resolution: {integrity: sha512-LDODD4TMYx7XXdpwxAVRAIAuB0bzv0s+ywFonY46k126qzQHT9ygyoa9tncmOiQmmDrik65UYsEkv3lbfqQ3yQ==} engines: {node: '>=14'} @@ -3357,6 +3437,10 @@ packages: resolution: {integrity: sha512-bTIgTsM2bWn3XklZISBTQX7ZSddGW+IO3bMdGaemHZ3tbqExMENHLx6kKZ/KlejgrMtj8q7wBItt51yegqalrA==} engines: {node: '>=18'} + gcp-metadata@3.5.0: + resolution: {integrity: sha512-ZQf+DLZ5aKcRpLzYUyBS3yo3N0JSa82lNDO8rj3nMSlovLcz2riKFBsYgDzeXcv75oo5eqB2lx+B14UvPoCRnA==} + engines: {node: '>=8.10.0'} + gcp-metadata@6.1.1: resolution: {integrity: sha512-a4tiq7E0/5fTjxPAaH4jpjkSv/uCaU2p5KC6HVGrvl0cDjA8iBZv4vv1gyzlmK0ZUKqwpOyQMKzZQe3lTit77A==} engines: {node: '>=14'} @@ -3447,10 +3531,19 @@ packages: resolution: {integrity: sha512-e27Z6EThmVNNvtYASwQxose/G57rkRuaRbQyxM2bvYLLX/GqWZ5chWq2EBoUchJbCc57eC9ArzO5wMsEmWftCw==} engines: {node: '>=18'} + google-auth-library@5.10.1: + resolution: {integrity: sha512-rOlaok5vlpV9rSiUu5EpR0vVpc+PhN62oF4RyX/6++DG1VsaulAFEMlDYBLjJDDPI6OcNOCGAKy9UVB/3NIDXg==} + engines: {node: '>=8.10.0'} + google-auth-library@9.15.1: resolution: {integrity: sha512-Jb6Z0+nvECVz+2lzSMt9u98UsoakXxA2HGHMCxh+so3n90XgYWkq5dur19JAJV7ONiJY22yBTyJB1TSkvPq9Ng==} engines: {node: '>=14'} + google-gax@1.15.4: + resolution: {integrity: sha512-Wzl43ueWEKNsLcMJ33sPps179nD7wn6Jcl/P+ZQNS+HxdCJcoQEgJe3AmulsJnatwjBE3pVPIE4HFJNhpRGvUw==} + engines: {node: '>=8.10.0'} + hasBin: true + google-gax@4.6.1: resolution: {integrity: sha512-V6eky/xz2mcKfAd1Ioxyd6nmA61gao3n01C+YeuIwu3vzM9EDR6wcVzMSIbLMDXWeoi9SHYctXuKYC5uJUT3eQ==} engines: {node: '>=14'} @@ -3463,6 +3556,12 @@ packages: resolution: {integrity: sha512-eAmLkjDjAFCVXg7A1unxHsLf961m6y17QFqXqAXGj/gVkKFrEICfStRfwUlGNfeCEjNRa32JEWOUTlYXPyyKvA==} engines: {node: '>=14'} + google-p12-pem@2.0.5: + resolution: {integrity: sha512-7RLkxwSsMsYh9wQ5Vb2zRtkAHvqPvfoMGag+nugl1noYO7gf0844Yr9TIFA5NEBMAeVt2Z+Imu7CQMp3oNatzQ==} + engines: {node: '>=8.10.0'} + deprecated: Package is no longer maintained + hasBin: true + gopd@1.2.0: resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==} engines: {node: '>= 0.4'} @@ -3474,6 +3573,10 @@ packages: resolution: {integrity: sha512-5bJ+nf/UCpAjHM8i06fl7eLyVC9iuNAjm9qzkiu2ZGhM0VscSvS6WDPfAwkdkBuoXGM9FJSbKl6wylMwP9Ktig==} engines: {node: ^12.22.0 || ^14.16.0 || ^16.0.0 || >=17.0.0} + gtoken@4.1.4: + resolution: {integrity: sha512-VxirzD0SWoFUo5p8RDP8Jt2AGyOmyYcT/pOUgDKJCK+iSw0TMqwrVfY37RXTNmoKwrzmDHSk0GMT9FsgVmnVSA==} + engines: {node: '>=8.10.0'} + gtoken@7.1.0: resolution: {integrity: sha512-pCcEwRi+TKpMlxAQObHDQ56KawURgyAf6jtIY046fJ5tIv3zDe/LEIubckAO8fj6JnAxLdmWkUfNyulQ2iKdEw==} engines: {node: '>=14.0.0'} @@ -3522,6 +3625,9 @@ packages: hermes-parser@0.25.1: resolution: {integrity: sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA==} + hex2dec@1.1.2: + resolution: {integrity: sha512-Yu+q/XWr2fFQ11tHxPq4p4EiNkb2y+lAacJNhAdRXVfRIcDH6gi7htWFnnlIzvqHMHoWeIsfXlNAjZInpAOJDA==} + html-entities@2.6.0: resolution: {integrity: sha512-kig+rMn/QOVRvr7c86gQ8lWXq+Hkv6CbAH1hLu+RG338StTpE8Z0b44SDVaqVu7HGKf27frdmUYEs9hTUX/cLQ==} @@ -3535,6 +3641,10 @@ packages: http-parser-js@0.5.10: resolution: {integrity: sha512-Pysuw9XpUq5dVc/2SMHpuTY01RFl8fttgcyunjL7eEMhGM3cI4eOmiCycJDVCo/7O7ClfQD3SaI6ftDzqOXYMA==} + http-proxy-agent@4.0.1: + resolution: {integrity: sha512-k0zdNgqWTGA6aeIRVpvfVob4fL52dTfaehylg0Y4UvSySvOq/Y+BOyPrgpUrA7HylqvU8vIZGsRuXmspskV0Tg==} + engines: {node: '>= 6'} + http-proxy-agent@5.0.0: resolution: {integrity: sha512-n2hY8YdoRE1i7r6M0w9DIw5GgZN0G25P8zLCRQ8rjXtTU3vsNFBI/vWK/UIeE6g5MUUz6avwAPXmL6Fy9D/90w==} engines: {node: '>= 6'} @@ -3696,6 +3806,10 @@ packages: resolution: {integrity: sha512-lZhclumE1G6VYD8VHe35wFaIif+CTy5SJIi5+3y4psDgWu4wPDoBhF8NxUOinEc7pHgiTsT6MaBb92rKhhD+Xw==} engines: {node: '>= 0.4'} + is-obj@2.0.0: + resolution: {integrity: sha512-drqDG3cbczxxEJRoOXcOjtdp1J/lyp1mNn0xaznRs8+muBhgQcrnbspox5X5fOw0HnMnbfDzvnEMEtqDEJEo8w==} + engines: {node: '>=8'} + is-regex@1.2.1: resolution: {integrity: sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==} engines: {node: '>= 0.4'} @@ -3708,6 +3822,9 @@ packages: resolution: {integrity: sha512-ISWac8drv4ZGfwKl5slpHG9OwPNty4jOWPRIhBpxOoD+hqITiwuipOQ2bNthAzwA3B4fIjO4Nln74N0S9byq8A==} engines: {node: '>= 0.4'} + is-stream-ended@0.1.4: + resolution: {integrity: sha512-xj0XPvmr7bQFTvirqnFr50o0hQIh6ZItDqloxt5aJrR4NQsYeSsyFQERYGCAzfindAcnKjINnwEEgLx4IqVzQw==} + is-stream@2.0.1: resolution: {integrity: sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==} engines: {node: '>=8'} @@ -3744,6 +3861,9 @@ packages: resolution: {integrity: sha512-a2xr4E3s1PjDS8ORcGgXpWx6V+liNs+O3JRD2mb9aeugD7rtkkZ0zgLdYgw0tWsKhsdiezGYptSiMlVazCBTuQ==} engines: {node: '>= 0.4'} + isarray@1.0.0: + resolution: {integrity: sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==} + isarray@2.0.5: resolution: {integrity: sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==} @@ -3927,6 +4047,9 @@ packages: engines: {node: '>=6'} hasBin: true + json-bigint@0.3.1: + resolution: {integrity: sha512-DGWnSzmusIreWlEupsUelHrhwmPPE+FiQvg+drKfk2p+bdEYa5mp4PJ8JsCWqae0M2jQNb0HPvnwvf1qOTThzQ==} + json-bigint@1.0.0: resolution: {integrity: sha512-SiPv/8VpZuWbvLSMtTDU8hEfrZWg/mH/nV/b4o0CYbSxu1UIQPLdwKOCIyLQX+VIPO5vrLX3i8qtqFyhdPSUSQ==} @@ -4100,12 +4223,19 @@ packages: resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==} engines: {node: '>=10'} + lodash.at@4.6.0: + resolution: {integrity: sha512-GOTh0SEp+Yosnlpjic+8cl2WM9MykorogkGA9xyIFkkObQ3H3kNZqZ+ohuq4K3FrSVo7hMcZBMataJemrxC3BA==} + deprecated: This package is deprecated. Use {Array|String}.prototype.at instead. + lodash.camelcase@4.3.0: resolution: {integrity: sha512-TwuEnCnxbc3rAvhf/LbG7tJUDzhqXyFnv3dtzLOPgCG/hODL7WFnsbwktkD7yUV0RrreP/l1PALq/YSg6VvjlA==} lodash.clonedeep@4.5.0: resolution: {integrity: sha512-H5ZhCF25riFd9uB5UCkVKo61m3S/xZk1x4wA6yp/L3RFP6Z/eHH1ymQcGLo7J3GMPfm0V/7m1tryHuGVxpqEBQ==} + lodash.has@4.5.2: + resolution: {integrity: sha512-rnYUdIo6xRCJnQmbVFEwcxF144erlD+M3YcJUVesflU9paQaE8p+fJDcIQrlMYbxoANFL+AB9hZrzSBBk5PL+g==} + lodash.includes@4.3.0: resolution: {integrity: sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==} @@ -4133,10 +4263,17 @@ packages: lodash@4.18.1: resolution: {integrity: sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==} + log-driver@1.2.7: + resolution: {integrity: sha512-U7KCmLdqsGHBLeWqYlFA0V0Sl6P08EE1ZrmA9cxjUE0WVqT9qnyVDPz1kzpFEP0jdJuFnasWIfSd7fsaNXkpbg==} + engines: {node: '>=0.8.6'} + log-update@6.1.0: resolution: {integrity: sha512-9ie8ItPR6tjY5uYJh8K/Zrv/RMZ5VOlOWvtZdEHYSTFKZfIBPQa9tOAEeAWhd+AnIneLJ22w5fjOYtoutpWq5w==} engines: {node: '>=18'} + long@4.0.0: + resolution: {integrity: sha512-XsP+KhQif4bjX1kbuSiySJFNAehNxgLb6hPRGJ9QsUr8ajHkuXGdrHmFUTUUXhDwVX2R5bY4JNZEwbUiMhV+MA==} + long@5.3.2: resolution: {integrity: sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==} @@ -4183,6 +4320,10 @@ packages: makeerror@1.0.12: resolution: {integrity: sha512-JmqCvUhmt43madlpFzG4BQzG2Z3m6tvQDNKdClZnO3VbIudJYmxsT0FNJMeiB2+JTSlTQTSbU8QdesVmwJcmLg==} + map-obj@4.3.0: + resolution: {integrity: sha512-hdN1wVrZbb29eBGiGjJbeP8JbKjq1urkHJ/LIP/NY48MZ1QVXUsQBV1G1zvYFHn1XE06cwjBsOI2K3Ulnj1YXQ==} + engines: {node: '>=8'} + math-intrinsics@1.1.0: resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} engines: {node: '>= 0.4'} @@ -4214,6 +4355,11 @@ packages: engines: {node: '>=4'} hasBin: true + mime@2.6.0: + resolution: {integrity: sha512-USPkMeET31rOMiarsBNIHZKLGgvKc/LrjofAnBlOttf5ajRvqiRA8QsenbcooctK6d6Ts6aqZXBA+XbkKthiQg==} + engines: {node: '>=4.0.0'} + hasBin: true + mime@3.0.0: resolution: {integrity: sha512-jSCU7/VB1loIWBZe14aEYHU/+1UMEHoaO7qxCOVJOw9GgH72VAWppxNcjU+x9a2k3GSIBXNKxXQFqRvvZ7vr3A==} engines: {node: '>=10.0.0'} @@ -4318,6 +4464,10 @@ packages: resolution: {integrity: sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==} engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + node-forge@0.10.0: + resolution: {integrity: sha512-PPmu8eEeG9saEUvI97fm4OYxXVB6bFvyNTyiUOBichBpFG8A1Ljw3bY62+5oOjDEMHRnd0Y7HQ+x7uzxOzC6JA==} + engines: {node: '>= 6.0.0'} + node-forge@1.4.0: resolution: {integrity: sha512-LarFH0+6VfriEhqMMcLX2F7SwSXeWwnEAJEsYm5QKWchiVYVvJyV9v7UDvUv+w5HO23ZpQTXDv/GxdDdMyOuoQ==} engines: {node: '>= 6.13.0'} @@ -4542,6 +4692,9 @@ packages: resolution: {integrity: sha512-oG4T3wCbfeuvljnyAzhBvpN45E8iOTXCU/TD3zXW80HA3dQ4ahdqMkWGiPWZvjpQwlbyHrPTWUAqUzGzv4l1JQ==} engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + process-nextick-args@2.0.1: + resolution: {integrity: sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==} + prompts@2.4.2: resolution: {integrity: sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q==} engines: {node: '>= 6'} @@ -4553,6 +4706,10 @@ packages: resolution: {integrity: sha512-SAzp/O4Yh02jGdRc+uIrGoe87dkN/XtwxfZ4ZyafJHymd79ozp5VG5nyZ7ygqPM5+cpLDjjGnYFUkngonyDPOQ==} engines: {node: '>=14.0.0'} + protobufjs@6.11.5: + resolution: {integrity: sha512-OKjVH3hDoXdIZ/s5MLv8O2X0s+wOxGfV7ar6WFSKGaSAxi/6gYn3px5POS4vi+mc/0zCOdL7Jkwrj0oT1Yst2A==} + hasBin: true + protobufjs@7.5.5: resolution: {integrity: sha512-3wY1AxV+VBNW8Yypfd1yQY9pXnqTAN+KwQxL8iYm3/BjKYMNg4i0owhEe26PWDOMaIrzeeF98Lqd5NGz4omiIg==} engines: {node: '>=12.0.0'} @@ -4561,6 +4718,15 @@ packages: resolution: {integrity: sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==} engines: {node: '>= 0.10'} + pump@3.0.4: + resolution: {integrity: sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==} + + pumpify@2.0.1: + resolution: {integrity: sha512-m7KOje7jZxrmutanlkS1daj1dS6z6BgslzOXmcSEpIlCxM3VJH7lG5QLeck/6hgF6F4crFf01UtQmNsJfweTAw==} + + punycode@1.4.1: + resolution: {integrity: sha512-jmYNElW7yvO7TV33CjSmvSiE2yco3bV2czu/OzDKdMNVZQWfxCblURLhf+47syQRBntjfLdd/H0egrzIG+oaFQ==} + punycode@2.3.1: resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} engines: {node: '>=6'} @@ -4634,6 +4800,9 @@ packages: resolution: {integrity: sha512-llUJLzz1zTUBrskt2pwZgLq59AemifIftw4aB7JxOqf1HY2FDaGDxgwpAPVzHU1kdWabH7FauP4i1oEeer2WCA==} engines: {node: '>=0.10.0'} + readable-stream@2.3.8: + resolution: {integrity: sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==} + readable-stream@3.6.2: resolution: {integrity: sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==} engines: {node: '>= 6'} @@ -4681,6 +4850,10 @@ packages: resolution: {integrity: sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA==} engines: {node: '>=18'} + retry-request@4.2.2: + resolution: {integrity: sha512-xA93uxUD/rogV7BV59agW/JHPGXeREMWiZc9jhcwY4YdZ7QOtC7qbomYg0n4wyk2lJhggjvKvhNX8wln/Aldhg==} + engines: {node: '>=8.10.0'} + retry-request@7.0.2: resolution: {integrity: sha512-dUOvLMJ0/JJYEn8NrpOaGNE7X3vpI5XlZS/u0ANjqtcZVKnIxP7IgCFwrKTxENw29emmwug53awKtaMm4i9g5w==} engines: {node: '>=14'} @@ -4715,6 +4888,9 @@ packages: resolution: {integrity: sha512-AURm5f0jYEOydBj7VQlVvDrjeFgthDdEF5H1dP+6mNpoXOMo1quQqJ4wvJDyRZ9+pO3kGWoOdmV08cSv2aJV6Q==} engines: {node: '>=0.4'} + safe-buffer@5.1.2: + resolution: {integrity: sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==} + safe-buffer@5.2.1: resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==} @@ -4739,6 +4915,10 @@ packages: scheduler@0.27.0: resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==} + semver@5.7.2: + resolution: {integrity: sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g==} + hasBin: true + semver@6.3.1: resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==} hasBin: true @@ -4789,6 +4969,9 @@ packages: resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} engines: {node: '>=8'} + shimmer@1.2.1: + resolution: {integrity: sha512-sQTKC1Re/rM6XyFM6fIAGHRPVGvyXfgzIDvzoq608vM+jeyVD0Tu1E6Np0Kc2zAIFWIj963V2800iF/9LPieQw==} + side-channel-list@1.0.1: resolution: {integrity: sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==} engines: {node: '>= 0.4'} @@ -4837,6 +5020,10 @@ packages: resolution: {integrity: sha512-stxByr12oeeOyY2BlviTNQlYV5xOj47GirPr4yA1hE9JCtxfQN0+tVbkxwCtYDQWhEKWFHsEK48ORg5jrouCAg==} engines: {node: '>=20'} + snakecase-keys@3.2.1: + resolution: {integrity: sha512-CjU5pyRfwOtaOITYv5C8DzpZ8XA/ieRsDpr93HI2r6e3YInC6moZpSQbmUtg8cTk58tq2x3jcG2gv+p1IZGmMA==} + engines: {node: '>=8'} + source-map-js@1.2.1: resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} engines: {node: '>=0.10.0'} @@ -4932,6 +5119,9 @@ packages: resolution: {integrity: sha512-UXSH262CSZY1tfu3G3Secr6uGLCFVPMhIqHjlgCUtCCcgihYc/xKs9djMTMUOb2j1mVSeU8EU6NWc/iQKU6Gfg==} engines: {node: '>= 0.4'} + string_decoder@1.1.1: + resolution: {integrity: sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==} + string_decoder@1.3.0: resolution: {integrity: sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==} @@ -4996,6 +5186,9 @@ packages: resolution: {integrity: sha512-tOG/7GyXpFevhXVh8jOPJrmtRpOTsYqUIkVdVooZYJS/z8WhfQUX8RJILmeuJNinGAMSu1veBr4asSHFt5/hng==} engines: {node: '>=18'} + teeny-request@6.0.3: + resolution: {integrity: sha512-TZG/dfd2r6yeji19es1cUIwAlVD8y+/svB1kAC2Y0bjEyysrfbO8EZvJBRwIE6WkwmUoB7uvWLwTIhJbMXZ1Dw==} + teeny-request@9.0.0: resolution: {integrity: sha512-resvxdc6Mgb7YEThw6G6bExlXKkv6+YbuzGg9xuXxSgxJF7Ozs+o8Y9+2R3sArdWdW8nOokoQb1yrpFB0pQK2g==} engines: {node: '>=14'} @@ -5004,6 +5197,9 @@ packages: resolution: {integrity: sha512-cAGWPIyOHU6zlmg88jwm7VRyXnMN7iV68OGAbYDk/Mh/xC/pzVPlQtY6ngoIH/5/tciuhGfvESU8GrHrcxD56w==} engines: {node: '>=8'} + through2@3.0.2: + resolution: {integrity: sha512-enaDQ4MUyP2W6ZyT6EsMzqBPZaM/avg8iuo+l2d3QCs0J+6RaqkHV/2/lOwDTueBHeJ/2LG9lrLW3d5rWPucuQ==} + through2@4.0.2: resolution: {integrity: sha512-iOqSav00cVxEEICeD7TjLB1sueEL+81Wpzp2bY17uZjZN0pWZPuo4suZ/61VujxmqSGFfgOcNuTZ85QJwNZQpw==} @@ -5035,6 +5231,15 @@ packages: tmpl@1.0.5: resolution: {integrity: sha512-3f0uOEAQwIqGuWW2MVzYg8fV/QNnc/IpuJNG837rLuczAaLVHslWHZQj4IGiEl5Hs3kkbhwL9Ab7Hrsmuj+Smw==} + to-no-case@1.0.2: + resolution: {integrity: sha512-Z3g735FxuZY8rodxV4gH7LxClE4H0hTIyHNIHdk+vpQxjLm0cwnKXq/OFVZ76SOQmto7txVcwSCwkU5kqp+FKg==} + + to-snake-case@1.0.0: + resolution: {integrity: sha512-joRpzBAk1Bhi2eGEYBjukEWHOe/IvclOkiJl3DtA91jV6NwQ3MwXA4FHYeqk8BNp/D8bmi9tcNbRu/SozP0jbQ==} + + to-space-case@1.0.0: + resolution: {integrity: sha512-rLdvwXZ39VOn1IxGL3V6ZstoTbwLRckQmn/U8ZDLuWwIXNpuZDhQ3AiRUlhTbOXFVE9C+dR51wM0CBDhk31VcA==} + toidentifier@1.0.1: resolution: {integrity: sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==} engines: {node: '>=0.6'} @@ -5091,6 +5296,10 @@ packages: resolution: {integrity: sha512-0fr/mIH1dlO+x7TlcMy+bIDqKPsw/70tVyeHW787goQjhmqaZe10uwLujubK9q9Lg6Fiho1KUKDYz0Z7k7g5/g==} engines: {node: '>=4'} + type-fest@0.12.0: + resolution: {integrity: sha512-53RyidyjvkGpnWPMF9bQgFtWp+Sl8O2Rp13VavmJgfAP9WWG6q6TkrKU8iyJdnwnfgHI6k2hTlgqH4aSdjoTbg==} + engines: {node: '>=10'} + type-fest@0.21.3: resolution: {integrity: sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w==} engines: {node: '>=10'} @@ -5183,6 +5392,15 @@ packages: resolution: {integrity: sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A==} hasBin: true + uuid@3.4.0: + resolution: {integrity: sha512-HjSDRw6gZE5JMggctHBcjVak08+KEVhSIiDzFnT9S9aegmp85S/bReBVTb4QTFaRNptJ9kuYaNhnbNEOkbKb/A==} + deprecated: Please upgrade to version 7 or higher. Older versions may use Math.random() in certain circumstances, which is known to be problematic. See https://v8.dev/blog/math-random for details. + hasBin: true + + uuid@7.0.3: + resolution: {integrity: sha512-DPSke0pXhTZgoF/d+WSt2QaKMCFSfx7QegxEWT+JOuHF5aWrKEn0G+ztjuJg/gG8/ItK+rbPCD/yNv8yyih6Cg==} + hasBin: true + uuid@8.3.2: resolution: {integrity: sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==} hasBin: true @@ -5283,6 +5501,10 @@ packages: jsdom: optional: true + walkdir@0.4.1: + resolution: {integrity: sha512-3eBwRyEln6E1MSzcxcVpQIhRG8Q1jLvEqRmCZqS3dsfXEDR/AhOF4d+jHg1qvDCpYaVRZjENPQyrVxAkQqxPgQ==} + engines: {node: '>=6.0.0'} + walker@1.0.8: resolution: {integrity: sha512-ts/8E8l5b7kY0vlWLewOkDXMmPdLcVV4GmOQLyxuSswIJsweeFZtAsMF7k1Nszz+TYBQrlYRmzOnr398y1JemQ==} @@ -6201,6 +6423,21 @@ snapshots: - encoding - supports-color + '@google-cloud/common@2.4.0': + dependencies: + '@google-cloud/projectify': 1.0.4 + '@google-cloud/promisify': 1.0.4 + arrify: 2.0.1 + duplexify: 3.7.1 + ent: 2.2.2 + extend: 3.0.2 + google-auth-library: 5.10.1 + retry-request: 4.2.2 + teeny-request: 6.0.3 + transitivePeerDependencies: + - encoding + - supports-color + '@google-cloud/common@5.0.2': dependencies: '@google-cloud/projectify': 4.0.0 @@ -6228,6 +6465,36 @@ snapshots: - supports-color optional: true + '@google-cloud/logging@7.3.0': + dependencies: + '@google-cloud/common': 2.4.0 + '@google-cloud/paginator': 2.0.3 + '@google-cloud/projectify': 1.0.4 + '@google-cloud/promisify': 1.0.4 + '@opencensus/propagation-stackdriver': 0.0.20 + arrify: 2.0.1 + dot-prop: 5.3.0 + eventid: 1.0.0 + extend: 3.0.2 + gcp-metadata: 3.5.0 + google-auth-library: 5.10.1 + google-gax: 1.15.4 + is: 3.3.2 + on-finished: 2.4.1 + pumpify: 2.0.1 + snakecase-keys: 3.2.1 + stream-events: 1.0.5 + through2: 3.0.2 + type-fest: 0.12.0 + transitivePeerDependencies: + - encoding + - supports-color + + '@google-cloud/paginator@2.0.3': + dependencies: + arrify: 2.0.1 + extend: 3.0.2 + '@google-cloud/paginator@5.0.2': dependencies: arrify: 2.0.1 @@ -6235,8 +6502,12 @@ snapshots: '@google-cloud/precise-date@4.0.0': {} + '@google-cloud/projectify@1.0.4': {} + '@google-cloud/projectify@4.0.0': {} + '@google-cloud/promisify@1.0.4': {} + '@google-cloud/promisify@4.0.0': {} '@google-cloud/storage@7.19.0': @@ -6267,11 +6538,20 @@ snapshots: '@js-sdsl/ordered-map': 4.4.2 optional: true + '@grpc/grpc-js@1.3.8': + dependencies: + '@types/node': 20.19.39 + '@grpc/grpc-js@1.9.15': dependencies: '@grpc/proto-loader': 0.7.15 '@types/node': 20.19.39 + '@grpc/proto-loader@0.5.6': + dependencies: + lodash.camelcase: 4.3.0 + protobufjs: 6.11.5 + '@grpc/proto-loader@0.7.15': dependencies: lodash.camelcase: 4.3.0 @@ -6782,6 +7062,20 @@ snapshots: '@open-draft/until@2.1.0': {} + '@opencensus/core@0.0.20': + dependencies: + continuation-local-storage: 3.2.1 + log-driver: 1.2.7 + semver: 6.3.1 + shimmer: 1.2.1 + uuid: 3.4.0 + + '@opencensus/propagation-stackdriver@0.0.20': + dependencies: + '@opencensus/core': 0.0.20 + hex2dec: 1.1.2 + uuid: 3.4.0 + '@opentelemetry/api@1.9.1': optional: true @@ -6944,6 +7238,8 @@ snapshots: '@tokenizer/token@0.3.0': {} + '@tootallnate/once@1.1.2': {} + '@tootallnate/once@2.0.0': {} '@turbo/darwin-64@2.9.6': @@ -8201,8 +8497,7 @@ snapshots: '@types/lodash@4.17.24': {} - '@types/long@4.0.2': - optional: true + '@types/long@4.0.2': {} '@types/mime@1.3.5': {} @@ -8504,7 +8799,6 @@ snapshots: abort-controller@3.0.0: dependencies: event-target-shim: 5.0.1 - optional: true accepts@1.3.8: dependencies: @@ -8646,6 +8940,11 @@ snapshots: async-function@1.0.0: {} + async-listener@0.6.10: + dependencies: + semver: 5.7.2 + shimmer: 1.2.1 + async-retry@1.3.3: dependencies: retry: 0.13.1 @@ -8877,6 +9176,11 @@ snapshots: content-type@1.0.5: {} + continuation-local-storage@3.2.1: + dependencies: + async-listener: 0.6.10 + emitter-listener: 1.1.2 + convert-source-map@2.0.0: {} cookie-signature@1.0.7: {} @@ -8885,6 +9189,8 @@ snapshots: cookie@1.1.1: {} + core-util-is@1.0.3: {} + cors@2.8.6: dependencies: object-assign: 4.1.1 @@ -8908,6 +9214,8 @@ snapshots: d3-voronoi@1.1.2: {} + d64@1.0.0: {} + damerau-levenshtein@1.0.8: {} data-uri-to-buffer@4.0.1: {} @@ -8978,12 +9286,23 @@ snapshots: dom-accessibility-api@0.6.3: {} + dot-prop@5.3.0: + dependencies: + is-obj: 2.0.0 + dunder-proto@1.0.1: dependencies: call-bind-apply-helpers: 1.0.2 es-errors: 1.3.0 gopd: 1.2.0 + duplexify@3.7.1: + dependencies: + end-of-stream: 1.4.5 + inherits: 2.0.4 + readable-stream: 2.3.8 + stream-shift: 1.0.3 + duplexify@4.1.3: dependencies: end-of-stream: 1.4.5 @@ -9007,6 +9326,10 @@ snapshots: dependencies: sax: 1.1.4 + emitter-listener@1.1.2: + dependencies: + shimmer: 1.2.1 + emittery@0.13.1: {} emoji-regex@10.6.0: {} @@ -9021,6 +9344,13 @@ snapshots: dependencies: once: 1.4.0 + ent@2.2.2: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + punycode: 1.4.1 + safe-regex-test: 1.1.0 + entities@4.5.0: {} entities@7.0.1: {} @@ -9301,11 +9631,15 @@ snapshots: etag@1.8.1: {} - event-target-shim@5.0.1: - optional: true + event-target-shim@5.0.1: {} eventemitter3@5.0.4: {} + eventid@1.0.0: + dependencies: + d64: 1.0.0 + uuid: 3.4.0 + execa@5.1.1: dependencies: cross-spawn: 7.0.6 @@ -9385,6 +9719,8 @@ snapshots: dependencies: fast-string-truncated-width: 3.0.3 + fast-text-encoding@1.0.6: {} + fast-wrap-ansi@0.2.0: dependencies: fast-string-width: 3.0.2 @@ -9602,6 +9938,17 @@ snapshots: functions-have-names@1.2.3: {} + gaxios@2.3.4: + dependencies: + abort-controller: 3.0.0 + extend: 3.0.2 + https-proxy-agent: 5.0.1 + is-stream: 2.0.1 + node-fetch: 2.7.0 + transitivePeerDependencies: + - encoding + - supports-color + gaxios@6.7.1: dependencies: extend: 3.0.2 @@ -9621,6 +9968,14 @@ snapshots: transitivePeerDependencies: - supports-color + gcp-metadata@3.5.0: + dependencies: + gaxios: 2.3.4 + json-bigint: 0.3.1 + transitivePeerDependencies: + - encoding + - supports-color + gcp-metadata@6.1.1: dependencies: gaxios: 6.7.1 @@ -9736,6 +10091,21 @@ snapshots: transitivePeerDependencies: - supports-color + google-auth-library@5.10.1: + dependencies: + arrify: 2.0.1 + base64-js: 1.5.1 + ecdsa-sig-formatter: 1.0.11 + fast-text-encoding: 1.0.6 + gaxios: 2.3.4 + gcp-metadata: 3.5.0 + gtoken: 4.1.4 + jws: 4.0.1 + lru-cache: 5.1.1 + transitivePeerDependencies: + - encoding + - supports-color + google-auth-library@9.15.1: dependencies: base64-js: 1.5.1 @@ -9748,6 +10118,27 @@ snapshots: - encoding - supports-color + google-gax@1.15.4: + dependencies: + '@grpc/grpc-js': 1.3.8 + '@grpc/proto-loader': 0.5.6 + '@types/fs-extra': 8.1.5 + '@types/long': 4.0.2 + abort-controller: 3.0.0 + duplexify: 3.7.1 + google-auth-library: 5.10.1 + is-stream-ended: 0.1.4 + lodash.at: 4.6.0 + lodash.has: 4.5.2 + node-fetch: 2.7.0 + protobufjs: 6.11.5 + retry-request: 4.2.2 + semver: 6.3.1 + walkdir: 0.4.1 + transitivePeerDependencies: + - encoding + - supports-color + google-gax@4.6.1: dependencies: '@grpc/grpc-js': 1.14.3 @@ -9771,12 +10162,26 @@ snapshots: google-logging-utils@1.1.3: {} + google-p12-pem@2.0.5: + dependencies: + node-forge: 0.10.0 + gopd@1.2.0: {} graceful-fs@4.2.11: {} graphql@16.13.2: {} + gtoken@4.1.4: + dependencies: + gaxios: 2.3.4 + google-p12-pem: 2.0.5 + jws: 4.0.1 + mime: 2.6.0 + transitivePeerDependencies: + - encoding + - supports-color + gtoken@7.1.0: dependencies: gaxios: 6.7.1 @@ -9836,6 +10241,8 @@ snapshots: dependencies: hermes-estree: 0.25.1 + hex2dec@1.1.2: {} + html-entities@2.6.0: {} html-escaper@2.0.2: {} @@ -9850,6 +10257,14 @@ snapshots: http-parser-js@0.5.10: {} + http-proxy-agent@4.0.1: + dependencies: + '@tootallnate/once': 1.1.2 + agent-base: 6.0.2 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + http-proxy-agent@5.0.0: dependencies: '@tootallnate/once': 2.0.0 @@ -10002,6 +10417,8 @@ snapshots: call-bound: 1.0.4 has-tostringtag: 1.0.2 + is-obj@2.0.0: {} + is-regex@1.2.1: dependencies: call-bound: 1.0.4 @@ -10015,6 +10432,8 @@ snapshots: dependencies: call-bound: 1.0.4 + is-stream-ended@0.1.4: {} + is-stream@2.0.1: {} is-string@1.1.1: @@ -10049,6 +10468,8 @@ snapshots: is@3.3.2: {} + isarray@1.0.0: {} + isarray@2.0.5: {} isexe@2.0.0: {} @@ -10426,6 +10847,10 @@ snapshots: jsesc@3.1.0: {} + json-bigint@0.3.1: + dependencies: + bignumber.js: 9.3.1 + json-bigint@1.0.0: dependencies: bignumber.js: 9.3.1 @@ -10599,10 +11024,14 @@ snapshots: dependencies: p-locate: 5.0.0 + lodash.at@4.6.0: {} + lodash.camelcase@4.3.0: {} lodash.clonedeep@4.5.0: {} + lodash.has@4.5.2: {} + lodash.includes@4.3.0: {} lodash.isboolean@3.0.3: {} @@ -10621,6 +11050,8 @@ snapshots: lodash@4.18.1: {} + log-driver@1.2.7: {} + log-update@6.1.0: dependencies: ansi-escapes: 7.3.0 @@ -10629,6 +11060,8 @@ snapshots: strip-ansi: 7.2.0 wrap-ansi: 9.0.2 + long@4.0.0: {} + long@5.3.2: {} loose-envify@1.4.0: @@ -10676,6 +11109,8 @@ snapshots: dependencies: tmpl: 1.0.5 + map-obj@4.3.0: {} + math-intrinsics@1.1.0: {} media-typer@0.3.0: {} @@ -10694,6 +11129,8 @@ snapshots: mime@1.6.0: {} + mime@2.6.0: {} + mime@3.0.0: optional: true @@ -10823,6 +11260,8 @@ snapshots: fetch-blob: 3.2.0 formdata-polyfill: 4.0.10 + node-forge@0.10.0: {} + node-forge@1.4.0: {} node-int64@0.4.0: {} @@ -11038,6 +11477,8 @@ snapshots: ansi-styles: 5.2.0 react-is: 18.3.1 + process-nextick-args@2.0.1: {} + prompts@2.4.2: dependencies: kleur: 3.0.3 @@ -11054,6 +11495,22 @@ snapshots: protobufjs: 7.5.5 optional: true + protobufjs@6.11.5: + dependencies: + '@protobufjs/aspromise': 1.1.2 + '@protobufjs/base64': 1.1.2 + '@protobufjs/codegen': 2.0.4 + '@protobufjs/eventemitter': 1.1.0 + '@protobufjs/fetch': 1.1.0 + '@protobufjs/float': 1.0.2 + '@protobufjs/inquire': 1.1.0 + '@protobufjs/path': 1.1.2 + '@protobufjs/pool': 1.1.0 + '@protobufjs/utf8': 1.1.0 + '@types/long': 4.0.2 + '@types/node': 20.19.39 + long: 4.0.0 + protobufjs@7.5.5: dependencies: '@protobufjs/aspromise': 1.1.2 @@ -11074,6 +11531,19 @@ snapshots: forwarded: 0.2.0 ipaddr.js: 1.9.1 + pump@3.0.4: + dependencies: + end-of-stream: 1.4.5 + once: 1.4.0 + + pumpify@2.0.1: + dependencies: + duplexify: 4.1.3 + inherits: 2.0.4 + pump: 3.0.4 + + punycode@1.4.1: {} + punycode@2.3.1: {} pure-rand@7.0.1: {} @@ -11137,6 +11607,16 @@ snapshots: react@19.2.5: {} + readable-stream@2.3.8: + dependencies: + core-util-is: 1.0.3 + inherits: 2.0.4 + isarray: 1.0.0 + process-nextick-args: 2.0.1 + safe-buffer: 5.1.2 + string_decoder: 1.1.1 + util-deprecate: 1.0.2 + readable-stream@3.6.2: dependencies: inherits: 2.0.4 @@ -11196,6 +11676,13 @@ snapshots: onetime: 7.0.0 signal-exit: 4.1.0 + retry-request@4.2.2: + dependencies: + debug: 4.4.3 + extend: 3.0.2 + transitivePeerDependencies: + - supports-color + retry-request@7.0.2: dependencies: '@types/request': 2.48.13 @@ -11250,6 +11737,8 @@ snapshots: has-symbols: 1.1.0 isarray: 2.0.5 + safe-buffer@5.1.2: {} + safe-buffer@5.2.1: {} safe-push-apply@1.0.0: @@ -11271,6 +11760,8 @@ snapshots: scheduler@0.27.0: {} + semver@5.7.2: {} + semver@6.3.1: {} semver@7.7.4: {} @@ -11367,6 +11858,8 @@ snapshots: shebang-regex@3.0.0: {} + shimmer@1.2.1: {} + side-channel-list@1.0.1: dependencies: es-errors: 1.3.0 @@ -11423,6 +11916,11 @@ snapshots: ansi-styles: 6.2.3 is-fullwidth-code-point: 5.1.0 + snakecase-keys@3.2.1: + dependencies: + map-obj: 4.3.0 + to-snake-case: 1.0.0 + source-map-js@1.2.1: {} source-map-support@0.5.13: @@ -11541,6 +12039,10 @@ snapshots: define-properties: 1.2.1 es-object-atoms: 1.1.1 + string_decoder@1.1.1: + dependencies: + safe-buffer: 5.1.2 + string_decoder@1.3.0: dependencies: safe-buffer: 5.2.1 @@ -11600,6 +12102,17 @@ snapshots: minizlib: 3.1.0 yallist: 5.0.0 + teeny-request@6.0.3: + dependencies: + http-proxy-agent: 4.0.1 + https-proxy-agent: 5.0.1 + node-fetch: 2.7.0 + stream-events: 1.0.5 + uuid: 7.0.3 + transitivePeerDependencies: + - encoding + - supports-color + teeny-request@9.0.0: dependencies: http-proxy-agent: 5.0.0 @@ -11617,6 +12130,11 @@ snapshots: glob: 7.2.3 minimatch: 3.1.5 + through2@3.0.2: + dependencies: + inherits: 2.0.4 + readable-stream: 3.6.2 + through2@4.0.2: dependencies: readable-stream: 3.6.2 @@ -11642,6 +12160,16 @@ snapshots: tmpl@1.0.5: {} + to-no-case@1.0.2: {} + + to-snake-case@1.0.0: + dependencies: + to-space-case: 1.0.0 + + to-space-case@1.0.0: + dependencies: + to-no-case: 1.0.2 + toidentifier@1.0.1: {} token-types@6.1.2: @@ -11696,6 +12224,8 @@ snapshots: type-detect@4.0.8: {} + type-fest@0.12.0: {} + type-fest@0.21.3: {} type-fest@5.6.0: @@ -11815,6 +12345,10 @@ snapshots: uuid@11.1.0: {} + uuid@3.4.0: {} + + uuid@7.0.3: {} + uuid@8.3.2: optional: true @@ -11946,6 +12480,8 @@ snapshots: transitivePeerDependencies: - msw + walkdir@0.4.1: {} + walker@1.0.8: dependencies: makeerror: 1.0.12 From e2ebfcbb167bf255c231c07be21e381635af5089 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 27 Apr 2026 17:14:19 +0800 Subject: [PATCH 05/26] feat(functions): add resolvedToday + avgResponseTimeMinutes to analytics province summary for Phase 7 --- .../analytics-snapshot-writer.test.ts | 46 +++++++++++++++++++ .../scheduled/analytics-snapshot-writer.ts | 28 +++++++++++ 2 files changed, 74 insertions(+) diff --git a/functions/src/__tests__/triggers/analytics-snapshot-writer.test.ts b/functions/src/__tests__/triggers/analytics-snapshot-writer.test.ts index 1a7964f3..7cc81ff8 100644 --- a/functions/src/__tests__/triggers/analytics-snapshot-writer.test.ts +++ b/functions/src/__tests__/triggers/analytics-snapshot-writer.test.ts @@ -203,4 +203,50 @@ describe('analyticsSnapshotWriter', () => { analyticsSnapshotWriterCore(adminDb, { date: dateStr, now: Timestamp.fromMillis(ts) }), ).resolves.not.toThrow() }) + + it('computes resolvedToday and avgResponseTimeMinutes for province summary', async () => { + const dayStart = new Date(`${dateStr}T00:00:00.000Z`).getTime() + await testEnv.withSecurityRulesDisabled(async (ctx) => { + await setDoc(doc(ctx.firestore(), 'report_ops', 'resolved-r1'), { + municipalityId: 'daet', + status: 'resolved', + severity: 'high', + reportType: 'flood', + createdAt: dayStart + 3600000, + updatedAt: dayStart + 7200000, + resolvedAt: dayStart + 7200000, + agencyIds: [], + activeResponderCount: 0, + requiresLocationFollowUp: false, + visibility: { scope: 'municipality', sharedWith: [] }, + schemaVersion: 1, + }) + await setDoc(doc(ctx.firestore(), 'report_ops', 'resolved-r2'), { + municipalityId: 'daet', + status: 'resolved', + severity: 'medium', + reportType: 'flood', + createdAt: dayStart + 7200000, + updatedAt: dayStart + 18000000, + resolvedAt: dayStart + 18000000, + agencyIds: [], + activeResponderCount: 0, + requiresLocationFollowUp: false, + visibility: { scope: 'municipality', sharedWith: [] }, + schemaVersion: 1, + }) + }) + + await analyticsSnapshotWriterCore(adminDb, { date: dateStr, now: Timestamp.fromMillis(ts) }) + + const provinceSnap = await adminDb + .collection('analytics_snapshots') + .doc(dateStr) + .collection('province') + .doc('summary') + .get() + const data = provinceSnap.data()! + expect(data.resolvedToday).toBe(2) + expect(data.avgResponseTimeMinutes).toBeCloseTo(120, 0) + }) }) diff --git a/functions/src/scheduled/analytics-snapshot-writer.ts b/functions/src/scheduled/analytics-snapshot-writer.ts index 2a74c097..0c14f0b5 100644 --- a/functions/src/scheduled/analytics-snapshot-writer.ts +++ b/functions/src/scheduled/analytics-snapshot-writer.ts @@ -92,6 +92,34 @@ export async function analyticsSnapshotWriterCore( schemaVersion: 1, }) + const startOfDayMs = new Date(`${date}T00:00:00.000Z`).getTime() + const endOfDayMs = startOfDayMs + 86400000 + const resolvedSnap = await db + .collection('report_ops') + .where('status', '==', 'resolved') + .where('resolvedAt', '>=', startOfDayMs) + .where('resolvedAt', '<', endOfDayMs) + .get() + const resolvedToday = resolvedSnap.size + const resolvedWithTimes = resolvedSnap.docs.filter((d) => { + const data = d.data() + return typeof data.createdAt === 'number' && typeof data.resolvedAt === 'number' + }) + const avgResponseTimeMinutes = + resolvedWithTimes.length > 0 + ? resolvedWithTimes.reduce((sum, d) => { + const data = d.data() + return sum + (data.resolvedAt - data.createdAt) / 60000 + }, 0) / resolvedWithTimes.length + : null + + await db + .collection('analytics_snapshots') + .doc(date) + .collection('province') + .doc('summary') + .set({ resolvedToday, avgResponseTimeMinutes }, { merge: true }) + log({ severity: 'INFO', code: 'analytics.done', From b64fc154e4bdf49d4c9538f9d6d343c27d7b71ea Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 27 Apr 2026 17:18:44 +0800 Subject: [PATCH 06/26] feat(admin-desktop): add bare-bones TOTP enrollment page at /totp-enroll for Phase 7 --- .../src/pages/TotpEnrollmentPage.tsx | 75 +++++++++++++++++++ apps/admin-desktop/src/routes.tsx | 2 + 2 files changed, 77 insertions(+) create mode 100644 apps/admin-desktop/src/pages/TotpEnrollmentPage.tsx diff --git a/apps/admin-desktop/src/pages/TotpEnrollmentPage.tsx b/apps/admin-desktop/src/pages/TotpEnrollmentPage.tsx new file mode 100644 index 00000000..c008c7ea --- /dev/null +++ b/apps/admin-desktop/src/pages/TotpEnrollmentPage.tsx @@ -0,0 +1,75 @@ +import { useState } from 'react' +import { getAuth, multiFactor, TotpMultiFactorGenerator } from 'firebase/auth' +import type { TotpSecret } from 'firebase/auth' + +export function TotpEnrollmentPage() { + const auth = getAuth() + const [totpSecret, setTotpSecret] = useState(null) + const [verificationCode, setVerificationCode] = useState('') + const [enrolled, setEnrolled] = useState(false) + const [error, setError] = useState(null) + + async function handleGenerate() { + setError(null) + const user = auth.currentUser + if (!user) { + setError('You must be logged in to enroll TOTP.') + return + } + try { + const session = await multiFactor(user).getSession() + const secret = await TotpMultiFactorGenerator.generateSecret(session) + setTotpSecret(secret) + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to generate secret') + } + } + + async function handleEnroll() { + setError(null) + const user = auth.currentUser + if (!user || !totpSecret) return + try { + const assertion = TotpMultiFactorGenerator.assertionForEnrollment( + totpSecret, + verificationCode, + ) + await multiFactor(user).enroll(assertion, 'Authenticator') + setEnrolled(true) + } catch (err) { + setError(err instanceof Error ? err.message : 'Enrollment failed') + } + } + + if (enrolled) return

TOTP enrolled successfully.

+ + return ( +
+

Enroll TOTP Authenticator

+ {!totpSecret ? ( + + ) : ( + <> +

+ Secret key: {totpSecret.secretKey} +

+

+ QR URI:{' '} + + {totpSecret.generateQrCodeUrl('Bantayog Alert', auth.currentUser?.email ?? 'admin')} + +

+ { + setVerificationCode(e.target.value) + }} + /> + + + )} + {error &&

{error}

} +
+ ) +} diff --git a/apps/admin-desktop/src/routes.tsx b/apps/admin-desktop/src/routes.tsx index 938db11a..21e5cc6f 100644 --- a/apps/admin-desktop/src/routes.tsx +++ b/apps/admin-desktop/src/routes.tsx @@ -1,6 +1,7 @@ import { createBrowserRouter } from 'react-router-dom' import { ProtectedRoute } from '@bantayog/shared-ui' import { LoginPage } from './pages/LoginPage' +import { TotpEnrollmentPage } from './pages/TotpEnrollmentPage' import { TriageQueuePage } from './pages/TriageQueuePage' import { AgencyAssistanceQueuePage } from './pages/AgencyAssistanceQueuePage' import { AnalyticsDashboardPage } from './pages/AnalyticsDashboardPage' @@ -8,6 +9,7 @@ import { RosterPage } from './pages/RosterPage' export const router = createBrowserRouter([ { path: '/login', element: }, + { path: '/totp-enroll', element: }, { path: '/', element: ( From dabc73ba3acaf161fea2ef6c89b609c18ffa76ca Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 27 Apr 2026 17:21:55 +0800 Subject: [PATCH 07/26] feat(scripts): add seed-break-glass-config.ts script for Phase 7 break-glass setup --- functions/package.json | 2 ++ pnpm-lock.yaml | 16 +++++++++ scripts/seed-break-glass-config.ts | 54 ++++++++++++++++++++++++++++++ 3 files changed, 72 insertions(+) create mode 100644 scripts/seed-break-glass-config.ts diff --git a/functions/package.json b/functions/package.json index 1ae6f520..d008157d 100644 --- a/functions/package.json +++ b/functions/package.json @@ -43,10 +43,12 @@ "geojson": "^0.5.0", "ngeohash": "^0.6.3", "sharp": "^0.34.5", + "bcryptjs": "^2.4.3", "zod": "^4.3.6" }, "devDependencies": { "@firebase/rules-unit-testing": "^5.0.0", + "@types/bcryptjs": "^2.4.6", "@types/ngeohash": "^0.6.8", "@types/node": "^20.12.0", "firebase": "^12.0.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index cbd67583..d036f084 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -315,6 +315,9 @@ importers: '@types/geojson': specifier: ^7946.0.16 version: 7946.0.16 + bcryptjs: + specifier: ^2.4.3 + version: 2.4.3 exifr: specifier: ^7.1.3 version: 7.1.3 @@ -343,6 +346,9 @@ importers: '@firebase/rules-unit-testing': specifier: ^5.0.0 version: 5.0.0(firebase@12.12.0) + '@types/bcryptjs': + specifier: ^2.4.6 + version: 2.4.6 '@types/ngeohash': specifier: ^0.6.8 version: 0.6.8 @@ -2158,6 +2164,9 @@ packages: '@types/babel__traverse@7.28.0': resolution: {integrity: sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==} + '@types/bcryptjs@2.4.6': + resolution: {integrity: sha512-9xlo6R2qDs5uixm0bcIqCeMCE6HiQsIyel9KQySStiyqNl2tnj2mP3DX1Nf56MD6KMenNNlBBsy3LJ7gUEQPXQ==} + '@types/body-parser@1.19.6': resolution: {integrity: sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==} @@ -2702,6 +2711,9 @@ packages: engines: {node: '>=6.0.0'} hasBin: true + bcryptjs@2.4.3: + resolution: {integrity: sha512-V/Hy/X9Vt7f3BbPJEi8BdVFMByHi+jNXrYkW3huaybV/kQ0KJg0Y6PkEMbn+zeT+i+SiKZ/HMqJGIIt4LZDqNQ==} + big-integer@1.6.52: resolution: {integrity: sha512-QxD8cf2eVqJOOz63z6JIN9BzvVs/dlySa5HGSBH5xtR8dPteIRQnBxxKqkNTiT6jbDTF6jAfrd4oMcND9RGbQg==} engines: {node: '>=0.6'} @@ -8424,6 +8436,8 @@ snapshots: dependencies: '@babel/types': 7.29.0 + '@types/bcryptjs@2.4.6': {} + '@types/body-parser@1.19.6': dependencies: '@types/connect': 3.4.38 @@ -9022,6 +9036,8 @@ snapshots: baseline-browser-mapping@2.10.20: {} + bcryptjs@2.4.3: {} + big-integer@1.6.52: {} big.js@6.2.2: {} diff --git a/scripts/seed-break-glass-config.ts b/scripts/seed-break-glass-config.ts new file mode 100644 index 00000000..314acc7c --- /dev/null +++ b/scripts/seed-break-glass-config.ts @@ -0,0 +1,54 @@ +/** + * Seed Break-Glass Configuration + * + * Creates the system_config/break_glass_config document with two + * bcrypt-hashed emergency codes. The plaintext codes are printed + * once and never stored — record them securely. + * + * Idempotent: if the document already exists, exits without changes. + * + * Usage: + * GOOGLE_APPLICATION_CREDENTIALS=path/to/key.json pnpm exec tsx scripts/seed-break-glass-config.ts + */ + +import { randomBytes } from 'node:crypto' + +import * as bcrypt from 'bcryptjs' +import { initializeApp, getApps } from 'firebase-admin/app' +import { getFirestore } from 'firebase-admin/firestore' + +const COST_FACTOR = 12 +const CONFIG_DOC = 'system_config/break_glass_config' + +if (getApps().length === 0) { + initializeApp() +} + +const db = getFirestore() + +async function main() { + const existing = await db.doc(CONFIG_DOC).get() + if (existing.exists) { + console.log('Break-glass config already exists. No changes made.') + return + } + + const codeA = randomBytes(16).toString('hex') + const codeB = randomBytes(16).toString('hex') + const [hashA, hashB] = await Promise.all([ + bcrypt.hash(codeA, COST_FACTOR), + bcrypt.hash(codeB, COST_FACTOR), + ]) + + await db.doc(CONFIG_DOC).set({ hashedCodes: [hashA, hashB] }) + + console.log('Break-glass codes seeded. Store these securely:') + console.log(`Controller A: ${codeA}`) + console.log(`Controller B: ${codeB}`) + console.log('These values will NOT be shown again.') +} + +main().catch((err) => { + console.error(err) + process.exit(1) +}) From 9096fd97582ef806476080e9cf9b845c968936b7 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 27 Apr 2026 17:33:39 +0800 Subject: [PATCH 08/26] test(functions): add edge-case tests for requireMfaAuth (null auth, missing firebase, non-string factor) --- .../__tests__/callables/https-error.test.ts | 28 +++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/functions/src/__tests__/callables/https-error.test.ts b/functions/src/__tests__/callables/https-error.test.ts index 920e1908..36e80ffe 100644 --- a/functions/src/__tests__/callables/https-error.test.ts +++ b/functions/src/__tests__/callables/https-error.test.ts @@ -101,4 +101,32 @@ describe('requireMfaAuth', () => { }) }).not.toThrow() }) + + it('throws mfa_required when auth is null', () => { + expect(() => { + requireMfaAuth({ auth: null }) + }).toThrow('mfa_required') + }) + + it('throws mfa_required when auth is undefined', () => { + expect(() => { + requireMfaAuth({}) + }).toThrow('mfa_required') + }) + + it('throws mfa_required when firebase claim is undefined', () => { + expect(() => { + requireMfaAuth({ + auth: { uid: 'u1', token: { role: 'superadmin' } }, + }) + }).toThrow('mfa_required') + }) + + it('throws mfa_required when sign_in_second_factor is a number', () => { + expect(() => { + requireMfaAuth({ + auth: { uid: 'u1', token: { firebase: { sign_in_second_factor: 42 } } }, + }) + }).toThrow('mfa_required') + }) }) From 127581b0e3430c95ea7472fc8237d904ec2dfa59 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 27 Apr 2026 17:35:52 +0800 Subject: [PATCH 09/26] docs: update progress and learnings for Phase 7 PRE-7 completion --- docs/learnings.md | 10 ++++++++++ docs/progress.md | 26 +++++++++++++++++++++++++- 2 files changed, 35 insertions(+), 1 deletion(-) diff --git a/docs/learnings.md b/docs/learnings.md index a21df7ea..c2299649 100644 --- a/docs/learnings.md +++ b/docs/learnings.md @@ -96,6 +96,16 @@ - Capacitor void-return callbacks need braces: `return () => { clearInterval(id) }`. - When refactoring from `refCount` to `Set`, remove ALL stale `refCount` references. +## Phase 7 — Provincial Superadmin + +- `@google-cloud/bigquery` `.table.query()` doesn't exist; use `bq.query()` directly for SQL queries. +- BigQuery query results are untyped; extract into typed helpers with `as unknown as RowType[]` to satisfy strict ESLint rules (`no-unsafe-member-access`, `no-unsafe-argument`). +- `@typescript-eslint/no-unnecessary-condition` flags `?.` on non-optional fields in function parameter types — use `.` when the type declares the field as required. +- Firestore path template literals (`db.doc(\`...\`)`) trigger `no-restricted-syntax`lint; use chained`.collection().doc()` instead. +- `@typescript-eslint/no-misused-promises` flags async onClick handlers; wrap with `() => void asyncFn()`. +- `bcryptjs` preferred over `bcrypt` in this repo — pure JS, no native compilation. +- `@google-cloud/logging` must be added as explicit dependency when using Cloud Logging API in triggers. + ## Misc - `navigator.clipboard` in happy-dom often needs to be defined as an own property before spying. diff --git a/docs/progress.md b/docs/progress.md index 37102689..47097ade 100644 --- a/docs/progress.md +++ b/docs/progress.md @@ -1,6 +1,30 @@ # Progress -## Current — Phase 6 Responder App (branch: `phase6/responder-app`) +## Current — Phase 7 Provincial Superadmin + NDRRMC + Break-Glass + +### PRE-7 — Audit & Auth Foundation (branch: `feature/phase7-pre`) + +| Task | Status | Notes | +| --------------------------------------- | ------- | ------------------------------------------------------------------------------------------------- | +| 1. Schema additions (shared-validators) | ✅ DONE | `dataIncidentDocSchema`, extended `breakglassEventDocSchema` + `agencyDocSchema`. 229 tests pass. | +| 2. `requireMfaAuth()` + tests | ✅ DONE | 6 test cases including edge cases (null auth, missing firebase, non-string factor). 14/14 pass. | +| 3. `audit-stream.ts` service | ✅ DONE | Fire-and-forget BigQuery streaming. `@google-cloud/bigquery@^7.9.2` added. | +| 4. Audit export batch + health check | ✅ DONE | 5min batch, 10min health check with FCM alert. `@google-cloud/logging` added. | +| 5. Analytics snapshot extension | ✅ DONE | `resolvedToday` + `avgResponseTimeMinutes` on province summary. 7/7 tests. | +| 6. Bare-bones TOTP enrollment page | ✅ DONE | `/totp-enroll` route, unprotected. Firebase v12 TOTP MFA. | +| 7. Seed break-glass config script | ✅ DONE | `bcryptjs`, idempotent, `system_config/break_glass_config`. | + +**Staging gate:** Pending — needs 24h soak before 7.A can deploy. + +### 7.A — Security Callables (branch: `feature/phase7-a`) — IN PROGRESS + +### 7.B — Superadmin UI (branch: `feature/phase7-b`) — BLOCKED by 7.A + +### 7.C — Drill & Verification — BLOCKED by 7.B + +--- + +## Phase 6 — Responder App (branch: `phase6/responder-app`) — COMPLETE | Task | Status | Notes | | ------------------------------------------------ | ------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | From 067c61ab84cae9796116dd935c7d0539f9dcc9f6 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 27 Apr 2026 17:51:11 +0800 Subject: [PATCH 10/26] feat(functions): add initiateBreakGlass + deactivateBreakGlass callables with 11 tests for Phase 7.A --- .../__tests__/callables/break-glass.test.ts | 343 ++++++++++++++++++ functions/src/callables/break-glass.ts | 135 +++++++ 2 files changed, 478 insertions(+) create mode 100644 functions/src/__tests__/callables/break-glass.test.ts create mode 100644 functions/src/callables/break-glass.ts diff --git a/functions/src/__tests__/callables/break-glass.test.ts b/functions/src/__tests__/callables/break-glass.test.ts new file mode 100644 index 00000000..90e96968 --- /dev/null +++ b/functions/src/__tests__/callables/break-glass.test.ts @@ -0,0 +1,343 @@ +import { describe, it, expect, vi, beforeEach, beforeAll } from 'vitest' +import * as bcrypt from 'bcryptjs' +import { initiateBreakGlassCore, deactivateBreakGlassCore } from '../../callables/break-glass.js' + +const mockStreamAuditEvent = vi.hoisted(() => vi.fn()) +vi.mock('../../services/audit-stream.js', () => ({ + streamAuditEvent: mockStreamAuditEvent, +})) + +vi.mock('firebase-functions/v2/https', () => ({ + onCall: vi.fn((_opts: unknown, fn: unknown) => fn), + HttpsError: class HttpsError extends Error { + code: string + constructor(code: string, message: string) { + super(message) + this.code = code + } + }, +})) + +const CODE_A = 'alpha-bravo-123' +const CODE_B = 'charlie-delta-456' +const CODE_WRONG = 'wrong-code-999' + +let hashedA: string +let hashedB: string + +beforeAll(async () => { + hashedA = await bcrypt.hash(CODE_A, 10) + hashedB = await bcrypt.hash(CODE_B, 10) +}) + +interface MockDb { + doc: ReturnType + collection: ReturnType + eventSetFn: ReturnType + eventUpdateFn: ReturnType +} + +function makeDb(configData: Record | null): MockDb { + const configDoc = { + exists: configData !== null, + data: () => configData, + } + const eventSetFn = vi.fn().mockResolvedValue(undefined) + const eventUpdateFn = vi.fn().mockResolvedValue(undefined) + return { + doc: vi.fn((path: string) => { + if (path === 'system_config/break_glass_config') { + return { get: vi.fn().mockResolvedValue(configDoc) } + } + throw new Error(`unexpected db.doc path: ${path}`) + }), + collection: vi.fn(() => ({ + doc: vi.fn(() => ({ + set: eventSetFn, + update: eventUpdateFn, + })), + })), + eventSetFn, + eventUpdateFn, + } +} + +function makeAuth() { + const setCustomUserClaims = vi.fn().mockResolvedValue(undefined) + const getUser = vi.fn().mockResolvedValue({ + customClaims: { + breakGlassSession: true, + breakGlassSessionId: 'existing-session', + breakGlassExpiresAt: Date.now() + 100000, + role: 'superadmin', + }, + }) + return { setCustomUserClaims, getUser } +} + +describe('initiateBreakGlassCore', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('rejects when config doc is missing', async () => { + const db = makeDb(null) as unknown as Parameters[0] + const auth = makeAuth() as unknown as Parameters[1] + + await expect( + initiateBreakGlassCore( + db, + auth, + { codeA: CODE_A, codeB: CODE_B, reason: 'emergency' }, + { uid: 'u1' }, + ), + ).rejects.toThrow('break_glass_config_missing') + + const err = await initiateBreakGlassCore( + db, + auth, + { codeA: CODE_A, codeB: CODE_B, reason: 'emergency' }, + { uid: 'u1' }, + ).catch((e: unknown) => e) + expect((err as { code: string }).code).toBe('not-found') + }) + + it('rejects when config has invalid hashedCodes', async () => { + const db = makeDb({ hashedCodes: ['only-one'] }) as unknown as Parameters< + typeof initiateBreakGlassCore + >[0] + const auth = makeAuth() as unknown as Parameters[1] + + await expect( + initiateBreakGlassCore( + db, + auth, + { codeA: CODE_A, codeB: CODE_B, reason: 'emergency' }, + { uid: 'u1' }, + ), + ).rejects.toThrow('break_glass_config_invalid') + + const err = await initiateBreakGlassCore( + db, + auth, + { codeA: CODE_A, codeB: CODE_B, reason: 'emergency' }, + { uid: 'u1' }, + ).catch((e: unknown) => e) + expect((err as { code: string }).code).toBe('failed-precondition') + }) + + it('rejects when hashedCodes is not an array', async () => { + const db = makeDb({ hashedCodes: 'not-array' }) as unknown as Parameters< + typeof initiateBreakGlassCore + >[0] + const auth = makeAuth() as unknown as Parameters[1] + + await expect( + initiateBreakGlassCore( + db, + auth, + { codeA: CODE_A, codeB: CODE_B, reason: 'emergency' }, + { uid: 'u1' }, + ), + ).rejects.toThrow('break_glass_config_invalid') + }) + + it('rejects when both codes match the same hash', async () => { + const db = makeDb({ hashedCodes: [hashedA, hashedA] }) as unknown as Parameters< + typeof initiateBreakGlassCore + >[0] + const auth = makeAuth() as unknown as Parameters[1] + + await expect( + initiateBreakGlassCore( + db, + auth, + { codeA: CODE_A, codeB: CODE_A, reason: 'emergency' }, + { uid: 'u1' }, + ), + ).rejects.toThrow('break_glass_codes_invalid') + + const err = await initiateBreakGlassCore( + db, + auth, + { codeA: CODE_A, codeB: CODE_A, reason: 'emergency' }, + { uid: 'u1' }, + ).catch((e: unknown) => e) + expect((err as { code: string }).code).toBe('unauthenticated') + }) + + it('rejects when codes do not match any hash', async () => { + const db = makeDb({ hashedCodes: [hashedA, hashedB] }) as unknown as Parameters< + typeof initiateBreakGlassCore + >[0] + const auth = makeAuth() as unknown as Parameters[1] + + await expect( + initiateBreakGlassCore( + db, + auth, + { codeA: CODE_WRONG, codeB: CODE_WRONG, reason: 'emergency' }, + { uid: 'u1' }, + ), + ).rejects.toThrow('break_glass_codes_invalid') + }) + + it('rejects when one code is correct but the other is wrong', async () => { + const db = makeDb({ hashedCodes: [hashedA, hashedB] }) as unknown as Parameters< + typeof initiateBreakGlassCore + >[0] + const auth = makeAuth() as unknown as Parameters[1] + + await expect( + initiateBreakGlassCore( + db, + auth, + { codeA: CODE_A, codeB: CODE_WRONG, reason: 'emergency' }, + { uid: 'u1' }, + ), + ).rejects.toThrow('break_glass_codes_invalid') + }) + + it('succeeds with correct codes, returns sessionId, sets claims, writes event', async () => { + const db = makeDb({ hashedCodes: [hashedA, hashedB] }) as unknown as Parameters< + typeof initiateBreakGlassCore + >[0] + const rawDb = db as unknown as MockDb + const auth = makeAuth() + + const result = await initiateBreakGlassCore( + db, + auth as unknown as Parameters[1], + { codeA: CODE_A, codeB: CODE_B, reason: 'system emergency' }, + { uid: 'u1' }, + ) + + expect(result.sessionId).toBeTruthy() + expect(typeof result.sessionId).toBe('string') + + expect(auth.setCustomUserClaims).toHaveBeenCalledWith( + 'u1', + expect.objectContaining({ + breakGlassSession: true, + breakGlassSessionId: result.sessionId, + breakGlassExpiresAt: expect.any(Number), + }), + ) + + expect(rawDb.eventSetFn).toHaveBeenCalledWith( + expect.objectContaining({ + sessionId: result.sessionId, + actorUid: 'u1', + action: 'initiated', + reason: 'system emergency', + schemaVersion: 1, + }), + ) + + expect(mockStreamAuditEvent).toHaveBeenCalledWith( + expect.objectContaining({ + eventType: 'break_glass_initiated', + actorUid: 'u1', + sessionId: result.sessionId, + }), + ) + }) + + it('succeeds with codes in reversed order', async () => { + const db = makeDb({ hashedCodes: [hashedA, hashedB] }) as unknown as Parameters< + typeof initiateBreakGlassCore + >[0] + const auth = makeAuth() as unknown as Parameters[1] + + const result = await initiateBreakGlassCore( + db, + auth, + { codeA: CODE_B, codeB: CODE_A, reason: 'reversed codes' }, + { uid: 'u1' }, + ) + + expect(result.sessionId).toBeTruthy() + }) + + it('sets expiresAt to approximately 4 hours from now', async () => { + const db = makeDb({ hashedCodes: [hashedA, hashedB] }) as unknown as Parameters< + typeof initiateBreakGlassCore + >[0] + const auth = makeAuth() + const before = Date.now() + + await initiateBreakGlassCore( + db, + auth as unknown as Parameters[1], + { codeA: CODE_A, codeB: CODE_B, reason: 'test' }, + { uid: 'u1' }, + ) + + const calls = auth.setCustomUserClaims.mock.calls + expect(calls.length).toBe(1) + const claimsArg = calls[0]![1] as { breakGlassExpiresAt: number } + const diff = claimsArg.breakGlassExpiresAt - before + expect(diff).toBeGreaterThanOrEqual(4 * 60 * 60 * 1000 - 2000) + expect(diff).toBeLessThanOrEqual(4 * 60 * 60 * 1000 + 2000) + }) +}) + +describe('deactivateBreakGlassCore', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('rejects when no active break glass session in claims', async () => { + const db = makeDb({ hashedCodes: [hashedA, hashedB] }) as unknown as Parameters< + typeof deactivateBreakGlassCore + >[0] + const auth = makeAuth() as unknown as Parameters[1] + + await expect(deactivateBreakGlassCore(db, auth, { uid: 'u1', claims: {} })).rejects.toThrow( + 'no_active_break_glass_session', + ) + + const err = await deactivateBreakGlassCore(db, auth, { uid: 'u1', claims: {} }).catch( + (e: unknown) => e, + ) + expect((err as { code: string }).code).toBe('failed-precondition') + }) + + it('removes break glass claims and keeps remaining claims', async () => { + const db = makeDb({ hashedCodes: [hashedA, hashedB] }) as unknown as Parameters< + typeof deactivateBreakGlassCore + >[0] + const rawDb = db as unknown as MockDb + const auth = makeAuth() + + await deactivateBreakGlassCore( + db, + auth as unknown as Parameters[1], + { + uid: 'u1', + claims: { + breakGlassSession: true, + breakGlassSessionId: 'existing-session', + breakGlassExpiresAt: 9999, + }, + }, + ) + + expect(auth.setCustomUserClaims).toHaveBeenCalledWith('u1', { + role: 'superadmin', + }) + expect(rawDb.eventUpdateFn).toHaveBeenCalledWith( + expect.objectContaining({ + action: 'deactivated', + deactivatedAt: expect.any(Number), + }), + ) + expect(mockStreamAuditEvent).toHaveBeenCalledWith( + expect.objectContaining({ + eventType: 'break_glass_deactivated', + actorUid: 'u1', + sessionId: 'existing-session', + }), + ) + }) +}) diff --git a/functions/src/callables/break-glass.ts b/functions/src/callables/break-glass.ts new file mode 100644 index 00000000..831844bf --- /dev/null +++ b/functions/src/callables/break-glass.ts @@ -0,0 +1,135 @@ +import { onCall, HttpsError } from 'firebase-functions/v2/https' +import { getFirestore, type Firestore } from 'firebase-admin/firestore' +import { getAuth, type Auth } from 'firebase-admin/auth' +import * as bcrypt from 'bcryptjs' +import { randomUUID } from 'node:crypto' +import { requireAuth, requireMfaAuth } from './https-error.js' +import { streamAuditEvent } from '../services/audit-stream.js' + +const FOUR_HOURS_MS = 4 * 60 * 60 * 1000 + +interface BreakGlassInput { + codeA: string + codeB: string + reason: string +} + +export async function initiateBreakGlassCore( + db: Firestore, + adminAuth: Auth, + input: BreakGlassInput, + actor: { uid: string }, +): Promise<{ sessionId: string }> { + const configDoc = await db.doc('system_config/break_glass_config').get() + if (!configDoc.exists) { + throw new HttpsError('not-found', 'break_glass_config_missing') + } + const { hashedCodes } = configDoc.data() as { hashedCodes: string[] } + if (!Array.isArray(hashedCodes) || hashedCodes.length < 2) { + throw new HttpsError('failed-precondition', 'break_glass_config_invalid') + } + + let matchedA = -1 + for (let i = 0; i < hashedCodes.length; i++) { + const hash = hashedCodes[i] + if (hash && (await bcrypt.compare(input.codeA, hash))) { + matchedA = i + break + } + } + let matchedB = -1 + for (let i = 0; i < hashedCodes.length; i++) { + const hash = hashedCodes[i] + if (hash && (await bcrypt.compare(input.codeB, hash))) { + matchedB = i + break + } + } + if (matchedA === -1 || matchedB === -1 || matchedA === matchedB) { + throw new HttpsError('unauthenticated', 'break_glass_codes_invalid') + } + + const sessionId = randomUUID() + const now = Date.now() + const expiresAt = now + FOUR_HOURS_MS + + await adminAuth.setCustomUserClaims(actor.uid, { + breakGlassSession: true, + breakGlassSessionId: sessionId, + breakGlassExpiresAt: expiresAt, + }) + + await db.collection('breakglass_events').doc(sessionId).set({ + sessionId, + actorUid: actor.uid, + action: 'initiated', + reason: input.reason, + sessionStartedAt: now, + expiresAt, + schemaVersion: 1, + }) + + void streamAuditEvent({ + eventType: 'break_glass_initiated', + actorUid: actor.uid, + sessionId, + occurredAt: now, + }) + + return { sessionId } +} + +export const initiateBreakGlass = onCall( + { region: 'asia-southeast1', enforceAppCheck: true }, + async (request) => { + const { uid } = requireAuth(request, ['superadmin']) + requireMfaAuth(request) + const data = request.data as BreakGlassInput + return initiateBreakGlassCore(getFirestore(), getAuth(), data, { uid }) + }, +) + +export async function deactivateBreakGlassCore( + db: Firestore, + adminAuth: Auth, + actor: { uid: string; claims: Record }, +): Promise { + const sessionId = actor.claims.breakGlassSessionId as string | undefined + if (!sessionId) { + throw new HttpsError('failed-precondition', 'no_active_break_glass_session') + } + + const userRecord = await adminAuth.getUser(actor.uid) + const currentClaims = userRecord.customClaims ?? {} + const remainingClaims: Record = {} + for (const [key, value] of Object.entries(currentClaims)) { + if ( + key !== 'breakGlassSession' && + key !== 'breakGlassSessionId' && + key !== 'breakGlassExpiresAt' + ) { + remainingClaims[key] = value + } + } + + await adminAuth.setCustomUserClaims(actor.uid, remainingClaims) + await db.collection('breakglass_events').doc(sessionId).update({ + action: 'deactivated', + deactivatedAt: Date.now(), + }) + + void streamAuditEvent({ + eventType: 'break_glass_deactivated', + actorUid: actor.uid, + sessionId, + occurredAt: Date.now(), + }) +} + +export const deactivateBreakGlass = onCall( + { region: 'asia-southeast1', enforceAppCheck: true }, + async (request) => { + const { uid, claims } = requireAuth(request, ['superadmin']) + await deactivateBreakGlassCore(getFirestore(), getAuth(), { uid, claims }) + }, +) From 7a0052adfa057cb892210aa69fce7388c212bcbd Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 27 Apr 2026 19:01:33 +0800 Subject: [PATCH 11/26] feat(functions): add sweepExpiredBreakGlassSessions scheduled trigger for Phase 7.A --- .../sweep-expired-break-glass-sessions.ts | 47 +++++++++++++++++++ 1 file changed, 47 insertions(+) create mode 100644 functions/src/triggers/sweep-expired-break-glass-sessions.ts diff --git a/functions/src/triggers/sweep-expired-break-glass-sessions.ts b/functions/src/triggers/sweep-expired-break-glass-sessions.ts new file mode 100644 index 00000000..13b15444 --- /dev/null +++ b/functions/src/triggers/sweep-expired-break-glass-sessions.ts @@ -0,0 +1,47 @@ +import { onSchedule } from 'firebase-functions/v2/scheduler' +import { getFirestore } from 'firebase-admin/firestore' +import { getAuth } from 'firebase-admin/auth' +import { streamAuditEvent } from '../services/audit-stream.js' + +export const sweepExpiredBreakGlassSessions = onSchedule( + { schedule: 'every 5 minutes', region: 'asia-southeast1', timeZone: 'UTC' }, + async () => { + const db = getFirestore() + const adminAuth = getAuth() + const now = Date.now() + + const snap = await db + .collection('breakglass_events') + .where('action', '==', 'initiated') + .where('expiresAt', '<', now) + .get() + + for (const doc of snap.docs) { + const { actorUid, sessionId } = doc.data() as { actorUid: string; sessionId: string } + try { + const userRecord = await adminAuth.getUser(actorUid) + const currentClaims = userRecord.customClaims ?? {} + const remaining: Record = {} + for (const [key, value] of Object.entries(currentClaims)) { + if ( + key !== 'breakGlassSession' && + key !== 'breakGlassSessionId' && + key !== 'breakGlassExpiresAt' + ) { + remaining[key] = value + } + } + await adminAuth.setCustomUserClaims(actorUid, remaining) + await doc.ref.update({ action: 'auto_expired', expiredAt: now }) + void streamAuditEvent({ + eventType: 'break_glass_auto_expired', + actorUid, + sessionId, + occurredAt: now, + }) + } catch (err: unknown) { + console.error('[sweep-break-glass] failed for session', sessionId, err) + } + } + }, +) From d7341ed3ee638e96f2c0a6a7c4f175a0561e0825 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 27 Apr 2026 19:09:58 +0800 Subject: [PATCH 12/26] feat(functions): add declareEmergency callable with 5 tests for Phase 7.A --- .../callables/declare-emergency.test.ts | 123 ++++++++++++++++++ functions/src/callables/declare-emergency.ts | 59 +++++++++ 2 files changed, 182 insertions(+) create mode 100644 functions/src/__tests__/callables/declare-emergency.test.ts create mode 100644 functions/src/callables/declare-emergency.ts diff --git a/functions/src/__tests__/callables/declare-emergency.test.ts b/functions/src/__tests__/callables/declare-emergency.test.ts new file mode 100644 index 00000000..db4b8777 --- /dev/null +++ b/functions/src/__tests__/callables/declare-emergency.test.ts @@ -0,0 +1,123 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import type { Firestore } from 'firebase-admin/firestore' + +const mockStreamAuditEvent = vi.hoisted(() => vi.fn()) +const mockSendMassAlertFcm = vi.hoisted(() => + vi.fn().mockResolvedValue({ successCount: 0, failureCount: 0, batchCount: 0 }), +) + +vi.mock('../../services/audit-stream.js', () => ({ + streamAuditEvent: mockStreamAuditEvent, +})) +vi.mock('../../services/fcm-mass-send.js', () => ({ + sendMassAlertFcm: mockSendMassAlertFcm, +})) +vi.mock('firebase-functions/v2/https', () => ({ + onCall: vi.fn((_opts: unknown, fn: unknown) => fn), + HttpsError: class HttpsError extends Error { + code: string + constructor(code: string, message: string) { + super(message) + this.code = code + } + }, +})) + +import { declareEmergencyCore } from '../../callables/declare-emergency.js' +import { ZodError } from 'zod' + +function createMockDb() { + const setFn = vi.fn().mockResolvedValue(undefined) + const docFn = vi.fn(() => ({ set: setFn })) + const collectionFn = vi.fn(() => ({ doc: docFn })) + return { + collection: collectionFn, + _setFn: setFn, + _collectionFn: collectionFn, + } as unknown as Firestore & { + _setFn: typeof setFn + _collectionFn: typeof collectionFn + } +} + +const validInput = { + hazardType: 'typhoon', + affectedMunicipalityIds: ['daet', 'san-vicente'], + message: 'Signal no. 3 raised', +} + +describe('declareEmergencyCore', () => { + let mockDb: ReturnType + + beforeEach(() => { + mockDb = createMockDb() + mockSendMassAlertFcm.mockClear() + mockStreamAuditEvent.mockClear() + }) + + it('writes alert doc with correct fields', async () => { + const result = await declareEmergencyCore(mockDb, validInput, { uid: 'admin-1' }) + + expect(result.alertId).toBeDefined() + expect(typeof result.alertId).toBe('string') + expect(mockDb._collectionFn).toHaveBeenCalledWith('alerts') + expect(mockDb._setFn).toHaveBeenCalledTimes(1) + + const calls = mockDb._setFn.mock.calls + expect(calls.length).toBeGreaterThan(0) + const setArg = (calls[0] as [Record])[0] + expect(setArg.alertType).toBe('emergency') + expect(setArg.hazardType).toBe('typhoon') + expect(setArg.affectedMunicipalityIds).toEqual(['daet', 'san-vicente']) + expect(setArg.message).toBe('Signal no. 3 raised') + expect(setArg.declaredBy).toBe('admin-1') + expect(setArg.declaredAt).toBeDefined() + expect(setArg.schemaVersion).toBe(1) + }) + + it('throws ZodError for empty hazardType', async () => { + await expect( + declareEmergencyCore(mockDb, { ...validInput, hazardType: '' }, { uid: 'admin-1' }), + ).rejects.toThrow(ZodError) + }) + + it('throws ZodError for empty municipalityIds', async () => { + await expect( + declareEmergencyCore( + mockDb, + { ...validInput, affectedMunicipalityIds: [] }, + { uid: 'admin-1' }, + ), + ).rejects.toThrow(ZodError) + }) + + it('calls sendMassAlertFcm with correct params', async () => { + await declareEmergencyCore(mockDb, validInput, { uid: 'admin-1' }) + + expect(mockSendMassAlertFcm).toHaveBeenCalledWith(mockDb, { + municipalityIds: ['daet', 'san-vicente'], + title: 'Emergency: typhoon', + body: 'Signal no. 3 raised', + }) + }) + + it('streams audit event', async () => { + const before = Date.now() + const result = await declareEmergencyCore(mockDb, validInput, { uid: 'admin-1' }) + const after = Date.now() + + expect(mockStreamAuditEvent).toHaveBeenCalledWith( + expect.objectContaining({ + eventType: 'emergency_declared', + actorUid: 'admin-1', + targetDocumentId: result.alertId, + metadata: { hazardType: 'typhoon' }, + }), + ) + const calls = mockStreamAuditEvent.mock.calls + expect(calls.length).toBeGreaterThan(0) + const callArg = (calls[0] as [{ occurredAt: number }])[0] + expect(callArg.occurredAt).toBeGreaterThanOrEqual(before) + expect(callArg.occurredAt).toBeLessThanOrEqual(after) + }) +}) diff --git a/functions/src/callables/declare-emergency.ts b/functions/src/callables/declare-emergency.ts new file mode 100644 index 00000000..a993674d --- /dev/null +++ b/functions/src/callables/declare-emergency.ts @@ -0,0 +1,59 @@ +import { onCall } from 'firebase-functions/v2/https' +import { getFirestore, type Firestore } from 'firebase-admin/firestore' +import { randomUUID } from 'node:crypto' +import { z } from 'zod' +import { requireAuth, requireMfaAuth } from './https-error.js' +import { streamAuditEvent } from '../services/audit-stream.js' +import { sendMassAlertFcm } from '../services/fcm-mass-send.js' + +const declareEmergencyInputSchema = z.object({ + hazardType: z.string().min(1).max(100), + affectedMunicipalityIds: z.array(z.string().min(1)).min(1), + message: z.string().min(1).max(500), +}) + +export async function declareEmergencyCore( + db: Firestore, + input: unknown, + actor: { uid: string }, +): Promise<{ alertId: string }> { + const validated = declareEmergencyInputSchema.parse(input) + const alertId = randomUUID() + const now = Date.now() + + await db.collection('alerts').doc(alertId).set({ + alertId, + alertType: 'emergency', + hazardType: validated.hazardType, + affectedMunicipalityIds: validated.affectedMunicipalityIds, + message: validated.message, + declaredBy: actor.uid, + declaredAt: now, + schemaVersion: 1, + }) + + void sendMassAlertFcm(db, { + municipalityIds: validated.affectedMunicipalityIds, + title: `Emergency: ${validated.hazardType}`, + body: validated.message, + }) + + void streamAuditEvent({ + eventType: 'emergency_declared', + actorUid: actor.uid, + targetDocumentId: alertId, + metadata: { hazardType: validated.hazardType }, + occurredAt: now, + }) + + return { alertId } +} + +export const declareEmergency = onCall( + { region: 'asia-southeast1', enforceAppCheck: true }, + async (request) => { + const { uid } = requireAuth(request, ['superadmin']) + requireMfaAuth(request) + return declareEmergencyCore(getFirestore(), request.data, { uid }) + }, +) From 5ca1398c4e99c477251d390b403aef33a8b6ade0 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 27 Apr 2026 19:16:32 +0800 Subject: [PATCH 13/26] feat(functions): add declareDataIncident + recordIncidentResponseEvent callables for Phase 7.A --- .../src/callables/declare-data-incident.ts | 71 ++++++++++++++++ .../record-incident-response-event.ts | 82 +++++++++++++++++++ 2 files changed, 153 insertions(+) create mode 100644 functions/src/callables/declare-data-incident.ts create mode 100644 functions/src/callables/record-incident-response-event.ts diff --git a/functions/src/callables/declare-data-incident.ts b/functions/src/callables/declare-data-incident.ts new file mode 100644 index 00000000..91f254b4 --- /dev/null +++ b/functions/src/callables/declare-data-incident.ts @@ -0,0 +1,71 @@ +import { onCall } from 'firebase-functions/v2/https' +import { getFirestore, type Firestore } from 'firebase-admin/firestore' +import { randomUUID } from 'node:crypto' +import { z } from 'zod' +import { requireAuth, requireMfaAuth } from './https-error.js' +import { streamAuditEvent } from '../services/audit-stream.js' + +const dataIncidentInputSchema = z.object({ + incidentType: z.enum([ + 'unauthorized_access', + 'data_loss', + 'data_corruption', + 'system_breach', + 'accidental_disclosure', + ]), + severity: z.enum(['critical', 'high', 'medium', 'low']), + affectedCollections: z.array(z.string().min(1)), + affectedDataClasses: z.array(z.string().min(1)), + estimatedAffectedSubjects: z.number().int().optional(), + summary: z.string().min(1).max(2000), +}) + +export async function declareDataIncidentCore( + db: Firestore, + input: unknown, + actor: { uid: string }, +): Promise<{ incidentId: string }> { + const validated = dataIncidentInputSchema.parse(input) + const incidentId = randomUUID() + const eventId = randomUUID() + const now = Date.now() + + await db.runTransaction(async (tx) => { + // eslint-disable-line @typescript-eslint/require-await -- Firestore SDK requires async callback + tx.set(db.collection('data_incidents').doc(incidentId), { + ...validated, + incidentId, + status: 'declared', + declaredAt: now, + declaredBy: actor.uid, + retentionExempt: false, + schemaVersion: 1, + }) + tx.set(db.collection('incident_response_events').doc(eventId), { + eventId, + incidentId, + phase: 'declared', + recordedBy: actor.uid, + recordedAt: now, + schemaVersion: 1, + }) + }) + + void streamAuditEvent({ + eventType: 'data_incident_declared', + actorUid: actor.uid, + sessionId: incidentId, + occurredAt: now, + }) + + return { incidentId } +} + +export const declareDataIncident = onCall( + { region: 'asia-southeast1', enforceAppCheck: true }, + async (request) => { + const { uid } = requireAuth(request, ['superadmin']) + requireMfaAuth(request) + return declareDataIncidentCore(getFirestore(), request.data, { uid }) + }, +) diff --git a/functions/src/callables/record-incident-response-event.ts b/functions/src/callables/record-incident-response-event.ts new file mode 100644 index 00000000..7d5b4dbb --- /dev/null +++ b/functions/src/callables/record-incident-response-event.ts @@ -0,0 +1,82 @@ +import { onCall, HttpsError } from 'firebase-functions/v2/https' +import { getFirestore, type Firestore } from 'firebase-admin/firestore' +import { randomUUID } from 'node:crypto' +import { z } from 'zod' +import { requireAuth, requireMfaAuth } from './https-error.js' +import { streamAuditEvent } from '../services/audit-stream.js' + +const PHASE_ORDER = [ + 'declared', + 'contained', + 'preserved', + 'assessed', + 'notified_npc', + 'notified_subjects', + 'post_report', + 'closed', +] as const + +const recordEventInputSchema = z.object({ + incidentId: z.string().min(1), + phase: z.enum(PHASE_ORDER), + notes: z.string().max(4000).optional(), +}) + +export async function recordIncidentResponseEventCore( + db: Firestore, + input: unknown, + actor: { uid: string }, +): Promise<{ eventId: string }> { + const validated = recordEventInputSchema.parse(input) + const eventId = randomUUID() + const now = Date.now() + + await db.runTransaction(async (tx) => { + const incidentRef = db.collection('data_incidents').doc(validated.incidentId) + const incidentSnap = await tx.get(incidentRef) + + if (!incidentSnap.exists) { + throw new HttpsError('not-found', 'incident_not_found') + } + + const incidentData = incidentSnap.data() as { status: string } + const currentIndex = PHASE_ORDER.indexOf(incidentData.status as (typeof PHASE_ORDER)[number]) + const nextIndex = PHASE_ORDER.indexOf(validated.phase) + + if (nextIndex !== currentIndex + 1) { + throw new HttpsError('failed-precondition', 'invalid_phase_transition') + } + + tx.update(incidentRef, { status: validated.phase }) + tx.set(db.collection('incident_response_events').doc(eventId), { + eventId, + incidentId: validated.incidentId, + phase: validated.phase, + notes: validated.notes, + recordedBy: actor.uid, + recordedAt: now, + schemaVersion: 1, + }) + }) + + void streamAuditEvent({ + eventType: 'incident_response_event_recorded', + actorUid: actor.uid, + sessionId: validated.incidentId, + metadata: { phase: validated.phase }, + occurredAt: now, + }) + + return { eventId } +} + +export const recordIncidentResponseEvent = onCall( + { region: 'asia-southeast1', enforceAppCheck: true }, + async (request) => { + const { uid } = requireAuth(request, ['superadmin']) + requireMfaAuth(request) + return recordIncidentResponseEventCore(getFirestore(), request.data, { + uid, + }) + }, +) From 9ebac118444800ac215b5d9b38925f785a18c3d6 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 27 Apr 2026 19:20:12 +0800 Subject: [PATCH 14/26] feat(functions): add setRetentionExempt, approveErasureRequest, toggleMutualAidVisibility callables for Phase 7.A --- .../src/callables/approve-erasure-request.ts | 42 ++++++++++++++++++ .../src/callables/set-retention-exempt.ts | 43 +++++++++++++++++++ .../callables/toggle-mutual-aid-visibility.ts | 34 +++++++++++++++ 3 files changed, 119 insertions(+) create mode 100644 functions/src/callables/approve-erasure-request.ts create mode 100644 functions/src/callables/set-retention-exempt.ts create mode 100644 functions/src/callables/toggle-mutual-aid-visibility.ts diff --git a/functions/src/callables/approve-erasure-request.ts b/functions/src/callables/approve-erasure-request.ts new file mode 100644 index 00000000..d30904d7 --- /dev/null +++ b/functions/src/callables/approve-erasure-request.ts @@ -0,0 +1,42 @@ +import { onCall, HttpsError } from 'firebase-functions/v2/https' +import { getFirestore, type Firestore } from 'firebase-admin/firestore' +import { requireAuth, requireMfaAuth } from './https-error.js' +import { streamAuditEvent } from '../services/audit-stream.js' + +export async function approveErasureRequestCore( + db: Firestore, + input: { erasureRequestId: string; approved: boolean; reason?: string }, + actor: { uid: string }, +): Promise { + const doc = await db.collection('erasure_requests').doc(input.erasureRequestId).get() + if (!doc.exists) { + throw new HttpsError('not-found', 'erasure_request_not_found') + } + const status = input.approved ? 'approved_pending_anonymization' : 'denied' + await doc.ref.update({ + status, + reviewedBy: actor.uid, + reviewedAt: Date.now(), + ...(input.reason ? { reviewReason: input.reason } : {}), + }) + void streamAuditEvent({ + eventType: 'erasure_request_reviewed', + actorUid: actor.uid, + targetDocumentId: input.erasureRequestId, + metadata: { approved: input.approved }, + occurredAt: Date.now(), + }) +} + +export const approveErasureRequest = onCall( + { region: 'asia-southeast1', enforceAppCheck: true }, + async (request) => { + const { uid } = requireAuth(request, ['superadmin']) + requireMfaAuth(request) + await approveErasureRequestCore( + getFirestore(), + request.data as { erasureRequestId: string; approved: boolean; reason?: string }, + { uid }, + ) + }, +) diff --git a/functions/src/callables/set-retention-exempt.ts b/functions/src/callables/set-retention-exempt.ts new file mode 100644 index 00000000..092f480b --- /dev/null +++ b/functions/src/callables/set-retention-exempt.ts @@ -0,0 +1,43 @@ +import { onCall, HttpsError } from 'firebase-functions/v2/https' +import { getFirestore, type Firestore } from 'firebase-admin/firestore' +import { requireAuth, requireMfaAuth } from './https-error.js' +import { streamAuditEvent } from '../services/audit-stream.js' + +const ALLOWED_COLLECTIONS = ['reports', 'report_private', 'report_ops', 'sms_inbox'] as const + +export async function setRetentionExemptCore( + db: Firestore, + input: { collection: string; documentId: string; exempt: boolean; reason: string }, + actor: { uid: string }, +): Promise { + if (!(ALLOWED_COLLECTIONS as readonly string[]).includes(input.collection)) { + throw new HttpsError('invalid-argument', 'collection_not_allowed') + } + await db.collection(input.collection).doc(input.documentId).update({ + retentionExempt: input.exempt, + retentionExemptReason: input.reason, + retentionExemptSetBy: actor.uid, + retentionExemptSetAt: Date.now(), + }) + void streamAuditEvent({ + eventType: 'retention_exempt_set', + actorUid: actor.uid, + targetCollection: input.collection, + targetDocumentId: input.documentId, + metadata: { exempt: input.exempt }, + occurredAt: Date.now(), + }) +} + +export const setRetentionExempt = onCall( + { region: 'asia-southeast1', enforceAppCheck: true }, + async (request) => { + const { uid } = requireAuth(request, ['superadmin']) + requireMfaAuth(request) + await setRetentionExemptCore( + getFirestore(), + request.data as { collection: string; documentId: string; exempt: boolean; reason: string }, + { uid }, + ) + }, +) diff --git a/functions/src/callables/toggle-mutual-aid-visibility.ts b/functions/src/callables/toggle-mutual-aid-visibility.ts new file mode 100644 index 00000000..b0afed51 --- /dev/null +++ b/functions/src/callables/toggle-mutual-aid-visibility.ts @@ -0,0 +1,34 @@ +import { onCall } from 'firebase-functions/v2/https' +import { getFirestore, type Firestore } from 'firebase-admin/firestore' +import { requireAuth } from './https-error.js' +import { streamAuditEvent } from '../services/audit-stream.js' + +export async function toggleMutualAidVisibilityCore( + db: Firestore, + input: { agencyId: string; visible: boolean }, + actor: { uid: string }, +): Promise { + await db.collection('agencies').doc(input.agencyId).update({ + mutualAidVisible: input.visible, + }) + void streamAuditEvent({ + eventType: 'mutual_aid_visibility_toggled', + actorUid: actor.uid, + targetCollection: 'agencies', + targetDocumentId: input.agencyId, + metadata: { visible: input.visible }, + occurredAt: Date.now(), + }) +} + +export const toggleMutualAidVisibility = onCall( + { region: 'asia-southeast1', enforceAppCheck: true }, + async (request) => { + const { uid } = requireAuth(request, ['superadmin', 'pdrrmo']) + await toggleMutualAidVisibilityCore( + getFirestore(), + request.data as { agencyId: string; visible: boolean }, + { uid }, + ) + }, +) From 1a6b76115927d63af5f5591975d49421d92fe691 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 27 Apr 2026 19:22:48 +0800 Subject: [PATCH 15/26] feat(functions): add upsertProvincialResource + archiveProvincialResource callables for Phase 7.A --- .../src/callables/provincial-resources.ts | 85 +++++++++++++++++++ 1 file changed, 85 insertions(+) create mode 100644 functions/src/callables/provincial-resources.ts diff --git a/functions/src/callables/provincial-resources.ts b/functions/src/callables/provincial-resources.ts new file mode 100644 index 00000000..8caf8556 --- /dev/null +++ b/functions/src/callables/provincial-resources.ts @@ -0,0 +1,85 @@ +import { onCall } from 'firebase-functions/v2/https' +import { getFirestore, type Firestore } from 'firebase-admin/firestore' +import { randomUUID } from 'node:crypto' +import { z } from 'zod' +import { requireAuth } from './https-error.js' +import { streamAuditEvent } from '../services/audit-stream.js' + +const upsertSchema = z.object({ + id: z.string().min(1).optional(), + name: z.string().min(1).max(200), + type: z.string().min(1).max(100), + quantity: z.number().int().nonnegative(), + unit: z.string().min(1).max(50), + location: z.string().min(1).max(300), + available: z.boolean(), +}) + +export async function upsertProvincialResourceCore( + db: Firestore, + input: unknown, + actor: { uid: string }, +): Promise<{ id: string }> { + const validated = upsertSchema.parse(input) + const id = validated.id ?? randomUUID() + const now = Date.now() + + await db.collection('provincial_resources').doc(id).set({ + id, + name: validated.name, + type: validated.type, + quantity: validated.quantity, + unit: validated.unit, + location: validated.location, + available: validated.available, + archived: false, + lastUpdatedBy: actor.uid, + lastUpdatedAt: now, + schemaVersion: 1, + }) + + void streamAuditEvent({ + eventType: 'provincial_resource_upserted', + actorUid: actor.uid, + targetCollection: 'provincial_resources', + targetDocumentId: id, + occurredAt: now, + }) + + return { id } +} + +export const upsertProvincialResource = onCall( + { region: 'asia-southeast1', enforceAppCheck: true }, + async (request) => { + const { uid } = requireAuth(request, ['superadmin', 'pdrrmo']) + return upsertProvincialResourceCore(getFirestore(), request.data, { uid }) + }, +) + +export async function archiveProvincialResourceCore( + db: Firestore, + input: { id: string }, + actor: { uid: string }, +): Promise { + await db.collection('provincial_resources').doc(input.id).update({ + archived: true, + archivedBy: actor.uid, + archivedAt: Date.now(), + }) + void streamAuditEvent({ + eventType: 'provincial_resource_archived', + actorUid: actor.uid, + targetCollection: 'provincial_resources', + targetDocumentId: input.id, + occurredAt: Date.now(), + }) +} + +export const archiveProvincialResource = onCall( + { region: 'asia-southeast1', enforceAppCheck: true }, + async (request) => { + const { uid } = requireAuth(request, ['superadmin', 'pdrrmo']) + return archiveProvincialResourceCore(getFirestore(), request.data as { id: string }, { uid }) + }, +) From ba88787cc36688858613cad586e1aabb09f76c89 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 27 Apr 2026 19:28:37 +0800 Subject: [PATCH 16/26] feat(rules): add Firestore read rules for data_incidents, provincial_resources, erasure_requests, system_health --- infra/firebase/firestore.rules | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/infra/firebase/firestore.rules b/infra/firebase/firestore.rules index 1ae7539b..2a2eee5f 100644 --- a/infra/firebase/firestore.rules +++ b/infra/firebase/firestore.rules @@ -480,6 +480,31 @@ service cloud.firestore { } } + // ================================================================ + // Phase 7: data incidents, provincial resources, erasure requests, + // system health. + // ================================================================ + + match /data_incidents/{id} { + allow read: if isSuperadmin() && isActivePrivileged(); + allow write: if false; + } + + match /provincial_resources/{id} { + allow read: if isAuthed(); + allow write: if false; + } + + match /erasure_requests/{id} { + allow read: if isSuperadmin() && isActivePrivileged(); + allow write: if false; + } + + match /system_health/{id} { + allow read: if isSuperadmin(); + allow write: if false; + } + // ================================================================ // Default deny — every collection not explicitly matched above. // Phase 2+ will add specific match blocks for reports, report_private, From 64b6111f5a808abe39f8bdac45403d41a4e07c25 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 27 Apr 2026 19:33:15 +0800 Subject: [PATCH 17/26] feat(functions,admin-desktop): export all 7.A callables and add admin-desktop callable wrappers --- apps/admin-desktop/src/services/callables.ts | 68 +++++++++++++++++++ .../src/callables/declare-data-incident.ts | 2 +- functions/src/index.ts | 12 ++++ 3 files changed, 81 insertions(+), 1 deletion(-) diff --git a/apps/admin-desktop/src/services/callables.ts b/apps/admin-desktop/src/services/callables.ts index b9e02967..d7c8ca80 100644 --- a/apps/admin-desktop/src/services/callables.ts +++ b/apps/admin-desktop/src/services/callables.ts @@ -143,4 +143,72 @@ export const callables = { functions, 'bulkAvailabilityOverride', )(payload).then((r) => r.data), + initiateBreakGlass: (payload: { codeA: string; codeB: string; reason: string }) => + httpsCallable( + functions, + 'initiateBreakGlass', + )(payload).then((r) => r.data), + deactivateBreakGlass: () => + httpsCallable>(functions, 'deactivateBreakGlass')({}).then((r) => r.data), + declareEmergency: (payload: { + hazardType: string + affectedMunicipalityIds: string[] + message: string + }) => + httpsCallable( + functions, + 'declareEmergency', + )(payload).then((r) => r.data), + declareDataIncident: (payload: { + incidentType: string + severity: string + affectedCollections: string[] + affectedDataClasses: string[] + estimatedAffectedSubjects?: number + summary: string + }) => + httpsCallable( + functions, + 'declareDataIncident', + )(payload).then((r) => r.data), + recordIncidentResponseEvent: (payload: { incidentId: string; phase: string; notes?: string }) => + httpsCallable( + functions, + 'recordIncidentResponseEvent', + )(payload).then((r) => r.data), + setRetentionExempt: (payload: { + collection: string + documentId: string + exempt: boolean + reason: string + }) => httpsCallable(functions, 'setRetentionExempt')(payload).then((r) => r.data), + approveErasureRequest: (payload: { + erasureRequestId: string + approved: boolean + reason?: string + }) => + httpsCallable(functions, 'approveErasureRequest')(payload).then((r) => r.data), + toggleMutualAidVisibility: (payload: { agencyId: string; visible: boolean }) => + httpsCallable( + functions, + 'toggleMutualAidVisibility', + )(payload).then((r) => r.data), + upsertProvincialResource: (payload: { + id?: string + name: string + type: string + quantity: number + unit: string + location: string + available: boolean + }) => + httpsCallable( + functions, + 'upsertProvincialResource', + )(payload).then((r) => r.data), + archiveProvincialResource: (payload: { id: string }) => + httpsCallable( + functions, + 'archiveProvincialResource', + )(payload).then((r) => r.data), } diff --git a/functions/src/callables/declare-data-incident.ts b/functions/src/callables/declare-data-incident.ts index 91f254b4..832c7b1f 100644 --- a/functions/src/callables/declare-data-incident.ts +++ b/functions/src/callables/declare-data-incident.ts @@ -31,7 +31,7 @@ export async function declareDataIncidentCore( const now = Date.now() await db.runTransaction(async (tx) => { - // eslint-disable-line @typescript-eslint/require-await -- Firestore SDK requires async callback + await Promise.resolve() // Firestore transaction callback requires async tx.set(db.collection('data_incidents').doc(incidentId), { ...validated, incidentId, diff --git a/functions/src/index.ts b/functions/src/index.ts index 434a061c..2fa4dcb6 100644 --- a/functions/src/index.ts +++ b/functions/src/index.ts @@ -140,3 +140,15 @@ export { smsInboundProcessor } from './firestore/sms-inbound-processor.js' export { analyticsSnapshotWriter } from './scheduled/analytics-snapshot-writer.js' export { auditExportBatch } from './triggers/audit-export-batch.js' export { auditExportHealthCheck } from './triggers/audit-export-health-check.js' +export { sweepExpiredBreakGlassSessions } from './triggers/sweep-expired-break-glass-sessions.js' +export { initiateBreakGlass, deactivateBreakGlass } from './callables/break-glass.js' +export { declareEmergency } from './callables/declare-emergency.js' +export { declareDataIncident } from './callables/declare-data-incident.js' +export { recordIncidentResponseEvent } from './callables/record-incident-response-event.js' +export { setRetentionExempt } from './callables/set-retention-exempt.js' +export { approveErasureRequest } from './callables/approve-erasure-request.js' +export { toggleMutualAidVisibility } from './callables/toggle-mutual-aid-visibility.js' +export { + upsertProvincialResource, + archiveProvincialResource, +} from './callables/provincial-resources.js' From 65d96122358289277e9c2f84cf53ae1a6e68622d Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 27 Apr 2026 20:08:26 +0800 Subject: [PATCH 18/26] fix: prettier formatting and add rule coverage tests for Phase 7 collections --- docs/superpowers/plans/2026-04-27-phase7.md | 1 - .../rules/public-collections.rules.test.ts | 56 ++++++++++++++++++- infra/firebase/firestore.rules.template | 25 +++++++++ 3 files changed, 80 insertions(+), 2 deletions(-) diff --git a/docs/superpowers/plans/2026-04-27-phase7.md b/docs/superpowers/plans/2026-04-27-phase7.md index 28be0428..2f5f246f 100644 --- a/docs/superpowers/plans/2026-04-27-phase7.md +++ b/docs/superpowers/plans/2026-04-27-phase7.md @@ -1116,7 +1116,6 @@ import { UserManagementPage } from './pages/UserManagementPage' import { ProvincialResourcesPage } from './pages/ProvincialResourcesPage' import { SystemHealthPage } from './pages/SystemHealthPage' import { BreakGlassPage } from './pages/BreakGlassPage' - ;}> } /> } /> diff --git a/functions/src/__tests__/rules/public-collections.rules.test.ts b/functions/src/__tests__/rules/public-collections.rules.test.ts index 7ef076e6..0d592007 100644 --- a/functions/src/__tests__/rules/public-collections.rules.test.ts +++ b/functions/src/__tests__/rules/public-collections.rules.test.ts @@ -1,7 +1,7 @@ import { assertFails, assertSucceeds } from '@firebase/rules-unit-testing' import { collection, getDocs, addDoc, doc, setDoc, getDoc } from 'firebase/firestore' import { afterAll, beforeAll, describe, it } from 'vitest' -import { authed, createTestEnv } from '../helpers/rules-harness.js' +import { authed, createTestEnv, unauthed } from '../helpers/rules-harness.js' import { seedActiveAccount, seedAgency, staffClaims, ts } from '../helpers/seed-factories.js' let env: Awaited> @@ -310,4 +310,58 @@ describe('privileged read tests for callable collections', () => { ) await assertSucceeds(getDocs(collection(db, 'incident_response_events'))) }) + + describe('Phase 7 collections', () => { + it('any authed user can read provincial_resources', async () => { + const db = authed(env, 'citizen-1', staffClaims({ role: 'citizen' })) + await assertSucceeds(getDocs(collection(db, 'provincial_resources'))) + }) + + it('unauthed user cannot read provincial_resources', async () => { + const db = unauthed(env) + await assertFails(getDocs(collection(db, 'provincial_resources'))) + }) + + it('superadmin with active privileged claim can read data_incidents', async () => { + const db = authed( + env, + 'super-1', + staffClaims({ role: 'provincial_superadmin', permittedMunicipalityIds: ['daet'] }), + ) + await assertSucceeds(getDocs(collection(db, 'data_incidents'))) + }) + + it('non-superadmin cannot read data_incidents', async () => { + const db = authed(env, 'citizen-1', staffClaims({ role: 'citizen' })) + await assertFails(getDocs(collection(db, 'data_incidents'))) + }) + + it('superadmin with active privileged claim can read erasure_requests', async () => { + const db = authed( + env, + 'super-1', + staffClaims({ role: 'provincial_superadmin', permittedMunicipalityIds: ['daet'] }), + ) + await assertSucceeds(getDocs(collection(db, 'erasure_requests'))) + }) + + it('non-superadmin cannot read erasure_requests', async () => { + const db = authed(env, 'citizen-1', staffClaims({ role: 'citizen' })) + await assertFails(getDocs(collection(db, 'erasure_requests'))) + }) + + it('superadmin can read system_health', async () => { + const db = authed( + env, + 'super-1', + staffClaims({ role: 'provincial_superadmin', permittedMunicipalityIds: ['daet'] }), + ) + await assertSucceeds(getDocs(collection(db, 'system_health'))) + }) + + it('non-superadmin cannot read system_health', async () => { + const db = authed(env, 'citizen-1', staffClaims({ role: 'citizen' })) + await assertFails(getDocs(collection(db, 'system_health'))) + }) + }) }) diff --git a/infra/firebase/firestore.rules.template b/infra/firebase/firestore.rules.template index 847bb835..3916e58d 100644 --- a/infra/firebase/firestore.rules.template +++ b/infra/firebase/firestore.rules.template @@ -457,6 +457,31 @@ service cloud.firestore { } } + // ================================================================ + // Phase 7: data incidents, provincial resources, erasure requests, + // system health. + // ================================================================ + + match /data_incidents/{id} { + allow read: if isSuperadmin() && isActivePrivileged(); + allow write: if false; + } + + match /provincial_resources/{id} { + allow read: if isAuthed(); + allow write: if false; + } + + match /erasure_requests/{id} { + allow read: if isSuperadmin() && isActivePrivileged(); + allow write: if false; + } + + match /system_health/{id} { + allow read: if isSuperadmin(); + allow write: if false; + } + // ================================================================ // Default deny — every collection not explicitly matched above. // Phase 2+ will add specific match blocks for reports, report_private, From 230507d0d5119ce61b21d8273d06a6fd807c9f9f Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 27 Apr 2026 20:53:19 +0800 Subject: [PATCH 19/26] fix(callables,rules): address all CodeRabbit/Sourcery review findings (13 fixes) CRITICAL: - initiateBreakGlassCore: merge existing custom claims before setCustomUserClaims (prevent role/municipalityId loss) - initiateBreakGlassCore: add race condition check (reject if active break-glass session already exists) - recordIncidentResponseEventCore: validate stored status is in PHASE_ORDER before phase comparison - upsertProvincialResourceCore: preserve archived flag on update via pre-read - setRetentionExemptCore: add document existence check before update() MAJOR: - analyticsSnapshotWriter: validate date with Date.parse + filter negative response times - toggleMutualAidVisibilityCore: add agency existence check before update - declareDataIncident: use targetDocumentId (incidentId) in audit event MINOR: - dataIncidentDocSchema: add nonnegative constraint + closedAt >= declaredAt - sweepExpiredBreakGlassSessions: log error in catch block - https-error.test.ts: strengthen assertions with instanceof + code checks - public-collections.rules.test.ts: add suspended-superadmin denial tests - audit-export-health-check.ts: add 30s BigQuery timeout --- .../__tests__/callables/break-glass.test.ts | 54 +++++++++++++++-- .../__tests__/callables/https-error.test.ts | 60 +++++++++++++++++++ .../rules/public-collections.rules.test.ts | 56 +++++++++++++++++ functions/src/callables/break-glass.ts | 22 +++++-- .../src/callables/declare-data-incident.ts | 2 +- .../src/callables/provincial-resources.ts | 7 ++- .../record-incident-response-event.ts | 8 ++- .../src/callables/set-retention-exempt.ts | 7 ++- .../callables/toggle-mutual-aid-visibility.ts | 9 ++- .../scheduled/analytics-snapshot-writer.ts | 11 +++- .../src/triggers/audit-export-health-check.ts | 2 + .../sweep-expired-break-glass-sessions.ts | 7 ++- .../src/incident-response.ts | 5 +- 13 files changed, 228 insertions(+), 22 deletions(-) diff --git a/functions/src/__tests__/callables/break-glass.test.ts b/functions/src/__tests__/callables/break-glass.test.ts index 90e96968..dccbfb0d 100644 --- a/functions/src/__tests__/callables/break-glass.test.ts +++ b/functions/src/__tests__/callables/break-glass.test.ts @@ -62,14 +62,12 @@ function makeDb(configData: Record | null): MockDb { } } -function makeAuth() { +function makeAuth(existingClaims?: Record) { const setCustomUserClaims = vi.fn().mockResolvedValue(undefined) const getUser = vi.fn().mockResolvedValue({ - customClaims: { - breakGlassSession: true, - breakGlassSessionId: 'existing-session', - breakGlassExpiresAt: Date.now() + 100000, + customClaims: existingClaims ?? { role: 'superadmin', + municipalityId: 'daet', }, }) return { setCustomUserClaims, getUser } @@ -198,6 +196,51 @@ describe('initiateBreakGlassCore', () => { ).rejects.toThrow('break_glass_codes_invalid') }) + it('preserves existing custom claims when initiating break-glass session', async () => { + const db = makeDb({ hashedCodes: [hashedA, hashedB] }) as unknown as Parameters< + typeof initiateBreakGlassCore + >[0] + const auth = makeAuth() + + const result = await initiateBreakGlassCore( + db, + auth as unknown as Parameters[1], + { codeA: CODE_A, codeB: CODE_B, reason: 'test' }, + { uid: 'u1' }, + ) + + const calls = auth.setCustomUserClaims.mock.calls + expect(calls.length).toBe(1) + const claimsArg = calls[0]![1] as Record + expect(claimsArg.role).toBe('superadmin') + expect(claimsArg.breakGlassSession).toBe(true) + expect(claimsArg.breakGlassSessionId).toBe(result.sessionId) + expect(claimsArg.breakGlassExpiresAt).toBeGreaterThan(Date.now()) + }) + + it('rejects when user already has an active break-glass session', async () => { + const db = makeDb({ hashedCodes: [hashedA, hashedB] }) as unknown as Parameters< + typeof initiateBreakGlassCore + >[0] + const auth = makeAuth() + auth.getUser = vi.fn().mockResolvedValue({ + customClaims: { + breakGlassSession: true, + breakGlassSessionId: 'existing-session', + breakGlassExpiresAt: Date.now() + 3600000, + }, + }) + + await expect( + initiateBreakGlassCore( + db, + auth as unknown as Parameters[1], + { codeA: CODE_A, codeB: CODE_B, reason: 'test' }, + { uid: 'u1' }, + ), + ).rejects.toThrow('active_break_glass_session_exists') + }) + it('succeeds with correct codes, returns sessionId, sets claims, writes event', async () => { const db = makeDb({ hashedCodes: [hashedA, hashedB] }) as unknown as Parameters< typeof initiateBreakGlassCore @@ -325,6 +368,7 @@ describe('deactivateBreakGlassCore', () => { expect(auth.setCustomUserClaims).toHaveBeenCalledWith('u1', { role: 'superadmin', + municipalityId: 'daet', }) expect(rawDb.eventUpdateFn).toHaveBeenCalledWith( expect.objectContaining({ diff --git a/functions/src/__tests__/callables/https-error.test.ts b/functions/src/__tests__/callables/https-error.test.ts index 36e80ffe..404a2b5d 100644 --- a/functions/src/__tests__/callables/https-error.test.ts +++ b/functions/src/__tests__/callables/https-error.test.ts @@ -42,11 +42,23 @@ describe('bantayogErrorToHttps', () => { describe('requireAuth', () => { it('throws unauthenticated when request.auth is null', () => { expect(() => requireAuth({ auth: null }, ['municipal_admin'])).toThrow(HttpsError) + try { + requireAuth({ auth: null }, ['municipal_admin']) + } catch (err) { + expect(err).toBeInstanceOf(HttpsError) + expect((err as HttpsError).code).toBe('unauthenticated') + } expect(() => requireAuth({ auth: null }, ['municipal_admin'])).toThrow('sign-in required') }) it('throws unauthenticated when request.auth is undefined', () => { expect(() => requireAuth({}, ['municipal_admin'])).toThrow(HttpsError) + try { + requireAuth({}, ['municipal_admin']) + } catch (err) { + expect(err).toBeInstanceOf(HttpsError) + expect((err as HttpsError).code).toBe('unauthenticated') + } }) it('throws permission-denied when role is not in allowed list', () => { @@ -57,6 +69,12 @@ describe('requireAuth', () => { }, } expect(() => requireAuth(request, ['municipal_admin'])).toThrow('role citizen is not allowed') + try { + requireAuth(request, ['municipal_admin']) + } catch (err) { + expect(err).toBeInstanceOf(HttpsError) + expect((err as HttpsError).code).toBe('permission-denied') + } }) it('throws permission-denied when role is missing', () => { @@ -67,6 +85,12 @@ describe('requireAuth', () => { }, } expect(() => requireAuth(request, ['municipal_admin'])).toThrow('role undefined is not allowed') + try { + requireAuth(request, ['municipal_admin']) + } catch (err) { + expect(err).toBeInstanceOf(HttpsError) + expect((err as HttpsError).code).toBe('permission-denied') + } }) it('returns uid and claims when role is allowed', () => { @@ -92,6 +116,14 @@ describe('requireMfaAuth', () => { auth: { uid: 'u1', token: { firebase: {} } }, }) }).toThrow('mfa_required') + try { + requireMfaAuth({ + auth: { uid: 'u1', token: { firebase: {} } }, + }) + } catch (err) { + expect(err).toBeInstanceOf(HttpsError) + expect((err as HttpsError).code).toBe('unauthenticated') + } }) it('passes when sign_in_second_factor is a string', () => { @@ -106,12 +138,24 @@ describe('requireMfaAuth', () => { expect(() => { requireMfaAuth({ auth: null }) }).toThrow('mfa_required') + try { + requireMfaAuth({ auth: null }) + } catch (err) { + expect(err).toBeInstanceOf(HttpsError) + expect((err as HttpsError).code).toBe('unauthenticated') + } }) it('throws mfa_required when auth is undefined', () => { expect(() => { requireMfaAuth({}) }).toThrow('mfa_required') + try { + requireMfaAuth({}) + } catch (err) { + expect(err).toBeInstanceOf(HttpsError) + expect((err as HttpsError).code).toBe('unauthenticated') + } }) it('throws mfa_required when firebase claim is undefined', () => { @@ -120,6 +164,14 @@ describe('requireMfaAuth', () => { auth: { uid: 'u1', token: { role: 'superadmin' } }, }) }).toThrow('mfa_required') + try { + requireMfaAuth({ + auth: { uid: 'u1', token: { role: 'superadmin' } }, + }) + } catch (err) { + expect(err).toBeInstanceOf(HttpsError) + expect((err as HttpsError).code).toBe('unauthenticated') + } }) it('throws mfa_required when sign_in_second_factor is a number', () => { @@ -128,5 +180,13 @@ describe('requireMfaAuth', () => { auth: { uid: 'u1', token: { firebase: { sign_in_second_factor: 42 } } }, }) }).toThrow('mfa_required') + try { + requireMfaAuth({ + auth: { uid: 'u1', token: { firebase: { sign_in_second_factor: 42 } } }, + }) + } catch (err) { + expect(err).toBeInstanceOf(HttpsError) + expect((err as HttpsError).code).toBe('unauthenticated') + } }) }) diff --git a/functions/src/__tests__/rules/public-collections.rules.test.ts b/functions/src/__tests__/rules/public-collections.rules.test.ts index 0d592007..80bf3352 100644 --- a/functions/src/__tests__/rules/public-collections.rules.test.ts +++ b/functions/src/__tests__/rules/public-collections.rules.test.ts @@ -363,5 +363,61 @@ describe('privileged read tests for callable collections', () => { const db = authed(env, 'citizen-1', staffClaims({ role: 'citizen' })) await assertFails(getDocs(collection(db, 'system_health'))) }) + + it('suspended superadmin cannot read data_incidents', async () => { + const db = authed( + env, + 'super-1', + staffClaims({ + role: 'provincial_superadmin', + permittedMunicipalityIds: ['daet'], + accountStatus: 'suspended', + }), + ) + await assertFails(getDocs(collection(db, 'data_incidents'))) + }) + + it('suspended superadmin cannot write data_incidents', async () => { + const db = authed( + env, + 'super-1', + staffClaims({ + role: 'provincial_superadmin', + permittedMunicipalityIds: ['daet'], + accountStatus: 'suspended', + }), + ) + await assertFails( + addDoc(collection(db, 'data_incidents'), { schemaVersion: 1, createdAt: ts }), + ) + }) + + it('suspended superadmin cannot read erasure_requests', async () => { + const db = authed( + env, + 'super-1', + staffClaims({ + role: 'provincial_superadmin', + permittedMunicipalityIds: ['daet'], + accountStatus: 'suspended', + }), + ) + await assertFails(getDocs(collection(db, 'erasure_requests'))) + }) + + it('suspended superadmin cannot write erasure_requests', async () => { + const db = authed( + env, + 'super-1', + staffClaims({ + role: 'provincial_superadmin', + permittedMunicipalityIds: ['daet'], + accountStatus: 'suspended', + }), + ) + await assertFails( + addDoc(collection(db, 'erasure_requests'), { schemaVersion: 1, createdAt: ts }), + ) + }) }) }) diff --git a/functions/src/callables/break-glass.ts b/functions/src/callables/break-glass.ts index 831844bf..74a1e9f9 100644 --- a/functions/src/callables/break-glass.ts +++ b/functions/src/callables/break-glass.ts @@ -3,16 +3,19 @@ import { getFirestore, type Firestore } from 'firebase-admin/firestore' import { getAuth, type Auth } from 'firebase-admin/auth' import * as bcrypt from 'bcryptjs' import { randomUUID } from 'node:crypto' +import { z } from 'zod' import { requireAuth, requireMfaAuth } from './https-error.js' import { streamAuditEvent } from '../services/audit-stream.js' const FOUR_HOURS_MS = 4 * 60 * 60 * 1000 -interface BreakGlassInput { - codeA: string - codeB: string - reason: string -} +const breakGlassInputSchema = z.object({ + codeA: z.string().min(1).max(100), + codeB: z.string().min(1).max(100), + reason: z.string().min(1).max(500), +}) + +type BreakGlassInput = z.infer export async function initiateBreakGlassCore( db: Firestore, @@ -49,11 +52,18 @@ export async function initiateBreakGlassCore( throw new HttpsError('unauthenticated', 'break_glass_codes_invalid') } + const userRecord = await adminAuth.getUser(actor.uid) + if (userRecord.customClaims?.breakGlassSession) { + throw new HttpsError('failed-precondition', 'active_break_glass_session_exists') + } + const sessionId = randomUUID() const now = Date.now() const expiresAt = now + FOUR_HOURS_MS + const existingClaims = userRecord.customClaims ?? {} await adminAuth.setCustomUserClaims(actor.uid, { + ...existingClaims, breakGlassSession: true, breakGlassSessionId: sessionId, breakGlassExpiresAt: expiresAt, @@ -84,7 +94,7 @@ export const initiateBreakGlass = onCall( async (request) => { const { uid } = requireAuth(request, ['superadmin']) requireMfaAuth(request) - const data = request.data as BreakGlassInput + const data = breakGlassInputSchema.parse(request.data) return initiateBreakGlassCore(getFirestore(), getAuth(), data, { uid }) }, ) diff --git a/functions/src/callables/declare-data-incident.ts b/functions/src/callables/declare-data-incident.ts index 832c7b1f..45d7d303 100644 --- a/functions/src/callables/declare-data-incident.ts +++ b/functions/src/callables/declare-data-incident.ts @@ -54,7 +54,7 @@ export async function declareDataIncidentCore( void streamAuditEvent({ eventType: 'data_incident_declared', actorUid: actor.uid, - sessionId: incidentId, + targetDocumentId: incidentId, occurredAt: now, }) diff --git a/functions/src/callables/provincial-resources.ts b/functions/src/callables/provincial-resources.ts index 8caf8556..f48c8335 100644 --- a/functions/src/callables/provincial-resources.ts +++ b/functions/src/callables/provincial-resources.ts @@ -24,6 +24,11 @@ export async function upsertProvincialResourceCore( const id = validated.id ?? randomUUID() const now = Date.now() + const existingDoc = await db.collection('provincial_resources').doc(id).get() + const existingArchived = existingDoc.exists + ? (existingDoc.data() as { archived?: boolean }).archived + : false + await db.collection('provincial_resources').doc(id).set({ id, name: validated.name, @@ -32,7 +37,7 @@ export async function upsertProvincialResourceCore( unit: validated.unit, location: validated.location, available: validated.available, - archived: false, + archived: existingArchived, lastUpdatedBy: actor.uid, lastUpdatedAt: now, schemaVersion: 1, diff --git a/functions/src/callables/record-incident-response-event.ts b/functions/src/callables/record-incident-response-event.ts index 7d5b4dbb..ece37000 100644 --- a/functions/src/callables/record-incident-response-event.ts +++ b/functions/src/callables/record-incident-response-event.ts @@ -40,9 +40,13 @@ export async function recordIncidentResponseEventCore( } const incidentData = incidentSnap.data() as { status: string } - const currentIndex = PHASE_ORDER.indexOf(incidentData.status as (typeof PHASE_ORDER)[number]) - const nextIndex = PHASE_ORDER.indexOf(validated.phase) + const currentStatus = incidentData.status + const currentIndex = PHASE_ORDER.indexOf(currentStatus as (typeof PHASE_ORDER)[number]) + if (currentIndex === -1) { + throw new HttpsError('failed-precondition', 'incident_has_invalid_status') + } + const nextIndex = PHASE_ORDER.indexOf(validated.phase) if (nextIndex !== currentIndex + 1) { throw new HttpsError('failed-precondition', 'invalid_phase_transition') } diff --git a/functions/src/callables/set-retention-exempt.ts b/functions/src/callables/set-retention-exempt.ts index 092f480b..e7f5b4b5 100644 --- a/functions/src/callables/set-retention-exempt.ts +++ b/functions/src/callables/set-retention-exempt.ts @@ -13,7 +13,12 @@ export async function setRetentionExemptCore( if (!(ALLOWED_COLLECTIONS as readonly string[]).includes(input.collection)) { throw new HttpsError('invalid-argument', 'collection_not_allowed') } - await db.collection(input.collection).doc(input.documentId).update({ + const docRef = db.collection(input.collection).doc(input.documentId) + const docSnap = await docRef.get() + if (!docSnap.exists) { + throw new HttpsError('not-found', 'document_not_found') + } + await docRef.update({ retentionExempt: input.exempt, retentionExemptReason: input.reason, retentionExemptSetBy: actor.uid, diff --git a/functions/src/callables/toggle-mutual-aid-visibility.ts b/functions/src/callables/toggle-mutual-aid-visibility.ts index b0afed51..7d92a4dd 100644 --- a/functions/src/callables/toggle-mutual-aid-visibility.ts +++ b/functions/src/callables/toggle-mutual-aid-visibility.ts @@ -1,4 +1,4 @@ -import { onCall } from 'firebase-functions/v2/https' +import { HttpsError, onCall } from 'firebase-functions/v2/https' import { getFirestore, type Firestore } from 'firebase-admin/firestore' import { requireAuth } from './https-error.js' import { streamAuditEvent } from '../services/audit-stream.js' @@ -8,7 +8,12 @@ export async function toggleMutualAidVisibilityCore( input: { agencyId: string; visible: boolean }, actor: { uid: string }, ): Promise { - await db.collection('agencies').doc(input.agencyId).update({ + const agencyRef = db.collection('agencies').doc(input.agencyId) + const agencyDoc = await agencyRef.get() + if (!agencyDoc.exists) { + throw new HttpsError('not-found', 'agency_not_found') + } + await agencyRef.update({ mutualAidVisible: input.visible, }) void streamAuditEvent({ diff --git a/functions/src/scheduled/analytics-snapshot-writer.ts b/functions/src/scheduled/analytics-snapshot-writer.ts index 0c14f0b5..0e60113c 100644 --- a/functions/src/scheduled/analytics-snapshot-writer.ts +++ b/functions/src/scheduled/analytics-snapshot-writer.ts @@ -92,7 +92,10 @@ export async function analyticsSnapshotWriterCore( schemaVersion: 1, }) - const startOfDayMs = new Date(`${date}T00:00:00.000Z`).getTime() + const startOfDayMs = Date.parse(`${date}T00:00:00.000Z`) + if (Number.isNaN(startOfDayMs)) { + throw new Error(`Invalid date format: ${date}`) + } const endOfDayMs = startOfDayMs + 86400000 const resolvedSnap = await db .collection('report_ops') @@ -103,7 +106,11 @@ export async function analyticsSnapshotWriterCore( const resolvedToday = resolvedSnap.size const resolvedWithTimes = resolvedSnap.docs.filter((d) => { const data = d.data() - return typeof data.createdAt === 'number' && typeof data.resolvedAt === 'number' + return ( + typeof data.createdAt === 'number' && + typeof data.resolvedAt === 'number' && + data.resolvedAt >= data.createdAt + ) }) const avgResponseTimeMinutes = resolvedWithTimes.length > 0 diff --git a/functions/src/triggers/audit-export-health-check.ts b/functions/src/triggers/audit-export-health-check.ts index 25b18070..f2c9bee4 100644 --- a/functions/src/triggers/audit-export-health-check.ts +++ b/functions/src/triggers/audit-export-health-check.ts @@ -29,12 +29,14 @@ export const auditExportHealthCheck = onSchedule( const [streamRows] = await bq.query( 'SELECT MAX(occurredAt) as lastAt FROM bantayog_audit.streaming_events', + { timeoutMs: 30000 }, ) const lastStreamMs = extractLastMs(streamRows as unknown as LastAtRow[]) const streamingGapSeconds = Math.floor((now - lastStreamMs) / 1000) const [batchRows] = await bq.query( 'SELECT MAX(timestamp) as lastAt FROM bantayog_audit.batch_events', + { timeoutMs: 30000 }, ) const lastBatchMs = extractLastDateMs(batchRows as unknown as LastAtRow[]) const batchGapSeconds = Math.floor((now - lastBatchMs) / 1000) diff --git a/functions/src/triggers/sweep-expired-break-glass-sessions.ts b/functions/src/triggers/sweep-expired-break-glass-sessions.ts index 13b15444..ddb03bd7 100644 --- a/functions/src/triggers/sweep-expired-break-glass-sessions.ts +++ b/functions/src/triggers/sweep-expired-break-glass-sessions.ts @@ -40,7 +40,12 @@ export const sweepExpiredBreakGlassSessions = onSchedule( occurredAt: now, }) } catch (err: unknown) { - console.error('[sweep-break-glass] failed for session', sessionId, err) + const message = err instanceof Error ? err.message : String(err) + const stack = err instanceof Error ? err.stack : undefined + console.error('[sweep-break-glass] failed for session', sessionId, { + message, + stack, + }) } } }, diff --git a/packages/shared-validators/src/incident-response.ts b/packages/shared-validators/src/incident-response.ts index fa66d516..497da23a 100644 --- a/packages/shared-validators/src/incident-response.ts +++ b/packages/shared-validators/src/incident-response.ts @@ -35,7 +35,7 @@ export const dataIncidentDocSchema = z severity: z.enum(['critical', 'high', 'medium', 'low']), affectedCollections: z.array(z.string().min(1)), affectedDataClasses: z.array(z.string().min(1)), - estimatedAffectedSubjects: z.number().int().optional(), + estimatedAffectedSubjects: z.number().int().nonnegative().optional(), summary: z.string().min(1).max(2000), status: z.enum([ 'declared', @@ -54,5 +54,8 @@ export const dataIncidentDocSchema = z schemaVersion: z.number().int().positive(), }) .strict() + .refine((data) => !data.closedAt || data.closedAt >= data.declaredAt, { + message: 'closedAt must be greater than or equal to declaredAt', + }) export type DataIncidentDoc = z.infer From a9c6bd922686ccdcb01f9147ee27d7351385b5c0 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 27 Apr 2026 21:11:08 +0800 Subject: [PATCH 20/26] =?UTF-8?q?fix(callables):=20address=20review=20find?= =?UTF-8?q?ings=20round=202=20=E2=80=94=20schema=20alignment,=20error=20lo?= =?UTF-8?q?gging,=20input=20validation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - break-glass.ts: align breakglass_events write fields with BreakglassEventDoc schema (actorUid -> actor, add correlationId + createdAt) - declare-data-incident.ts: remove redundant async/await Promise.resolve() in transaction - provincial-resources.ts: use ?? false for archived preservation (null-safety) - set-retention-exempt.ts: add Zod input schema, remove raw as cast - approve-erasure-request.ts: add Zod input schema, remove raw as cast - toggle-mutual-aid-visibility.ts: add Zod input schema, remove raw as cast - audit-export-health-check.ts: log error in FCM catch instead of swallowing --- .../src/callables/approve-erasure-request.ts | 26 ++++++++------- functions/src/callables/break-glass.ts | 4 ++- .../src/callables/declare-data-incident.ts | 2 +- .../src/callables/provincial-resources.ts | 2 +- .../src/callables/set-retention-exempt.ts | 32 +++++++++---------- .../callables/toggle-mutual-aid-visibility.ts | 23 +++++++------ .../src/triggers/audit-export-health-check.ts | 8 +++-- 7 files changed, 55 insertions(+), 42 deletions(-) diff --git a/functions/src/callables/approve-erasure-request.ts b/functions/src/callables/approve-erasure-request.ts index d30904d7..4c259ac4 100644 --- a/functions/src/callables/approve-erasure-request.ts +++ b/functions/src/callables/approve-erasure-request.ts @@ -1,29 +1,37 @@ import { onCall, HttpsError } from 'firebase-functions/v2/https' import { getFirestore, type Firestore } from 'firebase-admin/firestore' +import { z } from 'zod' import { requireAuth, requireMfaAuth } from './https-error.js' import { streamAuditEvent } from '../services/audit-stream.js' +const inputSchema = z.object({ + erasureRequestId: z.string().min(1), + approved: z.boolean(), + reason: z.string().max(1000).optional(), +}) + export async function approveErasureRequestCore( db: Firestore, - input: { erasureRequestId: string; approved: boolean; reason?: string }, + input: unknown, actor: { uid: string }, ): Promise { - const doc = await db.collection('erasure_requests').doc(input.erasureRequestId).get() + const parsed = inputSchema.parse(input) + const doc = await db.collection('erasure_requests').doc(parsed.erasureRequestId).get() if (!doc.exists) { throw new HttpsError('not-found', 'erasure_request_not_found') } - const status = input.approved ? 'approved_pending_anonymization' : 'denied' + const status = parsed.approved ? 'approved_pending_anonymization' : 'denied' await doc.ref.update({ status, reviewedBy: actor.uid, reviewedAt: Date.now(), - ...(input.reason ? { reviewReason: input.reason } : {}), + ...(parsed.reason ? { reviewReason: parsed.reason } : {}), }) void streamAuditEvent({ eventType: 'erasure_request_reviewed', actorUid: actor.uid, - targetDocumentId: input.erasureRequestId, - metadata: { approved: input.approved }, + targetDocumentId: parsed.erasureRequestId, + metadata: { approved: parsed.approved }, occurredAt: Date.now(), }) } @@ -33,10 +41,6 @@ export const approveErasureRequest = onCall( async (request) => { const { uid } = requireAuth(request, ['superadmin']) requireMfaAuth(request) - await approveErasureRequestCore( - getFirestore(), - request.data as { erasureRequestId: string; approved: boolean; reason?: string }, - { uid }, - ) + await approveErasureRequestCore(getFirestore(), request.data, { uid }) }, ) diff --git a/functions/src/callables/break-glass.ts b/functions/src/callables/break-glass.ts index 74a1e9f9..2e5850ce 100644 --- a/functions/src/callables/break-glass.ts +++ b/functions/src/callables/break-glass.ts @@ -71,9 +71,11 @@ export async function initiateBreakGlassCore( await db.collection('breakglass_events').doc(sessionId).set({ sessionId, - actorUid: actor.uid, + actor: actor.uid, action: 'initiated', reason: input.reason, + correlationId: sessionId, + createdAt: now, sessionStartedAt: now, expiresAt, schemaVersion: 1, diff --git a/functions/src/callables/declare-data-incident.ts b/functions/src/callables/declare-data-incident.ts index 45d7d303..825fa501 100644 --- a/functions/src/callables/declare-data-incident.ts +++ b/functions/src/callables/declare-data-incident.ts @@ -31,7 +31,7 @@ export async function declareDataIncidentCore( const now = Date.now() await db.runTransaction(async (tx) => { - await Promise.resolve() // Firestore transaction callback requires async + await Promise.resolve() tx.set(db.collection('data_incidents').doc(incidentId), { ...validated, incidentId, diff --git a/functions/src/callables/provincial-resources.ts b/functions/src/callables/provincial-resources.ts index f48c8335..fddda2e5 100644 --- a/functions/src/callables/provincial-resources.ts +++ b/functions/src/callables/provincial-resources.ts @@ -26,7 +26,7 @@ export async function upsertProvincialResourceCore( const existingDoc = await db.collection('provincial_resources').doc(id).get() const existingArchived = existingDoc.exists - ? (existingDoc.data() as { archived?: boolean }).archived + ? ((existingDoc.data() as { archived?: boolean }).archived ?? false) : false await db.collection('provincial_resources').doc(id).set({ diff --git a/functions/src/callables/set-retention-exempt.ts b/functions/src/callables/set-retention-exempt.ts index e7f5b4b5..2da813d9 100644 --- a/functions/src/callables/set-retention-exempt.ts +++ b/functions/src/callables/set-retention-exempt.ts @@ -1,35 +1,39 @@ import { onCall, HttpsError } from 'firebase-functions/v2/https' import { getFirestore, type Firestore } from 'firebase-admin/firestore' +import { z } from 'zod' import { requireAuth, requireMfaAuth } from './https-error.js' import { streamAuditEvent } from '../services/audit-stream.js' -const ALLOWED_COLLECTIONS = ['reports', 'report_private', 'report_ops', 'sms_inbox'] as const +const inputSchema = z.object({ + collection: z.enum(['reports', 'report_private', 'report_ops', 'sms_inbox']), + documentId: z.string().min(1), + exempt: z.boolean(), + reason: z.string().min(1), +}) export async function setRetentionExemptCore( db: Firestore, - input: { collection: string; documentId: string; exempt: boolean; reason: string }, + input: unknown, actor: { uid: string }, ): Promise { - if (!(ALLOWED_COLLECTIONS as readonly string[]).includes(input.collection)) { - throw new HttpsError('invalid-argument', 'collection_not_allowed') - } - const docRef = db.collection(input.collection).doc(input.documentId) + const parsed = inputSchema.parse(input) + const docRef = db.collection(parsed.collection).doc(parsed.documentId) const docSnap = await docRef.get() if (!docSnap.exists) { throw new HttpsError('not-found', 'document_not_found') } await docRef.update({ - retentionExempt: input.exempt, - retentionExemptReason: input.reason, + retentionExempt: parsed.exempt, + retentionExemptReason: parsed.reason, retentionExemptSetBy: actor.uid, retentionExemptSetAt: Date.now(), }) void streamAuditEvent({ eventType: 'retention_exempt_set', actorUid: actor.uid, - targetCollection: input.collection, - targetDocumentId: input.documentId, - metadata: { exempt: input.exempt }, + targetCollection: parsed.collection, + targetDocumentId: parsed.documentId, + metadata: { exempt: parsed.exempt }, occurredAt: Date.now(), }) } @@ -39,10 +43,6 @@ export const setRetentionExempt = onCall( async (request) => { const { uid } = requireAuth(request, ['superadmin']) requireMfaAuth(request) - await setRetentionExemptCore( - getFirestore(), - request.data as { collection: string; documentId: string; exempt: boolean; reason: string }, - { uid }, - ) + await setRetentionExemptCore(getFirestore(), request.data, { uid }) }, ) diff --git a/functions/src/callables/toggle-mutual-aid-visibility.ts b/functions/src/callables/toggle-mutual-aid-visibility.ts index 7d92a4dd..36ffbcb6 100644 --- a/functions/src/callables/toggle-mutual-aid-visibility.ts +++ b/functions/src/callables/toggle-mutual-aid-visibility.ts @@ -1,27 +1,34 @@ import { HttpsError, onCall } from 'firebase-functions/v2/https' import { getFirestore, type Firestore } from 'firebase-admin/firestore' +import { z } from 'zod' import { requireAuth } from './https-error.js' import { streamAuditEvent } from '../services/audit-stream.js' +const inputSchema = z.object({ + agencyId: z.string().min(1), + visible: z.boolean(), +}) + export async function toggleMutualAidVisibilityCore( db: Firestore, - input: { agencyId: string; visible: boolean }, + input: unknown, actor: { uid: string }, ): Promise { - const agencyRef = db.collection('agencies').doc(input.agencyId) + const parsed = inputSchema.parse(input) + const agencyRef = db.collection('agencies').doc(parsed.agencyId) const agencyDoc = await agencyRef.get() if (!agencyDoc.exists) { throw new HttpsError('not-found', 'agency_not_found') } await agencyRef.update({ - mutualAidVisible: input.visible, + mutualAidVisible: parsed.visible, }) void streamAuditEvent({ eventType: 'mutual_aid_visibility_toggled', actorUid: actor.uid, targetCollection: 'agencies', - targetDocumentId: input.agencyId, - metadata: { visible: input.visible }, + targetDocumentId: parsed.agencyId, + metadata: { visible: parsed.visible }, occurredAt: Date.now(), }) } @@ -30,10 +37,6 @@ export const toggleMutualAidVisibility = onCall( { region: 'asia-southeast1', enforceAppCheck: true }, async (request) => { const { uid } = requireAuth(request, ['superadmin', 'pdrrmo']) - await toggleMutualAidVisibilityCore( - getFirestore(), - request.data as { agencyId: string; visible: boolean }, - { uid }, - ) + await toggleMutualAidVisibilityCore(getFirestore(), request.data, { uid }) }, ) diff --git a/functions/src/triggers/audit-export-health-check.ts b/functions/src/triggers/audit-export-health-check.ts index f2c9bee4..ca88b1c0 100644 --- a/functions/src/triggers/audit-export-health-check.ts +++ b/functions/src/triggers/audit-export-health-check.ts @@ -58,8 +58,12 @@ export const auditExportHealthCheck = onSchedule( body: `Streaming gap: ${String(streamingGapSeconds)}s · Batch gap: ${String(batchGapSeconds)}s`, }, }) - } catch { - /* non-critical */ + } catch (err) { + console.error('[audit-export-health-check] failed to send FCM alert', { + streamingGapSeconds, + batchGapSeconds, + message: err instanceof Error ? err.message : String(err), + }) } } }, From ee7c4326b5c002c75ab3db1aa147e449ca4fbac8 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 27 Apr 2026 21:43:44 +0800 Subject: [PATCH 21/26] fix(break-glass): add MFA requirement to deactivateBreakGlass for security consistency --- functions/src/callables/break-glass.ts | 1 + functions/src/callables/declare-data-incident.ts | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/functions/src/callables/break-glass.ts b/functions/src/callables/break-glass.ts index 2e5850ce..5484714a 100644 --- a/functions/src/callables/break-glass.ts +++ b/functions/src/callables/break-glass.ts @@ -142,6 +142,7 @@ export const deactivateBreakGlass = onCall( { region: 'asia-southeast1', enforceAppCheck: true }, async (request) => { const { uid, claims } = requireAuth(request, ['superadmin']) + requireMfaAuth(request) await deactivateBreakGlassCore(getFirestore(), getAuth(), { uid, claims }) }, ) diff --git a/functions/src/callables/declare-data-incident.ts b/functions/src/callables/declare-data-incident.ts index 825fa501..bf10e46e 100644 --- a/functions/src/callables/declare-data-incident.ts +++ b/functions/src/callables/declare-data-incident.ts @@ -16,7 +16,7 @@ const dataIncidentInputSchema = z.object({ severity: z.enum(['critical', 'high', 'medium', 'low']), affectedCollections: z.array(z.string().min(1)), affectedDataClasses: z.array(z.string().min(1)), - estimatedAffectedSubjects: z.number().int().optional(), + estimatedAffectedSubjects: z.number().int().nonnegative().optional(), summary: z.string().min(1).max(2000), }) From 88afba18ac603d8e8ce1a05b916a967355322e7f Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 27 Apr 2026 21:44:08 +0800 Subject: [PATCH 22/26] fix(approve-erasure-request): safeParse with HttpsError + atomic transaction for review --- .../src/pages/MassAlertModal.tsx | 71 +- ...mark-dispatch-unable-to-complete.test.d.ts | 2 + ...-dispatch-unable-to-complete.test.d.ts.map | 1 + .../mark-dispatch-unable-to-complete.test.js | 225 +++++ ...rk-dispatch-unable-to-complete.test.js.map | 1 + .../callables/request-backup.test.d.ts | 2 + .../callables/request-backup.test.d.ts.map | 1 + .../callables/request-backup.test.js | 212 +++++ .../callables/request-backup.test.js.map | 1 + .../responder-shift-handoff.test.d.ts | 2 + .../responder-shift-handoff.test.d.ts.map | 1 + .../callables/responder-shift-handoff.test.js | 171 ++++ .../responder-shift-handoff.test.js.map | 1 + ...ubmit-responder-witnessed-report.test.d.ts | 2 + ...t-responder-witnessed-report.test.d.ts.map | 1 + .../submit-responder-witnessed-report.test.js | 240 ++++++ ...mit-responder-witnessed-report.test.js.map | 1 + .../__tests__/callables/trigger-sos.test.d.ts | 2 + .../callables/trigger-sos.test.d.ts.map | 1 + .../__tests__/callables/trigger-sos.test.js | 158 ++++ .../callables/trigger-sos.test.js.map | 1 + .../project-responder-locations.test.d.ts | 2 + .../project-responder-locations.test.d.ts.map | 1 + .../project-responder-locations.test.js | 143 ++++ .../project-responder-locations.test.js.map | 1 + .../callables/approve-erasure-request.d.ts | 10 + .../approve-erasure-request.d.ts.map | 1 + .../lib/callables/approve-erasure-request.js | 30 + .../callables/approve-erasure-request.js.map | 1 + functions/lib/callables/break-glass.d.ts | 22 + functions/lib/callables/break-glass.d.ts.map | 1 + functions/lib/callables/break-glass.js | 99 +++ functions/lib/callables/break-glass.js.map | 1 + .../lib/callables/declare-data-incident.d.ts | 10 + .../callables/declare-data-incident.d.ts.map | 1 + .../lib/callables/declare-data-incident.js | 59 ++ .../callables/declare-data-incident.js.map | 1 + .../lib/callables/declare-emergency.d.ts | 10 + .../lib/callables/declare-emergency.d.ts.map | 1 + functions/lib/callables/declare-emergency.js | 46 ++ .../lib/callables/declare-emergency.js.map | 1 + .../mark-dispatch-unable-to-complete.d.ts | 29 + .../mark-dispatch-unable-to-complete.d.ts.map | 1 + .../mark-dispatch-unable-to-complete.js | 127 +++ .../mark-dispatch-unable-to-complete.js.map | 1 + .../lib/callables/provincial-resources.d.ts | 16 + .../callables/provincial-resources.d.ts.map | 1 + .../lib/callables/provincial-resources.js | 64 ++ .../lib/callables/provincial-resources.js.map | 1 + .../record-incident-response-event.d.ts | 10 + .../record-incident-response-event.d.ts.map | 1 + .../record-incident-response-event.js | 65 ++ .../record-incident-response-event.js.map | 1 + functions/lib/callables/request-backup.d.ts | 29 + .../lib/callables/request-backup.d.ts.map | 1 + functions/lib/callables/request-backup.js | 112 +++ functions/lib/callables/request-backup.js.map | 1 + functions/lib/callables/responder-roster.d.ts | 31 + .../lib/callables/responder-roster.d.ts.map | 1 + functions/lib/callables/responder-roster.js | 237 ++++++ .../lib/callables/responder-roster.js.map | 1 + .../callables/responder-shift-handoff.d.ts | 38 + .../responder-shift-handoff.d.ts.map | 1 + .../lib/callables/responder-shift-handoff.js | 261 ++++++ .../callables/responder-shift-handoff.js.map | 1 + .../lib/callables/set-retention-exempt.d.ts | 11 + .../callables/set-retention-exempt.d.ts.map | 1 + .../lib/callables/set-retention-exempt.js | 30 + .../lib/callables/set-retention-exempt.js.map | 1 + .../submit-responder-witnessed-report.d.ts | 59 ++ ...submit-responder-witnessed-report.d.ts.map | 1 + .../submit-responder-witnessed-report.js | 192 +++++ .../submit-responder-witnessed-report.js.map | 1 + .../toggle-mutual-aid-visibility.d.ts | 9 + .../toggle-mutual-aid-visibility.d.ts.map | 1 + .../callables/toggle-mutual-aid-visibility.js | 22 + .../toggle-mutual-aid-visibility.js.map | 1 + functions/lib/callables/trigger-sos.d.ts | 25 + functions/lib/callables/trigger-sos.d.ts.map | 1 + functions/lib/callables/trigger-sos.js | 90 ++ functions/lib/callables/trigger-sos.js.map | 1 + .../project-responder-locations.d.ts | 16 + .../project-responder-locations.d.ts.map | 1 + .../scheduled/project-responder-locations.js | 102 +++ .../project-responder-locations.js.map | 1 + functions/lib/services/audit-stream.d.ts | 18 + functions/lib/services/audit-stream.d.ts.map | 1 + functions/lib/services/audit-stream.js | 19 + functions/lib/services/audit-stream.js.map | 1 + .../lib/triggers/audit-export-batch.d.ts | 2 + .../lib/triggers/audit-export-batch.d.ts.map | 1 + functions/lib/triggers/audit-export-batch.js | 25 + .../lib/triggers/audit-export-batch.js.map | 1 + .../triggers/audit-export-health-check.d.ts | 2 + .../audit-export-health-check.d.ts.map | 1 + .../lib/triggers/audit-export-health-check.js | 49 ++ .../triggers/audit-export-health-check.js.map | 1 + .../sweep-expired-break-glass-sessions.d.ts | 2 + ...weep-expired-break-glass-sessions.d.ts.map | 1 + .../sweep-expired-break-glass-sessions.js | 41 + .../sweep-expired-break-glass-sessions.js.map | 1 + .../src/callables/approve-erasure-request.ts | 53 +- .../src/callables/provincial-resources.ts | 44 +- .../callables/toggle-mutual-aid-visibility.ts | 14 +- packages/shared-validators/lib/index.d.ts | 10 +- packages/shared-validators/lib/index.d.ts.map | 2 +- packages/shared-validators/lib/index.js.map | 2 +- .../shared-validators/lib/reports.test.js | 7 - .../shared-validators/lib/reports.test.js.map | 2 +- .../shared-validators/lib/responders.d.ts | 24 - .../shared-validators/lib/responders.d.ts.map | 2 +- packages/shared-validators/lib/responders.js | 16 +- .../shared-validators/lib/responders.js.map | 2 +- .../lib/responders.test.js.map | 2 +- .../lib/sms-templates.d.ts.map | 2 +- .../shared-validators/lib/sms-templates.js | 26 +- .../lib/sms-templates.js.map | 2 +- pnpm-lock.yaml | 772 ++---------------- 118 files changed, 3389 insertions(+), 894 deletions(-) create mode 100644 functions/lib/__tests__/callables/mark-dispatch-unable-to-complete.test.d.ts create mode 100644 functions/lib/__tests__/callables/mark-dispatch-unable-to-complete.test.d.ts.map create mode 100644 functions/lib/__tests__/callables/mark-dispatch-unable-to-complete.test.js create mode 100644 functions/lib/__tests__/callables/mark-dispatch-unable-to-complete.test.js.map create mode 100644 functions/lib/__tests__/callables/request-backup.test.d.ts create mode 100644 functions/lib/__tests__/callables/request-backup.test.d.ts.map create mode 100644 functions/lib/__tests__/callables/request-backup.test.js create mode 100644 functions/lib/__tests__/callables/request-backup.test.js.map create mode 100644 functions/lib/__tests__/callables/responder-shift-handoff.test.d.ts create mode 100644 functions/lib/__tests__/callables/responder-shift-handoff.test.d.ts.map create mode 100644 functions/lib/__tests__/callables/responder-shift-handoff.test.js create mode 100644 functions/lib/__tests__/callables/responder-shift-handoff.test.js.map create mode 100644 functions/lib/__tests__/callables/submit-responder-witnessed-report.test.d.ts create mode 100644 functions/lib/__tests__/callables/submit-responder-witnessed-report.test.d.ts.map create mode 100644 functions/lib/__tests__/callables/submit-responder-witnessed-report.test.js create mode 100644 functions/lib/__tests__/callables/submit-responder-witnessed-report.test.js.map create mode 100644 functions/lib/__tests__/callables/trigger-sos.test.d.ts create mode 100644 functions/lib/__tests__/callables/trigger-sos.test.d.ts.map create mode 100644 functions/lib/__tests__/callables/trigger-sos.test.js create mode 100644 functions/lib/__tests__/callables/trigger-sos.test.js.map create mode 100644 functions/lib/__tests__/scheduled/project-responder-locations.test.d.ts create mode 100644 functions/lib/__tests__/scheduled/project-responder-locations.test.d.ts.map create mode 100644 functions/lib/__tests__/scheduled/project-responder-locations.test.js create mode 100644 functions/lib/__tests__/scheduled/project-responder-locations.test.js.map create mode 100644 functions/lib/callables/approve-erasure-request.d.ts create mode 100644 functions/lib/callables/approve-erasure-request.d.ts.map create mode 100644 functions/lib/callables/approve-erasure-request.js create mode 100644 functions/lib/callables/approve-erasure-request.js.map create mode 100644 functions/lib/callables/break-glass.d.ts create mode 100644 functions/lib/callables/break-glass.d.ts.map create mode 100644 functions/lib/callables/break-glass.js create mode 100644 functions/lib/callables/break-glass.js.map create mode 100644 functions/lib/callables/declare-data-incident.d.ts create mode 100644 functions/lib/callables/declare-data-incident.d.ts.map create mode 100644 functions/lib/callables/declare-data-incident.js create mode 100644 functions/lib/callables/declare-data-incident.js.map create mode 100644 functions/lib/callables/declare-emergency.d.ts create mode 100644 functions/lib/callables/declare-emergency.d.ts.map create mode 100644 functions/lib/callables/declare-emergency.js create mode 100644 functions/lib/callables/declare-emergency.js.map create mode 100644 functions/lib/callables/mark-dispatch-unable-to-complete.d.ts create mode 100644 functions/lib/callables/mark-dispatch-unable-to-complete.d.ts.map create mode 100644 functions/lib/callables/mark-dispatch-unable-to-complete.js create mode 100644 functions/lib/callables/mark-dispatch-unable-to-complete.js.map create mode 100644 functions/lib/callables/provincial-resources.d.ts create mode 100644 functions/lib/callables/provincial-resources.d.ts.map create mode 100644 functions/lib/callables/provincial-resources.js create mode 100644 functions/lib/callables/provincial-resources.js.map create mode 100644 functions/lib/callables/record-incident-response-event.d.ts create mode 100644 functions/lib/callables/record-incident-response-event.d.ts.map create mode 100644 functions/lib/callables/record-incident-response-event.js create mode 100644 functions/lib/callables/record-incident-response-event.js.map create mode 100644 functions/lib/callables/request-backup.d.ts create mode 100644 functions/lib/callables/request-backup.d.ts.map create mode 100644 functions/lib/callables/request-backup.js create mode 100644 functions/lib/callables/request-backup.js.map create mode 100644 functions/lib/callables/responder-roster.d.ts create mode 100644 functions/lib/callables/responder-roster.d.ts.map create mode 100644 functions/lib/callables/responder-roster.js create mode 100644 functions/lib/callables/responder-roster.js.map create mode 100644 functions/lib/callables/responder-shift-handoff.d.ts create mode 100644 functions/lib/callables/responder-shift-handoff.d.ts.map create mode 100644 functions/lib/callables/responder-shift-handoff.js create mode 100644 functions/lib/callables/responder-shift-handoff.js.map create mode 100644 functions/lib/callables/set-retention-exempt.d.ts create mode 100644 functions/lib/callables/set-retention-exempt.d.ts.map create mode 100644 functions/lib/callables/set-retention-exempt.js create mode 100644 functions/lib/callables/set-retention-exempt.js.map create mode 100644 functions/lib/callables/submit-responder-witnessed-report.d.ts create mode 100644 functions/lib/callables/submit-responder-witnessed-report.d.ts.map create mode 100644 functions/lib/callables/submit-responder-witnessed-report.js create mode 100644 functions/lib/callables/submit-responder-witnessed-report.js.map create mode 100644 functions/lib/callables/toggle-mutual-aid-visibility.d.ts create mode 100644 functions/lib/callables/toggle-mutual-aid-visibility.d.ts.map create mode 100644 functions/lib/callables/toggle-mutual-aid-visibility.js create mode 100644 functions/lib/callables/toggle-mutual-aid-visibility.js.map create mode 100644 functions/lib/callables/trigger-sos.d.ts create mode 100644 functions/lib/callables/trigger-sos.d.ts.map create mode 100644 functions/lib/callables/trigger-sos.js create mode 100644 functions/lib/callables/trigger-sos.js.map create mode 100644 functions/lib/scheduled/project-responder-locations.d.ts create mode 100644 functions/lib/scheduled/project-responder-locations.d.ts.map create mode 100644 functions/lib/scheduled/project-responder-locations.js create mode 100644 functions/lib/scheduled/project-responder-locations.js.map create mode 100644 functions/lib/services/audit-stream.d.ts create mode 100644 functions/lib/services/audit-stream.d.ts.map create mode 100644 functions/lib/services/audit-stream.js create mode 100644 functions/lib/services/audit-stream.js.map create mode 100644 functions/lib/triggers/audit-export-batch.d.ts create mode 100644 functions/lib/triggers/audit-export-batch.d.ts.map create mode 100644 functions/lib/triggers/audit-export-batch.js create mode 100644 functions/lib/triggers/audit-export-batch.js.map create mode 100644 functions/lib/triggers/audit-export-health-check.d.ts create mode 100644 functions/lib/triggers/audit-export-health-check.d.ts.map create mode 100644 functions/lib/triggers/audit-export-health-check.js create mode 100644 functions/lib/triggers/audit-export-health-check.js.map create mode 100644 functions/lib/triggers/sweep-expired-break-glass-sessions.d.ts create mode 100644 functions/lib/triggers/sweep-expired-break-glass-sessions.d.ts.map create mode 100644 functions/lib/triggers/sweep-expired-break-glass-sessions.js create mode 100644 functions/lib/triggers/sweep-expired-break-glass-sessions.js.map diff --git a/apps/admin-desktop/src/pages/MassAlertModal.tsx b/apps/admin-desktop/src/pages/MassAlertModal.tsx index 53511bef..6d11eda1 100644 --- a/apps/admin-desktop/src/pages/MassAlertModal.tsx +++ b/apps/admin-desktop/src/pages/MassAlertModal.tsx @@ -1,4 +1,4 @@ -import { useRef, useState } from 'react' +import { useState } from 'react' import { detectEncoding } from '@bantayog/shared-validators' import { callables } from '../services/callables' @@ -16,33 +16,21 @@ interface Props { } export function MassAlertModal({ municipalityId, onClose }: Props) { - const sendKeyRef = useRef(crypto.randomUUID()) - const escalateKeyRef = useRef(crypto.randomUUID()) const [message, setMessage] = useState('') const [reachPlan, setReachPlan] = useState(null) const [loading, setLoading] = useState(false) const [error, setError] = useState(null) - const [pagasaSignalRef, setPagasaSignalRef] = useState('') - const [notes, setNotes] = useState('') - - const normalizedMessage = message.trim() - const normalizedPagasaSignalRef = pagasaSignalRef.trim() - const normalizedNotes = notes.trim() const encoding = message ? detectEncoding(message).encoding : 'GSM-7' const handlePreview = () => { - if (!normalizedMessage) { - setError('Message cannot be empty') - return - } - setError(null) setLoading(true) + setError(null) void (async () => { try { const plan = await callables.massAlertReachPlanPreview({ targetScope: { municipalityIds: [municipalityId] }, - message: normalizedMessage, + message, }) setReachPlan(plan) } catch (err: unknown) { @@ -58,15 +46,14 @@ export function MassAlertModal({ municipalityId, onClose }: Props) { setError('Direct send is not available for this alert scope') return } - setError(null) setLoading(true) void (async () => { try { await callables.sendMassAlert({ reachPlan, - message: normalizedMessage, + message, targetScope: { municipalityIds: [municipalityId] }, - idempotencyKey: sendKeyRef.current, + idempotencyKey: crypto.randomUUID(), }) onClose() } catch (err: unknown) { @@ -78,23 +65,14 @@ export function MassAlertModal({ municipalityId, onClose }: Props) { } const handleEscalate = () => { - if (normalizedNotes.length > 2000) { - setError('Notes must be 2000 characters or fewer') - return - } - setError(null) setLoading(true) void (async () => { try { await callables.requestMassAlertEscalation({ - message: normalizedMessage, + message, targetScope: { municipalityIds: [municipalityId] }, - evidencePack: { - linkedReportIds: [], - ...(normalizedPagasaSignalRef ? { pagasaSignalRef: normalizedPagasaSignalRef } : {}), - ...(normalizedNotes ? { notes: normalizedNotes } : {}), - }, - idempotencyKey: escalateKeyRef.current, + evidencePack: { linkedReportIds: [] }, + idempotencyKey: crypto.randomUUID(), }) onClose() } catch (err: unknown) { @@ -120,8 +98,6 @@ export function MassAlertModal({ municipalityId, onClose }: Props) { onChange={(e) => { setMessage(e.target.value) setReachPlan(null) - sendKeyRef.current = crypto.randomUUID() - escalateKeyRef.current = crypto.randomUUID() }} rows={4} /> @@ -130,7 +106,7 @@ export function MassAlertModal({ municipalityId, onClose }: Props) { {reachPlan?.unicodeWarning && ⚠ UCS-2 (multi-byte)} {reachPlan && <> · Segments: {reachPlan.segmentCount}}

- {reachPlan && ( @@ -153,32 +129,9 @@ export function MassAlertModal({ municipalityId, onClose }: Props) { Send Alert {reachPlan?.route === 'ndrrmc_escalation' && ( - <> - - { - setPagasaSignalRef(e.target.value) - escalateKeyRef.current = crypto.randomUUID() - }} - /> - -