Skip to content

Public API v1 — Audit & Spec

Source of truth: backend/src/public-api/Audit date: 2026-05-23 (Horizonte 3 — Workstream I, task I1) Mount path: /public/v1 (all routes prefixed) Auth scheme: API key via x-api-key header (256-bit, SHA-256 hashed, 30-day expiry)

Overview

The public-api module is the external developer-facing API designed to be consumed by 3rd-party apps in the upcoming App Marketplace (workstream I2+). Today it has 4 endpoints across 3 resources (orders, menu items, reservations). All endpoints are guarded by ApiKeyGuard.

The marketplace OAuth flow (I2) is not yet implemented — current auth is a single x-api-key header tied to a user (with multi-tenant disambiguation via x-tenant-id).

Endpoint Inventory

#PathMethodAuthScopes (TBD)Rate LimitDescription
1/public/v1/ordersGETApiKeyGuard (x-api-key)orders.readglobal only (200 req/min)List orders for the API-key's tenant. Optional ?status= query (string passed verbatim to buildStatusFilter).
2/public/v1/menu/itemsGETApiKeyGuard (x-api-key)menu.readglobal only (200 req/min)List all menu items for the tenant.
3/public/v1/reservationsGETApiKeyGuard (x-api-key)reservations.readglobal only (200 req/min)List reservations. Optional ?date=YYYY-MM-DD query.
4/public/v1/reservationsPOSTApiKeyGuard (x-api-key)reservations.writeglobal only (200 req/min)Create a reservation. Body is un-validated any — see Security Concerns.

Multi-tenant key disambiguation

When the API key's user is linked to >1 tenant (via UserTenant junction), the request must also send x-tenant-id: <objectId>. If absent, ApiKeyGuard throws 400 BadRequestException. If the requested tenant is not in the user's memberships, throws 401 UnauthorizedException.

Single-tenant users: x-tenant-id is ignored — tenant is resolved automatically.

Module wiring

PublicApiModule (backend/src/public-api/public-api.module.ts) imports:

  • MongooseModule.forFeature([UserTenant]) — for the guard's tenant lookup
  • UsersModule — for findByApiKey
  • OrdersModule, MenuModule, ReservationsModule — for service injection

Registered in app.module.ts at line 175 (PublicApiModule).

