GitHat Backend Architecture Overview
Developer-focused reference for GitHat's serverless backend: Lambda+API Gateway topology, multi-tenant app-key model, RS256/KMS JWT auth, DynamoDB single-table design, and per-app isolation boundary.
Serverless Topology (Lambda + API Gateway via SAM)
The backend is a SAM-managed AWS stack defined in template.yaml. All functions run on Node.js 22.x, arm64, 512 MB, 30 s timeout, with AWS X-Ray active tracing and JSON-structured CloudWatch logs.
There is one shared AWS::Serverless::Api resource (GitHatApi, stage: prod) and one AWS::ApiGatewayV2::Api WebSocket resource (GitHatWebSocketApi). Every HTTP Lambda uses a /{proxy+} + ANY catch-all event so a single function owns its entire URL namespace; the internal routing (method + path pattern matching) is handled entirely by lib/router-dispatch.js inside each Lambda.
The 23 Lambda functions and their URL namespaces (from template.yaml Events blocks) are:
AuthRouterFunction — /auth, /auth/{proxy+}, /oauth/revoke, /oauth/introspect, /oauth/authorize, /oauth/token | UserRouterFunction — /user/{proxy+}, /webhooks, /webhooks/{proxy+} | OrgRouterFunction — /org, /org/{proxy+}, /orgs/{proxy+} | ScimRouterFunction — /scim/v2/{proxy+} | AppsRouterFunction — /apps, /apps/{proxy+} | AgentRouterFunction — /agent/{proxy+}, /eliza/{proxy+}, /mcp/{proxy+}, /verify/{proxy+} | BillingRouterFunction — /billing/{proxy+}, /usage/{proxy+}, /sebastn/{proxy+} | GitHubRouterFunction — /github, /github/{proxy+} | StorageRouterFunction — /storage/{proxy+} | EmailRouterFunction — /apps/{appId}/email/{proxy+} | DomainsRouterFunction — /domains, /domains/{proxy+} | AnalyticsRouterFunction — /analytics/{proxy+}, /observability/{proxy+}, /deployments/{proxy+} | SkillsRouterFunction — /skills, /skills/{proxy+}, /ratings, /ratings/{proxy+} | StatusRouterFunction — /health, /status, /status/{proxy+}, /.well-known/{proxy+}, /openapi.json, /openapi.yaml, /robots.txt, /metrics | SEOGeneratorFunction, AuditArchiverFunction, DomainOnboardingPollerFunction, PurgeDeletedOrgsFunction, ProbeUptimeFunction — scheduled/event-driven | WSConnectFunction, WSDisconnectFunction, WSSubscribeFunction, WSDefaultFunction — WebSocket routes ($connect, $disconnect, subscribe, $default).
Every router file follows the same pattern: a plain object maps 'METHOD /path' strings to handler functions, then calls dispatch(handlers, event) from lib/router-dispatch.js. Auth routers additionally call withAbuseGuard and await ensureSecretsLoaded() at cold-start to prime the Secrets Manager cache.
Request Dispatch (lib/router-dispatch.js)
dispatch(handlers, event) iterates the handlers object. Route keys are the string 'METHOD /path/pattern' where METHOD can be a literal HTTP verb or ANY. Path segments wrapped in {name} are path parameters; {name+} is a greedy segment. The router extracts captured values and merges them into event.pathParameters before calling the handler.
OPTIONS preflight requests are short-circuited before any route matching: the router calls initCors() to resolve the validated origin and returns a 204 with all CORS headers (Access-Control-Allow-Methods: GET,POST,PUT,PATCH,DELETE,OPTIONS; Access-Control-Allow-Headers: Content-Type,Authorization,X-GitHat-App-Key,X-CSRF-Token,X-Captcha-Pass,X-Captcha-Verified,Accept-Language; Access-Control-Max-Age: 86400; Vary: Origin). Access-Control-Allow-Credentials: true is included only when the origin was matched to a registered app.
When no route matches but the path itself matches with a different method, the router returns 405 with an Allow header (RFC 9110 §15.5.6). Unmatched paths return 404. Both fallbacks include Access-Control-Allow-Origin: *.
// Route key format: 'METHOD /path/{param}'
// matchRoute() supports {param} (single segment) and {param+} (greedy)
const handlers = {
'GET /apps/{appId}': require('./get').handler,
'POST /apps/{appId}/keys': require('./create-key').handler,
'DELETE /apps/{appId}/keys/{keyId}': require('./delete-key').handler,
};
exports.handler = async (event) => dispatch(handlers, event);Multi-Tenant App-Key Model
GitHat is itself a multi-tenant auth platform: any company (an 'org') registers on GitHat and gets their own app record with API keys. The DynamoDB GitHat-Apps table stores app rows (pk: ORG#{orgId}, sk: APP#{appId}) and API key rows (pk: APP#{appId}, sk: KEY#{keyId}) in the same physical table.
Two key types are issued (create-key.js): publishable keys with prefix pk_live_ (intended for frontend/browser use with Origin checking) and secret keys with prefix sk_live_ (server-to-server). The raw key is 48 random bytes encoded as hex (96 chars, 384 bits of entropy), returned once at creation and never stored. Only the SHA-256 hash and the first 14 characters (display prefix) are persisted in DynamoDB.
On each request, lib/cors.js reads the X-GitHat-App-Key or x-githat-app-key header (case-insensitive). getApiKeyByValue() in dynamo.js hashes the incoming key with SHA-256 and queries the KeyHashIndex GSI. Results are cached in a module-scope LRU Map (up to 256 entries, 60-second TTL, caching both hits and misses) to avoid a DynamoDB round-trip per request.
Once the key resolves to an appId, the app record is fetched to get the registered domain. The incoming Origin header is matched against app.domain and app.customDomain, accepting both apex (example.com) and www.example.com forms via the _hostMatchesApp() helper. On a match, setCorsOrigin(origin) is called, which binds the validated origin to the current request's AsyncLocalStorage context so all response helpers emit the correct Access-Control-Allow-Origin value.
For CORS preflight OPTIONS requests (which browsers do not attach custom headers to), cors.js falls back to querying the DomainIndex GSI by origin hostname, with a 60-second in-memory domain cache to limit DDB load. localhost origins bypass key validation in non-production environments to support local development.
Request header: X-GitHat-App-Key: pk_live_<96 hex chars>
Flow:
1. SHA-256(rawKey) → keyHash
2. LRU cache lookup (60s TTL, 256 entries)
3. On miss: GSI query KeyHashIndex on GitHat-Apps table
4. keyRecord.appId → getApp(orgId, appId) → app.domain
5. _hostMatchesApp(originHost, app) → setCorsOrigin(origin)
6. trackAppRequest(appId) + incrementUsageAggregate (fire-and-forget)RS256 JWT Auth + Session / tokenVersion Invalidation
User access and refresh tokens are always signed with RS256 via AWS KMS (lib/jwt-rs256.js). The private key never leaves the KMS HSM; signing is one kms:Sign API call per token issuance. The public key is fetched once per cold-start via kms:GetPublicKey, converted from DER SPKI to a jose CryptoKey, and cached at module scope. Verification is pure local crypto with no KMS round-trip. The KMS key ID is provided via the GITHAT_RS256_KMS_KEY_ID environment variable; if unset the module returns null and token issuance throws.
MCP, agent, and Eliza tokens remain HS256, signed with per-class secrets (GITHAT_MCP_SECRET, GITHAT_AGENT_SECRET, GITHAT_ELIZA_SECRET) from AWS Secrets Manager (/githat/prod/<logicalName>, 5-minute cache TTL). Per-class secrets mean a leak of one class does not compromise the others.
Token classes and TTLs: user access = 15 minutes (RS256), user refresh = 7 days (RS256), MCP = 5 minutes (HS256, mcp_live_ prefix), agent = 2 minutes (HS256, agent_live_ prefix), Eliza = 5 minutes (HS256, eliza_live_ prefix), MFA ticket = 5 minutes (HS256, separate mfa_ticket secret).
verifyToken() in lib/jwt.js reads the alg header from the unverified JWT to select the verification path (RS256 → jwt-rs256.verify; HS256 → jsonwebtoken.verify). After cryptographic verification, it cross-checks that the verified payload's type and tokenType match the unverified values used for routing, to prevent a crafted header from bypassing the type gate. The iss claim must equal JWT_ISSUER (default: https://api.githat.io) or the token is rejected.
generateTokenPair() embeds a tv (tokenVersion) claim in both access and refresh tokens. This integer is read from the user's DynamoDB row. The requireAuth middleware fetches the user record and compares decoded.tv to user.tokenVersion; a mismatch immediately returns 401 without a table scan. incrementTokenVersion() atomically ADD :1s to the user row via DynamoDB UpdateExpression and is called on: password change, MFA enable/disable, logout-all/revoke-all-sessions, and refresh-token reuse detection. A single bump invalidates all outstanding tokens for that user across all devices.
The aud (audience) claim is set to appId (the GitHat-Apps UUID) so tokens minted for one tenant app cannot be replayed against another, even though all apps share the same JWKS public key for verification.
JWKS is served at GET /.well-known/jwks.json by StatusRouterFunction (lib/jwt-rs256.js getPublicJwk()). The kid is the RFC 7638 JWK thumbprint of the public key, stable across Lambda containers for the same KMS key. The OpenID configuration discovery document is at GET /.well-known/openid-configuration.
Token type routing in verifyToken():
decode header (unverified) → alg
alg === 'RS256' → rs256.verify() (local, KMS public key cached)
alg === 'HS256' → must be type mcp|agent|eliza, else reject
'none' or unknown → reject
Post-verify checks:
verified.type === unverified.type (anti-tamper)
verified.tokenType === unverified.tokenType
verified.iss === TOKEN_ISSUER
In requireAuth middleware:
decoded.tv !== user.tokenVersion → 401 (invalidated session)DynamoDB Single-Table Design
GitHat uses multiple DynamoDB tables (not one global single table), but each table itself follows single-table design conventions with composite pk/sk keys. The pattern is consistent across tables: pk encodes the entity type and ID as a prefixed string, sk encodes the record type or relationship.
Key patterns used in production (from dynamo.js and template.yaml):
GitHat-Users: pk=USER#{userId}, sk=PROFILE — user profile rows. Email lookups go through the EmailIndex GSI (keyCondition: email = :email). tokenVersion is stored here and incremented atomically via ADD :1.
GitHat-Sessions: pk=USER#{userId}, sk=SESSION#{sessionId} — sessions under the user partition. getUserSessions uses begins_with(sk, 'SESSION#'). DynamoDB TTL at 7 days. Session rotation uses a TransactWriteCommand that atomically deletes the old session row (with attribute_exists(pk) condition) and puts the new one (with attribute_not_exists(pk) condition) in a single transaction.
GitHat-OrgMembers: pk=ORG#{orgId}, sk=MEMBER#{userId} — org membership. getUserOrgs queries the UserOrgsIndex GSI by userId to get all orgs for a user.
GitHat-Apps (overloaded table — three sk prefixes in one physical table): App rows: pk=ORG#{orgId}, sk=APP#{appId}. API key rows: pk=APP#{appId}, sk=KEY#{keyId}, with keyHash attribute indexed via KeyHashIndex GSI. App user tracking rows: pk=APP#{appId}, sk=USER#{userId}, TTL 90 days. App request tracking rows (hourly buckets) use a separate sk pattern.
GitHat-Agents: pk=AGENT#{walletAddress}, sk=CHAIN#{chainId} — agents keyed by Ethereum wallet + chain. consumeAgentNonce() uses a conditional UpdateExpression to atomically clear the challenge nonce (prevents replay and concurrent-use attacks).
GitHat-MCPServers: pk=MCP#{serverId}, sk=METADATA. DomainIndex GSI for domain lookups, ClientIdIndex for OAuth client ID lookups.
GitHat-VerifyTokens: pk=TOKEN#{token}, sk=TYPE#{type} — email verification, password reset, org invite tokens. TTL-based expiry. consumeVerifyToken() deletes with attribute_exists(pk) condition to prevent double-use in concurrent requests.
GitHat-RateLimits: pk=LIMIT#{identifier}, sk=ACTION#{action} — sliding window counters with conditional writes and bounded retry (max depth 3) to prevent stack overflow under contention.
Pagination is handled by the paginatedQuery() helper: accepts limit (clamped 1–100) and cursor (base64-encoded LastEvaluatedKey). When limit is provided, it returns {items, nextCursor}; otherwise it returns a plain array for backward compatibility.
BatchGet operations (getOrganizationsByIds, getUsersByIds) chunk at 100 keys per call and retry UnprocessedKeys with exponential backoff (50ms * 2^attempt, up to 5 attempts). BatchWrite operations (deleteAllApiKeys) chunk at 25 items and use the same retry pattern via _batchWriteWithRetry().
GitHat-Apps table — three sk prefix namespaces:
pk sk
ORG#{orgId} APP#{appId} ← app record
APP#{appId} KEY#{keyId} ← api key (keyHash GSI)
APP#{appId} USER#{userId} ← unique user tracking (90d TTL)
GitHat-Sessions table:
pk sk
USER#{userId} SESSION#{sessionId} (7d TTL, atomic rotation via TransactWrite)
GitHat-RateLimits table:
pk sk
LIMIT#{identifier} ACTION#{action} (window counter, atomic ADD)Per-App Isolation Boundary
Each tenant app registered on GitHat is scoped by its appId (a UUID). The isolation is enforced at multiple layers simultaneously.
JWT audience claim: generateTokenPair() sets aud = appId in every access and refresh token. The verifyToken path passes opts.audience when enforcing cross-app replay prevention so a token minted for app A is cryptographically rejected as the audience for app B.
CORS origin validation: initCors() resolves the CORS origin only to the domain registered for the specific app that presented the X-GitHat-App-Key. A valid key for app A cannot unlock CORS for app B's domain.
DynamoDB access patterns: app rows are scoped under ORG#{orgId} partition; API key and user-tracking rows are scoped under APP#{appId} partition. There is no cross-app query path in dynamo.js.
Middleware role checks: requireOrgMember, requireOrgAdmin, requireOrgOwner all verify that the authenticated user belongs to the org that owns the app. The org membership check includes an active !== false guard for SCIM-deactivated members.
MCP/agent/Eliza token re-validation: requireMCPAuth, requireAgentAuth, and requireElizaAuth each re-fetch the live server/agent record from DynamoDB after verifying the JWT signature. If the record's status is suspended (set by the corresponding revoke/suspend handler), the request is rejected with 403 even though the JWT's signature and TTL are still valid. This closes the window between suspension and natural token expiry (up to 5 minutes for MCP/Eliza, 2 minutes for agent).
API key lifecycle: createApiKey stores only keyHash (SHA-256) and keyPrefix (first 14 chars). The raw key is returned once and never persisted. deleteApiKey and deleteAllApiKeys call invalidateApiKeyCache to evict the in-memory LRU entry immediately, so revoked keys are rejected within the current Lambda container at the next request rather than waiting for the 60-second cache TTL.
Security Headers and Cookie Model
Every JSON response from getCorsHeaders() (lib/response.js) includes: X-Content-Type-Options: nosniff, X-Frame-Options: DENY, Strict-Transport-Security: max-age=63072000; includeSubDomains; preload, Referrer-Policy: strict-origin-when-cross-origin, Permissions-Policy: camera=(), microphone=(), geolocation=(), Cache-Control: no-store.
In production (NODE_ENV=production), session cookies use the __Host- prefix: __Host-githat_access (access token, 15 min) and __Host-githat_refresh (refresh token, 7 days). The __Host- prefix enforces Secure=true, Path=/, and no Domain= attribute, blocking sibling-subdomain injection attacks. Legacy cookie names githat_access and githat_refresh are read as fallback aliases during rolling deploys and during local development where the prefix is omitted.
The token extraction priority in middleware/auth.js getToken() is: (1) Authorization: Bearer <token> header, (2) __Host-githat_access cookie, (3) githat_access cookie (legacy fallback).
CORS origin values are scoped per-request via AsyncLocalStorage (_corsStore in lib/response.js) rather than module-level state, preventing origin leakage between concurrent invocations on warm containers.