add surface
go tool gpsystem add surface <module> <surface> [--db <name>]
Surfaces are parallel per-audience bundles inside one module, for example a public api next to an admin or backoffice surface (see Project structure). The surface name is free-form (lowercase letters and digits, starting with a letter); every surface is generated from the same minimal template. Each gets its own TypeSpec service, generated package, handler and base path:
| Surface | Base path |
|---|---|
api | /api/v1/<module> |
any other name, e.g. admin | /api/v1/<surface>/<module> |
The name carries no behavior: api is only special in that it mounts at the module root, while every other surface is namespaced under its own prefix so sibling surfaces never collide.
add domain; the old name still works as a hidden alias for one release. The CLI reads the old domains:/domainDBs: keys in gpsystem.yaml and migrates them to surfaces:/surfaceDBs: automatically on the next save. An existing module can be moved to the new layout by hand: git mv internal/modules/<module>/{api,web,admin} internal/modules/<module>/surfaces/, then gpsystem add core <module> for the shared skeletons, updating the import paths, changing the // gpsystem:domains anchor comment in register.go to // gpsystem:surfaces, and bumping the relative paths in the //go:generate directives and the api/oapi-codegen/*.yaml configs one level deeper (because of the surfaces/ wrapper).What it generates
A minimal skeleton: the contract (spec/typespec/modules/<module>/<surface>.tsp + models/<surface>.tsp), the oapi-codegen config (api/oapi-codegen/<module>-<surface>.yaml), a handler wired to the generated strict interface (its sample List calls a minimal service/ seam and wraps any error with errs.Wrap, so surface errors carry a stack from day one), a policy/ package with a registry stub wired up to enforce @permission/@policy tags, and a kept-empty http/mapper/ directory (.gitkeep) for your own code. Generated files carry no explanatory comments, only the gpsystem:* anchors the generator injects into. The generator wires no authentication or migrations: the kit's auth and rbac packages are available when you need them; see Policies for policies.
Alongside a module's first surface, the command also stamps the module-level shared skeletons: core/core.go + core/errors.go (the latter with an ErrNotFound errs.Define example) and repository/doc.go. Later surfaces skip these and reuse the existing shared core. For older modules without a shared core, add core retrofits the same skeletons.
Wiring happens via anchor insertions: an exported Register<Surface>(router, deps) function (e.g. RegisterAdmin) is appended at the file-level gpsystem:surfaces anchor in register.go, and the matching <module>.Register<Surface>(api, deps) call is inserted at the gpsystem:registrations anchor in cmd/<module>/main.go. Every surface therefore lives in its own function: per-surface middleware (e.g. admin JWT: adminGroup.Use(auth.Middleware(deps.Auth), rbac.RequireRole("admin")); see authentication) goes inside that surface's Register<Surface> function without touching the others.
@permission and @policy in the contract
Operations in the generated <surface>.tsp can be tagged with the @permission("...") / @policy("...") decorators: the decorator library (spec/typespec/lib/policy.tsp) came with the project scaffold, and main.tsp already imports it. The tags reach the OpenAPI spec as x-permission / x-policy extensions, and from there the generated PermissionByOperation / PolicyByOperation maps:
@post
@route("/orders")
@operationId("PlaceOrder")
@permission("orders.place")
@policy("orders.place")
placeOrder(@body body: PlaceOrderInput): Order | Unauthorized | Forbidden;
The enforcement side is scaffolded too: the policy/ package's registry stub (policy.New(), containing the gpsystem:policies anchor) is already threaded into the generated strict middleware inside Register<Surface> (policy.FiberEnforcer / policy.HTTPEnforcer, per engine). Protecting an operation therefore takes two steps: a tag in the .tsp and a reg.Register("...", ...) call at the anchor; a tagged but unregistered policy fails closed. Full semantics: Policies.
news + admin and newsadmin + api both produce Shop.NewsAdmin.yaml / Shop.Newsadmin.yaml, which collide on case-insensitive filesystems. Pick names that keep the concatenations distinct.Binding the repository to a named connection
By default the module's shared repository/ is flat, bound to the project's default database connection. --db <name> binds it to a named connection added earlier with add db, and renders the repository skeleton as a connection-named subfolder instead:
go tool gpsystem add db news analytics --connector bun
go tool gpsystem add surface news reports --db analytics
# -> internal/modules/news/repository/analytics/doc.go
<name> must already exist on the module (add db first); the command fails fast, listing the module's available connections, if it does not. The <Name>DB / <Name>Transactor fields the connection added to Dependencies are already there; only the repository directory changes. Surfaces without --db are unaffected and the module keeps the flat repository/ layout.
After generation
mise run generate
go run ./cmd/<module>
The new surface boots and serves immediately; implement it by fleshing out the service/ seam (putting rules shared across surfaces into the module's core/ package, where every surface can call them) and returning real data: the handler already calls the service and wraps its errors with errs.