Authentication Flow

  1. User logs into PopinaFlow normally (JWT).
  2. User calls internal endpoint to mint an API key: usersService.generateApiKey(userId) returns a raw 64-char hex string (256 bits). Storage is SHA-256 hash with 30-day apiKeyExpiresAt.
  3. Raw key is shown once — never recoverable.
  4. 3rd-party client sends x-api-key: <raw> on every request to /public/v1/*.
  5. Guard hashes the raw key, looks up the user, checks expiry, resolves tenant, attaches req.user and req.tenantId.

Note: There is currently no UI / no endpoint exposing generateApiKey to the user. This is a gap that I2 (OAuth install flow) will replace with a proper consent screen + per-app installation token.

Rate Limiting

  • Global: ThrottlerModule.forRoot([{ ttl: 60000, limit: 200 }]) in app.module.ts — 200 requests per 60 seconds per IP/tenant, applied via TenantThrottlerGuard as APP_GUARD.
  • Public-api specific: NONE. There is no @Throttle() decorator on the controller or any endpoint.

Recommendation: Add per-endpoint @Throttle() tighter than the 200/min default once the OAuth flow lands. Suggested initial limits:

EndpointSuggested limit
GET /orders60 req/min (paginated reads)
GET /menu/items60 req/min
GET /reservations60 req/min
POST /reservations10 req/min (writes; abuse-prone)

Gaps Identified

These are non-blocking documentation/quality gaps to address before public launch:

  1. No JSDoc / OpenAPI annotations — Controller methods have zero @nestjs/swagger decorators (@ApiOperation, @ApiResponse, @ApiTags, @ApiBearerAuth, etc.). Generated spec will list paths but no descriptions/schemas. Add in a follow-up before I6 (developer docs).
  2. No DTO validation on POST /reservations@Body() body: any is forwarded directly to reservationsService.create(). ValidationPipe is global with forbidNonWhitelisted: true, but any bypasses class-validator entirely. Need a CreatePublicReservationDto.
  3. No per-endpoint rate limiting — Falls back to 200/min global. Public API needs tighter quotas to prevent abuse.
  4. No scope checking — All endpoints grant full access to whatever the API key's tenant can do. Scopes (orders.read, menu.write, etc.) are not enforced. Blocks I2 — must be implemented as part of the OAuth flow.
  5. No pagination on GET /orders or GET /reservations — Returns full collection. A large tenant could exhaust memory or timeout. Add limit/offset or cursor pagination.
  6. No response envelope — Returns raw service output (Mongoose documents). Public API should wrap in { data, meta } for forward compatibility (e.g. adding pagination metadata later without breaking clients).
  7. No versioning strategy beyond URLpublic/v1 is hardcoded. No deprecation header convention documented. Define an API versioning policy before launch.
  8. No CORS allowlist for API clients — Current CORS is restricted to FRONTEND_URL. 3rd-party apps calling from a browser would be blocked. Document the recommendation: server-to-server only for v1.

Security Concerns — Não Publicar Antes de Resolver

These are security issues that must be triaged before the marketplace launches (I8). They do NOT block I1 itself but block public exposure.

S1 — GET /public/v1/orders exposes status filter as freeform string

@Query('status') status?: string is passed directly into ordersService.findAll(req.tenantId, status). Inside, buildStatusFilter(status) is called. If buildStatusFilter supports any Mongo operator notation ($ne, $in, etc.) coming from the string, this is a NoSQL injection risk. Action: verify buildStatusFilter only accepts a whitelist of status enum values; reject anything else with 400.

S2 — POST /public/v1/reservations accepts any body

The DTO is @Body() body: anyValidationPipe.whitelist/forbidNonWhitelisted cannot strip unknown fields when the type is any. A malicious client could try to inject tenant, pdv, createdBy, or other server-controlled fields. The service-side hopefully sets tenant from tenantId arg, but any extra fields in the body will be persisted by Mongoose if the schema allows them. Action: create CreatePublicReservationDto with @IsString, @IsDate, etc.; replace any.

S3 — API keys are user-scoped, not app-scoped

Today an API key is bound to a user (via findByApiKey), not an app or installation. This means:

  • Killing a bad app means killing the user's entire key (cascading damage to legitimate use).
  • No per-app audit trail — every request looks the same in logs.
  • No way to enforce app-specific scopes — the key inherits the user's full permissions.

Action: I2 must introduce AppInstallation.accessToken (per-app, per-tenant). Deprecate the user-scoped x-api-key flow OR keep it strictly internal (e.g. PopinaFlow Official apps only).

S4 — findByApiKey does not return what ApiKeyGuard expects on expiry

UsersService.findByApiKey() line 580 returns null for expired keys, but the guard treats this as "Invalid or expired API key" — both cases get the same opaque 401. Lower severity — desired behavior, just worth noting in docs (clients cannot distinguish revoked vs expired).

S5 — No request logging / audit trail

No mechanism currently captures which API key made which request. Required for incident response (e.g. "App X was compromised — show me every request it made in the last 24h"). Action: add a lightweight middleware or extend the existing Pino logger to tag x-api-key's userId + tenantId per request in the public-api module.

OpenAPI Spec Generation

@nestjs/swagger@^11.3.0 is installed. The spec helper lives at:

  • backend/src/public-api/swagger-setup.ts → exports setupPublicApiSwagger(app: INestApplication)

The helper builds a DocumentBuilder config (title, version, bearer auth scheme), creates the document with include: [PublicApiModule] (filters out internal routes), and mounts it at /api/public/docs (Swagger UI) with the raw JSON at /api/public/docs-json.

Follow-up: activate in main.ts

Once Sentry wiring (workstream H1-A2) is finalized in backend/src/main.ts, add after NestFactory.create():

typescript
import { setupPublicApiSwagger } from './public-api/swagger-setup';
// ...
setupPublicApiSwagger(app);

This is a 1-line change; intentionally deferred to avoid conflicts with the Sentry wiring agent. Owner of activation: whoever lands H1-A2.

References

  • backend/src/public-api/public-api.controller.ts
  • backend/src/public-api/public-api.module.ts
  • backend/src/public-api/api-key.guard.ts
  • backend/src/public-api/swagger-setup.ts (new)
  • backend/src/users/users.service.ts (lines 550–593: API key generation/lookup)
  • backend/src/app.module.ts line 112, 215: global throttler wiring
  • Plan: docs/plans/2026-05-23-strategic-roadmap-180d.md — Workstream I

Lançado sob a licença MIT.