pulse-account-api
Audience
PLANA staff. The backend behind every customer-facing call in my.planapulse.ai and the bridge between the account portal and tenant Odoos.
pulse-account-api is a Fastify v5 server. It authenticates customers, manages their account record on the platform DB, talks to each tenant's Odoo via XML-RPC, and proxies banking and AI-agent calls to the underlying services.
Stack
| Property | Value |
|---|---|
| Repo | git.planapulse.com/plana-pulse/pulse-account-api |
| Language | JavaScript ESM |
| Framework | Fastify v5 |
| Auth | JWT HS256 in pa_token cookie (15 min access, 30 day refresh) |
| Crypto | bcrypt for password hashes, jose for JWT |
| RPC to Odoo | XML-RPC (the only mechanism that works through auth_session_timeout) |
| Image base | node:20-alpine |
| Namespace | pulse-account |
| Port | 3000 |
| Database | plana_pulse_account on pg01 (user plana) |
| Cache | Redis at redis.redis:6379 |
API surface
Authentication
POST /api/auth/login email + password → sets pa_token cookie
POST /api/auth/logout clears the cookie
GET /api/auth/oidc/start redirects to Authentik authorize
GET /api/auth/oidc/callback exchanges code for tokens
GET /api/auth/me who am I (reads pa_token)
POST /api/auth/refresh refresh the JWTPortal
GET /api/portal/profile user's profile (name, email, lang)
PUT /api/portal/profile update profile
GET /api/portal/subscription current plan + status
GET /api/portal/integrations integration catalog with sync status
GET /api/portal/team team members
POST /api/portal/team/invite invite a teammate
GET /api/portal/settings timezone, lang, currency, alert thresholds
PUT /api/portal/settings update settings
GET /api/portal/apikeys list workspace API keys
POST /api/portal/apikeys create a key (returns plaintext once)
DELETE /api/portal/apikeys/:id revoke a keyBOS bridge to Odoo
GET /api/portal/odoo/:slug/kpis KPIs via XML-RPC to the tenant Odoo
GET /api/portal/odoo/:slug/cashflow cashflow series
GET /api/portal/odoo/:slug/alerts alerts feedBanking proxy
GET /api/banking/adapters list of PSD2 bank adapters
POST /api/banking/:bankId/consent start consent flow
GET /api/banking/:bankId/accounts accounts after consent
GET /api/banking/:bankId/accounts/:id/transactionsRoutes are proxied to pulse-banking:3200 with the workspace's API key injected.
AI agents proxy
POST /api/agents/:agentId/chat non-streaming
POST /api/agents/:agentId/chat/stream SSE streaming via reply.hijack()
GET /api/agents/:agentId/executions tool-call auditThe streaming proxy hijacks the Fastify response, opens an upstream SSE connection to ai-agents, and pipes bytes through. This is in the codebase under routes/agents.js.
Billing
GET /api/billing/invoices list invoices
GET /api/billing/invoices/:id invoice detailProxied to Stripe-backed pulse-billing (formerly through saas-orchestrator, now direct).
Webhooks (inbound)
POST /api/webhooks/provisioned from Crossplane after a PLANAClient becomes Ready
POST /api/webhooks/stripe Stripe events; verifies signatureAuthentication architecture
Two layers:
Cookie session for the browser —
pa_tokenis a JWT signed HS256 with a key in SOPS. Reads set the cookie's path to/,Secure,HttpOnly,SameSite=Lax. 15-minute lifetime; the frontend silently refreshes via/api/auth/refresh.X-API-Key for inbound API calls — workspace API keys (
pa_live_…) are accepted on the same endpoints when the cookie is absent.
The JWT is stateless but session revocation works via a Redis blocklist (pulse-auth:blocklist:{jti} with a TTL matching the JWT's exp). When a user logs out (or admin revokes), the JTI goes in the blocklist and any in-flight requests reusing it fail.
XML-RPC to tenant Odoo
pulse-account-api talks to each tenant Odoo via XML-RPC:
https://<tenant>.planapulse.app/xmlrpc/2/common # authenticate
https://<tenant>.planapulse.app/xmlrpc/2/object # execute_kwWhy XML-RPC and not JSON-RPC: auth_session_timeout (an OCA module shipped on every tenant) disables JSON-RPC for non-authenticated sessions, which has the side effect of breaking JSON-RPC entirely for outside callers. XML-RPC remains accessible with the API user's username + password (stored in SOPS per-tenant).
The API caches connection metadata in Redis per tenant with a 1-hour TTL.
Configuration
| Env var | Purpose |
|---|---|
JWT_SECRET | HMAC key for signing pa_token |
JWT_ISSUER | pulse-account-api |
OIDC_CLIENT_ID | pulse-account (in Authentik) |
OIDC_CLIENT_SECRET | SOPS |
OIDC_AUTH_URL | https://auth.planapulse.com/application/o/authorize/ |
OIDC_TOKEN_URL | https://auth.planapulse.com/application/o/token/ |
DB_URL | postgres://plana@pg01/plana_pulse_account |
REDIS_URL | redis://:<password>@redis.redis:6379/0 |
BANKING_API_URL | http://pulse-banking.pulse-banking:3200 |
BANKING_API_KEY | SOPS |
AGENTS_API_URL | http://ai-agents.ai-bos-agent:8000 |
AGENTS_API_KEY | SOPS |
EVENTS_API_URL | http://pulse-events.pulse-events:3001 |
EVENTS_API_KEY | SOPS |
INTERNAL_TOKEN | Service-to-service token from pulse-admin |
Secrets are mounted from a Kubernetes Secret produced by SOPS-secrets-operator.
Database — plana_pulse_account
Owner role: plana. Tables:
| Table | Purpose |
|---|---|
account | Customer accounts — id, email, name, lang, timezone, currency, alert_thresholds (JSONB) |
workspace | Per-workspace settings — slug, name, owner_account_id, settings |
account_workspace | M2M with role (owner, admin, member) |
api_key | Workspace API keys — bcrypt(token), workspace_id, name, scopes, created/last_used/revoked |
session | Refresh-token sessions (for revocation tracking) |
invitation | Pending team invites |
Schema migrations: a migrations/ directory of plain SQL files run on startup; the service tracks applied migrations in schema_migrations.
Health and readiness
| Endpoint | Purpose |
|---|---|
GET /healthz | Liveness — process is alive |
GET /readyz | Readiness — DB + Redis reachable |
Test coverage
| Layer | Coverage |
|---|---|
| Unit tests | 90%+ |
| Integration tests against real pg01 + Redis | Yes |
| End-to-end via Playwright | Selected critical paths |
Common operational tasks
Inspect a customer's account
kubectl -n pulse-account exec deploy/pulse-account-api -- \
psql "$DB_URL" -c "SELECT id, email, lang, timezone FROM account WHERE email='ceo@acme.bg'"Manually revoke all sessions for a user
kubectl -n pulse-account exec deploy/pulse-account-api -- \
redis-cli -h redis.redis -a "$REDIS_PASSWORD" --scan --pattern "pulse-auth:session:<account_id>:*" \
| xargs -I{} redis-cli -h redis.redis -a "$REDIS_PASSWORD" DEL {}The user must log in again on next request.
Replay a Stripe webhook
If a pulse-billing event was missed, replay from Stripe:
Stripe dashboard → Developers → Webhooks → resend the failed eventThe endpoint verifies signatures so replays from outside Stripe are rejected.
Diagnose a "502 Bad Gateway" on /api/portal/odoo/:slug/kpis
The error is from pulse-account-api failing to reach the tenant Odoo:
- Confirm the tenant is up:
curl -sI https://<slug>.planapulse.app/web/health - Check the API service log:
kubectl -n pulse-account logs deploy/pulse-account-api | grep <slug> | tail -50 - Common cause: the tenant's API user password (in SOPS) doesn't match the live Odoo. Re-set it from
pulse-adminor reset on the tenant directly.
Where to read more
- pulse-account — the Nuxt frontend
- pulse-banking — the proxied banking backend
- ai-agents — the proxied AI agent runtime
- Identity → API keys —
pa_live_*keys - Architecture → Envoy Gateway — how routing for
/apiworks