Skip to content

Unauthenticated (@Public()) endpoints ​

Every route that opts out of the global AppGuard is listed here with the reason it is allowed to, and the control that stands in for authentication. A route not on this list must not carry @Public().

This page is the SOC 2 artifact for CC6.1 / CC6.6 / CC6.7: an auditor can re-check it against grep -rn "@Public()" apps/agri-backend/src without reading the code. Last full sweep: 1 Sep 2026 (ENG-1403).

Rules ​

  • @Public() at class level makes every route in the controller public. Prefer method level β€” class-level decorators are how most of the ENG-1403 findings happened.
  • A route that a machine calls is not a reason to use @Public(). Use @ApiKeyAllowed() + ApiKeyGuard (x-api-key, routed by AppGuard), or an HMAC signature guard like VapiSignatureGuard.
  • Adding a @Public() route means adding a row here in the same PR.
  • Route paths that still read public/... while being authenticated are kept only because installed mobile builds and cached PWA bundles pin bare paths (see API versioning). The path is not the control.

Justified public routes ​

RouteWhy publicControl
GET / (app.controller.ts)liveness pingreturns a constant
GET /health, GET /health/readyplatform probesno data returned
POST /auth/verify-token, /session-login, /session-verifypre-authentication by definitionFirebase token/cookie verification is the check
POST /auth/registersignupRateLimitGuard; gated further by ENG-1400
POST /impersonate/tokenthe operator's browser holds no session yetoperator link TTL 300s and single-use (jti consumed in Redis); the 30-day demo token is reusable by design and re-checked by assertDemoOnlyTarget on every exchange
GET /public/reports/:shareTokenshare-by-linkrandomUUID() token (122 bits), unique sparse index; findByShareToken requires isPubliclyShared: true, so unsharing revokes at once. No expiry β€” accepted risk
POST /account/cancel-deletion-tokenemailed link, the user may be signed outZod-validated token with a service-side expiry
POST /signup-invites/previewinvite landing page renders before signupRateLimitGuard, token-scoped
GET /public/legal, GET /public/legal/:slugpublished legal documentsstatic content
GET + POST /webhooks/messaging/whatsappMeta calls itsignature verification; mismatch returns invalid_signature
POST /vapi/server-setting, POST /vapi/tools/*Vapi calls itVapiSignatureGuard (HMAC)
POST /integrations/fieldx/login, /register, /mergepartner pre-authenticationRateLimitGuard
GET /companies/:id/publiccompany name on the login / invite screen.select('_id name') β€” nothing else can leave

Known exception, tracked ​

RouteStatus
GET /public/attachments/:id@Public() with no tenancy check. Owned by ENG-664, not closed here

Accepted residual risks ​

  • GET /phone-numbers/findAll is authenticated but not company-scoped.PhoneNumber has no companyId; numbers are a platform-wide pool allocated by country (CompanyService.findPhoneIdForLanguage), and the route exists to render a picker over the unallocated pool. Any authenticated user can therefore enumerate every number. Every phone-number mutation is super-admin (AdminGuard). Giving PhoneNumber an owner plus a provisioning flow is a separate ticket.
  • Report share tokens never expire. Revocation works (unshare), entropy is fine; only the unbounded lifetime is outstanding.
  • There is no HTTP access log. The backend registers no nestjs-pino autoLogging or request middleware, so Loki cannot answer "is this route being called?" β€” during ENG-1403 that had to be inferred from downstream signals (queue activity, repo-wide grep). SOC 2 CC7.2 wants request-level monitoring; tracked separately.

Closed by ENG-1403 (1 Sep 2026) ​

Deleted β€” no callers in any client, and no production traffic:

  • POST /public/parse-call β€” superseded by the internal CallingService path
  • POST /public/process-audio β€” unmetered transcription spend per anonymous request
  • POST /call-logs, PUT /call-logs/:id β€” zero jobs on the call-log.receive queue in the 30 days to 1 Sep 2026 (that route was its only producer)
  • GET /call-logs/by-call-sid/:callSid β€” returned a transcript to anyone holding a call SID, a value that appears in Vapi, Twilio and our own logs
  • POST /public/push/test β€” unauthenticated sendToAll with a caller-supplied title, body and URL: a phishing channel carrying our own branding

Authenticated:

  • POST /public/push/subscribe, /unsubscribe β€” the userId now comes from the token, never the body, and a caller may only drop their own endpoint
  • POST /public/process-voice-note β€” userId/companyId in the body are overwritten from the token
  • GET /phone-numbers/* β€” reads authenticated, mutations super-admin
  • GET /leaderboard β€” @Public(), and getLeaderboardStats() returned the raw user document for every user of every tenant (email, phone, firebaseUid, memberships, platformRoles). Not exploitable today:LeaderboardModule is commented out of AppModule (ENG-402), so the route is not mounted β€” production boot logs never map /leaderboard. Hardened anyway, so re-enabling the module cannot reintroduce the leak: authenticated, @CompanyRequired(), scoped to the caller's company before aggregation, and projected to {_id, firstName, lastName, photoURL} by LeaderboardEntrySchema

Regression coverage: apps/agri-backend/src/public-endpoints.authz.integration.spec.ts (18 route assertions) and apps/agri-backend/src/leaderboard/leaderboard.schema.spec.ts (the projection, asserted at the schema because the route is not mounted).