2026-10-01 23:15:57 +02:00
<!doctype html>
< html lang = "en" data-theme = "tokyo" data-mode = "dark" >
< head >
< meta charset = "utf-8" >
< meta name = "viewport" content = "width=device-width,initial-scale=1" >
< meta name = "color-scheme" content = "dark light" >
< title > Go Loose · API access control< / title >
< link rel = "stylesheet" href = "/assets/style.css" >
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
<!-- The shared styling contract. Every palette, both appearances, in one
stylesheet, so switching is a change to an attribute rather than a request.
The origin is substituted by the server from GO_LOOSE_THEME_BASE_URL; when it
is unset the link is simply absent and this application's own theme blocks
take over unchanged. -->
< link rel = "stylesheet" href = "__GO_LOOSE_THEME_ORIGIN__/rt/v1/contract.css" data-theme-contract >
2026-10-01 23:15:57 +02:00
< / head >
< body >
< div class = "noise" > < / div >
< header >
< a class = "brand" href = "/" > < span class = "brand-mark" > GL< / span > < span > Go Loose< / span > < / a >
< nav >
< a href = "/docs" > API docs< / a >
2026-10-04 16:57:30 +02:00
< a href = "/roles" > Roles< / a >
2026-10-01 23:15:57 +02:00
< label class = "compact" > Tenant< select id = "tenant-context" > < / select > < / label >
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
<!-- The controls, injected rather than hand - written. They live in a shadow root
so this page's own CSS cannot reach them, and they read and write the same
data-theme and data-mode attributes the stylesheet blocks above key on, so
the values are already the ones this application was storing. -->
< bananas-theme-selector id = "theme" > < / bananas-theme-selector >
< bananas-appearance-toggle id = "mode" > < / bananas-appearance-toggle >
< div data-account-menu > < / div >
2026-10-01 23:15:57 +02:00
< / nav >
< / header >
< main >
< section class = "hero" >
< div >
< p class = "eyebrow" > MULTI-TENANT API ACCESS CONTROL< / p >
< h1 > One key plane.< br > < span > Every Go API.< / span > < / h1 >
< p class = "lede" > Register applications, issue scoped keys, import OpenAPI contracts, and authorize requests without coupling identity logic to every service.< / p >
< / div >
< div class = "status-card" >
< div class = "pulse" > < / div >
< div > < strong > Authorization plane< / strong > < span > Operational< / span > < / div >
< code > POST /api/v1/authorize< / code >
< / div >
< / section >
< div id = "notice" class = "notice" hidden > < / div >
< section class = "stats" id = "stats" >
< article > < span > Tenants< / span > < strong id = "tenant-count" > —< / strong > < / article >
< article > < span > Applications< / span > < strong id = "app-count" > —< / strong > < / article >
< article > < span > API keys< / span > < strong id = "key-count" > —< / strong > < / article >
< article > < span > Contracts< / span > < strong id = "contract-count" > —< / strong > < / article >
< article > < span > Users< / span > < strong id = "user-count" > —< / strong > < / article >
< / section >
< section class = "workspace" >
< aside >
< button class = "tab active" data-panel = "applications" > 01 < span > Applications< / span > < / button >
< button class = "tab" data-panel = "keys" > 02 < span > API keys< / span > < / button >
< button class = "tab" data-panel = "contracts" > 03 < span > Contracts< / span > < / button >
< button class = "tab" data-panel = "tenants" > 04 < span > Tenants< / span > < / button >
< button class = "tab" data-panel = "users" > 05 < span > Users< / span > < / button >
< / aside >
< div class = "panels" >
< section class = "panel active" id = "applications" >
2026-10-04 12:24:13 +02:00
< div class = "panel-head" > < div > < p class = "eyebrow" > SERVICE CATALOG< / p > < h2 > Applications< / h2 > < / div > < button data-dialog = "app-dialog" data-tenant-application-action > New application< / button > < / div >
2026-10-01 23:15:57 +02:00
< div class = "card-grid" id = "app-list" > < / div >
< / section >
< section class = "panel" id = "keys" >
2026-10-04 12:24:13 +02:00
< div class = "panel-head" > < div > < p class = "eyebrow" > CREDENTIALS< / p > < h2 > API keys< / h2 > < / div > < button data-dialog = "key-dialog" data-tenant-key-action > Issue key< / button > < / div >
2026-10-01 23:15:57 +02:00
< div class = "table-wrap" > < table > < thead > < tr > < th > Name< / th > < th > Application< / th > < th > Prefix< / th > < th > Status< / th > < th > Last used< / th > < th > < / th > < / tr > < / thead > < tbody id = "key-list" > < / tbody > < / table > < / div >
< / section >
< section class = "panel" id = "contracts" >
2026-10-04 12:24:13 +02:00
< div class = "panel-head" > < div > < p class = "eyebrow" > DISCOVERY< / p > < h2 > OpenAPI contracts< / h2 > < / div > < button data-dialog = "contract-dialog" data-tenant-contract-action > Import contract< / button > < / div >
2026-10-01 23:15:57 +02:00
< div class = "table-wrap" > < table > < thead > < tr > < th > Application< / th > < th > OpenAPI< / th > < th > Endpoints< / th > < th > Source< / th > < th > Imported< / th > < / tr > < / thead > < tbody id = "contract-list" > < / tbody > < / table > < / div >
< / section >
< section class = "panel" id = "tenants" >
< div class = "panel-head" > < div > < p class = "eyebrow" > ORGANIZATIONS< / p > < h2 > Tenants< / h2 > < / div > < button id = "new-tenant" data-dialog = "tenant-dialog" > New tenant< / button > < / div >
< div class = "card-grid" id = "tenant-list" > < / div >
< / section >
< section class = "panel" id = "users" >
2026-10-04 12:24:13 +02:00
< div class = "panel-head" > < div > < p class = "eyebrow" > CLIENT IDENTITIES< / p > < h2 > Users & access< / h2 > < / div > < button data-dialog = "user-dialog" data-tenant-user-action > Invite user< / button > < / div >
2026-10-01 23:15:57 +02:00
< div class = "table-wrap" > < table > < thead > < tr > < th > User< / th > < th > Tenant< / th > < th > Role< / th > < th > Status< / th > < th > Applications< / th > < th > < / th > < / tr > < / thead > < tbody id = "user-list" > < / tbody > < / table > < / div >
2026-10-04 16:57:30 +02:00
< p class = "muted" > Roles are per tenant and grant capabilities. < a href = "/roles" > Manage roles< / a > to review or define your own. < a href = "/profile" > Open your own profile< / a > to change your name and picture.< / p >
2026-10-01 23:15:57 +02:00
< / section >
< / div >
< / section >
< / main >
< dialog id = "tenant-dialog" > < form id = "tenant-form" > < div class = "dialog-head" > < h3 > Create tenant< / h3 > < button type = "button" class = "close" > × < / button > < / div > < label > Name< input name = "name" required > < / label > < label > Slug< input name = "slug" required pattern = "[a-z0-9][a-z0-9-]{1,62}" placeholder = "nmbs" > < / label > < button type = "submit" > Create tenant< / button > < / form > < / dialog >
< dialog id = "app-dialog" > < form id = "app-form" > < div class = "dialog-head" > < h3 > Register application< / h3 > < button type = "button" class = "close" > × < / button > < / div > < label > Tenant< select name = "tenant_id" class = "tenant-select" required > < / select > < / label > < label > Name< input name = "name" required > < / label > < label > Slug< input name = "slug" required pattern = "[a-z0-9][a-z0-9-]{1,62}" placeholder = "guess" > < / label > < label > Description< textarea name = "description" > < / textarea > < / label > < label > Allowed hosts < small > (one per line, empty allows all)< / small > < textarea name = "allowed_hosts" placeholder = "nmbs.guess.local" > < / textarea > < / label > < button type = "submit" > Register application< / button > < / form > < / dialog >
< dialog id = "key-dialog" > < form id = "key-form" > < div class = "dialog-head" > < h3 > Issue API key< / h3 > < button type = "button" class = "close" > × < / button > < / div > < label > Application< select name = "application_id" class = "app-select" required > < / select > < / label > < label > Key name< input name = "name" required placeholder = "production-gateway" > < / label > < label > Expires at < small > (optional)< / small > < input type = "datetime-local" name = "expires_at" > < / label > < button type = "submit" > Issue key< / button > < / form > < / dialog >
< dialog id = "contract-dialog" > < form id = "contract-form" > < div class = "dialog-head" > < h3 > Import OpenAPI contract< / h3 > < button type = "button" class = "close" > × < / button > < / div > < label > Application< select name = "application_id" class = "app-select" required > < / select > < / label > < div class = "segmented" > < label > < input type = "radio" name = "source_type" value = "document" checked > Paste< / label > < label > < input type = "radio" name = "source_type" value = "url" > Scan URL< / label > < / div > < label > Source URL < small > (optional when pasting)< / small > < input type = "url" name = "source_url" placeholder = "https://service/openapi.json" > < / label > < label > OpenAPI JSON or YAML< textarea name = "document" rows = "12" placeholder = "openapi: 3.1.0 paths: ..." > < / textarea > < / label > < button type = "submit" > Import contract< / button > < / form > < / dialog >
< dialog id = "client-dialog" > < form id = "client-form" > < div class = "dialog-head" > < h3 > Configure client login< / h3 > < button type = "button" class = "close" > × < / button > < / div > < input type = "hidden" name = "application_id" > < label > Client ID< input name = "client_id" readonly > < / label > < label > Redirect URIs < small > (one exact callback URL per line)< / small > < textarea name = "redirect_uris" rows = "5" placeholder = "https://nmbs.guess.local/auth/callback" required > < / textarea > < / label > < p class = "muted" > Saving rotates the client secret. Existing deployments must be updated immediately.< / p > < button type = "submit" > Save and rotate secret< / button > < / form > < / dialog >
2026-10-04 16:57:30 +02:00
< dialog id = "user-dialog" > < form id = "user-form" > < div class = "dialog-head" > < h3 > Invite tenant user< / h3 > < button type = "button" class = "close" > × < / button > < / div > < label > Tenant< select name = "tenant_id" class = "tenant-select" required > < / select > < / label > < label > Email< input type = "email" name = "email" required > < / label > < label > Display name< input name = "display_name" > < / label > < label > Initial password < small > (minimum 12 characters)< / small > < input type = "password" name = "password" minlength = "12" autocomplete = "new-password" required > < / label > < label > Role< select name = "role_id" id = "invite-role" required > < / select > < / label > < p class = "muted" > You can only assign roles that are no more powerful than your own.< / p > < button type = "submit" > Invite user< / button > < / form > < / dialog >
< dialog id = "access-dialog" > < form id = "access-form" > < div class = "dialog-head" > < h3 > Manage user access< / h3 > < button type = "button" class = "close" > × < / button > < / div > < input type = "hidden" name = "user_id" > < input type = "hidden" name = "tenant_id" > < input type = "hidden" name = "approval_mode" > < label id = "approval-tenant-label" hidden > Tenant< select name = "approval_tenant_id" class = "tenant-select" > < / select > < / label > < label > Role< select name = "role_id" id = "access-role" required > < / select > < / label > < fieldset > < legend > Applications< / legend > < div id = "access-apps" class = "check-list" > < / div > < / fieldset > < button type = "submit" > Update access< / button > < / form > < / dialog >
2026-10-01 23:15:57 +02:00
< dialog id = "secret-dialog" > < div class = "dialog-head" > < h3 id = "secret-title" > Secret issued< / h3 > < button type = "button" class = "close" > × < / button > < / div > < p id = "secret-description" > This secret is shown once. Copy it now.< / p > < div class = "secret" > < code id = "new-secret" > < / code > < button id = "copy-secret" type = "button" > Copy< / button > < / div > < / dialog >
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
< script src = "/assets/account.js" defer > < / script >
2026-10-01 23:15:57 +02:00
< script src = "/assets/app.js" defer > < / script >
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
<!-- Loaded from the theme service rather than vendored, so a change to the controls
needs no rebuild here. A module import is a CORS request, so this deployment's
origin must be in the service's CORS_ALLOWED_ORIGINS; a same-origin
deployment needs nothing configured. -->
< script type = "module" src = "__GO_LOOSE_THEME_ORIGIN__/rt/v1/components.js" data-bananas-components > < / script >
2026-10-01 23:15:57 +02:00
< / body >
< / html >