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

101
migrations/001_init.sql Normal file
View File

@@ -0,0 +1,101 @@
-- gsc-shell-api — initial schema.
--
-- Lives in `gsc_core` so it's reachable from every app that needs chrome data.
-- Owned by the `gsc_shell` role (created out of band).
--
-- Idempotent: every CREATE uses IF NOT EXISTS where possible.
CREATE SCHEMA IF NOT EXISTS shell;
-- Apps registry.
-- An "app" is a frontend that wants to render chrome from this service.
CREATE TABLE IF NOT EXISTS shell.apps (
app_key TEXT PRIMARY KEY CHECK (app_key ~ '^[a-z][a-z0-9-]*$'),
display_name TEXT NOT NULL,
base_url TEXT NOT NULL,
is_active BOOLEAN NOT NULL DEFAULT true,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- Menu zones. Mirrors what the existing AdminShell expects.
DO $$
BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'menu_zone' AND typnamespace = 'shell'::regnamespace) THEN
CREATE TYPE shell.menu_zone AS ENUM ('topbar', 'sidebar', 'footer', 'user-menu');
END IF;
END $$;
-- Menu items. One row per nav entry per app per zone.
-- Hierarchical via parent_id (sidebar submenus, etc.).
CREATE TABLE IF NOT EXISTS shell.menu_items (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
app_key TEXT NOT NULL REFERENCES shell.apps(app_key) ON DELETE CASCADE,
parent_id UUID REFERENCES shell.menu_items(id) ON DELETE CASCADE,
zone shell.menu_zone NOT NULL,
key TEXT NOT NULL CHECK (key ~ '^[a-z][a-z0-9_-]*$'),
translation_key TEXT NOT NULL, -- e.g. 'menu.dashboard'
href TEXT NOT NULL, -- relative within app, or absolute if is_external
icon TEXT, -- Phosphor class, e.g. 'ph-house'
sort_order INT NOT NULL DEFAULT 0,
is_external BOOLEAN NOT NULL DEFAULT false,
is_active BOOLEAN NOT NULL DEFAULT true,
metadata JSONB NOT NULL DEFAULT '{}',
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE (app_key, zone, key)
);
CREATE INDEX IF NOT EXISTS idx_menu_items_app_zone
ON shell.menu_items (app_key, zone, sort_order)
WHERE is_active;
CREATE INDEX IF NOT EXISTS idx_menu_items_parent
ON shell.menu_items (parent_id)
WHERE parent_id IS NOT NULL;
-- Role grants. Empty grants = visible to anyone authenticated. OR semantics
-- across rows: user sees the item if they hold ANY listed role.
-- Roles are Keycloak realm roles; we store their names as plain text since
-- Keycloak is the source of truth.
CREATE TABLE IF NOT EXISTS shell.menu_role_grants (
menu_item_id UUID NOT NULL REFERENCES shell.menu_items(id) ON DELETE CASCADE,
role TEXT NOT NULL,
PRIMARY KEY (menu_item_id, role)
);
-- Per-app branding. Logo, product name, footer text. Optional for now.
CREATE TABLE IF NOT EXISTS shell.branding (
app_key TEXT PRIMARY KEY REFERENCES shell.apps(app_key) ON DELETE CASCADE,
logo_url TEXT NOT NULL,
product_name TEXT NOT NULL,
footer_html TEXT,
brand_color TEXT, -- optional CSS color override
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- updated_at maintenance.
CREATE OR REPLACE FUNCTION shell.touch_updated_at() RETURNS trigger AS $$
BEGIN
NEW.updated_at := now();
RETURN NEW;
END $$ LANGUAGE plpgsql;
DO $$
BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_trigger WHERE tgname = 'apps_touch_updated_at') THEN
CREATE TRIGGER apps_touch_updated_at
BEFORE UPDATE ON shell.apps
FOR EACH ROW EXECUTE FUNCTION shell.touch_updated_at();
END IF;
IF NOT EXISTS (SELECT 1 FROM pg_trigger WHERE tgname = 'menu_items_touch_updated_at') THEN
CREATE TRIGGER menu_items_touch_updated_at
BEFORE UPDATE ON shell.menu_items
FOR EACH ROW EXECUTE FUNCTION shell.touch_updated_at();
END IF;
IF NOT EXISTS (SELECT 1 FROM pg_trigger WHERE tgname = 'branding_touch_updated_at') THEN
CREATE TRIGGER branding_touch_updated_at
BEFORE UPDATE ON shell.branding
FOR EACH ROW EXECUTE FUNCTION shell.touch_updated_at();
END IF;
END $$;