Build a secure extension
PlatformKit has one supported application-extension seam: starterapp.WithModules.
The fastest correct start is to generate one:
platformkit new module invoice
The generated module already embodies every rule below — tenant-scoped queries, per-route scope checks, server-owned identity, canonical entity IDs, append-only migrations — and ships a test that fails the moment tenant isolation breaks. It registers itself, so adding a module never edits main.go.
To read the same thing by hand, see pk-apps/reference/custommodule. That directory is a runnable teaching reference, not a shipped product, module, or alternate starter.
The reference demonstrates the required defaults:
- Build the module on
ModuleEnv.DB, the starter's shared connection pool.
The pool is whichever engine the application configured — SQLite or Postgres — so write portable SQL, or ship an adapter per engine the way the built-in modules do.
- Apply append-only, embedded migrations and record each applied filename.
- Declare every machine capability in
ModulePlugin.APIKeyScopes. - Enforce the matching scope in every authenticated route, while allowing the
reserved interactive admin scope where appropriate.
- Obtain tenant and subject from
portslib.RequestActor; never trust body or
query identity.
- Generate IDs and timestamps on the server.
- Scope every read, update, and delete query by tenant.
- Reject unknown JSON fields and trailing JSON values.
- Publish route metadata through
OpenAPIOperation. - Test anonymous access, insufficient scopes, server-owned identity,
cross-tenant reads, migrations, and the happy path.
Authentication is not authorization
RegisterRoutes inherits identity resolution, the anonymous-mutation gate, and the request-body limit. It does not infer a domain policy for your module. A route that only calls RequestActor is authenticated but not authorized.
Declare application scopes:
return starterapp.ModulePlugin{
ID: "reservation",
RegisterRoutes: handler.RegisterRoutes,
APIKeyScopes: []string{
"reservations:read",
"reservations:write",
},
}
Then enforce one in the handler:
tenantID, subject, ok := portslib.RequestActor(w, r)
if !ok {
return
}
principal := identity.PrincipalFromContext(r.Context())
if !principal.HasScope("admin") &&
!principal.HasScope("reservations:write") {
http.Error(w, "forbidden: reservations:write scope required", http.StatusForbidden)
return
}
Unknown API-key scopes are rejected. Declaring the scope in APIKeyScopes makes it issuable; it does not authorize a route by itself.
Keep product code downstream
Put the module's domain model, routes, migrations, policy, and tests in the repository that owns the application. PlatformKit remains a generic foundation whether the downstream product is a marketplace, CRM, booking system, or internal tool.