Taxbox Architecture
Host shell and domain MFEs over a single gateway to auth, billing, and vault services — JuiceFS tenant prefixes, dual Valkey, inbound webhooks, and desktop STS mount for CPA firm workflows.
SnapNgo system map (TaxBox)
┌──────────────────────────────────────────────────┐
│ Host Shell │
│ (with Auth) │
│ ┌──────────┐ ┌───────────┐ ┌─────────┐ │
│ │ Auth MFE │ │Billing MFE│ │Vault MFE│ │
│ └────┬─────┘ └─────┬─────┘ └────┬────┘ │
└───────┼─────────────┼────────────┼───────────────┘
│ │ │
└─────────┐ │ ┌────────┘
▼ ▼ ▼
┌──────────────────────────────────────────────────┐
│ API Gateway │
└───┬─────────────┬─────────────┬──────────────────┘
│ │ │
▼ ▼ ▼
┌─────────────┐ ┌─────────────┐ ┌─────────────┐
│ Auth │ │ Billing │ │ Vault │
│ Microservice│ │ Microservice│ │ Microservice│
└─────────────┘ └─────────────┘ └──────┬──────┘
│
│ Enqueues Job
User File Upload ▼
│ ┌──────────────┐
▼ │ Valkey │
┌──────────────┐ Webhook Event │ Agent Queue │
│ JuiceFS S3 ├───────────────►└──────┬───────┘
│ (Prefix) │ routes to Vault │ BRPOP / Consume
└──────────────┘ ▼
┌──────────────┐
│ Agent Worker │
│ (Python) │
└──────────────┘
packages (import, not deploy):
@taxbox/plans · @taxbox/ui
@snapngo/shared · @snapngo/observability
SnapNgo layers
Host shell holds session and active workspace, then loads Auth, Billing, and Vault MFEs. Each remote calls the API gateway; the gateway routes to the matching service. Storage and async work sit under Vault: JuiceFS prefixes, agent Valkey, Python worker.
- Host shell — session, active workspace, app frame
- Auth / Billing / Vault MFEs — independently deployable UI
- API gateway — JWT / device token; injects
x-user-*; proxies - Auth / Billing / Vault services — identity, entitlement, files
- JuiceFS + valkey-agent + worker — blobs and inbox jobs
Mental model (services)
Four jobs under the SnapNgo gateway. Data ownership is deliberate so isolation is structural, not a filter bolted onto one monolith.
- auth — people, workspaces (orgs), members, roles, invites, desktop pairing
- billing — Stripe, subscriptions, entitlement flags (
readAllowed/writeAllowed) - vault — if membership + entitlement allow → S3 presign / STS under
orgs/{organizationId}/ - gateway — door: verify identity, strip bearer, inject trusted headers, proxy
There is no separate “vault” table. A workspace is an auth organization; file storage is keyed by that id. Vault never owns Stripe rows; billing never stores files.
Shared packages
Not deployable services — libraries every app imports so plan rules, auth headers, and UI tokens stay single-source across the monorepo.
-
@taxbox/plans— plan catalog (Basic, Pro, ClientPays, FirmStarter, FirmPro, …):safeLimit,seatLimit, billing mode. Billing entitlement and checkout read this; vault does not invent plan numbers. -
@snapngo/shared— error shapes, OpenAPI helpers, health helpers, gateway header names, service port map. Shared contract so MFEs and services agree on identity headers and error envelopes. -
@snapngo/observability— Pino logging, OTEL middleware, span helpers. Same telemetry spine on gateway and services without copy-paste. -
@taxbox/ui— design tokens, theme CSS, ThemeProvider, Radix-backed components. Host shell and remotes share look and behavior.
Scope split: @taxbox/* for product packages (plans, UI);
@snapngo/* for platform libraries (shared, observability).
Request path (service detail)
Host shell / MFE
│
▼
┌───────────────────┐
│ api-gateway │ JWT · rate-limit · x-user-* headers
└─────────┬─────────┘
│
┌─────┼──────────────┐
▼ ▼ ▼
auth billing vault
│ │ ├── JuiceFS S3 (orgs/{id}/…)
│ │ └── valkey-agent → agent-worker
└─────┴──────┬───────┘
▼
Postgres
+
valkey-gateway (edge rate-limit only)
Gateway as trust boundary
The browser only talks to the gateway. On authenticated routes the gateway verifies JWT (or resolves a desktop device token), then injects:
x-user-id,x-user-email,x-user-name,x-user-rolex-active-organization-id
Authorization is stripped after verify so backends trust gateway headers — not a re-used bearer. Public paths are narrow: inbound webhooks and desktop pairing start/poll. Everything else requires identity.
Workspaces, payers, and vaults
Workspaces are boxes. Subscriptions live on the payer box — not on every client safe.
ONE firm plan subscription
│
▼
┌───────────────────┐
│ Firm account │ ← checkout FirmStarter / FirmPro here
└─────────┬─────────┘
│ funds up to safeLimit
┌─────────────┼─────────────┐
▼ ▼ ▼
Client vault Client vault Client vault
(firm_pool) (firm_pool) (firm_pool)
no own Stripe no own Stripe no own Stripe
Exception: ClientPays vault → own Stripe sub, does not consume firm safe slot
Personal vault → taxpayer Basic/Pro on that workspace
Org metadata carries kind (firm / personal / client) and
billing (firm_pool / client_pays / personal), plus
firmOrgId when a client vault is firm-linked. Entitlement resolution uses the
billable org (firm for pool vaults; the vault itself for Client Pays / personal).
Isolation model
Access is resolved before entitlement. Vault does not invent tenancy from the request body alone.
- Platform admin → allow
- Direct
memberrow for (user, vault org) → use that role - Else firm staff: client org has
firmOrgIdand user is a member of the firm → firm role - Else deny
Write roles: owner, admin, staff, collaborator.
Read-only: viewer, member. Object keys live under
orgs/{organizationId}/… — cross-tenant paths are not a query join problem;
the prefix is the tenancy boundary. Billing can still refuse write when
pending_payment, past due, or over safeLimit.
Critical path: browser upload
1. Web → gateway → auth: JWT
2. Web → gateway → vault: POST /vault/presign/upload
3. Gateway: verify JWT, set x-user-*, strip Authorization
4. Vault: resolve membership (member and/or firmOrgId)
5. Vault → billing: GET /entitlement?organizationId=…
6. Billing: firm or vault sub → writeAllowed?
7. Vault: presigned PUT under orgs/{id}/…
8. Browser: PUT bytes directly to JuiceFS (not through app servers)
File bytes never transit auth or billing. Vault is policy + credentials; JuiceFS holds blobs.
Storage + agent pipeline
JuiceFS exposes an S3-compatible API. Inbox layout:
orgs/{organizationId}/inbox/YYYY-MM-DD/{source}-….
When an object lands under an inbox prefix, the storage webhook (secret-checked, fast ACK) enqueues work. Two Valkey instances stay isolated on purpose:
- valkey-gateway — rate-limit / edge only
- valkey-agent — inbox job queue (
inbox:jobs); Pythonagent-workerBRPOPs
Postgres holds durable map + run logs (object key ↔ job ↔ agent status). Mixing rate-limit keys with agent jobs on one Redis is a production failure mode we designed out.
Email / SMS / browser / desktop put
│
▼
JuiceFS orgs/{id}/inbox/…
│ S3 notify (or inline enqueue after PutObject)
▼
POST /api/v1/webhooks/storage ← secret, fast ACK
├─► valkey-agent (queue)
└─► Postgres (map + logs)
│
▼
agent-worker (Python)
read object → classify / extract → update run
Inbound webhooks
Files can enter without a logged-in browser session. Gateway bypasses JWT on
/api/v1/webhooks/*; vault enforces secrets and vault resolution.
- Email (SendGrid Inbound Parse) — local-part / slug → vault; attachments → inbox
- SMS (Twilio) — body line
vault:{slug}; media → inbox - Storage — S3 event on inbox prefix → agent pipeline trigger
Unknown email vaults return 200 without storing (avoid provider retry storms).
Storage requires STORAGE_WEBHOOK_SECRET. Only inbox prefixes wake the agent —
not the whole vault tree.
Desktop pairing
Pairing binds a device to a user — not who pays and not workspace ownership. Direction is device → web (reverse of “type a code into the app”).
- Desktop
pairing/start→ shows shortuserCode, keepsdeviceCodesecret (15m) - Signed-in web
/desktop→ approve code - Desktop polls with deviceCode → one-shot
deviceToken(~90d) - List safes +
vault-credentials→ STS underorgs/{id}/; remount ~50m
Gateway treats opaque device tokens like users (resolve-token → x-user-*).
Raw codes/tokens are hashed at rest; unknown poll codes return pending (no enumeration).
Firm staff see firm-linked client vaults without per-vault membership invites.
What each service refuses to own
- Gateway — no orgs, no Stripe, no S3 product rules
- Auth — no charges, no file bytes, no “is this vault paid?”
- Billing — no membership source of truth, no file storage
- Vault — no Stripe, no firm/client create, no long-term membership truth
That split keeps failure modes local: a billing outage can fail entitlement closed; a vault bug cannot rewrite who is on the firm roster.
Isolation is structural: workspace id as storage prefix, membership + firm linkage as access, billing entitlement as capacity, gateway as the only public door. UI remotes and backend services share the same cut lines so tenancy cannot drift between shell and S3.