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 viax-api-keyheader (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
| # | Path | Method | Auth | Scopes (TBD) | Rate Limit | Description |
|---|---|---|---|---|---|---|
| 1 | /public/v1/orders | GET | ApiKeyGuard (x-api-key) | orders.read | global only (200 req/min) | List orders for the API-key's tenant. Optional ?status= query (string passed verbatim to buildStatusFilter). |
| 2 | /public/v1/menu/items | GET | ApiKeyGuard (x-api-key) | menu.read | global only (200 req/min) | List all menu items for the tenant. |
| 3 | /public/v1/reservations | GET | ApiKeyGuard (x-api-key) | reservations.read | global only (200 req/min) | List reservations. Optional ?date=YYYY-MM-DD query. |
| 4 | /public/v1/reservations | POST | ApiKeyGuard (x-api-key) | reservations.write | global 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 lookupUsersModule— forfindByApiKeyOrdersModule,MenuModule,ReservationsModule— for service injection
Registered in app.module.ts at line 175 (PublicApiModule).
Authentication Flow
- User logs into PopinaFlow normally (JWT).
- 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-dayapiKeyExpiresAt. - Raw key is shown once — never recoverable.
- 3rd-party client sends
x-api-key: <raw>on every request to/public/v1/*. - Guard hashes the raw key, looks up the user, checks expiry, resolves tenant, attaches
req.userandreq.tenantId.
Note: There is currently no UI / no endpoint exposing
generateApiKeyto 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 }])inapp.module.ts— 200 requests per 60 seconds per IP/tenant, applied viaTenantThrottlerGuardasAPP_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:
| Endpoint | Suggested limit |
|---|---|
GET /orders | 60 req/min (paginated reads) |
GET /menu/items | 60 req/min |
GET /reservations | 60 req/min |
POST /reservations | 10 req/min (writes; abuse-prone) |
Gaps Identified
These are non-blocking documentation/quality gaps to address before public launch:
- No JSDoc / OpenAPI annotations — Controller methods have zero
@nestjs/swaggerdecorators (@ApiOperation,@ApiResponse,@ApiTags,@ApiBearerAuth, etc.). Generated spec will list paths but no descriptions/schemas. Add in a follow-up before I6 (developer docs). - No DTO validation on
POST /reservations—@Body() body: anyis forwarded directly toreservationsService.create().ValidationPipeis global withforbidNonWhitelisted: true, butanybypasses class-validator entirely. Need aCreatePublicReservationDto. - No per-endpoint rate limiting — Falls back to 200/min global. Public API needs tighter quotas to prevent abuse.
- 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. - No pagination on
GET /ordersorGET /reservations— Returns full collection. A large tenant could exhaust memory or timeout. Addlimit/offsetor cursor pagination. - 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). - No versioning strategy beyond URL —
public/v1is hardcoded. No deprecation header convention documented. Define an API versioning policy before launch. - 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: any — ValidationPipe.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→ exportssetupPublicApiSwagger(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():
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.tsbackend/src/public-api/public-api.module.tsbackend/src/public-api/api-key.guard.tsbackend/src/public-api/swagger-setup.ts(new)backend/src/users/users.service.ts(lines 550–593: API key generation/lookup)backend/src/app.module.tsline 112, 215: global throttler wiring- Plan:
docs/plans/2026-05-23-strategic-roadmap-180d.md— Workstream I