Application Routing
The Productify Proxy selects a route by host and path, authenticates the caller, resolves the tenant, adds identity headers, and rewrites the path when it forwards to the Manager. The vhost shape below is what the Manager's renderer produces for an application domain.
Hosts and paths
- One vhost per application domain. The Manager renders one site block for each domain an application deployment has. Caddy selects the site block by the request's
Host. A request for a host no vhost claims is answered by a catch-all with421 Misdirected Request; the proxy never falls back to a default application. - Routes are tried in order; the first matching route that ends in
reverse_proxy(orrespond,file_server) wins. The renderer emits the platform routes first, then any backend-service routes, then the catch-allroute /*. - Caddy's
pathmatcher matches the whole path./pilet-feed/*does not match/pilet-feed. Where a bare path must match too, the renderer gives both patterns:@pilet_feed path /pilet-feed /pilet-feed/*.
Routes in a rendered vhost
| Matcher | Who may call | Forwarded to | Notes |
|---|---|---|---|
@auth path /auth/* | anyone | caddy-security portal (in-process) | productify_before_auth counts users on the login page |
@esb path /esb/* | machine users only | Manager /esb/<app_id>{rest} | uri strip_prefix /esb then rewrite /esb/<app_id>{uri} |
@pilet_feed path /pilet-feed /pilet-feed/* | anyone | Manager /pilet-feed/<app_id> or /pilet-feed/<app_id>/<tenant_id> | Absolute rewrite: the client's remainder is dropped, the query string is kept |
@pilets path /pilets/* | anyone | Manager /pilet-bundle/<app_id>{rest} | uri strip_prefix /pilets then uri path_regexp ^/ /pilet-bundle/<app_id>/ (keeps encoding) |
@language_packs path /language-packs/* | anyone | Manager /language-packs/{path} | Forwarded verbatim. The application id stays the client's: packs are public given a known id |
@comms path /comms/api/* | logged-in users (PAT or browser) | Manager /api/proxy/comms/<app_id>{rest} | Identity headers injected; no HTML injection or metrics wrapper (they buffer the body) |
@comms_ws path /comms/ws | logged-in users | Manager /api/proxy/comms/<app_id>/ws | Absolute rewrite; WebSocket upgrade |
@svc_<n> path <prefix> <prefix>/* | logged-in users | the backend service's upstream | Full productify_protect chain; path forwarded unchanged |
route /* | logged-in users | the application's upstream | Full productify_protect chain |
Routes that terminate at the Manager on a proxy-secret-gated path (pilet-feed, pilets, language-packs, comms) add header_up X-Proxy-Auth {env.PFY_MANAGER_TOKEN} on the outbound hop. Routes that terminate at an application upstream never do.
Rewrites and percent-encoding
The proxy uses three ways to change the path before forwarding, and they treat percent-encoding differently. Measured with the proxy's own Caddy build against an echo server, for the request /proj/@myapp%2Fdashboard/1.0.0?x=1%202:
| Directive | Path the upstream receives | Encoding |
|---|---|---|
reverse_proxy ... { rewrite /prefix{uri} } | /prefix/proj/@myapp/dashboard/1.0.0?x=1%202 | lost in the path (%2F became /); query kept |
uri strip_prefix /x then uri path_regexp ^/ /prefix/ | /prefix/proj/@myapp%2Fdashboard/1.0.0?x=1%202 | kept |
| no rewrite | /x/proj/@myapp%2Fdashboard/1.0.0?x=1%202 | kept |
Rule: {uri} inside a rewrite expands to the decoded path. uri strip_prefix and uri path_regexp keep the raw path. The query string survives all three.
Consequences in the rendered vhost:
- The pilet bundle route (
/pilets/*) usesuri path_regexp, so a pilet named@myapp/dashboard(link/pilets/<project>/@myapp%2Fdashboard/1.0.0) reaches the Manager with the encoded slash intact and resolves to the four-segment bundle route. - The ESB route (
/esb/*) and the comms route (/comms/api/*) userewrite ...{uri}. A percent-encoded slash in an ESB endpoint path is therefore decoded before it reaches the Manager. Avoid encoded slashes in ESB endpoint paths. - The pilet feed and comms WebSocket rewrites are absolute (no
{uri}) so a caller cannot lengthen the path and land on a different Manager route.
The protection chain
productify_protect <app_id> with <policy> expands to these handlers, in this order. The order is fixed by code and cannot be changed from the Caddyfile.
| # | Handler | What it does |
|---|---|---|
| 1 | productify_sanitize | Deletes every proxy-managed identity header from the request (X-User-*, X-Tenant-*, X-Auth-Type, X-Proxy-Auth, all X-Token-*). A client can never forge an identity. |
| 2 | productify_auth | If Authorization: Bearer is present, validates it as a personal access token with the Manager and sets identity headers. Otherwise does nothing. |
| 3 | authorize with <policy> | caddy-security. Accepts a valid portal session (cookie) or bearer JWT and injects X-Token-Subject, X-Token-User-Email, X-Token-User-Name, X-Token-User-Roles. Anonymous requests get 302 to the portal. |
| 4 | productify_tenant_selector | Serves /_tenant*; otherwise reads the signed tenant cookie (or the vhost pin) and sets X-Tenant-ID, X-Tenant-Name. |
| 5 | productify_oauth_validation | For browser sessions (has X-Token-Subject, no X-User-ID): asks the Manager for the Productify user and tenant and sets X-User-*, X-Auth-Type: oauth, X-Tenant-*. |
| 6 | productify_html_inject | Adds window.__PRODUCTIFY__ to HTML responses. |
| 7 | productify | Access log line and metrics. |
| 8 | productify_activator | Only with an activator sub-block: scale-from-zero handling around the reverse_proxy that follows. |
Authentication outcomes
| Request | Result |
|---|---|
| No credential, browser or API | 302 to /auth/oauth2/<realm>?redirect_url=... |
Authorization: Basic ... on an application route | Ignored by the PAT step; 302 to the portal |
| Valid PAT, user has access to the application | Forwarded with X-Auth-Type: personal-token |
| Valid PAT, user has no access to the application | 403 Forbidden: no access to this application |
| PAT the Manager reports invalid | Falls through to the OAuth check; 302 to the portal |
| PAT and the Manager is unreachable or returns non-200 | 503 Authentication service unavailable (fails closed; a negative answer is never cached) |
| Portal session, Manager authorises the user for the app | Forwarded with X-Auth-Type: oauth |
| Portal session, Manager answers "not valid" | 403 Forbidden: not authorized for this application |
| Portal session, Manager unreachable | Forwarded with only the X-Token-* headers; no X-User-* headers. The upstream sees an authenticated but unresolved user |
Machine user credential on /esb/* | Forwarded with X-Auth-Type: machine-user |
No Authorization header on /esb/* | 401 Unauthorized: Missing Authorization header |
PAT or non-machine credential on /esb/* | 403 ESB access requires Machine User authentication |
Machine user on /esb/*, Manager unreachable | 503 Authentication service unavailable |
Successful PAT and OAuth validations are cached for auth_cache_ttl (default 45 s), keyed by a hash of application, credential and selected tenant. Errors are never cached.
Tenant resolution
A user may belong to several tenants. The proxy resolves one tenant per request and forwards it as X-Tenant-ID and X-Tenant-Name.
Shared host (default)
The selection lives in a cookie named pfy_tenant_<app_id>, so two applications on one apex domain never collide. The cookie value is signed with HMAC-SHA256 using a key derived from the proxy secret, expires after 30 minutes (refreshed on use), is bound to the user who set it, and is checked against the user's live membership set (cached tenant_membership_cache_ttl, default 60 s). An unsigned, tampered, expired or foreign cookie is ignored.
The selector endpoints, under /_tenant by default (selector_path), require a logged-in caller:
| Endpoint | Behaviour |
|---|---|
GET /_tenant | JSON { "current": {...}, "available": [ { "id", "name", "role" } ] } from the Manager's membership catalog |
POST /_tenant {"tenant_id":"..."} | Re-validates membership, sets the signed cookie, answers 204. 400 on a missing id, 403 if the user is not a member |
GET /_tenant/select?redirect=... | Hosted, server-rendered chooser. A user with exactly one tenant is selected automatically and redirected |
GET /_tenant/logout | Clears the tenant cookie and answers 303 to logout_redirect (default /auth/logout, the portal logout), ending both sessions |
After POST /_tenant the identity headers change on the next request; single-page applications should reload or refetch.
Dedicated host (tenant <uuid>)
A tenant can have its own host (<app>.<tenant>.<base>). The renderer pins every identity directive on that vhost with tenant <uuid>. On a pinned host the cookie is never read, membership of the pinned tenant is checked on every request (cached 60 s), and a caller who is not a member, has no identity, or whose membership cannot be verified gets 403. This is deliberately stricter than the cookie path, which lets a request through unenriched when the Manager cannot be reached. POST /_tenant on a pinned host answers 409 Conflict.
Headers your application receives
| Header | Value | Set by |
|---|---|---|
X-User-ID | Productify user UUID | productify_auth, productify_oauth_validation, productify_esb_auth |
X-User-Email | same | |
X-User-Name | Display name | same |
X-User-Username | Username | same |
X-Auth-Type | personal-token, oauth or machine-user | same |
X-Tenant-ID | Tenant UUID | tenant selector, or the validating step when the Manager returns a tenant |
X-Tenant-Name | Tenant display name | same |
X-Token-Subject | Portal: IdP subject. PAT: the user UUID | caddy-security authorize; productify_auth |
X-Token-User-Email, X-Token-User-Name, X-Token-User-Roles | Claims from the portal session | caddy-security authorize |
X-User-Tenant-Role | Membership role name | Only when the application opted in (forward_role) |
X-User-Tenant-Permissions | Comma-separated permission list | Only when the application opted in |
X-User-Picture | Picture URL | Only when the application opted in |
Trust these headers only behind the proxy: productify_sanitize guarantees a client cannot supply them, but a backend reachable without the proxy has no such guarantee. Gate authorisation decisions on X-User-Tenant-Permissions, not on the role name.
window.__PRODUCTIFY__
For text/html responses (up to 8 MiB), productify_html_inject adds a <script id="productify-config"> to <head> (or <body> with inject_position body) that defines:
window.__PRODUCTIFY__ = {
user: { id, email, name, username, picture },
tenant: { id, name },
application: { id },
auth_type: "oauth" | "personal-token" | "machine-user",
available_tenants: [{ id, name }],
tenantRole: "...", // only when the app opted in
tenantPermissions: ["..."], // only when the app opted in
logout_url: "/_tenant/logout",
// ...plus the application's frontend configuration from the Manager
};The frontend configuration comes from GET /api/frontend-config/<app_id>/<tenant_id>, scoped to the same tenant the object names in tenant.id: the pin on a dedicated host, or the tenant the cookie or the validating step resolved on a shared host. Only when a shared host has no tenant resolved does the proxy fall back to GET /api/frontend-config/<app_id>, which returns project and application defaults without tenant overrides (that route is deprecated on the Manager). The answer is cached for frontend_config_cache_ttl (default 60 s), keyed by application and tenant. Point your "Sign out" link at logout_url.
Manager API calls
Every call carries the shared secret in X-Proxy-Auth.
| Endpoint | Used by | Body / purpose |
|---|---|---|
POST /api/validate-proxy-auth | productify_auth, productify_esb_auth, tenant selector | {"authorization_header": "...", "application_id": "...", "tenant_id": "..."}; returns user, tenant, application.has_access, available_tenants |
POST /api/validate-oauth-user | productify_oauth_validation | {"user_id", "email", "name", "application_id", "picture", "tenant_id"}; returns user and tenant |
GET /api/proxy/user/<user_id>/tenants?application_id=... | tenant selector | Membership catalog with roles |
GET /api/frontend-config/<app_id>/<tenant_id> | productify_html_inject | Frontend configuration for the resolved tenant; GET /api/frontend-config/<app_id> only when no tenant is resolved |
POST /api/proxy/wake, GET /api/proxy/ready | activator | Scale-from-zero wake and readiness |
GET /health | /readyz probe | Readiness of the proxy depends on a 2xx from here |
Scale-from-zero
When a deployment carries the scale_to_zero flag, the renderer adds activator <env> to that vhost's productify_protect. No API sets that flag today, so a Manager-rendered vhost never carries one in practice; see Known Issues. The activator does nothing on the healthy path. It engages only when the reverse_proxy fails at the transport level and the Manager's cached state says the deployment is scale-to-zero eligible with no healthy backends. It then wakes the deployment through the Manager (one wake per application and environment, debounced) and:
- answers browsers (
Accept: text/html) with an immediate202waiting page that polls the proxy's own/_pfy/readyand reloads when the backend is up; - holds API requests, up to
activate_timeout, and replays the original request once the backend is ready; after the timeout, or overactivator_max_in_flight, it answers503withRetry-After: 5and a JSON body{ "error", "app", "retry_after" }.
A genuine outage of a running deployment is passed through unchanged; the activator never masks it.
Examples
Two applications on separate hosts. Each host also needs an /auth/* route, omitted here for brevity; see Caddyfile Reference.
http://app1.localhost {
route /* {
productify_protect 100 with pocketpolicy
reverse_proxy app1-backend:8080
}
}
http://app2.localhost {
route /* {
productify_protect 200 with pocketpolicy
reverse_proxy app2-backend:8080
}
}Two applications under one host by path prefix:
http://apps.localhost {
@app1 path /app1 /app1/*
route @app1 {
productify_protect 100 with pocketpolicy
uri strip_prefix /app1
reverse_proxy app1-backend:8080
}
@app2 path /app2 /app2/*
route @app2 {
productify_protect 200 with pocketpolicy
uri strip_prefix /app2
reverse_proxy app2-backend:8080
}
}