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:
149
internal/db/db.go
Normal file
149
internal/db/db.go
Normal 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()
|
||||
}
|
||||
Reference in New Issue
Block a user