STALL.ONE
<< Back to TaxBox
WHITE_PAPER // SYSTEM DESIGN

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.

Project
TaxBox
Audience
Lead eng · architects
Stack
SnapNgo · MFEs · Gateway · JuiceFS · Valkey
WHITE_PAPER SNAPNGO MICROSERVICES MULTI_TENANT JUICEFS
┌──────────────────────────────────────────────────┐
│                    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).

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-role
  • x-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 member row for (user, vault org) → use that role
  • Else firm staff: client org has firmOrgId and 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); Python agent-worker BRPOPs

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”).

  1. Desktop pairing/start → shows short userCode, keeps deviceCode secret (15m)
  2. Signed-in web /desktop → approve code
  3. Desktop polls with deviceCode → one-shot deviceToken (~90d)
  4. List safes + vault-credentials → STS under orgs/{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.

Outcome

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.