Foundation API Guide
Audience: frontend engineers and external integrators building on Foundation. Coverage: the subset of Cloud Functions you actually call from a client (sign-in, demo, identity, voting, tenants, account, legal, pairing, support).
For an exhaustive catalog of every CF including server-only triggers and background tasks, see the Cloud Functions Reference.
Setup
Foundation exposes its backend as Firebase callable Cloud Functions in the solanavote-devnet project, region us-east1. From a web client:
import { initializeApp } from 'firebase/app';
import { getFunctions, httpsCallable } from 'firebase/functions';
const app = initializeApp({
apiKey: '…',
authDomain: 'solanavote-devnet.firebaseapp.com',
projectId: 'solanavote-devnet',
// …
});
const fns = getFunctions(app, 'us-east1');
Every callable returns { data: <result> } on success and throws an HttpsError with code (e.g. 'functions/unauthenticated', 'functions/resource-exhausted') and message on failure. The examples below assume the standard Firebase JS SDK; if you're using @plantagoai/auth shared package the auth flow is wrapped for you.
Auth requirements at a glance
| Endpoint class | Auth |
|---|---|
| Sign-in / demo / public ToS | none |
| App-internal callables (vote, tenant, identity, account) | Firebase ID token (signed-in user) |
| Admin callables | Ring ≤ 1 (TENANT_ADMIN) custom claim |
Sign-in is invite-only: a user can't create a Firebase account just by knowing the project's API key. The checkInviteOnSignup blocking trigger rejects sign-up if no invites/{email} doc exists.
App Check
Most Foundation callables currently carve out App Check enforcement (enforceAppCheck: false) because reCAPTCHA Enterprise tokens don't reliably attach inside the iOS Foundation Mobile WKWebView. Once the Option A custom App Check provider lands (memory: feat/appcheck-bridge-2026-04-28), the carve-outs will be removed. Until then, callers don't need to attach App Check tokens — but they're recommended where the path is App-Check-clean.
Sign-in
Email-link sign-in
The main app at foundation-global.com uses Firebase email-link sign-in routed through the Foundation resendInviteLink callable, so the email comes via Resend with the Foundation-branded template instead of Firebase's default mailer. The user clicks the link, lands back on the site, and signInWithEmailLink completes the auth.
The companion sites (partners.foundation-global.com, docs.foundation-global.com) use a simpler email + password sign-in for invited reviewers — see scripts/provision-partner.mjs.
import { httpsCallable } from 'firebase/functions';
import { signInWithEmailLink, isSignInWithEmailLink } from 'firebase/auth';
const sendSignInLink = httpsCallable<
{ email: string; site: 'yc' | 'docs' | 'app' },
{ status: 'ok'; sent: boolean; reason?: 'no-access' }
>(fns, 'resendInviteLink');
// 1. User submits the form on the sign-in page
await sendSignInLink({ email: 'someone@example.com', site: 'app' });
// Show "Check your email" UI — same message regardless of `sent`
// (anti-enumeration: unknown emails get sent: false silently).
// 2. User clicks the link, lands back at <origin>/<path>
if (isSignInWithEmailLink(auth, window.location.href)) {
const cachedEmail = window.localStorage.getItem('plantagoai:signin-email-app');
await signInWithEmailLink(auth, cachedEmail, window.location.href);
}
Errors you might see:
functions/resource-exhausted— per-email rate limit hit (3-10/hour depending on caller's ring).functions/invalid-argument— malformed email.
iOS OTP sign-in
iOS Foundation Mobile uses a 6-digit code instead of an email link, because Gmail iOS opens links in its in-app browser and bypasses Universal Links.
// 1. Request a code
let request = try await Functions.functions(region: "us-east1")
.httpsCallable("requestSignInCode")
.call(["email": email])
// 2. User reads code from email, types into app
let verify = try await Functions.functions(region: "us-east1")
.httpsCallable("verifySignInCode")
.call(["email": email, "code": typed])
// 3. Sign in with the returned custom token
let token = (verify.data as! [String: Any])["token"] as! String
try await Auth.auth().signIn(withCustomToken: token)
Caps: 3 codes/hour per email, 5 attempts per code, 10-min code lifetime.
Demo flow (casual visitors)
Foundation pre-seeds 20 demo identities (demo-user-pool-01..20), each with its own Firebase UID, Solana keypair, and Semaphore nullifier. Reviewers can demo without going through the invite flow, and concurrent reviewers don't collide on the on-chain "already voted" check.
import { httpsCallable } from 'firebase/functions';
import { signInWithCustomToken } from 'firebase/auth';
const tryDemo = httpsCallable<unknown, {
token: string;
uid: string;
slot: string;
tenantSlug: string;
}>(fns, 'tryDemo');
const releaseDemoSlot = httpsCallable<unknown, {
released: boolean;
slot?: string;
}>(fns, 'releaseDemoSlot');
// Sign in as a demo user
const { data } = await tryDemo({});
await signInWithCustomToken(auth, data.token);
window.location.href = `/t/${data.tenantSlug}/`;
// On sign-out — call BEFORE Firebase signOut() (after sign-out the
// callable can't authenticate, and the slot would have to wait for
// the 30-min stale-claim recovery to be reused)
try { await releaseDemoSlot({}); } catch { /* best-effort */ }
await signOut(auth);
Errors:
functions/resource-exhausted— pool full (all 20 slots claimed within last 30 min) OR per-IP rate limit (10/hour, 30/day) hit. Both surface as the same code; the message text differs.
The evoting-frontend/src/lib/auth.ts helper translates resource-exhausted to a typed DemoCapacityError so the Landing page can show a "demo at capacity" message distinct from generic errors.
Identity & Proof of Humanity
Two flows coexist. New integrations should use the humanity-seal flow (iOS Foundation Mobile). The older Self Protocol flow is preserved for users enrolled before May 2026.
Humanity-seal flow (current)
The iOS app does the heavy lifting (App Attest, liveness, ePassport NFC, anti-spoof, face match), produces a SHA-256 hash of the canonical artifact set, and calls anchorCommitment:
// iOS — after the on-device biometric flow has produced artifacts
let result = try await Functions.functions(region: "us-east1")
.httpsCallable("anchorCommitment")
.call([
"commitment": [
"hashHex": commitment.hashHex, // 64 lowercase hex chars
"producedAtMs": commitment.producedAtMs,
"kinds": ["app-attest", "liveness", "epassport", "anti-spoof", "face-match"],
],
"artifacts": artifacts.map { $0.toDict() },
])
The server re-derives the canonical bytes, verifies the hash, and enqueues anchorIdentityCommitmentTask which:
- Anchors the hash on the
identity_commitmentsSolana program. - Stamps
users/{uid}.humanityVerified = truefor the iOS app's first-paint cache. - Writes a placeholder
identity_proofs/{0x<hashHex>}doc withvoterId: uid, commitment: ""so the desktop AccessGate sees the user as enrolled.
The desktop's IdentityProofView component watches identity_proofs (subscribed by voterId) and, on seeing the placeholder, automatically calls attachSemaphoreCommitment to fill in the device-local Semaphore commitment:
const attach = httpsCallable<
{ commitment: string },
{ status: 'attached' | 'already_attached'; commitment: string; nullifier: string; trustTier: 'high' | 'medium' | 'low' }
>(fns, 'attachSemaphoreCommitment');
const { data } = await attach({ commitment: '0xabc...' });
Errors:
functions/failed-precondition(seal-mismatch) — the iOS-supplied hash doesn't re-derive from the artifacts. Indicates tampering or a serialization bug.functions/already-exists—attachSemaphoreCommitmentrejects a different commitment on an already-bound proof. Force-resetting viaadminResetUserHumanityis the only way out (preserves Sybil resistance).
Self Protocol flow (legacy)
Pre-May 2026 enrollments. The Self mobile app POSTs directly to verifyPassportProof (HTTP, API-key auth — used by integrators with their own apps). Server writes identity_proofs/{nullifier} with empty commitment; desktop fills it via attachSemaphoreCommitment (same callable as above).
Voting & Governance
Voting is two-tier: Firestore for the rich UX state and on-chain Anchor program for the durable record. Cloud Tasks workers reconcile between the two with retry + DLQ.
The shipped (post-Rocket) frontend uses the server-authoritative Phase-2 callables in functions/proposal-voting.js. The CF derives anonymous_hash from your uid — you never send it — and reads tenant_id from your custom claim, not the request body.
const createProposalDraft = httpsCallable<
{
title: string; description: string; category: string;
options: Array<{ label: string; description: string }>;
geographicScope: string; location: string;
supportThreshold: number; totalEligibleVoters: number;
votingDurationHours: number; tags: string[];
author: string; authorWallet: string;
pillar?: 1 | 2 | 3; governanceConfig?: object;
},
{ status: 'created'; proposalId: string; proposal: object; chainPending: boolean }
>(fns, 'createProposalDraft');
const castProposalSupport = httpsCallable<
{ proposalId: string },
{ status: 'recorded'; signatureId: string; anonymousHash: string; transitionedTo: string | null; supportCount: number; chainPending: boolean }
>(fns, 'castProposalSupport');
const castProposalVote = httpsCallable<
{ proposalId: string; optionId: string },
{ status: 'recorded'; voteId: string; anonymousHash: string; chainPending: boolean }
>(fns, 'castProposalVote');
Lifecycle:
createProposalDraftwritesproposals/{uuid}(withoptionskeyed by id) + best-effort enqueuesmintProposalOnChainTask. Status starts atround0(oractiveif the governance template opens directly with a vote round).- Other voters call
castProposalSupport. Whensupport_countreachessupport_threshold, the proposal transitions automatically (typically →active, stamping the voting window). - During voting,
castProposalVoterecords the vote invotes+ enqueuesmirrorVoteOnChainTask. Anonymous voting (Semaphore ZK proofs) goes through theverifyAnonymousVoteHTTP endpoint instead. - The on-chain Anchor program is the durable record. Voting rounds expire automatically (
expireVotingRoundsScheduledruns every 10 min) or on admin demand (expireVotingRounds).
An older
createProposal/submitSupport/activateProposal/castVotepath (functions/index.js) still exists for pre-cutover clients but new integrations should use the callables above.
AI-assisted drafting:
// Pillar-aware draft from a minimal seed (pillar: 1 = governance, 2 = grant, 3 = product)
const generate = httpsCallable<
{ title: string; pillar: 1 | 2 | 3; category?: string; location?: string; scope?: string; threshold?: number; amount?: number; quantity?: number; unit?: string },
{ description: string; _provider: string; _tokens: object }
>(fns, 'generateProposalDraft');
// Constitution compliance scoring
const evaluate = httpsCallable<
{ proposalTitle: string; proposalDescription: string; constitution: { preamble: string; principles: Array<{ title: string; description: string; weight: string }> } },
{ overallScore: number; status: 'compliant' | 'concern' | 'violation'; violations: object[]; reasoning: string }
>(fns, 'evaluateProposal');
Both use Claude via @plantagoai/ai (Gemini fallback) with prompt caching, and share a per-uid rate limit (20/hour, 60/day).
Pillar 2 — Allocations (Your Share)
Fund-allocation proposals live in their own allocations collection but share the proposal doc shape. Same callable patterns as Pillar 1.
const createAllocation = httpsCallable<
{
title: string; description: string; category: string;
options: Array<{ label: string; description: string }>;
geographicScope: string; location: string;
supportThreshold: number; totalEligibleVoters: number;
votingDurationHours: number; tags: string[];
author: string; authorWallet: string;
amount: number; poolPct: number; disbursementSchedule: string;
},
{ status: 'created'; allocationId: string; allocation: object; chainPending: boolean }
>(fns, 'createAllocation');
// Crowd-sourced funding option (round0/draft only, one per caller)
const submitAllocationOption = httpsCallable<
{ allocationId: string; label: string; description: string; amount?: number },
{ status: 'recorded'; optionId: string; optionCount: number }
>(fns, 'submitAllocationOption');
const castAllocationSupport = httpsCallable<
{ allocationId: string },
{ status: 'recorded'; signatureId: string; transitionedTo: string | null; supportCount: number; chainPending: boolean }
>(fns, 'castAllocationSupport');
const castAllocationVote = httpsCallable<
{ allocationId: string; optionId: string },
{ status: 'recorded'; voteId: string; chainPending: boolean }
>(fns, 'castAllocationVote');
Pillar 3 — Marketplace (Your Market)
Product requests move gathering → bidding → voting → delivered. Suppliers submit crowd-sourced bids (embedded in product_requests.bids[]); members vote to select a supplier.
// Supplier bid — gated on the demand threshold; first bid opens `bidding`.
// One bid per caller, deduped by supplier name.
const submitProductBid = httpsCallable<
{
productId: string; supplierName: string;
pricePerUnit: number; retailPrice: number;
unit?: string; certifications?: string[];
deliveryDays?: number; sampleAvailable?: boolean;
},
{ status: 'recorded'; bidId: string; transitionedTo: string | null; bidCount: number }
>(fns, 'submitProductBid');
// Member selects a supplier bid (one vote per product)
const castProductVote = httpsCallable<
{ productId: string; bidId: string },
{ status: 'recorded'; anonymousHash: string; chainPending: boolean }
>(fns, 'castProductVote');
Population & geography
Optional self-service calls that set the caller's population codes / geolocation, used by the population-scoped governance program.
// Set nationality → derives [global, continent, country] population codes
const setMyNationality = httpsCallable<{ iso: string }, { populations: number[] }>(
fns, 'setMyNationality',
);
// Opt-in location → appends an H3 cell to the voter profile
const setMyLocation = httpsCallable<{ lat: number; lng: number }, { uid: string; cell: string; h3Cells: string[] }>(
fns, 'setMyLocation',
);
Tenants & Membership
const createTenant = httpsCallable<
{ slug: string; name: string; type: 'pilot' | 'production' | 'demo'; config?: object },
{ tenantId: string }
>(fns, 'createTenant'); // Platform owner only
const joinTenant = httpsCallable<{ tenantId: string }, { status: 'joined' }>(
fns, 'joinTenant',
);
const updateMembership = httpsCallable<
{ tenantId: string; voterId: string; role?: string; status?: 'active' | 'suspended' },
{ status: 'ok' }
>(fns, 'updateMembership'); // Tenant admin only
Tenant scoping is enforced in Firestore rules and in CFs via the tenantId custom claim. Frontend should use the tenantQuery() / tenantCreate() helpers from @plantagoai/firebase-core rather than building queries by hand.
Wallet
Each user has their own Solana keypair stored server-side, KMS-encrypted in user_wallets/{uid}. On the first call that touches Solana, getUserKeypair() lazy-creates the keypair and funds it with 0.1 SOL from the API wallet (HB4b3wJ6BBZ7kQdX92n752TYZdVu8gcfFqiSCXBqwKrq).
const getMyWallet = httpsCallable<unknown, { pubkey: string; balanceSol: number }>(
fns, 'getMyWallet',
);
const { data } = await getMyWallet({});
console.log(data.pubkey, data.balanceSol);
Top-ups are automatic: every chain-write CF calls topUpIfLow(uid) which refills the wallet to 0.1 SOL whenever it dips below 0.01 SOL.
Pairing (desktop ↔ mobile)
Foundation requires a paired mobile session for desktop access — the phone is the master key. From the desktop:
// 1. Desktop generates a code
const requestCode = httpsCallable<unknown, { code: string; expiresAtMs: number }>(
fns, 'requestPairingCode',
);
const { data: { code } } = await requestCode({});
// Show the code to the user; they type it into the mobile app
// 2. Mobile claims it (running inside the Foundation Mobile app)
const claim = httpsCallable<{ code: string }, { sessionId: string }>(
fns, 'claimPairingSession',
);
// 3. Mobile heartbeats periodically (every 10s)
const heartbeat = httpsCallable<{ sessionId: string }, { ok: true }>(
fns, 'heartbeatPairingSession',
);
// 4. On sign-out, mobile releases
const release = httpsCallable<{ sessionId: string }, { ok: true }>(
fns, 'releasePairingSession',
);
Desktop's AccessGate watches pairing_sessions for an active doc with status: paired AND lastHeartbeatAt within the lease grace window (30 s). Both signals required so silent mobile death is detected client-side without waiting for the server sweep. See docs/architecture_pairing-lease-pattern-2026-04-26.md.
mintWebSessionToken is the mobile-side counterpart: mobile mints a short-lived custom token that the desktop swaps into a Firebase session, so users don't have to re-enter their email on every desktop seat.
Account lifecycle (GDPR)
// Article 20 — data portability
const exportMyData = httpsCallable<unknown, { bundle: object; exportedAt: string }>(
fns, 'exportMyData',
);
// Article 17 — right to erasure (90-day grace period)
const requestDeletion = httpsCallable<{ reason?: string }, { scheduledFor: string }>(
fns, 'requestAccountDeletion',
);
const cancelDeletion = httpsCallable<unknown, { status: 'cancelled' }>(
fns, 'cancelAccountDeletion',
);
// Immediate deletion (after grace window, or admin override)
const deleteAccount = httpsCallable<unknown, { status: 'deleted' }>(
fns, 'deleteMyAccount',
);
Financial records (votes, on-chain tx receipts) are retained 6-10 years per GDPR Article 17(3)(b), with PII anonymized. The data map lives in defineUserDataMap() in functions/account-deletion.js.
Legal & consent
const getTos = httpsCallable<{ site?: 'foundation' | 'docs' | 'yc' }, {
version: string;
contentHash: string;
body: string;
}>(fns, 'getTermsOfService');
const recordTos = httpsCallable<
{ documentType: string; version: string; contentHash: string },
{ status: 'ok' }
>(fns, 'recordTosAcceptance');
const checkTos = httpsCallable<
{ documentType: string; version: string },
{ accepted: boolean; acceptedAt?: string }
>(fns, 'checkTosAcceptance');
Bumping the version (e.g. tos-foundation_v2 → v3) forces re-acceptance for everyone. The desktop's TosAcceptanceGate blocks access until checkTosAcceptance returns accepted: true.
The biometric / manual-review consent surfaces (getBiometricNotice, recordBiometricConsent, etc.) follow the same pattern, gated to specific flows (BIPA + GDPR Article 9 special category data).
Support
const submitTicket = httpsCallable<
{ subject: string; body: string; category?: 'bug' | 'access' | 'feedback' | 'other' },
{ ticketId: string }
>(fns, 'submitSupportTicket');
Lands in support_tickets/{id} for ops triage. Includes the caller's uid and User-Agent for follow-up.
Error handling pattern
Wrap every callable in a try/catch and switch on code:
import type { FirebaseError } from 'firebase/app';
try {
await someCallable({});
} catch (err) {
const fbErr = err as FirebaseError;
switch (fbErr.code) {
case 'functions/unauthenticated':
// not signed in or token expired — re-authenticate
break;
case 'functions/permission-denied':
// signed in but lacks the required ring/access claim
break;
case 'functions/resource-exhausted':
// rate-limited; tell the user to retry shortly
break;
case 'functions/failed-precondition':
// request invalid given current state (e.g. proposal already activated)
break;
case 'functions/already-exists':
// idempotent conflict (different commitment on bound proof, etc.)
break;
default:
// network / internal error
}
}
The CF source code documents the specific error codes each function can throw — see the Cloud Functions Reference entries for any function and its source line.
Discovery
Beyond this guide:
- Source: all callables live in
functions/*.js. Every function has an inline header comment explaining purpose, idempotency contract, and reasoning behind any non-obvious decision. - Tests:
functions/__tests__/mocks@plantagoai/messaging,@plantagoai/auth, and the Anchor client. - Frontend wrappers:
evoting-frontend/src/lib/has typed httpsCallable wrappers for most of the integration-facing CFs. - CF reference:
cf-reference.md— every CF including server-only triggers and background tasks.