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>
150 lines
3.4 KiB
Go
150 lines
3.4 KiB
Go
// 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()
|
|
}
|