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?.route === 'ndrrmc_escalation' && (
- <>
-
- {
- setPagasaSignalRef(e.target.value)
- escalateKeyRef.current = crypto.randomUUID()
- }}
- />
-
-