Skip to content

Write a service

A Plat5 service is an HTTP server in any language. It listens on the paths Plat5 rewrites to, and reads ids from them. The starters (Go, Node, Bun) are complete examples.

Listen on the path with the owner’s id in it, and scope every lookup by that id.

projects/handler.go
// GET /organizations/:organization_id/projects
func (h *Handler) List(c fiber.Ctx) error {
orgID := c.Params("organization_id")
list, err := h.store.ListByOrg(c.Context(), orgID)
if err != nil {
return errors.InternalError()
}
return c.JSON(fiber.Map{"projects": list})
}

There is no auth code. By the time this runs, Plat5 has signed the caller in, checked the route’s permission, applied the rate limit and recorded the request.

routes.yml
services:
api:
organization:
routes:
- path: /org/projects
upstream: /organizations/{subject.organization_id}/projects
methods:
GET:
POST:
required_labels: [projects:write]
- path: /org/projects/{project_id}
upstream: /organizations/{subject.organization_id}/projects/{path.project_id}
methods:
GET:
PATCH:
required_labels: [projects:write]
  • path is what clients call. upstream is what your service receives.
  • {subject.organization_id} is filled from the credential. {path.project_id} is copied from the client’s path.
  • required_labels are the permissions a member’s role must grant. See Roles and permissions.
  • Add rate_limit: { requests: 30, window_seconds: 60 } to a route to set its own limit.

Locally, list the file under routes: in plat5.yml and say where the service listens under upstreams::

plat5.yml
upstreams:
api: "3000" # your service on the host, port 3000
routes:
- ./routes.identity.yml
- ./routes.audit.yml
- ./routes.yml

Then plat5 routes apply, or plat5 start, publishes them. The gateway picks up changes without a restart.

On a write, tell the audit log what changed with a response header. Plat5 stores it and removes it before the response reaches the client.

HTTP/1.1 200 OK
X-Plat5-Audit-Details: {"name":{"from":"Launch","to":"Launch v2"}}

On a create, also send the new id as X-Plat5-Audit-Created. Never put a secret in either header.

Plat5 has one way to do the parts every service shares. The starters already follow it, so keep their shape as you add endpoints:

  • Errors use the envelope: {"error":{"type","code","message","request_id","details"}}, with a message you’d show a person.
  • Lists take limit and starting_after, and return the collection with has_more.
  • Health and metrics stay on the internal port: /health/live, /health/ready, /metrics.

The full set is in Standards.

  • Trust the path. Don’t parse Authorization or X-API-Key (Plat5 removes them), and don’t validate tokens.
  • Stay private. The path is only trustworthy if nothing but the gateway can reach your service.
  • Enforce state rules, like “an archived project is read-only”. Those hold no matter who asks.
  • Log the X-Request-ID and pass traceparent on to anything you call. Don’t set X-Request-ID yourself.
  • Store owners, not authors. Your service isn’t told who called. If who did something matters to your product, make it a record the member owns, created on a member route. Otherwise, it’s in the audit log.

When an organization or member is removed, your service may hold data for it. Read the lifecycle feed and act on organization.deleted and member.removed:

GET /internal/lifecycle-events?starting_after=<last id you processed>
X-Plat5-Internal-Token: <INTERNAL_AUTH_TOKEN>

The feed is on identity’s internal port, not the gateway. Acting on an event must be safe to repeat.

More: gateway-contract.md, routes.md, telemetry.md.