@the-situation/privy-backend
v0.5.1
Published
Effect-native Privy backend services for The Situation stack
Downloads
40
Readme
@the-situation/privy-backend
Effect-native Privy backend for auth, wallet management, and Starknet-ready account flows used by The Situation webapp.
Local Development
cp packages/privy-backend/.env.example packages/privy-backend/.env
# Fill required env vars in .env
bun run --filter '@the-situation/privy-backend' devHealth check:
curl http://localhost:3000/healthServer endpoints include:
GET /healthPOST /privy/create-walletPOST /privy/public-keyPOST /privy/deploy-walletPOST /privy/executeGET /privy/user-wallets
Environment Variables
| Variable | Required | Default | Description |
|---|---|---|---|
| PRIVY_APP_ID | Yes | — | Privy app ID |
| PRIVY_APP_SECRET | Yes | — | Privy app secret |
| PRIVY_WALLET_AUTH_PRIVATE_KEY | No | — | Optional raw-sign authorization private key override |
| RPC_URL | Yes | — | Starknet RPC URL |
| READY_CLASS_HASH (or READY_CLASSHASH) | Yes | — | Ready account class hash |
| CLIENT_URL | Recommended | — | Trusted frontend origin used in Privy wallet signing requests |
| PRIVY_API_BASE_URL | No | https://api.privy.io | Privy API base URL |
| HOST | No | 0.0.0.0 | HTTP bind host |
| PORT | No | 3000 | HTTP bind port |
| ALLOW_ORIGIN_OVERRIDE | No | false | Enables direct caller override of outbound Origin header |
| ORIGIN_OVERRIDE_ALLOWLIST | Conditionally (required if ALLOW_ORIGIN_OVERRIDE=true) | — | Comma-separated absolute origins |
| PAYMASTER_URL | No | https://sepolia.paymaster.avnu.fi (when enabled) | Paymaster RPC URL |
| PAYMASTER_MODE | No | sponsored | sponsored or default |
| PAYMASTER_API_KEY | Conditionally (required for sponsored mode) | — | Paymaster API key |
| GAS_TOKEN_ADDRESS | Conditionally (required for default mode) | — | Gas token for default paymaster mode |
| IDEMPOTENCY_STORE | No | memory | memory (default, process-local) or redis (shared across instances) |
| REDIS_URL | Conditionally (required when IDEMPOTENCY_STORE=redis) | — | Redis connection URL used by the shared idempotency store |
| IDEMPOTENCY_TTL_SECONDS | No | 300 | TTL (seconds) for cached idempotent responses |
| AUTH_CACHE_FALLBACK_TTL_SECONDS | No | 60 | Fallback TTL for the JWT verify cache when verified claims have no exp. |
| PAYMASTER_TOKENS_TTL_SECONDS | No | 300 | TTL for the service-scoped cache of paymaster.getSupportedTokens() results. |
| AUTH_CACHE_DISABLED | No | false | Set to a truthy value (true/1/yes/on, case-insensitive) to bypass the JWT verify cache entirely (verify every request upstream). Incident-response lever — no code deploy required. |
| PAYMASTER_TOKENS_CACHE_DISABLED | No | false | Set to a truthy value (true/1/yes/on, case-insensitive) to bypass the paymaster getSupportedTokens cache entirely (fetch on every execute). Incident-response lever — no code deploy required. |
Performance
The backend ships with three in-process caches that cut redundant upstream calls on hot request paths. All are process-local and bounded.
- JWT verify cache (
privyAuthPlugin). Verified Privy tokens are cached in a bounded (10k entries) in-memory map keyed by the sha256 digest of the raw token. TTL is taken from the JWTexpclaim when present, else falls back toAUTH_CACHE_FALLBACK_TTL_SECONDS(default 60s). Invalid tokens and upstream outages are never cached. Concurrent requests with the same bearer token dedupe to a single upstream verify. - Paymaster tokens cache (service-scoped). When
PAYMASTER_MODE=defaultand noGAS_TOKEN_ADDRESSis configured, the service probespaymaster.getSupportedTokens()once perPAYMASTER_TOKENS_TTL_SECONDS(default 300s) instead of per-execute. Concurrent cold-cache executes dedupe to one fetch. Failures are never cached. - Memoized
RpcProvider. EachmakePrivyStarknetServiceinstance creates oneRpcProviderand reuses it for allwaitForTransactioncalls andcreateAccountconstructions — sharing the underlying keep-alive connection pool.
Both TTL caches can be opted out by passing null to the authCache / paymasterTokensCache option on the relevant factory. Tests inject custom clocks and cache instances via the same options.
For ops, the same opt-out is exposed via env vars without a code deploy: set AUTH_CACHE_DISABLED=true to verify every JWT upstream, or PAYMASTER_TOKENS_CACHE_DISABLED=true to fetch paymaster tokens on every execute. Both accept the case-insensitive truthy strings true/1/yes/on; anything else (including unset, empty, or false) keeps the default cache enabled. Worked example: if Privy revokes a token mid-window, the cache may still serve the cached claims for up to AUTH_CACHE_FALLBACK_TTL_SECONDS (or until the JWT exp) — flipping AUTH_CACHE_DISABLED=true and rolling the deployment makes every request re-verify upstream until the incident is resolved.
Webapp Integration Contract
Auth semantics
Authorization: Bearer <privy_user_jwt>is required for all/privy/*endpoints.401is returned for missing/invalid auth.503is returned when upstream auth verification is unavailable.- Wallet-scoped endpoints enforce authenticated user ownership checks.
Idempotency semantics
POST /privy/deploy-walletandPOST /privy/executeaccept optionalIdempotency-Keyheader.- Reusing the same key with the same authenticated user and same request payload replays the previous result.
- Reusing the same key with a different payload returns
409. - If omitted, service generates an idempotency key for outbound Privy signing calls.
- The backend is pluggable via
IDEMPOTENCY_STORE:memory(default) — process-local in-memory dedupe. Multi-machine deployments MUST either route all write traffic to a single machine or switch to the shared backend before scaling horizontally.redis— shared replay store backed by Redis. Safe across multiple instances. A Redis client must be wired into the package viaresolveIdempotencyStore({ redisClientFactory })— this package does not take a direct redis dependency.
Deploy wallet request
POST /privy/deploy-wallet
{
"walletId": "wallet-123",
"wait": true,
"usePaymaster": false
}waitmaps towaitForReceipt.usePaymasterenables paymaster-aware account execution path.
Execute request
POST /privy/execute
{
"walletId": "wallet-123",
"wait": true,
"usePaymaster": true,
"call": {
"contractAddress": "0x...",
"entrypoint": "transfer",
"calldata": {
"recipient": "0x...",
"amount": "1"
}
}
}- Provide exactly one of
callorcalls. - Response shape is flat (
walletId,address,publicKey,transactionHash,result, optionalreceipt).
Origin override hardening
- Default mode (
ALLOW_ORIGIN_OVERRIDE=false) ignores caller-providedoriginand uses configuredCLIENT_URL. - If override mode is enabled, caller
originmust be:- a valid absolute
http(s)URL, and - present in
ORIGIN_OVERRIDE_ALLOWLIST.
- a valid absolute
CLIENT_URLis required whenALLOW_ORIGIN_OVERRIDE=true. It is the fail-closed fallback origin used when the caller does not supply one.- Configuration fails closed if override mode is enabled without a non-empty allowlist.
ORIGIN_OVERRIDE_ALLOWLISTentries must be exact origins — scheme + host + optional port only. The loader rejects:- any path component (including a bare trailing slash like
https://ok.com/), - any query string (
?foo=bar) or fragment (#frag), - any userinfo component (
https://user:[email protected]—URL.originsilently drops userinfo, so this is rejected to prevent credential-bearing env entries from collapsing to the same stored origin as a clean entry), - any wildcard / pattern tokens (
*,?, URL-encoded%2A/%2a), - schemes other than
httporhttps, - malformed URLs.
- any path component (including a bare trailing slash like
- These format checks run regardless of whether override is enabled — a misconfigured allowlist in a dormant
ALLOW_ORIGIN_OVERRIDE=falseenvironment still fails loud so the error is caught before the flag is flipped. - Case is normalized via
URL.origin(HTTPS://Example.COM:443canonicalizes tohttps://example.com). - Duplicate entries are deduped silently.
Startup warning
When ALLOW_ORIGIN_OVERRIDE=true resolves successfully, the loader emits exactly one structured warning per successful config load:
console.warn('[privy-starknet] origin-override-enabled', {
marker: 'origin-override-enabled',
allowlistSize: <N>,
});Grep marker for log pipelines: [privy-starknet] origin-override-enabled. Pipe this to your observability tool to alert on services that boot with override enabled.
Audit log format
makePrivyStarknetService({ ..., auditLog }) accepts an optional synchronous sink that receives one OriginOverrideAuditEvent per rawSign call. If you do not pass an auditLog sink, audit events are dropped silently — wire the sink to your logging pipeline or origin-override decisions will be unobservable in production.
type OriginAuditRoute = 'rawSign';
interface OriginOverrideAuditEvent {
readonly decision: 'allowed' | 'denied' | 'fallback' | 'no-override';
readonly reason:
| 'allowed'
| 'not-in-allowlist'
| 'invalid-url'
| 'no-caller-origin'
| 'override-disabled'
| 'allowlist-empty';
readonly route: OriginAuditRoute; // closed union — currently only 'rawSign'
readonly resolvedOrigin?: string; // the outbound Origin header (when decided)
readonly allowlistSize: number;
readonly userIdDigest?: string; // sha256(appId + ':' + userId), hex, truncated to 12 chars
}Worked example (an allowed decision on a two-entry allowlist):
{
"decision": "allowed",
"reason": "allowed",
"route": "rawSign",
"resolvedOrigin": "https://ok.com",
"allowlistSize": 2,
"userIdDigest": "a1b2c3d4e5f6"
}Decision taxonomy:
| Caller origin | allowOriginOverride | decision | reason |
|---|---|---|---|
| (any) | false | no-override | override-disabled |
| empty | true | fallback | no-caller-origin |
| malformed URL | true | denied | invalid-url |
| not in allowlist | true | denied | not-in-allowlist |
| in allowlist | true | allowed | allowed |
Notes for ops:
userIdDigestis a truncated salted sha256 of the caller-supplieduserId, not the plaintext userId. The salt is the PrivyappId, so ops correlate by computingsha256(appId + ':' + userId).slice(0, 12)client-side. Within a single tenant the digest is stable; across tenants the same userId maps to distinct digests (defeats cross-deployment rainbow correlation). The sink never sees the rawuserId,messageHash, oruserJwt.- One audit event is emitted per
rawSigninvocation.buildReadyAccountdoes not double-emit — only the innerrawSignsurface is audited. - The
OriginOverrideAuditSinksignature is declared synchronous ((event) => void) to nudge callers toward sync sinks. At runtime the impl tolerates async sinks: both synchronous throws and rejected promises are swallowed so a broken logger never aborts a sign call. - Operators must wire this sink into their logging pipeline. Without a sink, decisions are silent — you lose the audit trail entirely. Ensure the sink is non-blocking (pipe to your observability tool asynchronously so it does not stall signing).
Deploying To Fly.io
This package includes:
packages/privy-backend/fly.tomlDockerfile.privy-backend(repo root, indexer-style workspace build)
The Docker build uses the workspace bun.lock with --frozen-lockfile for reproducible installs.
cd packages/privy-backend
# First-time setup
fly apps create situation-privy-backend
# Required only when using the default in-memory idempotency store
# (IDEMPOTENCY_STORE unset or =memory). When IDEMPOTENCY_STORE=redis is
# configured with a REDIS_URL and client factory, replay state is shared
# across machines and you can scale horizontally.
fly scale count 1
# Set required secrets
fly secrets set \
PRIVY_APP_ID=your-app-id \
PRIVY_APP_SECRET=your-app-secret \
RPC_URL=https://api.cartridge.gg/x/starknet/sepolia \
READY_CLASS_HASH=0xready-account-class-hash \
CLIENT_URL=https://your-webapp.example
# Optional: enable tightly allowlisted origin override
# fly secrets set \
# ALLOW_ORIGIN_OVERRIDE=true \
# ORIGIN_OVERRIDE_ALLOWLIST=https://app.the-situation.com,https://staging.the-situation.com
# Optional secrets
# fly secrets set PAYMASTER_API_KEY=your-paymaster-key
fly deployValidate deployment:
curl https://situation-privy-backend.fly.dev/healthUseful Fly commands:
fly logs
fly machines list
fly machines restart