CommonGage Security Overview
Last updated: 2026-09-06
What CommonGage Is
CommonGage is a multi-tenant SaaS platform for community engagement operations. Its customers are organizations that run programming across multiple sites — community-based organizations and nonprofits, school districts, and networks of either.
It stores operational and staff data: event calendars, per-site obligation tracking, cost and in-kind records, and accounts for the organization's own staff. Where an organization enables the Participants module, CommonGage also stores names and organizational email addresses for the volunteers, board members, and other non-staff people it records as doing the engagement work, with self-reported hours attached to those records. CommonGage does not store student records. It holds no student names, grades, government identifiers, or any data about a minor — the people CommonGage tracks, staff and Participants alike, are adults doing the work, not the students and families the organization serves. It is a record of what an organization did, not a registry of the community it serves.
Architecture
Runtime: Cloudflare Workers (V8 isolate, no persistent process, no OS). There is no server to SSH into, no filesystem, no network perimeter. Every request is stateless and isolated by the Cloudflare platform.
Database: Neon PostgreSQL (serverless, TLS-only). All connections require TLS. The application connects as a least-privilege role with no superuser, RLS-bypass, or role-creation privileges.
Frontend: Static SPA served from Cloudflare Pages. No server-side rendering; the API is the only data path.
Encryption
| Layer | Implementation |
|---|---|
| In transit | TLS 1.2+ enforced by Cloudflare on all inbound and Neon on all DB connections |
| At rest | Neon encrypts data at rest (AES-256); Cloudflare encrypts Worker secrets at rest |
| Passwords | PBKDF2-HMAC-SHA256 with a 16-byte random per-user salt, 100,000 iterations (a fixed application constant, embedded per-hash in the stored value so a future increase would not invalidate existing hashes) |
| JWTs | HS256 signed with a 32-byte random SESSION_SECRET stored as a Cloudflare Worker secret (never in wrangler.toml) |
Multi-Tenant Isolation
All customer data is stored in a single PostgreSQL database, isolated by Row-Level Security (RLS). Each customer organization is a separate tenant.
How it works:
- The application connects as a least-privilege PostgreSQL role that cannot bypass row-level security.
- Each request establishes its tenant context within a transaction before any tenant-scoped query runs.
- Every tenant-scoped table carries a row-level security policy that restricts rows to the request's tenant context.
- Row-level security is forced on those tables, so the policy applies to the application role unconditionally.
Verified: An integration test asserts that a query executed with one tenant's context returns zero rows belonging to another tenant.
Authentication & Authorization
Password auth (the default for all customers): Invite-token flow. An administrator sends an invite; the recipient sets a password on first login. Passwords are never stored in plaintext.
SSO (available where a customer already runs it). Optional; not required to use the product.
Google Workspace — OpenID Connect. The security boundary is cryptographic: CommonGage verifies the signature on Google's id_token against Google's published signing keys, and checks the issuer, the audience, the expiry, the token's age, and a single-use nonce it generated for that sign-in. It then requires the token's hd (hosted domain) claim and matches it against a domain the organization has pre-registered with us. The consequences of each check are deliberate: without the audience check, a token minted for any other Google application would be accepted; without hd, a personal Google account would satisfy the flow. Both are enforced unconditionally, with no configuration that disables them. The authorization code exchange additionally uses PKCE.
ClassLink — OAuth 2.0, used by districts that authenticate through an existing ClassLink launchpad. CommonGage validates the ClassLink TenantId against a pre-provisioned mapping it controls; unknown tenant IDs are rejected with 403.
Account creation under SSO: by default, a valid Workspace sign-in from a registered domain does not by itself create an account — the person must already have one, or an unexpired invitation from an administrator. Organizations that prefer automatic provisioning can enable it, in which case a first-time user is created at the lowest access level with no zone or site scope. Elevating anyone remains a deliberate administrative act either way.
Where an organization has moved fully to SSO, password sign-in can be switched off for that organization, so that disabling a Workspace account is sufficient to end access to CommonGage.
JWT session tokens: HS256, 8-hour expiry, issued by the API and verified on every protected request via jose (WebCrypto-native, no hand-rolled crypto). A session token is never placed in a URL: an SSO redirect carries a single-use code, valid for 60 seconds, which the application exchanges for the session — so the token does not appear in browser history, a Referer header, or a server access log.
OAuth CSRF: Every SSO redirect generates a random state value stored as an HttpOnly; SameSite=Lax; Secure cookie, validated on return before any external call is made, and cleared whether the attempt succeeds or fails.
Authorization model: Three access levels — organization-wide, zone-scoped, and site-scoped (org_admin, zone_coordinator, building_staff as internal identifiers; each customer's interface displays these in its own vocabulary). The level is encoded in the JWT and re-enforced by the API on every request. Zone- and site-scoped access is further constrained by scope grants.
Rate limiting: Authentication endpoints (sign-in and invite acceptance) are rate-limited per IP via Cloudflare Workers Rate Limiting.
iCal Subscription Feed
CommonGage offers an optional, read-only calendar feed (GET /feed/:token.ics) that a staff member can subscribe to from Google Calendar, Outlook, or Apple Calendar. This is the product's only unauthenticated, data-returning endpoint, so it is documented here explicitly rather than folded into the section above.
The credential is a bearer token in the URL, not a JWT. A person generates it from Settings; the plaintext is shown exactly once, at generation, and is never stored or logged anywhere — only a SHA-256 hash of it is kept. Anyone holding the URL can read the feed, the same trust model any calendar-subscription link carries; this is why the token is revocable at any time from Settings, and why the feed's contents are limited (see Data Inventory) rather than mirroring everything an authenticated session could see.
Every request re-derives scope. The feed does not store or cache what a subscriber may see. Each request resolves the token to its owner and recomputes that person's visible events from the database at request time — the same scope logic (getVisibleScopes) an authenticated API call uses. A person's access changing (a scope grant revoked, a token revoked, an account deactivated) takes effect on the very next feed request, with no session to expire and no cache to clear.
An unknown, revoked, or malformed token receives an identical 404. The feed never distinguishes "this token doesn't exist" from "this token was revoked," which would otherwise let a caller confirm a token had ever been valid. The endpoint is also rate-limited per IP, the same defense applied to the other unauthenticated lookups in this section.
Data minimization is deliberate. The feed carries an event's title, time, and site name — never a participant's name and never an event's description, both narrower than what an authenticated view of the same data shows. See Data Inventory for the full accounting.
CORS
The API allows requests from four production origins: https://commongage.com, https://www.commongage.com, https://app.commongage.com (the product application), and https://commongage.pages.dev (preview deploys). Local development origins (http://localhost:*, http://127.0.0.1:*) are also allowed and are not customer-facing. All other origins receive an empty Access-Control-Allow-Origin, blocking cross-origin reads in compliant browsers.
Allowed methods: GET, POST, PUT, PATCH, DELETE, OPTIONS. Allowed headers: Content-Type, Authorization, X-Dev-Tenant-Id. Credentials (cookies) are permitted on cross-origin requests from the allowed origins.
Email Infrastructure
CommonGage receives inbound email at log@commongage.com via Cloudflare Email Routing, which forwards messages to a Cloudflare Worker endpoint for processing. This is used by the Communications module's BCC-to-log feature: staff BCC an organizational email address when sending press releases or outreach emails, and the system creates a structured Communications record from the email headers and body.
Inbound only. The email path receives; it does not send on behalf of users or relay messages. Outbound notification emails (flag alerts, participation notifications, attestation and RSVP links) are sent via Cloudflare's Email Send binding (env.EMAIL.send()), a platform-level capability that does not relay through log@commongage.com.
No new subprocessor. Cloudflare Email Routing is a Cloudflare platform feature, covered by the existing Cloudflare subprocessor entry — it is not a separate service or vendor.
Secrets Management
All secrets (DATABASE_URL, SESSION_SECRET, CLASSLINK_CLIENT_SECRET, ANTHROPIC_API_KEY, APP_URL) are stored as Cloudflare Worker secrets (encrypted at rest, injected at runtime, never in source control or wrangler.toml).
The .dev.vars file (local development only) is in .gitignore and has never been committed.
AI / LLM Usage
CommonGage uses Anthropic Claude (model claude-haiku-4-5-20251001) for a fixed set of server-side purposes, each described below. Every call is a direct call to the Anthropic Messages API (https://api.anthropic.com/v1/messages, API version 2023-06-01) — a direct commercial API integration, not a reseller or cloud-marketplace path. The API key is a Cloudflare Worker secret (see Secrets Management, above). The model never makes or influences an access-control decision on any path.
Event classification
CommonGage classifies calendar events against Karen Mapp's Dual Capacity-Building Framework (2019).
What is sent. CommonGage sends event title, description, hub name, and engagement type to Anthropic for classification. No staff name, email address, site identifier, or participant data field is included in the prompt; because titles and descriptions are free text authored by the customer's own staff or drawn from the customer's calendar feeds, they may incidentally contain names or other identifying details the customer chose to write into an event's title or description.
What the classifier returns. Four booleans — capabilities, connections, confidence, cognition — one per capacity outcome in the framework — plus a one-sentence rationale. There is no numeric confidence score anywhere in the classifier; "confidence" is one of the four capacity outcomes (self-efficacy and advocacy), not a probability.
What persists, and where. Two endpoints call the model. POST /api/classify/suggest returns a suggestion in the response without writing to the database. POST /api/classify/batch (requires organization-wide access) persists the four booleans to the event record, flagged as suggested — not confirmed; a human must separately confirm a classification before it is treated as final. Neither path lets the classifier's output make or influence an access-control decision.
Retention and training. Anthropic does not retain event data sent for classification for training purposes under the API terms; each request is stateless. (See also Data Retention Policy, "Subprocessor Retention.")
Find: ordering a question into a structured search
The Find screen lets a user ask a question about their own organization's records in their own words. The model's only job is to translate that sentence into a structured search — which collection to look in, and how to narrow it. It does not answer the question, and it never sees an answer.
What is sent. The user's question as typed, plus a schema listing the names of the collections and fields their role and their organization's licensed modules permit them to reach. On a follow-up that narrows an existing search, the structured form of the search so far is also sent, which carries the filter values from that user's own earlier questions. No record from the database is sent on this path — the prompt builder is a pure function of the schema, the caller's role, and the question, with no database handle and no result set among its inputs. Because the question is free text the user typed, it may contain whatever that user chose to write into it.
What the model returns, and what authority it has. A short program naming a collection and its conditions. It is a proposal, and it carries no authority of its own: the proposed search is checked against the caller's licensed modules, role, and field-level permissions before it runs, and a search that fails that check is refused regardless of what produced it. A program that would read anything other than records — or that names a collection the caller cannot reach — is refused at the same layer. Records are then read from the database under the caller's own permissions, exactly as they would be for a search built without a model.
When no model is configured. Find falls back to deterministic keyword matching and answers anyway. The surface tells the user which path was taken.
Retention and training. The same API terms apply: not retained for training, each request stateless.
Communications: media monitoring
Organizations that license the Communications module can set up a watch profile — a topic an administrator wants press coverage tracked for. Two calls support it.
Generating a watch profile. CommonGage sends the organization's name, its location where the administrator supplied one, and the topic string as typed. The model returns search phrases — direct, contextual, and deliberately adversarial ones, so the scan surfaces critical coverage rather than only favourable coverage. Nothing from the organization's own records is sent.
Running the scan. This is the one path where a call reaches the public web: the scan is a model call with Anthropic's server-side web search tool enabled (web_search_20250305, capped at five searches per pass), and the search phrases above are issued as queries. The organization's name and the administrator's topic are therefore visible to Anthropic and to the search infrastructure behind that tool, in the same way they would be if a staff member typed them into a search engine. What comes back is public media coverage — headline, link, publication, date, and an extract — which is stored as Communications records for staff review.
What is never sent on either call. No event, participation, person, expense, obligation, or any other record from the organization's database. Both prompts are built from the organization's name, the administrator's topic, and the terms generated from them.
Retention and training. The same API terms apply: not retained for training, each request stateless. Web search queries issued by the tool are subject to Anthropic's own handling of that feature.
Vendor product vs. staff pasting into consumer tools. Some organizations restrict staff from entering internal data into consumer chatbot accounts — a policy that addresses unmanaged, human-initiated disclosure into a consumer tool with no data processing agreement in place. CommonGage's model calls are a different category of risk: scoped, server-side, single-purpose API calls with fixed prompt schemas, defined field sets (above), and a signed data processing agreement. There is no general-purpose chat surface in the product. Where a CommonGage user's own typing does reach the model — a Find question, or a Communications watch topic (both documented above) — it reaches a prompt whose schema and permitted output are fixed by CommonGage, on a call that cannot read anything the user could not already read, and whose output is re-checked against that user's permissions before anything is acted on. Staff should be told, as with any free-text field, that what they type into a Find question is what gets sent.
Calendar feeds are treated as untrusted external input. Parsed event content is stored as structured data and passed to the LLM as a fixed schema; the classifier cannot modify data outside its designated output fields.
Logging
Worker logs (accessible via Cloudflare dashboard) contain:
- Request method, path, status code, duration (from Hono's logger middleware)
- Error messages and HTTP status codes for failed external calls (SSO providers, iCal fetches)
- Cron job summary statistics (source counts, event upsert counts)
Logs do not contain: JWT tokens, passwords, password hashes, auth codes, database credentials, or any field that could identify an individual participant.