Skip to content

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 with 421 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 (or respond, file_server) wins. The renderer emits the platform routes first, then any backend-service routes, then the catch-all route /*.
  • Caddy's path matcher 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 ​

MatcherWho may callForwarded toNotes
@auth path /auth/*anyonecaddy-security portal (in-process)productify_before_auth counts users on the login page
@esb path /esb/*machine users onlyManager /esb/<app_id>{rest}uri strip_prefix /esb then rewrite /esb/<app_id>{uri}
@pilet_feed path /pilet-feed /pilet-feed/*anyoneManager /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/*anyoneManager /pilet-bundle/<app_id>{rest}uri strip_prefix /pilets then uri path_regexp ^/ /pilet-bundle/<app_id>/ (keeps encoding)
@language_packs path /language-packs/*anyoneManager /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/wslogged-in usersManager /api/proxy/comms/<app_id>/wsAbsolute rewrite; WebSocket upgrade
@svc_<n> path <prefix> <prefix>/*logged-in usersthe backend service's upstreamFull productify_protect chain; path forwarded unchanged
route /*logged-in usersthe application's upstreamFull 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:

DirectivePath the upstream receivesEncoding
reverse_proxy ... { rewrite /prefix{uri} }/prefix/proj/@myapp/dashboard/1.0.0?x=1%202lost 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%202kept
no rewrite/x/proj/@myapp%2Fdashboard/1.0.0?x=1%202kept

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/*) uses uri 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/*) use rewrite ...{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.

#HandlerWhat it does
1productify_sanitizeDeletes 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.
2productify_authIf Authorization: Bearer is present, validates it as a personal access token with the Manager and sets identity headers. Otherwise does nothing.
3authorize 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.
4productify_tenant_selectorServes /_tenant*; otherwise reads the signed tenant cookie (or the vhost pin) and sets X-Tenant-ID, X-Tenant-Name.
5productify_oauth_validationFor 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-*.
6productify_html_injectAdds window.__PRODUCTIFY__ to HTML responses.
7productifyAccess log line and metrics.
8productify_activatorOnly with an activator sub-block: scale-from-zero handling around the reverse_proxy that follows.

Authentication outcomes ​

RequestResult
No credential, browser or API302 to /auth/oauth2/<realm>?redirect_url=...
Authorization: Basic ... on an application routeIgnored by the PAT step; 302 to the portal
Valid PAT, user has access to the applicationForwarded with X-Auth-Type: personal-token
Valid PAT, user has no access to the application403 Forbidden: no access to this application
PAT the Manager reports invalidFalls through to the OAuth check; 302 to the portal
PAT and the Manager is unreachable or returns non-200503 Authentication service unavailable (fails closed; a negative answer is never cached)
Portal session, Manager authorises the user for the appForwarded with X-Auth-Type: oauth
Portal session, Manager answers "not valid"403 Forbidden: not authorized for this application
Portal session, Manager unreachableForwarded 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 unreachable503 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:

EndpointBehaviour
GET /_tenantJSON { "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/logoutClears 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 ​

HeaderValueSet by
X-User-IDProductify user UUIDproductify_auth, productify_oauth_validation, productify_esb_auth
X-User-EmailEmailsame
X-User-NameDisplay namesame
X-User-UsernameUsernamesame
X-Auth-Typepersonal-token, oauth or machine-usersame
X-Tenant-IDTenant UUIDtenant selector, or the validating step when the Manager returns a tenant
X-Tenant-NameTenant display namesame
X-Token-SubjectPortal: IdP subject. PAT: the user UUIDcaddy-security authorize; productify_auth
X-Token-User-Email, X-Token-User-Name, X-Token-User-RolesClaims from the portal sessioncaddy-security authorize
X-User-Tenant-RoleMembership role nameOnly when the application opted in (forward_role)
X-User-Tenant-PermissionsComma-separated permission listOnly when the application opted in
X-User-PicturePicture URLOnly 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:

js
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.

EndpointUsed byBody / purpose
POST /api/validate-proxy-authproductify_auth, productify_esb_auth, tenant selector{"authorization_header": "...", "application_id": "...", "tenant_id": "..."}; returns user, tenant, application.has_access, available_tenants
POST /api/validate-oauth-userproductify_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 selectorMembership catalog with roles
GET /api/frontend-config/<app_id>/<tenant_id>productify_html_injectFrontend configuration for the resolved tenant; GET /api/frontend-config/<app_id> only when no tenant is resolved
POST /api/proxy/wake, GET /api/proxy/readyactivatorScale-from-zero wake and readiness
GET /health/readyz probeReadiness 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 immediate 202 waiting page that polls the proxy's own /_pfy/ready and 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 over activator_max_in_flight, it answers 503 with Retry-After: 5 and 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.

nginx
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:

nginx
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
  }
}

See also ​