Appearance
Backend Conventions
Postmill's backend follows a strict NestJS layering discipline. Every request passes through every layer — no shortcuts.
NestJS layering
Controller → Service/Manager → RepositoryWhen a manager is involved (orchestration/coordination across domains):
Controller → Manager → Service → RepositoryWhat goes where
| Layer | Responsibility | Must NOT |
|---|---|---|
| Controller | HTTP route wiring (@Get, @Post), @Body/@Query/@Param extraction, @UseGuards decorators, @CheckPolicies, @RequirePermission | Call Prisma, contain business logic |
| Service | Business logic, validation, cross-domain coordination | Call Prisma directly |
| Manager | Multi-step orchestration, transaction boundaries, workflow coordination | Call Prisma directly |
| Repository | Prisma queries ONLY — findMany, create, update, $queryRaw | Contain business logic |
Cross-domain calls
When a service needs data from another domain, it calls that domain's service — never its repository:
ts
// CORRECT — call the service
const post = await this._postsService.getPost(orgId, postId);
// WRONG — never reach into another domain's repository
const post = await this._postsRepository.findById(postId);Thin backend app
apps/backend is kept intentionally thin — mostly controllers + module wiring. Real logic lives in libraries/nestjs-libraries. The backend imports and re-exports from shared libraries; it should not contain substantial business logic, database access, or provider integrations.
Sanctioned exceptions
Two situations are allowed to bypass the normal layering rule by design:
- Seeders and migration steps under
database/seeds/**— notablyBackfillServiceandRbacSeeder— intentionally usePrismaService+$transactiondirectly for cross-table backfills and seeds. - Cross-domain leaf-reads where routing "up" through the owning service would create a Nest dependency-injection cycle. These are deliberate, behavior-neutral reads and must carry a
// layering: sanctioned leaf-readcomment:PostsService→AnalyticsRepository/CampaignsRepository(the analytics/campaigns services depend onPostsService).OrgMediaProviderSettingsService→@Optional() OrgAiSettingsRepository(the Qwen/Google universal-credential read;OrgAiSettingsServicedepends on this package'sProviderCredentialLinkService).AiMediaService(ai/governance/media.service.ts) →@Optional() OrgAiSettingsRepository(universal-credential fallback; same DI-cycle rationale as above).StripeService→StripeEventRepository(narrow Stripe-webhook idempotency/grace reads, no service-level cycle).PostActivity(Inngest) →CampaignsRepository(UTMutmEnabledflag) andPostsRepository(atomic publish claim).OrgVpnConfigService→OrgProviderConfigRepository(OrgProviderConfigServicedepends back on this service; used to clear orphaned channelvpnSelectionrows).StorageService→ subscription read via the repository (routing throughSubscriptionServicewould close a DI cycle that crashes Nest at boot).WebhooksService→IntegrationRepository(id-only ownership check, no token decrypt).NotificationService→OrganizationRepository(OrganizationServicedepends onNotificationService).
DTO validation
The global ValidationPipe is configured with:
ts
new ValidationPipe({
transform: true,
whitelist: true,
forbidNonWhitelisted: true,
})| Setting | Effect |
|---|---|
transform: true | Auto-casts query/param strings to their declared types |
whitelist: true | Strips properties not declared in the DTO class |
forbidNonWhitelisted | Returns a 400 error when unknown properties are sent |
Rule: Every new optional field must be declared on its DTO class. Unknown fields are rejected.
Two orthogonal access gates
Routes are gated by two independent guards. Do not merge them.
| Gate | Decorator | Guard | Question | Failure |
|---|---|---|---|---|
| Billing/tier | @CheckPolicies([Action, Section]) | PoliciesGuard | Has this org paid for this feature? | SubscriptionException → HTTP 402 |
| RBAC | @RequirePermission(resource, action) | OrgRbacGuard | Is this member allowed to do this? | ForbiddenException → HTTP 403 |
ts
@CheckPolicies([AuthorizationActions.Create, Sections.TEAM_MEMBERS]) // 402 if plan lacks it
@RequirePermission('settings', 'update') // 403 if role lacks it
@Post('/team')Rules:
@RequirePermissionresolves the acting user's membership (UserOrganization.roleId→AppRole→ permissions).manageon a resource implies every action on it.User.isSuperAdmin(the platform operator flag) bypasses RBAC — it does not bypass billing. It is a different axis from the orgownerrole.- The seeded system roles are
owner,admin,editor,member,viewer(seelibraries/nestjs-libraries/src/database/seeds/rbac-seeder.tsfor the exact catalog); orgs can define custom roles via/settings/roles. - Guard sources:
apps/backend/src/services/auth/rbac/org-rbac.guard.tsandrequire-permission.decorator.ts.
CSRF protection
CSRF middleware is applied to all cookie-authenticated mutating routes (POST, PUT, PATCH, DELETE). The middleware checks for a matching x-csrf-token header on state-changing requests.
Exemptions:
- Routes authenticated via
Authorizationheader (API keys, OAuth tokens). - Routes authenticated via browser extension JWT (different session model).
- All routes when
NOT_SECUREDenv var is set (dev/local only).
Security invariants
safeFetch for outbound HTTP
Every outbound HTTP call that involves a user-influenced URL must go through safeFetch (libraries/nestjs-libraries/src/dtos/webhooks/safe.fetch.ts):
safeFetch lives in libraries/nestjs-libraries/src/dtos/webhooks/safe.fetch.ts and enforces:
isSafePublicHttpsUrlvalidation — blocks private IPs, localhost, etc.ssrfSafeDispatcher— custom undici dispatcher.- Manual per-hop redirect re-validation (prevents DNS rebinding attacks).
Areas covered: webhook dispatch, provider HTTP fetches, watchlist probes. Never use bare fetch(url) where url is user-influenced. DTO validation alone doesn't survive DNS rebinding or 30x redirects.
The SSRF_ALLOWED_PRIVATE_CIDRS env var allows self-hosted instances to whitelist internal network ranges.
EncryptionService for secrets
All at-rest secrets are encrypted with AES-256-GCM via EncryptionService. Encrypted values use the v2: prefix. The service reads ENCRYPTION_KEY or falls back to deriving a key from JWT_SECRET.
This is a single-key model: one deployment-wide key encrypts every secret, regardless of organization. There is no per-org crypto key — an organizationId column scopes storage, and cross-org isolation is enforced by query scoping, not by separate keys. EncryptionService (the per-org domain path) is a thin wrapper over AuthService.fixedEncryption/fixedDecryption (the global-row path); the split is an implementation detail — both derive the identical key and produce the identical v2: envelope, so never mix the two decrypt routes for the same row.
No secrets in logs
Use NestJS Logger.warn(message) / Logger.error(message) — never console.log(err). Raw API response bodies and full prompt bodies are stripped before logging. Error messages stored in Errors.body are redacted before persist.
JWT configuration
- Algorithm pinned to
HS256. - New tokens carry
expwith sliding renewal. - IDs and secrets generated with CSPRNG.
NOT_SECURED bypass
When NOT_SECURED=true (dev/local only):
- HSTS and CSP headers are skipped.
- CSRF middleware is disabled.
- CopilotKit policy gate is bypassed.
Response headers never expose JWTs, even under NOT_SECURED.
Repository pattern
Base classes
ts
// Typed access to a single Prisma model
export class PrismaRepository<T extends keyof PrismaService> {
public model: Pick<PrismaService, T>;
// ...
}
// Transaction wrapper
export class PrismaTransaction {
public model: Pick<PrismaService, '$transaction'>;
// ...
}Both are in libraries/nestjs-libraries/src/database/prisma/prisma.service.ts.
Domain structure
Each domain has its own directory under database/prisma/<domain>/:
database/prisma/
├── ai-rag/ → ai-rag.repository.ts
├── ai-settings/ → ai-settings.repository.ts, org-ai-settings.repository.ts
├── analytics/ → analytics.repository.ts
├── announcements/ → announcements.repository.ts
├── api-keys/ → api-keys.repository.ts
├── audit/ → audit.repository.ts
├── auth-providers/ → auth-provider.repository.ts
├── autopost/ → autopost.repository.ts
├── brands/ → brands.repository.ts
├── campaigns/ → campaigns.repository.ts
├── emails/ → email-log.repository.ts
├── featured-providers/→ featured-provider.repository.ts
├── integrations/ → integration.repository.ts
├── media/ → media.repository.ts, multipart-upload.repository.ts
├── media-providers/ → org-media-provider-settings.repository.ts
├── notifications/ → notifications.repository.ts
├── oauth/ → oauth.repository.ts
├── organizations/ → organization.repository.ts
├── posts/ → posts.repository.ts
├── provider-configs/ → provider-config.repository.ts, org-provider-config.repository.ts
├── roles/ → roles.repository.ts
├── sets/ → sets.repository.ts
├── short-links/ → org-shortlink-settings.repository.ts
├── signatures/ → signature.repository.ts
├── social-comments/ → social.comments.repository.ts
├── storage/ → storage.repository.ts
├── subscriptions/ → subscription.repository.ts
├── users/ → users.repository.ts
├── watchlist/ → watchlist.repository.ts
└── webhooks/ → webhooks.repository.tsEach repository typically has a companion service file in the same directory (e.g., posts.service.ts).
Module wiring
All provider domains (AI, Media, Storage, Short-link, Social, VPN, Content Packs, Email, Auth) register through the shared ProviderKernel at module init. apps/backend/src/providers.generated.ts (hand-maintained despite the name) imports every provider package's modules into a single providerModules array; ProvidersBootstrap.onModuleInit (apps/backend/src/providers.bootstrap.ts) walks that array and calls kernel.register(mod) for each, honoring the DEV_DISABLE_* feature flags. Malformed manifests or duplicate registrations are fatal to boot. The kernel is the only registry — there is no fallback.
Resolution is through ProviderResolutionService — the kernel is the sole resolution path. Channel provider integrations resolve the same way: social adapters are wrapped in SocialProviderKernelAdapter and registered like every other domain; IntegrationManager resolves them by identifier.
See Architecture and Provider Framework.
Verified against v1.0.0