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
Identity verification runs through a single Rarimo/RariMe lane. See
docs/identity-architecture-current.md for the full picture. The Self Protocol path,
the Semaphore commitment-attach step, and the device-attestation humanity seal were all
retired — see docs/superpowers/plans/2026-08-31-foundation-next-web-identity-consolidation.md.
startL2Verification
callable · requireAuth-equivalent (local requireUid guard) · AppCheck off (carve-out) · functions/founders/passport.js:109
Founders-campaign passport verification (the "L2" lane), gated behind the Founders feature flag (assertFoundersEnabled()). Requests a RariMe verification link from the self-hosted verificator-svc and returns the deep link for QR rendering; the caller polls getL2VerificationStatus (functions/founders/passport.js:159) until it reports verified, which claims the passport nullifier via mintPohRoot (functions/lib/poh-root.js) and creates/upgrades the Founders member record. No passport data ever reaches Foundation servers — the proof is generated on-device in RariMe. mode: "switch" (legacy "add"/"replace" values still accepted) retires the caller's other passport credentials rather than adding a second one.
Input: { mode?: "switch" | "add" | "replace" }
Output: { status: "already_verified_l2", memberNumber } | { status: "request_created", deepLink, getProofParamsUrl }
castAnonymousVote
onRequest (HTTP) · unauthenticated by design · n/a · functions/anonymous-vote.js:216
Second half of the per-proposal Rarimo anonymous-vote gate — explicitly replaces the retired getSemaphoreGroup + verifyAnonymousVote pair (see the file header comment). Deliberately unauthenticated: an auth token here would re-link the voter to the vote. Polls the caller-supplied session's RariMe proof; once verified, claims the per-proposal nullifier (functions/lib/proposal-nullifiers.js, create-only, tombstoned on reuse) and increments the proposal's vote counters. The eligibility check (PoH root admission, tenant match, abuse flag, tier ≥ proposal's minTier) runs earlier, in the authenticated startAnonymousVoteProof (functions/anonymous-vote.js:181), which deliberately does not persist the caller's uid — the session doc holds only { proposalId, eventId, status, createdAt } — so nothing stored links the caller to the nullifier claimed here.
Input (HTTP body): { proposalId: string, optionId: string, sessionId: string }
Output: { status: "voted", proposalId } (200) · { status: "pending" } (202) · { error: "Session does not belong to this proposal" } (403) · { error: "Already voted on this proposal" } (409)
createPoll
callable · requireAuth-equivalent (local requireUid guard) · AppCheck off (carve-out) · functions/polls.js:574
Creates a public poll. Requires a verified Rarimo nullifier from a poll-creation-scoped verification session (see startPollVerification/getPollVerificationStatus below) — not merely spam prevention: the on-chain Poll PDA seeds on [b"poll", title.as_bytes(), creator_nullifier_hash], so a constant or client-supplied nullifier would let unrelated creators collide on the same PDA. Session check, duplicate-title check, poll-doc create, and session burn all run inside one Firestore transaction. Enqueues the on-chain mint via Cloud Tasks.
Input: { title: string (≤32 bytes UTF-8), description: string (≤512 bytes UTF-8), options: string[] (2-10 items, each ≤64 bytes UTF-8), sessionId: string }
Output: { pollId: string }
startPollVerification
callable · requireAuth-equivalent (local requireUid guard) · AppCheck off (carve-out) · functions/polls.js:646
Mints a fresh, random session id and requests a RariMe verification link scoped to it — used for both poll creation (pollId set to the POLL_CREATION_VERIFICATION_SCOPE sentinel) and per-poll voting. A session id, not the caller's real uid, is sent to verificator-svc: the provider's readback endpoints take only an identity string with no event scoping, so a real uid shared across scopes (e.g. Founders registration) could otherwise satisfy an unrelated poll's check.
Input: { pollId: string } — a real poll id, or the poll-creation sentinel
Output: { sessionId: string, deepLink: string, getProofParamsUrl: string }
getPollVerificationStatus
callable · requireAuth-equivalent (local requireUid guard) · AppCheck off (carve-out) · functions/polls.js:685
Read-only status poll for a startPollVerification session. Once RariMe reports verified, returns the nullifier hash for UX convenience only — createPoll/castPollVote never trust this value; they re-derive it themselves via the session id.
Input: { sessionId: string }
Output: { status: "pending" | "verified" | ... , nullifierHashHex?: string }
castPollVote
callable · requireAuth-equivalent (local requireUid guard) · AppCheck off (carve-out) · functions/polls.js:796
Records a vote on a poll. Same session-id indirection as createPoll: the nullifier is never accepted from the client and never derived from the caller's real uid. Vote dedup is a Firestore create() collision on {pollId}_{nullifierHashHex} (not the session), so a session-burn race can produce at most a redundant write, never a double vote. Enqueues the on-chain mirror via Cloud Tasks.
Input: { pollId: string, optionIndex: number, sessionId: string }
Output: { status: "recorded", voteId: string }
queryIdentityClaims
callable · requireAuth + verified-member check · AppCheck off (carve-out) · functions/attestations.js:280
Returns the identity_claims/{walletAddress} record (citizenship, sex, age-threshold, document validity) for a given wallet. Same auth posture as queryAttestations: authenticated and verified-member only, since this is a cross-user lookup of data more sensitive than a VOTED/SUPPORTED_PROPOSAL attestation.
Input: { walletAddress: string }
Output: { claims: { citizenship, sex, ageAtLeastYears, documentNotExpired, status, onChainAddress } | null }
resetAttestationsByType
callable · requireRing(PLATFORM_OWNER) · AppCheck off (carve-out) · functions/attestations.js:361
Alpha/beta testing reset: batch-deletes soulbound attestation Firestore docs for the given types (1 = VOTED, 2 = SUPPORTED_PROPOSAL; type 0 = VERIFIED_HUMAN is explicitly rejected — resetting humanity proofs is a separate, more restricted path). Deletes only the Firestore mirror; on-chain PDAs are not closed. Ring 0 only — the attestations collection is not tenant-scoped, so a TENANT_ADMIN reset would wipe attestations across every tenant.
Input: { types: number[] } — subset of [0, 1, 2, 3] (0 is rejected)
Output: { deleted: number, byType: Record<string, number> }
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 }
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[] }
Pillar 3 — Commerce agents (Your Market bulk-buy)
A second write path on the same pillar: verified members commit qty + max unit price into market_pools (demand, not a charge); policy auto-RFQs known suppliers at ≥ 80% fill; host Place order charges. Does not replace the vote-on-bids callables above. Architecture: Your Market architecture — current. Client guide: Your Market API guide. Source: functions/market.js.
Auth on every demand line is requireAuth + requireVerifiedMember — the same sybil gate as Voice writes. Source/ops callables also require requireRing(TENANT_ADMIN).
runMarketAgent
callable · requireAuth + requireVerifiedMember (agent source also requireRing(TENANT_ADMIN)) · AppCheck off (carve-out) · functions/market.js
Standard agent loop (Pool or Source). Skills load as tool results. UI is a presentation tool (present_pools / present_checkout / present_quotes / present_review_queue); the server fills records from session-issued IDs. No charge / capture_payment tool exists — unknown tool names are returned in unknownTools and do nothing.
Input: { agent?: "pool" | "source", message: string, history?: Array<{ role: "user" | "assistant", content: string }> }
Output: { lastUi: { name, records, layout }, unknownTools: string[], aggregate, search }
commitToMarketPool
callable · requireAuth + requireVerifiedMember · AppCheck off (carve-out) · functions/market.js
Host Join control. Writes one demand line (qty + maxUnitPrice) inside a per-pool transaction, then evaluates policy in-band (RFQ / auto-award / review queue). Caps apply to the resulting qty on that member's line (qty-cap → resource-exhausted).
Input: { poolId: string, qty: number, maxUnitPrice: number }
Output: { ok: true, commitmentId: string, orderId: string | null }
placeMarketOrder
callable · requireAuth + requireVerifiedMember · AppCheck off (carve-out) · functions/market.js
Host Place order. The model cannot call this. Requires a payment nonce. Marks the allocated checkout order paid.
Input: { orderId: string, nonce: string }
Output: { ok: true, orderId: string, paymentRef: string }
applyMarketAction
callable · requireRing(TENANT_ADMIN) · AppCheck off (carve-out) · functions/market.js
Ops apply of a staged RFQ/award. Guardrails re-check at apply time against the live ceiling and rating, not the values at stage time.
Input: { actionId: string, poolId: string }
Output: { ok: true }
listMarketMemory / correctMarketMemory / deleteMarketMemory
callable · requireAuth + requireVerifiedMember · AppCheck off (carve-out) · functions/market.js
Member-visible typed facts. Extraction reads user/assistant text only (never tool results). No-op list when MARKET_MEMORY_ENABLED=false.
Input (list): { keys?: string[] } → { enabled: boolean, facts: Array<{ key, value, category }> }
Input (correct): { key: string, value: string, category?: string }
Input (delete): { key: string }
seedMarketDemo
callable · requireRing(TENANT_ADMIN) · AppCheck off (carve-out) · functions/market.js
Seeds the near-threshold olive-oil pool (olive-oil-5l at 7/10 fill, known supplier rating 4.5) so one verified join can trip RFQ → eligible award → checkout.
Input: {}
Output: { ok: true, pool, review }
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 deterministic identity_proofs nullifier (not a real Rarimo proof — see scripts/seed-demo-pool.mjs). 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. |
mintPollOnChainTask |
functions/on-chain-tasks.js:505 |
Cloud Tasks | Mints the on-chain Poll PDA for a createPoll call. |
mirrorPollVoteOnChainTask |
functions/on-chain-tasks.js:578 |
Cloud Tasks | Mirrors a castPollVote to chain; best-effort POLL_VOTED attestation after success. |
issueIdentityClaimsOnChainTask |
functions/on-chain-tasks.js:1079 |
Cloud Tasks | Mints a holder's soulbound identity_claims PDA. |
revokeIdentityClaimsOnChainTask |
functions/on-chain-tasks.js:1123 |
Cloud Tasks | Closes a holder's identity_claims PDA (GDPR Article 17 erasure follow-up). |
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.