Foundation Cloud Functions Reference
Audience: Foundation engineers, security reviewers, integrators reading the source.
Source of truth: functions/*.js — when this doc disagrees with the code, trust the code and open a PR.
Scope: every exported Cloud Function in the solanavote-devnet Firebase project. Last full sweep: 2026-05-01. Pillar-2/3, wallet-admin, contracts, and population callables verified against source 2026-06-03.
All callables run in us-east1 unless noted otherwise. App Check enforcement is governed by the ENFORCE_APP_CHECK env var (functions/lib/app-check.js); per-callable overrides via enforceAppCheck: false are noted as "AppCheck off (carve-out)" — these are pre-sign-in or WKWebView paths where reCAPTCHA Enterprise tokens can't reliably attach (see Option A — App Check bridge and the project memory).
For a usage-oriented walkthrough with examples, see Foundation API guide.
How to read this reference
Each function lists, in order:
- Trigger —
callable(Firebase httpsCallable from a signed-in client),onRequest(raw HTTPS endpoint, no Firebase SDK wrapper),onDocumentCreated/onDocumentWritten(Firestore trigger),beforeUserCreated/beforeUserSignedIn(Auth blocking trigger),onTaskDispatched(Cloud Tasks queue worker),onSchedule(Cloud Scheduler). - Auth — what middleware the function applies.
requireAuthrequires any signed-in user;requireRing(N)requires the caller'sringclaim ≤ N;unauthenticatedmeans no auth check (used for sign-in flows). - App Check —
default(follows project ENFORCE_APP_CHECK),off (carve-out)with reason, orn/a(server-only triggers). - Source — file:line for the export.
- Purpose — one paragraph.
- Input / Output — JSON shape. Field types in TypeScript style. Optional fields suffixed with
?.
Auth & Invitations
The invite-only sign-in flow. A user lands on the invite landing page, requests access, an admin approves, an email-link sign-in goes out via Resend, the user clicks, Firebase blocks the sign-in if no invite exists, and the access claims (foundation/docs/yc) are stamped on first sign-in.
requestSelfAccess
callable · unauthenticated · AppCheck off (carve-out) · functions/index.js:3192
Public form on the landing page. Writes access_requests/{email} for an admin to approve later. Per-IP and per-email rate limits to deter spam.
Input: { email: string, name?: string, reason?: string, captchaToken?: string }
Output: { status: "ok" } — silent success even if email is already on file (anti-enumeration).
approveAccessRequest
callable · requireRing(TENANT_ADMIN) · AppCheck off (carve-out) · functions/index.js:3031
Admin moves an access_requests/{email} doc to invites/{email} with the chosen role. Idempotent.
Input: { email: string, role: string, tenantId?: string }
Output: { status: "ok" }
rejectAccessRequest
callable · requireRing(TENANT_ADMIN) · AppCheck off (carve-out) · functions/index.js:3051
Marks an access request rejected (no email sent — silent reject).
Input: { email: string, reason?: string }
Output: { status: "ok" }
inviteDirect
callable · requireRing(TENANT_ADMIN) · AppCheck off (carve-out) · functions/index.js:3069
Admin sends a direct invite without going through the access-request flow. Use for known users (founders, early access).
Input: { email: string, role: string, tenantId?: string }
Output: { status: "ok" }
inviteUserWithAccess
callable · requireRing(TENANT_ADMIN) · AppCheck off (carve-out) · functions/user-management.js:118
Like inviteDirect, but lets the admin pre-set per-site access flags (foundation/docs/yc) on the invite doc. Applied to the user's custom claims by applyInviteAccessClaims on first sign-in.
Input: { email: string, role: string, access: { foundation: boolean, docs: boolean, yc: boolean }, tenantId?: string }
Output: { status: "invited", email, role, access }
setAllowSelfRequest
callable · requireRing(TENANT_ADMIN) · AppCheck off (carve-out) · functions/index.js:3088
Toggle whether the public landing-page form accepts self-requests.
Input: { allowSelfRequest: boolean }
Output: { status: "ok" }
resendInviteLink
callable · unauthenticated · AppCheck off (carve-out) · functions/index.js:3313
Generates a Firebase email-link sign-in URL via Admin SDK and sends it via Resend with the Foundation-branded template. Used by the main app's invite flow and the legacy voice.foundation-global.com/finish-signin page. Rate-limited per email; tier-gated (privileged callers get higher caps). Server-allowlisted site parameter selects the continue URL. (The partners and docs sites switched to email + password sign-in on 2026-05-03 and no longer call this — see scripts/provision-partner.mjs.)
Input: { email: string, platform?: "web" | "ios", site?: "app" | "yc" | "docs" }
Output: { status: "ok", sent: boolean, reason?: "no-access" } — sent: false returned for unknown emails (anti-enumeration).
requestSignInCode
callable · unauthenticated · AppCheck off (carve-out) · functions/index.js:3603
iOS-specific OTP flow (replaces email link for iOS Foundation Mobile, where Universal Links are fragile). Generates a 6-digit code stored at signin_codes/{sha256(email)}, sends via Resend. Per-email rate-limit (3/hour).
Input: { email: string }
Output: { status: "ok", sent: boolean }
verifySignInCode
callable · unauthenticated · AppCheck off (carve-out) · functions/index.js:3694
Exchanges a valid 6-digit code for a Firebase custom token. iOS app then signInWithCustomToken. Per-code attempt limit (5).
Input: { email: string, code: string }
Output: { status: "ok", token: string }
sendInviteEmail (trigger)
onDocumentCreated invites/{email} · n/a · functions/index.js:2969
Wraps createSendInviteEmailTrigger from @plantagoai/auth. Generates a sign-in link with admin.auth().generateSignInWithEmailLink() and sends via Resend with the Foundation invite template.
checkInviteOnSignup (blocking trigger)
beforeUserCreated · n/a · functions/index.js:2983
Rejects sign-in if no invites/{email} doc exists. On accept, returns customClaims: { ring, role, tenantId } based on the invite's role.
backfillTenantClaim (blocking trigger)
beforeUserSignedIn · n/a · functions/index.js:3012
Composed of two handlers: createBackfillTenantClaimTrigger (tops up missing tenantId) + applyInviteAccessClaimsHandler (copies invites.access onto custom claims). Both return {customClaims} patches; this composer merges them so neither clobbers the other.
Identity & Proof of Humanity
Two coexisting flows: an older Self Protocol path (April users) and a newer humanity-seal path (May+ users on iOS Foundation Mobile). The bridge between them — so the desktop AccessGate sees either flow as "verified" — lives in anchorIdentityCommitmentTask.
verifyPassportProof
onRequest (HTTP, no Firebase SDK wrap) · API-key auth · n/a · functions/index.js:1250
Self Protocol mobile app POSTs a Groth16 ZK proof from an NFC ePassport scan. Server verifies, derives nullifier, writes identity_proofs/{nullifier} with commitment: "". One-passport-one-nullifier: re-using a passport for a different voterId is rejected (Sybil resistance).
Input (HTTP body): { proof, nullifier, voterId, attestationId, ... } (see Self SDK)
Output: { status: "verified", nullifier, voterId, proofType, trustTier } or { error: "NULLIFIER_ALREADY_USED" } (409).
attachSemaphoreCommitment
callable · requireAuth · AppCheck off (carve-out) · functions/index.js:1425
Desktop second half of the old PoH flow. After the iOS app writes the empty-commitment identity_proofs doc, the desktop generates a Semaphore identity locally and calls this to bind the commitment. Idempotent: same commitment → already_attached; different commitment on a bound proof → already-exists error.
Input: { commitment: string } — 0x-prefixed hex, ≤66 chars (BN254 field element).
Output: { status: "attached" | "already_attached", commitment, nullifier?, trustTier? }
anchorCommitment
callable · requireAuth · AppCheck off (carve-out) · functions/index.js:2756
New humanity-seal flow entry point (iOS Foundation Mobile). Takes a humanity-seal commitment hash + the artifacts that produced it (App Attest, liveness, ePassport, anti-spoof, face match). Server re-derives the canonical bytes and verifies the hash, then enqueues an on-chain anchor task.
Input: { commitment: { hashHex, producedAtMs, kinds }, artifacts: Artifact[], biometricSeal? }
Output: { status: "queued" | "anchored" | "anchor-failed", commitmentDocPath, ... }
anchorIdentityCommitmentTask (background task)
onTaskDispatched · n/a · functions/on-chain-tasks.js:364
Cloud Tasks worker. Anchors the humanity-seal hash to the identity_commitments Anchor program on Solana devnet, then writes back the on-chain receipt. Stamps users/{uid}.humanityVerified = true. Also writes a placeholder identity_proofs/{0x<hashHex>} doc so the desktop AccessGate (which queries by voterId) bridges from the new flow to the old gate's expectations. The desktop's auto-attach logic then fills the Semaphore commitment.
recordMobileAttestation
callable · requireAuth · AppCheck off (carve-out) · functions/index.js:2575
iOS App Attest flow. Verifies an Apple-signed attestation against a server-issued nonce. Result feeds into the humanity-seal artifacts.
Input: { nonce: string, attestation: { platform: "ios" | "android", ... } }
Output: { status: "ok", attestationId, ... }
issueAttestationNonce
callable · requireAuth · AppCheck off (carve-out) · functions/index.js:2565
Pre-attestation step. Issues a single-use nonce stored at attestation_nonces/{uid} for recordMobileAttestation to consume.
Input: {}
Output: { nonce: string, expiresAtMs: number }
getSemaphoreGroup
onRequest (HTTP) · API-key auth · n/a · functions/index.js:1553
Returns the cached Semaphore group for a tier (read-only). Used by external integrators generating ZK proofs.
Input (query): ?tier=high|medium|low
Output: Group JSON.
rebuildSemaphoreGroups (trigger)
onDocumentWritten identity_proofs/{nullifier} · n/a · functions/index.js:1612
Rebuilds the three tier-filtered Semaphore groups in semaphore_groups/{tier} whenever an identity_proofs doc changes. Caches LMT root + members for verifyAnonymousVote to look up.
verifyAnonymousVote
onRequest (HTTP) · n/a · n/a · functions/index.js:1634
Verifies a Semaphore ZK proof against a tier group + signal hash. Used by the on-chain mirror task before submitting a vote.
approveManualReview
callable · requireRing(TENANT_ADMIN) · AppCheck off (carve-out) · functions/index.js:1779
Admin approves a manual_review_requests/{id} (Phase-3 fallback for users who can't NFC-scan an ePassport). Writes a low-trust identity_proofs doc.
flagUserAbuse / unflagUserAbuse
callable · requireRing(TENANT_ADMIN) · AppCheck off (carve-out) · functions/index.js:3932,3962
Adds/removes a voterId from abuse_registry/{voterId}. A flagged voter cannot re-enroll under a new nullifier.
Voting & Governance
The on-chain Anchor program is the source of truth; Firestore is a read-mirror plus the off-chain rich state (titles, descriptions, AI-generated drafts).
createVoterAccount
callable · requireAuth · AppCheck off (carve-out) · functions/index.js:2150
Creates the per-user Solana voter PDA on devnet. Lazy-funded from the API wallet.
Input: {}
Output: { voterPda: string, signature: string }
createProposal
callable · requireAuth · AppCheck off (carve-out) · functions/index.js:2240
Creates a proposal: writes Firestore doc, enqueues mintProposalOnChainTask to mint the on-chain proposal account.
Input: { title, description, votingDurationHours?, ... }
Output: { proposalId, status: "queued" }
castVote
callable · requireAuth · AppCheck off (carve-out) · functions/index.js:2338
Records a vote in Firestore + enqueues mirrorVoteOnChainTask to mirror to the Anchor program.
Input: { proposalId: string, choice: "yes" | "no" | "abstain", weight?: number }
Output: { status: "queued" }
submitSupport
callable · requireAuth · AppCheck off (carve-out) · functions/index.js:2397
For supporter-signature proposals — submit a non-binding endorsement that counts toward an activation threshold.
Input: { proposalId: string }
Output: { status: "queued" }
activateProposal
callable · requireAuth · AppCheck off (carve-out) · functions/index.js:2458
Promotes a proposal from "supporter signatures" phase to "voting" phase once the threshold is met.
Input: { proposalId: string }
Output: { status: "ok" }
evaluateProposal
callable · requireAuth · AppCheck off (carve-out) · functions/index.js:824
Evaluates a proposal against a community constitution via Claude (Gemini fallback), with prompt caching. Per-uid rate limit (20/hour, 60/day shared with generateProposalDraft via ai_generation_usage/{uid}).
Input: { proposalTitle: string, proposalDescription: string, constitution: { preamble: string, principles: Array<{ title, description, weight }> } }
Output: { overallScore: number, status: "compliant" | "concern" | "violation", violations: [...], reasoning: string, evaluatedAt, _provider, _tokens }
generateProposalDraft
callable · requireAuth · AppCheck off (carve-out) · functions/index.js:1117
AI-drafted description for any pillar from a minimal seed. Dispatches to a pillar-specific drafting style (1 = governance, 2 = grant/allocation, 3 = product/RFQ). Gemini fallback; same per-uid rate limit as evaluateProposal.
Input: { title: string, pillar: 1 | 2 | 3, category?, location?, scope?, threshold?, amount?, quantity?, unit? }
Output: { description: string, _provider, _tokens }
createProposalDraft
callable · requireAuth · AppCheck off (carve-out) · functions/proposal-voting.js:1156
Phase-2 server-authoritative proposal create (the post-Rocket path; distinct from the older createProposal). Strict allowlist validation, writes proposals/{uuid} with options keyed by id, stamps proposer_uid + tenant_id from the auth context, and best-effort enqueues mintProposalOnChainTask.
Input: { title, description, category, options: Array<{ label, description }>, geographicScope, location, supportThreshold, totalEligibleVoters, votingDurationHours, tags, author, authorWallet, placeId?, coordinates?, ageMin?, ageMax?, governanceTemplateId?, governanceConfig?, pillar? }
Output: { status: "created", proposalId, proposal, chainPending }
castProposalVote
callable · requireAuth · AppCheck off (carve-out) · functions/proposal-voting.js:267
Records a Pillar-1 vote in votes and increments proposal aggregates in one transaction. anonymous_hash is server-derived from the uid (client never supplies it). Proposal must be active. Enqueues mirrorVoteOnChainTask + a VOTED attestation (both non-fatal).
Input: { proposalId: string, optionId: string }
Output: { status: "recorded", voteId, anonymousHash, chainPending }
castProposalSupport
callable · requireAuth · AppCheck off (carve-out) · functions/proposal-voting.js:465
Records a supporter_signatures doc for a round0 proposal. Crossing support_threshold transitions the proposal (→ active stamps the voting window). Enqueues mirrorSupportOnChainTask + SUPPORTED_PROPOSAL attestation.
Input: { proposalId: string }
Output: { status: "recorded", signatureId, anonymousHash, transitionedTo, supportCount, chainPending }
expireVotingRounds
callable · requireRing(TENANT_ADMIN) · functions/governance-rounds.js:185
Admin-on-demand sweep: closes voting rounds whose endsAt has passed and finalizes results.
expireVotingRoundsScheduled (scheduled)
onSchedule every 10 min · n/a · functions/governance-rounds.js:235
Same logic as above, but fires on a cron so admins don't have to.
mintProposalOnChainTask / mirrorVoteOnChainTask / mirrorSupportOnChainTask (background tasks)
onTaskDispatched · n/a · functions/on-chain-tasks.js:108,179,264
Cloud Tasks workers that mirror Firestore writes to the Anchor program with retry + DLQ. 24-hour exponential backoff; on retry exhaustion the payload + last error land in chain_dlq.
mintMissingProposalTwins
callable · requireRing(TENANT_ADMIN) · AppCheck off (carve-out) · functions/index.js:2032
Admin backfill: enqueues the on-chain twin mint for any proposals doc still missing on_chain_address (e.g. seeded proposals that bypassed createProposalDraft). Idempotent.
Input: {}
Output: { enqueued: number, skipped: string[] }
Pillar 2 — Allocations (Your Share)
Server-authoritative writes for the allocations collection (functions/allocations.js). Dedicated parallel of the Pillar-1 voting path — own collections (allocations, allocation_votes, allocation_signatures), no shared implementation. Same identity conventions: tenant_id from the signed claim, server-derived anonymous_hash, in-transaction dedup, async on-chain mirror via Cloud Tasks.
createAllocation
callable · requireAuth · AppCheck off (carve-out) · functions/allocations.js:812
Creates a fund-allocation proposal. Mirrors createProposalDraft plus the P2 fields (amount, poolPct, disbursementSchedule). Strict allowlist validation; best-effort mintAllocationOnChainTask enqueue.
Input: { title, description, category, options: Array<{ label, description }>, geographicScope, location, supportThreshold, totalEligibleVoters, votingDurationHours, tags, author, authorWallet, amount, poolPct, disbursementSchedule, placeId?, coordinates?, ageMin?, ageMax?, governanceTemplateId?, governanceConfig? }
Output: { status: "created", allocationId, allocation, chainPending }
submitAllocationOption
callable · requireAuth · AppCheck off (carve-out) · functions/allocations.js:954
Crowd-sourced funding option: a member submits a candidate spending plan as an OPTION on an allocation while it is still in round0/draft (before the option set freezes at twin-mint). One submission per caller; capped at the on-chain 10-option limit; tenant fail-closed. Each option is stamped submitted_by / submitted_at / tenant_id.
Input: { allocationId: string, label: string, description: string, amount?: number }
Output: { status: "recorded", optionId, optionCount }
castAllocationVote
callable · requireAuth · AppCheck off (carve-out) · functions/allocations.js:209
Records an allocation_votes doc + increments aggregates (allocation must be active). Enqueues mirrorAllocationVoteOnChainTask + VOTED attestation.
Input: { allocationId: string, optionId: string }
Output: { status: "recorded", voteId, anonymousHash, chainPending }
castAllocationSupport
callable · requireAuth · AppCheck off (carve-out) · functions/allocations.js:402
Records an allocation_signatures doc for a round0 allocation; crossing support_threshold transitions it. Enqueues mirrorAllocationSupportOnChainTask + SUPPORTED_PROPOSAL attestation.
Input: { allocationId: string }
Output: { status: "recorded", signatureId, anonymousHash, transitionedTo, supportCount, chainPending }
adminUpdateAllocationStatus
callable · requireRing(TENANT_ADMIN) · AppCheck off (carve-out) · functions/allocations.js:968
Admin status transition. → active stamps the voting window AND mints the on-chain twin (the moment the option set freezes). Tenant-scoped.
Input: { allocationId: string, status: "draft" | "round0" | "active" | "closed" | "approved" | "rejected" }
Output: { allocationId, status, updatedBy }
mintMissingAllocationTwins
callable · requireRing(TENANT_ADMIN) · AppCheck off (carve-out) · functions/allocations.js:867
Admin backfill for allocations missing on_chain_address. Skips round0/draft (those mint on activation so crowd-sourced options stay open). Idempotent.
Input: {}
Output: { enqueued: number, skipped: string[] }
Pillar 3 — Marketplace (Your Market)
Product-request lifecycle: gathering → bidding → voting → delivered/cancelled. Bids are crowd-sourced and embedded in product_requests/{id}.bids[]; member votes select a supplier and live in product_votes.
submitProductBid
callable · requireAuth · AppCheck off (carve-out) · functions/proposal-voting.js:727
Supplier appends a bid to product_requests/{id}.bids[]. Gated on the demand threshold (demandCount >= minimumThreshold); the first bid on a gathering product opens the bidding phase. One bid per caller, deduped by supplier name, capped at 10. Tenant fail-closed.
Input: { productId: string, supplierName: string, pricePerUnit: number, retailPrice: number, unit?: string, certifications?: string[], deliveryDays?: number, sampleAvailable?: boolean }
Output: { status: "recorded", bidId, transitionedTo, bidCount }
castProductVote
callable · requireAuth · AppCheck off (carve-out) · functions/proposal-voting.js:481
Records a supplier-selection vote in product_votes (one per anonymous_hash per product). Enqueues mintProductOnChainTask + mirrorProductVoteOnChainTask + VOTED attestation (all non-fatal).
Input: { productId: string, bidId: string }
Output: { status: "recorded", anonymousHash, chainPending }
adminUpdateProductStatus
callable · requireRing(TENANT_ADMIN) · AppCheck off (carve-out) · functions/voter-profile.js:316
Admin status transition for a product request.
Input: { productId: string, status: "gathering" | "bidding" | "voting" | "delivered" | "cancelled" }
Output: { productId, status, updatedBy }
mintMissingProductTwins
callable · requireRing(TENANT_ADMIN) · AppCheck off (carve-out) · functions/proposal-voting.js:609
Admin backfill: mints on-chain twins for products with ≥ 1 bid still missing on_chain_address. Products with 0 bids are skipped (nothing to vote on). Idempotent.
Input: {}
Output: { enqueued: number, skipped: string[] }
Population & geography
Voter population codes (UN M49) and opt-in H3 geolocation, used by the population-scoped on-chain governance program (functions/population.js).
setMyNationality
callable · requireAuth · AppCheck off (carve-out) · functions/population.js:188
Self-service: the authenticated voter sets their own nationality. Validates the ISO 3166-1 code, derives [1 (global), continent, country], writes voters/{uid}.populations, best-effort syncs to the identity-registry program.
Input: { iso: string }
Output: { populations: number[] }
setMyLocation
callable · requireAuth · AppCheck off (carve-out) · functions/population.js:247
Converts a lat/lng to an H3 cell at the default resolution, appends it (dedup) to voters/{uid}.h3_cells, best-effort syncs to chain.
Input: { lat: number, lng: number }
Output: { uid, cell, h3Cells: string[] }
registerPopulation
callable · requireRing(TENANT_ADMIN) · AppCheck off (carve-out) · functions/population.js:52
Admin registers a named population code in population_registry/{code}. Idempotent for same code+id; already-exists if the code is claimed by a different id.
Input: { code: number, id: string, label: string, kind: string, calling?: string }
Output: { code, id, status: "created" | "updated" }
assignVoterPopulations
callable · requireRing(TENANT_ADMIN) · AppCheck off (carve-out) · functions/population.js:108
Admin: resolves a voter's nationality (from their latest identity_proofs jurisdiction disclosure, or the nationality arg), derives M49 codes, writes voters/{uid}.populations, best-effort on-chain sync.
Input: { uid: string, nationality?: string }
Output: { uid, populations: number[], nationalityResolved }
Tenants & Membership
createTenant
callable · requireRing(PLATFORM_OWNER) · AppCheck off (carve-out) · functions/tenants.js:36
Provision a new tenant doc. Restricted to platform owner.
Input: { slug, name, type: "pilot" | "production" | "demo", ... }
Output: { tenantId }
joinTenant
callable · requireAuth · AppCheck off (carve-out) · functions/tenants.js:122
Self-service tenant join. Writes tenant_memberships/{tenantId}_{uid}.
Input: { tenantId: string }
Output: { status: "joined" }
updateMembership
callable · requireRing(TENANT_ADMIN) · AppCheck off (carve-out) · functions/tenants.js:218
Admin updates a member's role/status within a tenant.
Input: { tenantId, voterId, role?, status? }
Output: { status: "ok" }
Demo flow
Pre-seeded 20-slot pool. Each slot has its own Firebase UID, Solana keypair, and Semaphore nullifier. See scripts/seed-demo-pool.mjs and the Foundation API guide for usage.
tryDemo
onCall · unauthenticated · AppCheck off · functions/demo-access.js:144
Atomically claims the lowest free (or stale-claimed >30 min) slot, returns a Firebase custom token for that slot's UID. Per-IP rate limit: 10/hour, 30/day.
Input: {}
Output: { token, uid, slot, tenantSlug } or resource-exhausted if pool full.
releaseDemoSlot
onCall · requireAuth (must be a demo-user-pool-* UID) · AppCheck off · functions/demo-access.js:220
Frees the caller's slot. Frontend invokes before signOut(). Best-effort — the 30-min stale recovery in tryDemo is the safety net.
Input: {}
Output: { released: true, slot } | { released: false, reason }
cleanupDemoData
callable · requireRing(TENANT_ADMIN) · functions/demo-cleanup.js:113
Wipes accumulated demo-tenant content (proposals, votes, supporter signatures) so the demo feels fresh. Slot identities are preserved — only the content is reset.
Wallet & Solana
getMyWallet
callable · requireAuth · AppCheck off (carve-out) · functions/index.js:2518
Returns the user's Solana pubkey + balance. Lazy-creates the keypair via getUserKeypair() if missing.
Input: {}
Output: { pubkey: string, balanceSol: number }
solanaClientSmokeTest
callable · requirePlatformOwner · AppCheck off (carve-out) · functions/index.js:2077
Deploy-time health check: loads keypair, opens RPC, builds Anchor program, fetches recent blockhash. No tx submitted. Used by the Phase-1 deploy gate.
Input: {}
Output: { status: "ok", blockhash, slot }
getWalletHealth
callable · requireRing(TENANT_ADMIN) · AppCheck off (carve-out) · functions/admin-wallet.js:73
Read-only health report: API-wallet balance, each program account's balance (governance / share / market / identity-registry), and up to 200 user custodial wallets. No state mutation. (Programs only need rent-exemption — healthy is balance > 0.)
Input: {}
Output: { apiWallet: { pubkey, balanceSol, threshold, healthy }, programs: [...], userWallets: [...], checkedAt }
airdropApiWallet
callable · requireRing(TENANT_ADMIN) · AppCheck off (carve-out) · functions/admin-wallet.js:151
Requests a 1-SOL devnet faucet airdrop to the API wallet. Devnet/localhost only (hard-guarded). Returns a structured { error } object (does not throw) on non-devnet RPC or faucet rate-limit/dry conditions.
Input: {}
Output: { signature, pubkey, newBalance } or { error: "devnet-only" | "faucet-unavailable", message, ... }
fundUserWallet
callable · requireRing(TENANT_ADMIN) · AppCheck off (carve-out) · functions/admin-wallet.js:210
Transfers SOL from the API wallet to a specific user's custodial wallet. Capped at MAX_FUND_SOL (1.0) per call. Only public keys are returned.
Input: { uid: string, amountSol: number } — amountSol ∈ (0, 1.0]
Output: { signature, fromPubkey, toPubkey, amountSol }
getContractsInfo
callable · requireRing(TENANT_ADMIN) · AppCheck off (carve-out) · functions/admin-contracts.js:44
Read-only: the preflight contract registry (checks + narratives) and best-effort on-chain program IDs (governance / share / market / identity). Safe to call on every admin-UI load.
Input: {}
Output: { checks: {...}, narratives: {...}, onChainConfig: { governance, share, market, identity } | { error } }
runPreflightTest
callable · requireRing(TENANT_ADMIN) · AppCheck off (carve-out) · functions/admin-contracts.js:84
Exercises a registered preflight contract against a supplied ctx so the admin UI can test contract behavior without a real on-chain operation.
Input: { operation: string, ctx: object }
Output: { passed: true } or { passed: false, failedCheck, errorCode, message }
Pairing (desktop ↔ mobile)
Desktop seats are gated by an active paired mobile session — phone is the master key. See docs/architecture_pairing-lease-pattern-2026-04-26.md.
mintWebSessionToken
callable · requireAuth · AppCheck off (carve-out) · functions/index.js:4105
Mobile mints a short-lived custom token for the desktop to swap into a Firebase session. Auth-time freshness check: token rejected if auth_time is older than the freshness window.
Input: { deviceId: string }
Output: { token: string, expiresAtMs: number }
requestPairingCode
callable · requireAuth · AppCheck off (carve-out) · functions/index.js:4179
Desktop generates a short-lived pairing code that the user types into the mobile app to bind the two sessions.
Input: {}
Output: { code: string, expiresAtMs: number }
claimPairingSession
callable · requireAuth · functions/index.js:4276
Mobile redeems the desktop's pairing code, creates the pairing_sessions/{id} doc with status paired.
heartbeatPairingSession
callable · requireAuth · functions/index.js:4297
Mobile sends a periodic heartbeat. Desktop's AccessGate uses lastHeartbeatAt + a grace window to detect mobile death client-side without waiting for the server sweep.
releasePairingSession
callable · requireAuth · functions/index.js:4327
Explicit unpair (sign-out, manual disconnect, supersede). Mobile calls this before its own signOut().
cleanupStalePairings (scheduled)
onSchedule · n/a · functions/index.js:4349
Periodic sweep: marks pairing_sessions with stale lastHeartbeatAt as expired. Belt-and-suspenders for the client-side lease check.
Account lifecycle
GDPR Article 17 (right to erasure) + Article 20 (data portability). Financial records are retained 6-10 years per Article 17(3)(b) — see @plantagoai/auth defineUserDataMap.
requestAccountDeletion
callable · requireAuth · AppCheck off (carve-out) · functions/account-deletion.js:193
Schedules a deletion 90 days from now (deletion_requests/{uid} with scheduledFor). Reversible via cancelAccountDeletion.
cancelAccountDeletion
callable · requireAuth · AppCheck off (carve-out) · functions/account-deletion.js:207
Cancel a pending deletion within the grace window.
deleteMyAccount
callable · requireAuth · AppCheck off (carve-out) · functions/account-deletion.js:133
Immediate deletion path (the user has confirmed, the grace window has elapsed, or admin override). Walks the data map: delete/anonymize/retain per collection.
exportMyData
callable · requireAuth · AppCheck off (carve-out) · functions/account-deletion.js:174
GDPR Article 20 export. Returns a JSON bundle of every Firestore doc keyed on the caller's uid + their on-chain accounts.
Admin & Ops
listUsers
callable · requireRing(TENANT_ADMIN) · AppCheck off (carve-out) · functions/user-management.js:53
Paginated admin view: returns { users, pageToken }. Each user includes claims (ring/role/access/demo/manualReview), Firebase Auth metadata, and lastSession (latest sessions/{id} doc).
Input: { pageToken?, maxResults? }
Output: { users: UserRow[], pageToken: string | null }
setUserAccess
callable · requireRing(TENANT_ADMIN) · AppCheck off (carve-out) · functions/user-management.js:118
Edit a user's claims (ring, role, per-site access map) and/or toggle disabled. Self-disable guarded.
adminResetUserHumanity
callable · requireRing(TENANT_ADMIN) · AppCheck off (carve-out) · functions/user-management.js:208
Wipes identity_commitments, clears users.humanityVerified, revokes refresh tokens. User must re-sign-in via email link and re-verify from scratch. Use for new-device, reset, or recovery scenarios — not compromised accounts.
adminManualApproveHumanity
callable · requireRing(TENANT_ADMIN) · AppCheck off (carve-out) · functions/user-management.js:307
Bypass for users who can't complete the iPhone PoH flow. Writes synthetic voters, identity_proofs, tenant_memberships, and stamps humanityVerified: true + manualReview: true claim. Surfaces in the admin UI as an orange shield.
Input: { uid: string, reason?: string, tenantId?: string }
Output: { uid, tenantId, nullifier, commitment }
adminLockoutUser
callable · requireRing(TENANT_ADMIN) · AppCheck off (carve-out) · functions/user-management.js:447
Three-layer revocation for compromised accounts: access.foundation=false, disabled=true, refresh tokens revoked. Reversible.
adminUpdateVoterStatus
callable · requireRing(TENANT_ADMIN) · functions/voter-profile.js:179
Admin edits voters/{uid} status fields (active/suspended/etc.).
registerVoterProfile / updateVoterVerification
callable · requireAuth · functions/voter-profile.js:54,121
User-facing voter-profile CRUD (display name, bio, etc.).
logSession
callable · requireAuth · AppCheck off (carve-out) · functions/user-management.js:410
Audit telemetry: every site mount calls this. Writes sessions/{id} with { uid, site, ts, userAgent }. Best-effort; client never blocks on it.
adminStatus
onRequest · admin auth · n/a · functions/admin-status.js:28
JSON status endpoint for ops dashboards. Returns project version, recent error counts, queue depth.
getOpsSummary
callable · requireRing(TENANT_ADMIN) · functions/index.js:3822
Live ops dashboard data: user counts, recent errors, DLQ depth, last deploy.
submitSupportTicket
callable · requireAuth · functions/index.js:4051
User-facing support form. Writes support_tickets/{id} for ops triage.
sendNotification
callable · requireRing(TENANT_ADMIN) · functions/index.js:1882
Admin-only fan-out: send a transactional email + in-app notification to a target user list. Wraps @plantagoai/messaging.
Legal & Consent
@plantagoai/legal is the source of truth for ToS / privacy policy text. These callables expose it to the frontend + record consent.
getTermsOfService
callable · unauthenticated · functions/legal.js:310
Returns the current ToS document for a given site (foundation, docs, yc).
Input: { site?: string }
Output: { version, contentHash, body }
getPrivacyPolicy
callable · unauthenticated · functions/legal.js:302
Same shape, returns the privacy policy.
recordTosAcceptance
callable · requireAuth · functions/legal.js:318
Writes users/{uid}/legal_consent/{documentType}_{version} with timestamp + content hash.
checkTosAcceptance
callable · requireAuth · functions/legal.js:333
Returns whether the caller has accepted a specific ToS version. The desktop's TosAcceptanceGate calls this before letting the user into the app.
getBiometricNotice / recordBiometricConsent / checkBiometricConsent
callable · functions/legal.js:364,372,394
BIPA + GDPR Article 9 (special category data) consent for face-match liveness. Surfaced before the iOS humanity-seal flow runs.
getManualReviewNotice / recordManualReviewConsent / checkManualReviewConsent
callable · functions/legal.js:424,432,447
Phase-3 consent for the human-reviewer photo-match path.
DLQ admin
When mintProposalOnChainTask / mirrorVoteOnChainTask / mirrorSupportOnChainTask exhaust retries, payload + last error land in chain_dlq. These callables let an admin triage.
listChainDlq (public read)
onRequest · API-key auth · functions/chain-dlq.js:37
Read-only list endpoint for ops dashboards.
listChainDlqAdmin
callable · requireRing(TENANT_ADMIN) · functions/chain-dlq.js:106
Same data, with admin auth, plus richer per-entry context (full payload, retry history).
retryChainDlq
callable · requireRing(TENANT_ADMIN) · functions/chain-dlq.js:163
Re-enqueues a DLQ entry to the originating queue.
deleteChainDlq
callable · requireRing(TENANT_ADMIN) · functions/chain-dlq.js:230
Removes a DLQ entry without retry (for poison messages).
Background tasks (recap)
| Function | File:line | Trigger | Purpose |
|---|---|---|---|
sendMail |
functions/index.js:317 |
Firestore mail/{id} |
Gmail SMTP fallback for transactional email (legacy; most paths now use Resend via @plantagoai/messaging). |
lookupPopulation |
functions/index.js:662 |
Firestore proposals/{proposalId} |
On proposal create, looks up total_eligible_voters for the proposal's location (Wikidata / World Bank, cached in populations/{placeId}). |
mintProposalOnChainTask |
functions/on-chain-tasks.js:108 |
Cloud Tasks | Mints proposal account on Anchor program. |
mirrorVoteOnChainTask |
functions/on-chain-tasks.js:179 |
Cloud Tasks | Mirrors a vote to chain. |
mirrorSupportOnChainTask |
functions/on-chain-tasks.js:264 |
Cloud Tasks | Mirrors a supporter signature to chain. |
anchorIdentityCommitmentTask |
functions/on-chain-tasks.js:364 |
Cloud Tasks | Anchors humanity-seal hash to chain; writes the bridge identity_proofs doc for desktop AccessGate. |
cleanupOldSessions |
functions/cleanup.js:38 |
onSchedule | Delete sessions/{id} older than retention. |
cleanupOldRequestLogs |
functions/cleanup.js:54 |
onSchedule | Trim API request logs. |
cleanupAttestationNonces |
functions/cleanup.js:68 |
onSchedule | Expire single-use attestation nonces. |
cleanupOldMail |
functions/cleanup.js:85 |
onSchedule | Trim mail/ collection (Gmail SMTP path). |
cleanupStalePairings |
functions/index.js:4349 |
onSchedule | Expire stale pairing_sessions. |
Personal site (separate Firebase Hosting target)
These are unrelated to the Foundation app — they back the dagangilat-site Hosting target.
| Function | Trigger | Auth | Source |
|---|---|---|---|
personalChat |
onRequest | none (rate-limited) | functions/personal-site.js:94 |
personalContact |
onRequest | none (rate-limited) | functions/personal-site.js:163 |
On-chain program IDs (Solana devnet)
The pillar-separated Anchor programs the chain-mirror tasks and wallet/contracts admin callables target:
| Program | Program ID |
|---|---|
| pillar1-governance | 8HfCAdu1UQAEmSN7LwXEwrpUTZycidM5BdeHsT9YbeK8 |
| pillar2-share | HGfJ1JR8FM5tpE4GBjc48mVXeVXGYiLyVMVe2QeVSyWJ |
| pillar3-market | 75nVM4vN8EvnxwxZyvVbPgtTGjLkQtS2V5ti8ohNYcCB |
| identity-registry | EgWb3fdVLp7Qon3p1UEAA3iwrbCGu4ro56MY7hrBFaoV |
| attestations | GQrFse7NiB6QdqtagGayNYwrr8zn4W4uWhji57VkKGky |
Conventions
- Region:
us-east1for all Foundation functions. (Personal site: same.) - Auth middleware:
requireAuth/requireRing(N)/requirePlatformOwnerfrom@plantagoai/auth/middleware. Refusing auth throwsHttpsError("unauthenticated"|"permission-denied", …). - Rate limiting: sliding-window counters under
*_rate/{hashedKey}collections. Read-modify-write in transactions. - Idempotency: every Firestore doc write uses deterministic doc ids (uid, email, hash) so retries collapse cleanly. Re-running anything in this list should be safe.
- Admin audit: every privilege-escalating action writes
admin_audit_log/{auto}with{ action, actorUid, targetUid?, reason?, at }. - App Check carve-outs: explicit
enforceAppCheck: falseis documented inline at each call site with the reason. The general "WKWebView can't fetch reCAPTCHA Enterprise tokens" comment indicates the Option A bridge is the planned fix.