feat: gsc-shell-api v0.1 — central chrome data API

Tiny Go service that returns ShellConfig JSON for any registered app.
Backs the runtime-loaded <AppShell> component being added to
@limitless/ui (next).

Endpoints:
  GET /api/v1/shell/{appKey}  → app + branding + user + menus, ETag-cached
  GET /api/v1/apps            → registered app inventory
  GET /healthz, /readyz       → ops probes

Auth:
  Keycloak Bearer JWT validated against the gosecCloud realm.
  Discovery URL is overridable so pods can hit Keycloak via the
  in-cluster service (https://keycloak.keycloak.svc.cluster.local:8443)
  while still validating the canonical issuer (auth.gosec.cloud).
  Lazy JWKS init — pod stays up if Keycloak is briefly unreachable.

Data model (gsc_core.shell):
  apps · menu_items (zone enum: topbar/sidebar/footer/user-menu) ·
  menu_role_grants (Keycloak realm roles, OR semantics, empty=all) ·
  branding

Seed includes the 8 gsc-crm sidebar items + topbar search +
user-menu (settings/support/logout) + footer (docs).

K8s:
  Namespace gsc-shell (ambient mesh).
  Deployment 2 replicas, internal-only ingress shell-api.gosec.internal,
  EJBCA SERVER cert.
  ServiceEntry for auth.gosec.cloud (vestigial — discovery now uses
  in-cluster path; keeping for ad-hoc curl from inside pods).
  Added to keycloak/allow-keycloak-clients AuthorizationPolicy out of band.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Claude
2026-05-09 20:10:22 +02:00
commit 7fb24e0452
15 changed files with 1195 additions and 0 deletions

149
internal/db/db.go Normal file
View File

@@ -0,0 +1,149 @@
// Package db contains the queries the API serves.
//
// All reads are filtered against a per-app key. Role filtering happens
// in-memory after the menu_items + role_grants are loaded — the joins
// are small enough (typically <100 rows per app) that hand-filtering in
// Go is simpler than a SQL CTE.
package db
import (
"context"
"fmt"
"github.com/jackc/pgx/v5/pgxpool"
)
type DB struct {
pool *pgxpool.Pool
}
func New(ctx context.Context, url string) (*DB, error) {
pool, err := pgxpool.New(ctx, url)
if err != nil {
return nil, fmt.Errorf("pgxpool.New: %w", err)
}
if err := pool.Ping(ctx); err != nil {
return nil, fmt.Errorf("ping: %w", err)
}
return &DB{pool: pool}, nil
}
func (d *DB) Close() { d.pool.Close() }
type App struct {
Key string
DisplayName string
BaseURL string
}
type Branding struct {
LogoURL string
ProductName string
FooterHTML *string
BrandColor *string
}
type MenuItem struct {
ID string
ParentID *string
Zone string
Key string
TranslationKey string
Href string
Icon *string
SortOrder int
IsExternal bool
Roles []string // empty = visible to anyone authenticated
}
func (d *DB) GetApp(ctx context.Context, appKey string) (*App, error) {
row := d.pool.QueryRow(ctx, `
SELECT app_key, display_name, base_url
FROM shell.apps
WHERE app_key = $1 AND is_active = true
`, appKey)
var a App
if err := row.Scan(&a.Key, &a.DisplayName, &a.BaseURL); err != nil {
return nil, err
}
return &a, nil
}
func (d *DB) GetBranding(ctx context.Context, appKey string) (*Branding, error) {
row := d.pool.QueryRow(ctx, `
SELECT logo_url, product_name, footer_html, brand_color
FROM shell.branding
WHERE app_key = $1
`, appKey)
var b Branding
if err := row.Scan(&b.LogoURL, &b.ProductName, &b.FooterHTML, &b.BrandColor); err != nil {
return nil, err
}
return &b, nil
}
func (d *DB) ListMenuItems(ctx context.Context, appKey string) ([]MenuItem, error) {
rows, err := d.pool.Query(ctx, `
SELECT
mi.id::text,
mi.parent_id::text,
mi.zone::text,
mi.key,
mi.translation_key,
mi.href,
mi.icon,
mi.sort_order,
mi.is_external,
COALESCE(
(SELECT array_agg(role ORDER BY role) FROM shell.menu_role_grants WHERE menu_item_id = mi.id),
ARRAY[]::text[]
) AS roles
FROM shell.menu_items mi
WHERE mi.app_key = $1 AND mi.is_active = true
ORDER BY mi.zone, mi.sort_order, mi.key
`, appKey)
if err != nil {
return nil, err
}
defer rows.Close()
var out []MenuItem
for rows.Next() {
var m MenuItem
var parent *string
if err := rows.Scan(
&m.ID, &parent, &m.Zone, &m.Key, &m.TranslationKey, &m.Href,
&m.Icon, &m.SortOrder, &m.IsExternal, &m.Roles,
); err != nil {
return nil, err
}
if parent != nil && *parent != "" {
m.ParentID = parent
}
out = append(out, m)
}
return out, rows.Err()
}
// ListApps returns every active app — used by GET /api/v1/apps.
func (d *DB) ListApps(ctx context.Context) ([]App, error) {
rows, err := d.pool.Query(ctx, `
SELECT app_key, display_name, base_url
FROM shell.apps
WHERE is_active = true
ORDER BY app_key
`)
if err != nil {
return nil, err
}
defer rows.Close()
var out []App
for rows.Next() {
var a App
if err := rows.Scan(&a.Key, &a.DisplayName, &a.BaseURL); err != nil {
return nil, err
}
out = append(out, a)
}
return out, rows.Err()
}