2026-10-01 23:15:57 +02:00
{
"openapi" : "3.1.0" ,
"info" : {
"title" : "Go Loose API" ,
"version" : "0.1.0" ,
"description" : "Multi-tenant API-key management and runtime authorization."
} ,
"servers" : [ { "url" : "/" } ] ,
"components" : {
"securitySchemes" : {
"session" : { "type" : "apiKey" , "in" : "cookie" , "name" : "go_loose_session" } ,
"apiKey" : { "type" : "apiKey" , "in" : "header" , "name" : "X-API-Key" } ,
"clientBasic" : { "type" : "http" , "scheme" : "basic" } ,
"userBearer" : { "type" : "http" , "scheme" : "bearer" }
} ,
"schemas" : {
"Error" : { "type" : "object" , "properties" : { "error" : { "type" : "string" } } } ,
"AuthorizationRequest" : {
"type" : "object" ,
"required" : [ "application" , "method" , "path" ] ,
"properties" : {
"tenant" : { "type" : "string" , "example" : "nmbs" } ,
"application" : { "type" : "string" , "example" : "guess" } ,
"method" : { "type" : "string" , "example" : "GET" } ,
"path" : { "type" : "string" , "example" : "/api/guesses" } ,
"host" : { "type" : "string" , "example" : "nmbs.guess.local" }
}
} ,
"AuthorizationResponse" : {
"type" : "object" ,
"properties" : {
"allowed" : { "type" : "boolean" } ,
"reason" : { "type" : "string" } ,
"tenant_id" : { "type" : "string" , "format" : "uuid" } ,
"tenant_slug" : { "type" : "string" } ,
"application_id" : { "type" : "string" , "format" : "uuid" } ,
"application_slug" : { "type" : "string" } ,
"api_key_id" : { "type" : "string" , "format" : "uuid" } ,
"api_key_name" : { "type" : "string" }
}
2026-10-04 12:24:13 +02:00
} ,
"Membership" : {
"type" : "object" ,
"description" : "A tenant role held by a user, with the capabilities it grants." ,
"properties" : {
"tenant_id" : { "type" : "string" , "format" : "uuid" } ,
"tenant_slug" : { "type" : "string" } ,
"tenant_name" : { "type" : "string" } ,
2026-10-04 16:57:30 +02:00
"role" : { "type" : "string" , "description" : "Slug of the role, which may be a role this tenant defined." } ,
"role_id" : { "type" : "string" , "format" : "uuid" , "description" : "Identifier of the role row. Send this back when assigning the role." } ,
"permissions" : { "type" : "array" , "items" : { "$ref" : "#/components/schemas/PermissionValue" } }
2026-10-04 12:24:13 +02:00
}
} ,
"Profile" : {
"type" : "object" ,
"properties" : {
"id" : { "type" : "string" , "format" : "uuid" } ,
"email" : { "type" : "string" , "format" : "email" } ,
"display_name" : { "type" : "string" } ,
"avatar_url" : { "type" : "string" , "description" : "Absolute picture URL, or an empty string when no picture is set." } ,
"memberships" : { "type" : "array" , "items" : { "$ref" : "#/components/schemas/Membership" } } ,
"is_system_administrator" : { "type" : "boolean" } ,
"name_customized" : { "type" : "boolean" , "description" : "True once the user set the display name themselves, which stops the identity provider from overwriting it." } ,
"created_at" : { "type" : "string" , "format" : "date-time" } ,
"last_login_at" : { "type" : "string" , "format" : "date-time" } ,
"profile_updated_at" : { "type" : "string" , "format" : "date-time" } ,
"avatar_updated_at" : { "type" : "string" , "format" : "date-time" }
}
} ,
"ProfileUpdate" : {
"type" : "object" ,
"description" : "Send display_name, avatar, or remove_avatar. avatar accepts raw base64 or a data URL." ,
"properties" : {
"display_name" : { "type" : "string" , "minLength" : 1 , "maxLength" : 200 } ,
"avatar" : { "type" : "string" , "description" : "Base64 or data URL of a JPEG, PNG, or GIF up to 2 MiB and 4096 by 4096 pixels." } ,
"remove_avatar" : { "type" : "boolean" }
}
} ,
2026-10-04 16:57:30 +02:00
"Role" : {
2026-10-04 12:24:13 +02:00
"type" : "object" ,
2026-10-04 16:57:30 +02:00
"description" : "A named bundle of permissions. A built-in role has no tenant_id, is visible to every tenant, and cannot be edited or deleted. A tenant may define its own." ,
"required" : [ "id" , "slug" , "name" , "system" , "permissions" ] ,
2026-10-04 12:24:13 +02:00
"properties" : {
2026-10-04 16:57:30 +02:00
"id" : { "type" : "string" , "format" : "uuid" } ,
"tenant_id" : { "type" : "string" , "format" : "uuid" , "description" : "Absent for built-in roles." } ,
"slug" : { "type" : "string" , "pattern" : "^[a-z0-9][a-z0-9-]{0,62}$" } ,
"name" : { "type" : "string" , "minLength" : 1 , "maxLength" : 100 } ,
"description" : { "type" : "string" , "maxLength" : 500 } ,
"system" : { "type" : "boolean" , "description" : "True for built-in roles, which are read only." } ,
"permissions" : { "type" : "array" , "items" : { "$ref" : "#/components/schemas/PermissionValue" } } ,
"member_count" : { "type" : "integer" , "description" : "Members currently holding the role. A role that still has members cannot be deleted." } ,
"assignable" : { "type" : "boolean" , "description" : "Whether the caller may assign this role. False when the role grants a permission the caller does not hold, which is what stops role assignment becoming a way to gain authority." }
}
} ,
"PermissionValue" : {
"type" : "string" ,
feat: account menu, database roles, and single logout
Bundles the console work from the current branch: the header account
control, database-backed roles and permissions, and signing out across
the applications that share this sign-in.
The account control replaces a separate identity chip and a separate
sign-out button, which meant the header carried two controls describing
one fact. web/account.js is mounted into every page by a placeholder and
owns the keyboard handling, the outside-click behaviour and the sign-out
form once. It also fills the trigger's name, which the previous markup
left empty.
Signing out revokes the hosted-login bearer sessions this installation
issued, then redirects to the provider's end_session_endpoint when the
discovery document advertises one. Applications that authenticate
against this installation therefore lose their session too; one pointed
straight at an identity provider is out of its reach and is unchanged.
Roles and permissions become rows rather than a closed enum, so a tenant
can extend the catalog without a migration, and a role can no longer be
granted a capability that is not in it. requireSameOrigin now compares
against the origin the request arrived on instead of the configured base
URL, which defaulted to http://localhost:8080 and so rejected every
write in a deployment that never set it.
Two bugs found while verifying in a browser:
- A profile picture never rendered. avatarURL built an absolute URL
from the configured base URL, and the page's own img-src 'self'
blocked it. The console's own pages now take a root-relative path;
only /connect/userinfo, read from another origin, needs an absolute
one, and it follows the request.
- absoluteURL preferred the configuration over the request while its
comment claimed the opposite, which also misdirected the logout
redirect.
Card layout: the footer was one two-column row shared between a label
and a forty-character identifier, so the identifier took the width and
crushed its label into three stacked words. Each pair is a line now, the
value truncates with the full text in the title, and cards align their
footers across a row.
2026-10-05 19:18:07 +02:00
"description" : "The name of a catalog entry, such as users.manage or reports.export. Names are rows in the permission catalog, so an installation may add its own; the nine seeded ones are the capabilities the console enforces." ,
"pattern" : "^[a-z0-9][a-z0-9]*(\\.[a-z0-9]+)*$" ,
"maxLength" : 100
2026-10-04 16:57:30 +02:00
} ,
feat: account menu, database roles, and single logout
Bundles the console work from the current branch: the header account
control, database-backed roles and permissions, and signing out across
the applications that share this sign-in.
The account control replaces a separate identity chip and a separate
sign-out button, which meant the header carried two controls describing
one fact. web/account.js is mounted into every page by a placeholder and
owns the keyboard handling, the outside-click behaviour and the sign-out
form once. It also fills the trigger's name, which the previous markup
left empty.
Signing out revokes the hosted-login bearer sessions this installation
issued, then redirects to the provider's end_session_endpoint when the
discovery document advertises one. Applications that authenticate
against this installation therefore lose their session too; one pointed
straight at an identity provider is out of its reach and is unchanged.
Roles and permissions become rows rather than a closed enum, so a tenant
can extend the catalog without a migration, and a role can no longer be
granted a capability that is not in it. requireSameOrigin now compares
against the origin the request arrived on instead of the configured base
URL, which defaulted to http://localhost:8080 and so rejected every
write in a deployment that never set it.
Two bugs found while verifying in a browser:
- A profile picture never rendered. avatarURL built an absolute URL
from the configured base URL, and the page's own img-src 'self'
blocked it. The console's own pages now take a root-relative path;
only /connect/userinfo, read from another origin, needs an absolute
one, and it follows the request.
- absoluteURL preferred the configuration over the request while its
comment claimed the opposite, which also misdirected the logout
redirect.
Card layout: the footer was one two-column row shared between a label
and a forty-character identifier, so the identifier took the width and
crushed its label into three stacked words. Each pair is a line now, the
value truncates with the full text in the title, and cards align their
footers across a row.
2026-10-05 19:18:07 +02:00
"Permission" : {
2026-10-04 16:57:30 +02:00
"type" : "object" ,
feat: account menu, database roles, and single logout
Bundles the console work from the current branch: the header account
control, database-backed roles and permissions, and signing out across
the applications that share this sign-in.
The account control replaces a separate identity chip and a separate
sign-out button, which meant the header carried two controls describing
one fact. web/account.js is mounted into every page by a placeholder and
owns the keyboard handling, the outside-click behaviour and the sign-out
form once. It also fills the trigger's name, which the previous markup
left empty.
Signing out revokes the hosted-login bearer sessions this installation
issued, then redirects to the provider's end_session_endpoint when the
discovery document advertises one. Applications that authenticate
against this installation therefore lose their session too; one pointed
straight at an identity provider is out of its reach and is unchanged.
Roles and permissions become rows rather than a closed enum, so a tenant
can extend the catalog without a migration, and a role can no longer be
granted a capability that is not in it. requireSameOrigin now compares
against the origin the request arrived on instead of the configured base
URL, which defaulted to http://localhost:8080 and so rejected every
write in a deployment that never set it.
Two bugs found while verifying in a browser:
- A profile picture never rendered. avatarURL built an absolute URL
from the configured base URL, and the page's own img-src 'self'
blocked it. The console's own pages now take a root-relative path;
only /connect/userinfo, read from another origin, needs an absolute
one, and it follows the request.
- absoluteURL preferred the configuration over the request while its
comment claimed the opposite, which also misdirected the logout
redirect.
Card layout: the footer was one two-column row shared between a label
and a forty-character identifier, so the identifier took the width and
crushed its label into three stacked words. Each pair is a line now, the
value truncates with the full text in the title, and cards align their
footers across a row.
2026-10-05 19:18:07 +02:00
"description" : "An entry in the installation-wide permission catalog." ,
"required" : [ "name" , "label" , "enforced" ] ,
2026-10-04 16:57:30 +02:00
"properties" : {
feat: account menu, database roles, and single logout
Bundles the console work from the current branch: the header account
control, database-backed roles and permissions, and signing out across
the applications that share this sign-in.
The account control replaces a separate identity chip and a separate
sign-out button, which meant the header carried two controls describing
one fact. web/account.js is mounted into every page by a placeholder and
owns the keyboard handling, the outside-click behaviour and the sign-out
form once. It also fills the trigger's name, which the previous markup
left empty.
Signing out revokes the hosted-login bearer sessions this installation
issued, then redirects to the provider's end_session_endpoint when the
discovery document advertises one. Applications that authenticate
against this installation therefore lose their session too; one pointed
straight at an identity provider is out of its reach and is unchanged.
Roles and permissions become rows rather than a closed enum, so a tenant
can extend the catalog without a migration, and a role can no longer be
granted a capability that is not in it. requireSameOrigin now compares
against the origin the request arrived on instead of the configured base
URL, which defaulted to http://localhost:8080 and so rejected every
write in a deployment that never set it.
Two bugs found while verifying in a browser:
- A profile picture never rendered. avatarURL built an absolute URL
from the configured base URL, and the page's own img-src 'self'
blocked it. The console's own pages now take a root-relative path;
only /connect/userinfo, read from another origin, needs an absolute
one, and it follows the request.
- absoluteURL preferred the configuration over the request while its
comment claimed the opposite, which also misdirected the logout
redirect.
Card layout: the footer was one two-column row shared between a label
and a forty-character identifier, so the identifier took the width and
crushed its label into three stacked words. Each pair is a line now, the
value truncates with the full text in the title, and cards align their
footers across a row.
2026-10-05 19:18:07 +02:00
"name" : { "$ref" : "#/components/schemas/PermissionValue" } ,
"label" : { "type" : "string" , "maxLength" : 100 } ,
"description" : { "type" : "string" , "maxLength" : 500 } ,
"system" : { "type" : "boolean" , "description" : "True for the seeded capabilities, which cannot be deleted or renamed." } ,
"enforced" : { "type" : "boolean" , "description" : "True when the console itself checks for this permission, so granting it changes what the console allows. False for a permission added through the API: it is reported to client applications but nothing here tests for it." } ,
"role_count" : { "type" : "integer" , "description" : "Roles currently granting it. A permission that a role still grants cannot be deleted." }
}
} ,
"PermissionRequest" : {
"type" : "object" ,
"required" : [ "name" , "label" ] ,
"properties" : {
"name" : { "$ref" : "#/components/schemas/PermissionValue" } ,
"label" : { "type" : "string" , "minLength" : 1 , "maxLength" : 100 } ,
"description" : { "type" : "string" , "maxLength" : 500 }
2026-10-04 16:57:30 +02:00
}
} ,
"RoleRequest" : {
"type" : "object" ,
"required" : [ "name" , "permissions" ] ,
"properties" : {
"tenant_id" : { "type" : "string" , "format" : "uuid" , "description" : "Required when creating. Ignored when updating, which takes the role's own tenant." } ,
"slug" : { "type" : "string" , "pattern" : "^[a-z0-9][a-z0-9-]{0,62}$" , "description" : "Required when creating, and fixed for the life of the role." } ,
"name" : { "type" : "string" , "minLength" : 1 , "maxLength" : 100 } ,
"description" : { "type" : "string" , "maxLength" : 500 } ,
feat: account menu, database roles, and single logout
Bundles the console work from the current branch: the header account
control, database-backed roles and permissions, and signing out across
the applications that share this sign-in.
The account control replaces a separate identity chip and a separate
sign-out button, which meant the header carried two controls describing
one fact. web/account.js is mounted into every page by a placeholder and
owns the keyboard handling, the outside-click behaviour and the sign-out
form once. It also fills the trigger's name, which the previous markup
left empty.
Signing out revokes the hosted-login bearer sessions this installation
issued, then redirects to the provider's end_session_endpoint when the
discovery document advertises one. Applications that authenticate
against this installation therefore lose their session too; one pointed
straight at an identity provider is out of its reach and is unchanged.
Roles and permissions become rows rather than a closed enum, so a tenant
can extend the catalog without a migration, and a role can no longer be
granted a capability that is not in it. requireSameOrigin now compares
against the origin the request arrived on instead of the configured base
URL, which defaulted to http://localhost:8080 and so rejected every
write in a deployment that never set it.
Two bugs found while verifying in a browser:
- A profile picture never rendered. avatarURL built an absolute URL
from the configured base URL, and the page's own img-src 'self'
blocked it. The console's own pages now take a root-relative path;
only /connect/userinfo, read from another origin, needs an absolute
one, and it follows the request.
- absoluteURL preferred the configuration over the request while its
comment claimed the opposite, which also misdirected the logout
redirect.
Card layout: the footer was one two-column row shared between a label
and a forty-character identifier, so the identifier took the width and
crushed its label into three stacked words. Each pair is a line now, the
value truncates with the full text in the title, and cards align their
footers across a row.
2026-10-05 19:18:07 +02:00
"permissions" : { "type" : "array" , "items" : { "$ref" : "#/components/schemas/PermissionValue" } ,
"description" : "Names from the permission catalog. An unknown name is rejected with 400." }
2026-10-04 12:24:13 +02:00
}
2026-10-01 23:15:57 +02:00
}
}
} ,
"paths" : {
"/healthz" : {
"get" : {
"tags" : [
"Monitoring"
] ,
"summary" : "Liveness check" ,
"responses" : { "200" : { "description" : "Healthy" } }
}
} ,
"/install" : {
"get" : {
"tags" : [ "Installation" ] ,
"summary" : "Show the first-run installer" ,
"responses" : { "200" : { "description" : "Installer form" } , "303" : { "description" : "Already installed" } }
} ,
"post" : {
"tags" : [ "Installation" ] ,
"summary" : "Persist Google OIDC configuration and optional demo data" ,
"requestBody" : { "required" : true , "content" : { "application/x-www-form-urlencoded" : { "schema" : { "type" : "object" , "required" : [ "csrf_token" , "oidc_client_id" , "oidc_client_secret" ] , "properties" : { "csrf_token" : { "type" : "string" } , "oidc_client_id" : { "type" : "string" } , "oidc_client_secret" : { "type" : "string" , "format" : "password" } , "seed_demo" : { "type" : "boolean" } } } } } } ,
"responses" : { "303" : { "description" : "Installation completed; continue to Google OIDC login" } , "409" : { "description" : "Already installed" } , "502" : { "description" : "Google OIDC discovery failed" } }
}
} ,
"/auth/password" : {
"post" : {
"tags" : [ "User Action" ] ,
"summary" : "Authenticate a database-backed user" ,
"requestBody" : { "required" : true , "content" : { "application/x-www-form-urlencoded" : { "schema" : { "type" : "object" , "required" : [ "email" , "password" ] , "properties" : { "email" : { "type" : "string" , "format" : "email" } , "password" : { "type" : "string" , "format" : "password" } , "return" : { "type" : "string" , "format" : "uri" } } } } } } ,
"responses" : { "303" : { "description" : "Login succeeded" } , "401" : { "description" : "Invalid email or password" } , "429" : { "description" : "Temporarily rate limited" } }
}
} ,
"/api/v1/authorize" : {
"post" : {
"tags" : [
"Authorization Management"
] ,
"summary" : "Authorize an incoming API request" ,
"security" : [ { "apiKey" : [ ] } ] ,
"requestBody" : { "required" : true , "content" : { "application/json" : { "schema" : { "$ref" : "#/components/schemas/AuthorizationRequest" } } } } ,
"responses" : {
"200" : { "description" : "Authorized" , "content" : { "application/json" : { "schema" : { "$ref" : "#/components/schemas/AuthorizationResponse" } } } } ,
"401" : { "description" : "Missing or invalid key" , "content" : { "application/json" : { "schema" : { "$ref" : "#/components/schemas/Error" } } } } ,
"403" : { "description" : "Key not allowed for this request" , "content" : { "application/json" : { "schema" : { "$ref" : "#/components/schemas/AuthorizationResponse" } } } }
}
}
} ,
"/api/v1/dashboard" : {
"get" : {
"tags" : [
"Dashboard Management"
] ,
"summary" : "Get management console data" ,
2026-10-04 12:24:13 +02:00
"description" : "Requires a role that grants console.read. Each tenant and user entry carries the capability names its role grants." ,
2026-10-01 23:15:57 +02:00
"security" : [ { "session" : [ ] } ] ,
"responses" : { "200" : { "description" : "Dashboard data" } }
}
} ,
2026-10-04 12:24:13 +02:00
"/profile" : {
"get" : {
"tags" : [ "Profile" ] ,
"summary" : "Open the self-service profile screen" ,
"description" : "Available to every authenticated management session, including self-service User members." ,
"security" : [ { "session" : [ ] } ] ,
"responses" : { "200" : { "description" : "Profile screen" } , "303" : { "description" : "Redirect to login" } }
}
} ,
"/api/v1/profile" : {
"get" : {
"tags" : [ "Profile" ] ,
"summary" : "Read the caller's own profile and tenant roles" ,
"security" : [ { "session" : [ ] } ] ,
"responses" : {
"200" : { "description" : "Profile" , "content" : { "application/json" : { "schema" : { "$ref" : "#/components/schemas/Profile" } } } } ,
"401" : { "description" : "Authentication required" }
}
} ,
"put" : {
"tags" : [ "Profile" ] ,
"summary" : "Update the caller's display name or profile picture" ,
"security" : [ { "session" : [ ] } ] ,
"requestBody" : { "required" : true , "content" : { "application/json" : { "schema" : { "$ref" : "#/components/schemas/ProfileUpdate" } } } } ,
"responses" : {
"200" : { "description" : "Updated profile" , "content" : { "application/json" : { "schema" : { "$ref" : "#/components/schemas/Profile" } } } } ,
"400" : { "description" : "Invalid display name or unsupported picture" }
}
}
} ,
"/api/v1/avatars/{key}" : {
"get" : {
"tags" : [ "Profile" ] ,
"summary" : "Fetch a profile picture" ,
"description" : "Public on purpose so other applications can embed the picture with a plain image tag. The key is 256 bits of randomness and exposes nothing about the user." ,
"parameters" : [ { "in" : "path" , "name" : "key" , "required" : true , "schema" : { "type" : "string" } } ] ,
"responses" : {
"200" : { "description" : "Image bytes" , "content" : { "image/jpeg" : { "schema" : { "type" : "string" , "format" : "binary" } } , "image/png" : { "schema" : { "type" : "string" , "format" : "binary" } } , "image/gif" : { "schema" : { "type" : "string" , "format" : "binary" } } } } ,
"304" : { "description" : "Not modified" } ,
"404" : { "description" : "No picture for that key" }
}
}
} ,
2026-10-01 23:15:57 +02:00
"/api/v1/tenants" : {
"post" : {
"tags" : [
"Tenant Management"
] ,
"summary" : "Create a tenant" ,
"security" : [ { "session" : [ ] } ] ,
"responses" : { "201" : { "description" : "Tenant created" } }
}
} ,
"/api/v1/applications" : {
"post" : {
"tags" : [
"Application Management"
] ,
"summary" : "Register an application" ,
"security" : [ { "session" : [ ] } ] ,
"responses" : { "201" : { "description" : "Application registered" } }
}
} ,
"/api/v1/keys" : {
"post" : {
"tags" : [
"Key Management"
] ,
"summary" : "Issue an API key" ,
"security" : [ { "session" : [ ] } ] ,
"responses" : { "201" : { "description" : "Key created; plaintext secret is returned once" } }
}
} ,
"/api/v1/keys/{id}/revoke" : {
"post" : {
"tags" : [
"Key Management"
] ,
"summary" : "Revoke an API key" ,
"security" : [ { "session" : [ ] } ] ,
"parameters" : [ { "in" : "path" , "name" : "id" , "required" : true , "schema" : { "type" : "string" , "format" : "uuid" } } ] ,
"responses" : { "204" : { "description" : "Key revoked" } }
}
} ,
"/api/v1/contracts" : {
"post" : {
"tags" : [
"Contract Management"
] ,
"summary" : "Import a pasted contract or scan an OpenAPI URL" ,
"security" : [ { "session" : [ ] } ] ,
"responses" : { "201" : { "description" : "Contract imported" } }
}
} ,
"/connect/authorize" : {
"get" : {
"tags" : [
"User Action"
] ,
"summary" : "Start hosted client application login" ,
"parameters" : [
{ "in" : "query" , "name" : "response_type" , "required" : true , "schema" : { "type" : "string" , "const" : "code" } } ,
{ "in" : "query" , "name" : "client_id" , "required" : true , "schema" : { "type" : "string" } } ,
{ "in" : "query" , "name" : "redirect_uri" , "required" : true , "schema" : { "type" : "string" , "format" : "uri" } } ,
{ "in" : "query" , "name" : "state" , "required" : true , "schema" : { "type" : "string" } }
] ,
"responses" : { "302" : { "description" : "Redirect to login or exact client callback" } }
}
} ,
"/connect/token" : {
"post" : {
"tags" : [
"User Action"
] ,
"summary" : "Exchange a one-time authorization code" ,
2026-10-04 12:24:13 +02:00
"description" : "The nested user object carries role, permissions, and avatar_url for the tenant owning the application." ,
2026-10-01 23:15:57 +02:00
"security" : [ { "clientBasic" : [ ] } ] ,
"requestBody" : { "required" : true , "content" : { "application/x-www-form-urlencoded" : { "schema" : { "type" : "object" } } } } ,
"responses" : { "200" : { "description" : "Opaque client user session" } , "401" : { "description" : "Invalid code or client credentials" } }
}
} ,
"/connect/userinfo" : {
"get" : {
"tags" : [
"User Action"
] ,
2026-10-04 12:24:13 +02:00
"summary" : "Validate a client user session and read the caller's role and picture" ,
"description" : "Returns id, email, display_name, avatar_url, tenant and application identity, role, and permissions." ,
2026-10-01 23:15:57 +02:00
"security" : [ { "userBearer" : [ ] } ] ,
"responses" : { "200" : { "description" : "Authenticated client user" } , "401" : { "description" : "Invalid, revoked, or expired session" } }
}
} ,
"/connect/logout" : {
"post" : {
"tags" : [
"User Action"
] ,
"summary" : "Revoke a client user session" ,
"security" : [ { "userBearer" : [ ] } ] ,
"responses" : { "204" : { "description" : "Session revoked" } }
}
} ,
"/api/v1/applications/{id}/client-config" : {
"post" : {
"tags" : [
"Application Management"
] ,
"summary" : "Configure exact redirect URIs and rotate the client secret" ,
"security" : [ { "session" : [ ] } ] ,
"parameters" : [ { "in" : "path" , "name" : "id" , "required" : true , "schema" : { "type" : "string" , "format" : "uuid" } } ] ,
"responses" : { "200" : { "description" : "Configuration saved; client secret returned once" } }
}
} ,
"/api/v1/users" : {
"post" : {
"tags" : [
"User Management"
] ,
2026-10-04 16:57:30 +02:00
"summary" : "Invite a tenant user with a role" ,
"description" : "Requires users.manage in the tenant, which owner, admin, developer, and operator hold. role_id must name a role of that tenant whose permissions are a subset of the caller's own." ,
2026-10-01 23:15:57 +02:00
"security" : [ { "session" : [ ] } ] ,
"responses" : { "201" : { "description" : "User invited" } }
}
} ,
"/api/v1/users/{id}/access" : {
"put" : {
"tags" : [
"User Management"
] ,
"summary" : "Set tenant role and application access" ,
2026-10-04 16:57:30 +02:00
"description" : "Requires users.manage. The caller may not grant a role more powerful than their own, and may not demote the last owner of a tenant." ,
2026-10-01 23:15:57 +02:00
"security" : [ { "session" : [ ] } ] ,
"parameters" : [ { "in" : "path" , "name" : "id" , "required" : true , "schema" : { "type" : "string" , "format" : "uuid" } } ] ,
"responses" : { "204" : { "description" : "Access updated" } }
}
} ,
2026-10-04 12:24:13 +02:00
"/api/v1/roles" : {
"get" : {
2026-10-04 16:57:30 +02:00
"tags" : [ "Role Management" ] ,
"summary" : "List the roles available in a tenant" ,
"description" : "Returns every built-in role plus the roles this tenant defined, each annotated with whether the caller may assign it." ,
"security" : [ { "session" : [ ] } ] ,
"parameters" : [ { "in" : "query" , "name" : "tenant_id" , "required" : true , "schema" : { "type" : "string" , "format" : "uuid" } } ] ,
"responses" : { "200" : { "description" : "Role catalog" , "content" : { "application/json" : { "schema" : { "type" : "object" , "properties" : { "roles" : { "type" : "array" , "items" : { "$ref" : "#/components/schemas/Role" } } } } } } } , "404" : { "description" : "Not a member of that tenant" } }
} ,
"post" : {
"tags" : [ "Role Management" ] ,
"summary" : "Define a role for a tenant" ,
feat: account menu, database roles, and single logout
Bundles the console work from the current branch: the header account
control, database-backed roles and permissions, and signing out across
the applications that share this sign-in.
The account control replaces a separate identity chip and a separate
sign-out button, which meant the header carried two controls describing
one fact. web/account.js is mounted into every page by a placeholder and
owns the keyboard handling, the outside-click behaviour and the sign-out
form once. It also fills the trigger's name, which the previous markup
left empty.
Signing out revokes the hosted-login bearer sessions this installation
issued, then redirects to the provider's end_session_endpoint when the
discovery document advertises one. Applications that authenticate
against this installation therefore lose their session too; one pointed
straight at an identity provider is out of its reach and is unchanged.
Roles and permissions become rows rather than a closed enum, so a tenant
can extend the catalog without a migration, and a role can no longer be
granted a capability that is not in it. requireSameOrigin now compares
against the origin the request arrived on instead of the configured base
URL, which defaulted to http://localhost:8080 and so rejected every
write in a deployment that never set it.
Two bugs found while verifying in a browser:
- A profile picture never rendered. avatarURL built an absolute URL
from the configured base URL, and the page's own img-src 'self'
blocked it. The console's own pages now take a root-relative path;
only /connect/userinfo, read from another origin, needs an absolute
one, and it follows the request.
- absoluteURL preferred the configuration over the request while its
comment claimed the opposite, which also misdirected the logout
redirect.
Card layout: the footer was one two-column row shared between a label
and a forty-character identifier, so the identifier took the width and
crushed its label into three stacked words. Each pair is a line now, the
value truncates with the full text in the title, and cards align their
footers across a row.
2026-10-05 19:18:07 +02:00
"description" : "Requires roles.manage in the tenant. The role may not grant an enforced permission the caller does not already hold, so an administrator cannot mint their way above their own authority. Whoever administers the catalog may also grant the custom permissions they added, since nobody holds those yet." ,
2026-10-04 16:57:30 +02:00
"security" : [ { "session" : [ ] } ] ,
"requestBody" : { "content" : { "application/json" : { "schema" : { "$ref" : "#/components/schemas/RoleRequest" } } } } ,
"responses" : { "201" : { "description" : "Role created" , "content" : { "application/json" : { "schema" : { "$ref" : "#/components/schemas/Role" } } } } , "400" : { "description" : "Malformed slug or name, or an unknown permission" } , "404" : { "description" : "The caller does not hold roles.manage, or the role would exceed their authority" } , "409" : { "description" : "The slug is already used in this tenant" } }
}
} ,
"/api/v1/roles/{id}" : {
"put" : {
"tags" : [ "Role Management" ] ,
"summary" : "Rename a role and replace its permissions" ,
"description" : "Built-in roles are immutable. Saving replaces the permissions of every member holding the role, so treat it as a live change." ,
"security" : [ { "session" : [ ] } ] ,
"parameters" : [ { "in" : "path" , "name" : "id" , "required" : true , "schema" : { "type" : "string" , "format" : "uuid" } } ] ,
"requestBody" : { "content" : { "application/json" : { "schema" : { "$ref" : "#/components/schemas/RoleRequest" } } } } ,
"responses" : { "200" : { "description" : "Role updated" , "content" : { "application/json" : { "schema" : { "$ref" : "#/components/schemas/Role" } } } } , "400" : { "description" : "Malformed name or an unknown permission" } , "404" : { "description" : "Unknown role, a built-in role, or a role belonging to another tenant" } }
} ,
"delete" : {
"tags" : [ "Role Management" ] ,
"summary" : "Delete one of the tenant's own roles" ,
"security" : [ { "session" : [ ] } ] ,
"parameters" : [ { "in" : "path" , "name" : "id" , "required" : true , "schema" : { "type" : "string" , "format" : "uuid" } } ] ,
"responses" : { "204" : { "description" : "Role deleted" } , "404" : { "description" : "Unknown role, a built-in role, or a role belonging to another tenant" } , "409" : { "description" : "The role still has members" } }
}
} ,
feat: account menu, database roles, and single logout
Bundles the console work from the current branch: the header account
control, database-backed roles and permissions, and signing out across
the applications that share this sign-in.
The account control replaces a separate identity chip and a separate
sign-out button, which meant the header carried two controls describing
one fact. web/account.js is mounted into every page by a placeholder and
owns the keyboard handling, the outside-click behaviour and the sign-out
form once. It also fills the trigger's name, which the previous markup
left empty.
Signing out revokes the hosted-login bearer sessions this installation
issued, then redirects to the provider's end_session_endpoint when the
discovery document advertises one. Applications that authenticate
against this installation therefore lose their session too; one pointed
straight at an identity provider is out of its reach and is unchanged.
Roles and permissions become rows rather than a closed enum, so a tenant
can extend the catalog without a migration, and a role can no longer be
granted a capability that is not in it. requireSameOrigin now compares
against the origin the request arrived on instead of the configured base
URL, which defaulted to http://localhost:8080 and so rejected every
write in a deployment that never set it.
Two bugs found while verifying in a browser:
- A profile picture never rendered. avatarURL built an absolute URL
from the configured base URL, and the page's own img-src 'self'
blocked it. The console's own pages now take a root-relative path;
only /connect/userinfo, read from another origin, needs an absolute
one, and it follows the request.
- absoluteURL preferred the configuration over the request while its
comment claimed the opposite, which also misdirected the logout
redirect.
Card layout: the footer was one two-column row shared between a label
and a forty-character identifier, so the identifier took the width and
crushed its label into three stacked words. Each pair is a line now, the
value truncates with the full text in the title, and cards align their
footers across a row.
2026-10-05 19:18:07 +02:00
"/api/v1/permissions" : {
"get" : {
"tags" : [ "Role Management" ] ,
"summary" : "List the permission catalog" ,
"description" : "Every management session may read it, because the role editor renders its checkboxes from this list." ,
"security" : [ { "session" : [ ] } ] ,
"responses" : { "200" : { "description" : "Catalog" , "content" : { "application/json" : { "schema" : { "type" : "object" , "properties" : { "permissions" : { "type" : "array" , "items" : { "$ref" : "#/components/schemas/Permission" } } } } } } } }
} ,
"post" : {
"tags" : [ "Role Management" ] ,
"summary" : "Add a permission to the catalog" ,
"description" : "Requires a system administrator or tenants.manage, because the catalog is shared by every tenant. The new name becomes grantable in any role and is reported through /connect/userinfo, but nothing in the console checks for it, so it cannot unlock console behaviour." ,
"security" : [ { "session" : [ ] } ] ,
"requestBody" : { "content" : { "application/json" : { "schema" : { "$ref" : "#/components/schemas/PermissionRequest" } } } } ,
"responses" : { "201" : { "description" : "Permission added" , "content" : { "application/json" : { "schema" : { "$ref" : "#/components/schemas/Permission" } } } } , "400" : { "description" : "Malformed name or label" } , "404" : { "description" : "The caller may not administer the catalog" } , "409" : { "description" : "The name is already in the catalog, including a seeded one" } }
}
} ,
"/api/v1/permissions/{name}" : {
"delete" : {
"tags" : [ "Role Management" ] ,
"summary" : "Delete a permission that no role grants" ,
"security" : [ { "session" : [ ] } ] ,
"parameters" : [ { "in" : "path" , "name" : "name" , "required" : true , "schema" : { "$ref" : "#/components/schemas/PermissionValue" } } ] ,
"responses" : { "204" : { "description" : "Permission deleted" } , "400" : { "description" : "Malformed name" } , "404" : { "description" : "Unknown permission or the caller may not administer the catalog" } , "409" : { "description" : "A role still grants it, or it is one of the seeded capabilities" } }
}
} ,
2026-10-04 16:57:30 +02:00
"/roles" : {
"get" : {
"tags" : [ "Role Management" ] ,
"summary" : "Role management screen" ,
2026-10-04 12:24:13 +02:00
"security" : [ { "session" : [ ] } ] ,
2026-10-04 16:57:30 +02:00
"responses" : { "200" : { "description" : "HTML page" } }
2026-10-04 12:24:13 +02:00
}
} ,
2026-10-01 23:15:57 +02:00
"/api/v1/users/{id}/approve" : {
"post" : {
"tags" : [ "User Management" ] ,
"summary" : "Approve a waiting-room user into a tenant" ,
2026-10-04 16:57:30 +02:00
"description" : "Requires a system administrator session. Unlike the tenant-scoped endpoints this is not limited by the caller's own role, because it grants a first membership across tenants." ,
2026-10-01 23:15:57 +02:00
"security" : [ { "session" : [ ] } ] ,
"parameters" : [ { "in" : "path" , "name" : "id" , "required" : true , "schema" : { "type" : "string" , "format" : "uuid" } } ] ,
"responses" : { "204" : { "description" : "User approved" } , "404" : { "description" : "User or permission not found" } }
}
}
}
}