Shape of the system
One application service.services/core-service is a NestJS application and it is the entire backend. It serves the application API, the public /go click redirect, the partner endpoints, webhooks from Shopify and Stripe, and a Socket.IO gateway for real-time events. A single PostgreSQL database sits behind it, accessed through Prisma.
The repository is a pnpm workspace with two packages: services/core-service and subgraph. Solidity contracts live at the repository root under contracts/ and are built with Hardhat.
/go redirect, now runs in-process. If you meet references to an “ad-service”, widgets, embed tokens or /track/* endpoints in older material, they describe a system that no longer exists.
The request path
- Prefix. Every route is under
/apiexcept health probes, the/goredirect and the/partner/*wire, which are served at the root. - Validation. A global
ValidationPiperuns withwhitelist,transformandforbidNonWhitelisted. Unknown body or query fields are rejected with400, not silently dropped. - Rate limiting. One global throttler applies a generous per-user limit (per IP for anonymous callers). Sensitive routes override it with stricter tiers: on-chain writes, live chain reads, auth and wallet actions, telemetry sinks, and the partner read routes each have their own tier.
- Authentication. A guard verifies the Clerk session token (see below), then role, ownership and account-status guards run. Machine callers use HMAC or bearer guards instead; there are no API keys for users.
- Controller, then service, then Prisma. About a third of the modules add a repository layer between service and Prisma.
auth.token).
Authentication and tenants
Users sign in with Clerk. The backend verifies every session token through one verifier, which is used by the HTTP guard, the rate limiter and the WebSocket handshake alike. The platform is multi-tenant. A tenant is a partitioned marketplace with its own Clerk instance, allowed frontend origins, join policy and optional fee. The token’s issuer decides the tenant, and a user’s tenant never changes after registration. Campaign reads are scoped to the caller’s tenant on the server; a campaign from another tenant returns404, exactly like one that does not exist. Partners are onboarded as tenants, not as forks of the code.
Roles are ADVERTISER, PUBLISHER, ADMIN and SUPER_ADMIN. Only the first two can self-register, and a user holds exactly one role.
On-chain and off-chain
The contract is an audit trail of attribution. It never holds or moves money. Funds live in Stripe and in the ledger tables. Nothing is written on-chain when a shopper clicks; the chain is written only after an order has been attributed. Details in Smart contracts.
After a transaction, the backend can read derived state from a subgraph that indexes the registry’s events.
Design decisions worth knowing
- Attribution is last-click. The newest eligible click wins. This is a product decision, enforced in the theme embed and in the order resolver. See Attribution engine.
- Fail closed on money. When evidence is incomplete or contradictory (a click from another store, a coupon that does not resolve, a candidate list that is too large), the order is left unpaid with a reason rather than guessed.
- Ack fast, process later. Webhooks and partner pushes are persisted and acknowledged quickly. Attribution, chain writes and money movement happen asynchronously, with pollers that retry stuck work.
- Idempotency everywhere. The order inbox de-duplicates on event id. Every ledger entry carries a unique idempotency key. Repeated deliveries are absorbed, never double-applied.
- Append-only money. Ledger entries are never updated or deleted. Corrections are new entries.
- Provider-neutral core. Shopify-specific code sits behind a provider interface so other commerce platforms reuse the same attribution and money engine.
- Stores are soft-deleted. Store rows are never hard-deleted, so history and money remain traceable.
Names that lie
These are the traps that cost new engineers the most time.Keeping this page honest
Write only what code cannot tell you: decisions, reasons and traps. If an answer is available fromls, the schema or a controller, link to it instead. An unverified claim is worse than a gap.
Authentication
Clerk is the authentication system. UAM and Pebble use separate Clerk instances; when both are configured,TokenVerifierService routes by issuer before verifying
the signature and configured claims. AuthUserResolver scopes database matches to
the token’s tenant.
UAM-1065’s AUTH_GLOBAL_JWT_GUARD_ENABLED startup switch defaults to false. When
enabled, the global guard runs JwtAuthGuard on every HTTP route except handlers
or controllers marked @Public(). Explicit route guards remain active when the
switch is off, and public service routes retain their own signature guards.
After Clerk sign-in and email verification, registration stores the native Clerk
subject and creates the advertiser/publisher wallet and party. Existing imported
identities can resolve through external_id or a tenant-scoped verified-email
match. HTTP relinking compares the previous identity and journals the update in
one transaction; WebSocket resolution is read-only.
History: Migrated from Auth0 in UAM-739 (2026-07); retired in UAM-1064 (2026-11).