Skip to content

[WIP] Sentinel v5#53

Open
BK1031 wants to merge 117 commits into
mainfrom
v5
Open

[WIP] Sentinel v5#53
BK1031 wants to merge 117 commits into
mainfrom
v5

Conversation

@BK1031
Copy link
Copy Markdown
Contributor

@BK1031 BK1031 commented Jan 17, 2026

Sentinel v5 rewrite, see design doc here for more info.

@netlify
Copy link
Copy Markdown

netlify Bot commented Jan 17, 2026

Deploy Preview for gr-sentinel failed. Why did it fail? →

Name Link
🔨 Latest commit aba3aac
🔍 Latest deploy log https://app.netlify.com/projects/gr-sentinel/deploys/696b40e767b51e0008505343

@netlify
Copy link
Copy Markdown

netlify Bot commented Feb 24, 2026

Deploy Preview for gr-sentinel failed. Why did it fail? →

Name Link
🔨 Latest commit c60f72e
🔍 Latest deploy log https://app.netlify.com/projects/gr-sentinel/deploys/6a08d93c254cea0008e50203

BK1031 and others added 24 commits February 24, 2026 01:23
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
BK1031 added 30 commits May 4, 2026 18:21
Alumni went to UCSB so they have a real graduate level to select. UCSB
email users (no role) are current students by definition. Only Mentors,
Sponsors, and Other roles get the N/A option and the contextual note.
Adds the 5 narrowly-scoped POSTs the discord consume flow will fan out
to:
  POST /core/entity
  POST /core/users
  POST /core/entity/:id/email-auth
  POST /core/entity/:id/phone-auth
  POST /core/entity/:id/external-auth

email-auth bcrypt-hashes via existing service.HashPassword and validates
with ValidatePassword. external-auth gets a new
service.CreateExternalAuthForEntity helper (read paths existed; create
did not). User gains an InitialRole field (member|alumni|mentor|sponsor
|other) so we can record what role the user picked from the consent
dialog.
Discord: POST /discord/onboarding-tokens/:id/consume validates the
token, enforces the initial_role enum and the past-grad-year + UCSB
email rule, then orchestrates the 5 core endpoints in sequence
(entity -> user -> email-auth -> phone-auth -> external-auth) and
finally marks the token used_at + entity_id linked. Best-effort: a
mid-flight failure leaves an orphan Entity for a future cleanup job.

Web: replace the mocked setTimeout submit with an actual axios POST,
toast errors from the response, then run the convergence animation and
redirect to /auth/login?email=<email> on success. Adds the academic-
step grad-year gate (toast on Continue when grad_year is in the past
+ email is @ucsb.edu).

initial_role is derived from nonStudentRole + the same domain-match
guard the academic note uses: UCSB email or no role pick -> "member".
The credentials-step "Are you a current student?" dialog rendered
softly the first time it opened and crisp on subsequent opens. Caused
by the browser deferring compositor-layer promotion until after the
first zoom-in-95 animation frame; sub-pixel positions get rendered
without anti-alias-friendly layering and look blurred.

transform-gpu hints translateZ(0) onto the dialog content from initial
mount, so the layer exists before the animation starts. Same fix is
broadly recommended for backdrop-filter / animated translate stacks.
The credentials-step dialog rendered blurry on first open because the
overlay's backdrop-filter was leaking into the sibling content during
the initial compositor-layer setup. transform-gpu didn't help — the
real fix is to not run the filter at all.

Aligns the overlay with shadcn's default treatment: bg-black/50, no
backdrop-filter. Content keeps our customizations (bg-popover, ring
border, p-4, sm:max-w-sm). The transform-gpu hint is no longer needed.
Several read paths used 'var x []T' and let gorm Find populate, which
leaves the slice nil when no rows match. Go marshals nil slices as null
instead of [], forcing every consumer to defensive-coalesce. Replace
with 'x := []T{}' across user, group, application, entity_login.

Also marks Entity.User and Entity.ServiceAccount as omitempty so the
entity JSON is no longer cluttered with the irrelevant nil sibling.
POST /api/auth/login/email-password (oauth service)
POST /api/auth/refresh             (oauth service)

Both return { access_token, refresh_token, expires_in, entity_id }
scoped to the Sentinel app (client_id "sentinel", scope
"user:read user:write groups:read applications:read"). Mirror of OAuth
token responses but without the OAuth dance — these are for first-party
Sentinel clients (the web app), not third-party integrators.

Login flow: oauth POSTs core's new internal /core/login/email-password
which wraps service.LoginEmailPassword; on success oauth builds claims
and mints the pair using the same generateToken helper that backs
authorization_code exchange. Refresh validates the JWT, revokes the
old refresh token, and mints a fresh pair. Both record an entity_login
for audit.

Kerbecs gets a new /api/auth/* route pointing at oauth, and oauth
registers /auth/** as a second route prefix with Rincon.
Replaces the LoginPage mock submit with an actual POST to
/api/auth/login/email-password, stores the resulting access/refresh
pair in localStorage via a new lib/auth.ts helper, and runs the
existing convergence + checkmark transition before navigating to /.

When the user arrives from onboarding via /auth/login?email=...,
the email field is prefilled and the header copy switches to
"Account created — sign in with the password you just set."

Errors from the endpoint (401 invalid credentials, 400 etc.) surface
as sonner toasts; the user stays on the form. Google/Discord provider
buttons still mock for now — those plug into /auth/login/{google,discord}
when the OAuth provider integrations land.
RequireAuth wraps the AppShell route subtree. Without a stored session
it Navigate-redirects to /auth/login while preserving the intended
location in state.from so LoginPage can route the user back after
sign-in. Bare /, /applications, /groups, etc. now bounce to login.

api.ts gains two interceptors:
- request: attaches Authorization: Bearer <accessToken> from the stored
  session when one exists.
- response: on 401, calls /auth/refresh once (coalesced across
  concurrent in-flight requests via a shared promise), saves the new
  pair, and retries the original request. If refresh itself fails it
  clears the session and force-navigates to /auth/login. The refresh
  request URL is exempt so we don't loop.

LoginPage reads state.from?.pathname (set by RequireAuth) and routes
back there on success; defaults to / when arriving fresh or from
onboarding (?email=...).
…n header

Adds TanStack Query as the data layer. main.tsx wraps the app in a
QueryClientProvider with retry: 1 and refetchOnWindowFocus: false.

lib/auth.ts grows from storage helpers to also export an Entity type
(mirrors core's response shape) and a useAuth() hook that:
  - reads the local session synchronously
  - queries GET /core/entity/<entityId> via useQuery, keyed by entityId,
    5min staleTime, only enabled when a session exists
  - exposes { session, user, isLoading, isAuthenticated, refresh, logout }

logout() clears the session, drops the query cache, and force-navigates
to /auth/login. refresh() invalidates the currentEntity query so
mutations (e.g. settings edits, future) can call it and have all
consumers re-render.

AppHeader is the first consumer: drops mockUser and renders a
Skeleton avatar while loading, then the real name / email / avatar.
Sign-out wired to logout().
Adds the two public entity endpoints that frontends and authenticated
third-party clients should use, replacing direct hits to /core/entity/:id
(which is now strictly service-to-service).

Both handlers gate via the existing AuthChecker + Require() idiom:
  - GetMe (/entities/@me) requires user:read scope; resolves the entity
    from the bearer's subject claim.
  - GetEntity (/entities/:id) requires user:read AND the URL :id must
    match the bearer's entity_id. Self-only until shared-group / admin
    scope authz lands.

Two new accessors in api.go bottom: GetRequestTokenEntityID,
RequestTokenHasEntityID, RequestTokenExists. Rincon route prefix
/entities/** registered for core, kerbecs gets /api/entities/* upstream.

Web's useAuth swaps from GET /core/entity/:id to GET /entities/@me — no
longer needs to know its own entity_id at call time (the bearer is the
identity).
…ntities/:id

Third-party apps still gated on user:read scope and matching entity_id;
first-party tokens (audience sentinel) skip the id check so team
directory / cross-entity reads from the Sentinel web app work without
a dedicated admin scope. When a real cross-user scope (eg users:read)
is needed for non-sentinel callers, add it to the Any() chain.
Reads the first_name from the live entity (via useAuth) instead of
mockUser, with a skeleton placeholder while the query is in flight.

Recently-accessed and recent-activity sections still mock — they need a
per-user logins endpoint (we dropped /entities/@me/logins earlier) and
a way to fetch app metadata for each distinct client_id in the user's
history.
Cloudflare sets CF-Connecting-IP server-side (unspoofable as long as the
origin only accepts traffic from CF's IP ranges) so we can rely on it
for the real client IP without configuring trusted proxies in gin. Falls
back to c.ClientIP() in dev where no CF is in front.

Helper lives at the bottom of oauth/api/api.go; all three entity-login
recording sites (login, refresh, OAuth code exchange) swap over.
Public endpoint exposing the user's session history. Filters via query
params:
  client_id  match one application's logins (eg sentinel, blix)
  scope      exact-match scope string
  before     RFC3339 cutoff, returns rows created before this time
  after      RFC3339 cutoff, returns rows created after this time
  limit      integer cap, default unlimited

service.GetEntityLogins refactored to take an EntityLoginsFilter struct
so the parameter list doesn't grow unbounded; existing
/core/entity/:entityID/logins caller updated.

Authz follows the /entities/:id pattern: aud=sentinel (first-party app)
bypasses self-only; third-party tokens need user:read scope AND must
match the bearer's user_id claim. Two new helpers in api.go bottom:
GetRequestTokenUserID, RequestTokenHasUserID.
Replaces mockRecentLogins with a useQuery against
GET /users/{user_id}/logins?limit=5, keyed off the user id from
useAuth(). Skeleton rows while in flight, empty-state copy when there
are no logins, then real rows showing the client_id, scope, ip_address
and created_at.

Recently-accessed apps still mocked — needs per-app-metadata fanout
(dedupe client_ids in logins + bulk fetch /applications/client/:id),
which is a separate piece of work.
After logins resolve, dedupe client_ids and fire one
GET /applications/client/<cid> per unique value (cached 5min). Display
becomes 'client_id · App Name' with the name in muted text when the
fetch succeeds; falls back to just client_id otherwise.

Uses useQueries so all the per-app fetches happen in parallel and share
the React Query cache with anywhere else we might fetch the same app.
…tly Accessed

Per-user 'apps signed into, ordered by most-recent-access' aggregated
server-side via DISTINCT-ON-equivalent (GROUP BY client_id with MAX
created_at) joined to the application table. Single query returns
denormalized {Application, last_accessed_at} so the frontend skips the
client-side dedupe + per-app fanout that would have missed apps with
lopsided login distributions (50 sentinel logins drowning out one Blix
login last week).

  service.GetAccessedApplicationsForEntity(entityID, limit)
    returns []AccessedApplication{model.Application, last_accessed_at}
  GET /api/users/:id/applications?limit=N
    same authz as /users/:id/logins (aud=sentinel bypass OR
    user:read + matching user_id)

HomePage swaps mockApplications for a useQuery against the new endpoint.
Skeleton tiles while loading, friendly empty copy when there are no
logins yet, real AppCard grid otherwise.
…tions

Disambiguates from a future /users/:id/applications endpoint that would
list apps the user owns (registered as OAuth clients). The frontend
useQuery key, handler name, and route all rename in lockstep.
Distinct from redirect_uris (OAuth callbacks) — this is where to send
the user to actually open the app, used by the dashboard's Recently
Accessed tiles and anywhere else we surface a clickable app.

  model.Application gains LaunchURL string
  init job sets the Sentinel app's launch_url to
    https://sso.gauchoracing.com on first create
  HomePage AccessedApplication response type adds launch_url, maps to
    the Application.url field the AppCard renders

Existing rows: AutoMigrate adds the column nullable; backfill the
Sentinel row in dev manually if needed. When other apps are registered
they should set launch_url at create time.
Previously rsa.GenerateKey was called in InitializeKeys every time core
booted, so every air-triggered rebuild invalidated every active session.
Users were getting logged out on every code change.

Adds model.SigningKey with Active boolean. InitializeKeys now:
  - loads the active key from signing_key table if one exists
  - otherwise generates a fresh keypair, PEM-encodes it, persists with
    Active=true, and uses it

The Active flag is unused today (always one active key) but is the
schema we need for rotation: mint a new active key, mark the old one
inactive, expose both public halves via JWKS until old tokens age out.
That work adds a kid header to JWTs and per-kid lookup in ValidateToken
but doesn't require a migration.

Sessions now survive core restarts cleanly.
Replaces the 'Coming soon' stub on /applications with a real listing
backed by GET /applications. Search input filters client-side across
name/description/client_id; results sort alphabetically. Loading
skeletons, empty-state copy for both 'no apps registered' and 'no
matches'.

Pulls AppCard out of HomePage into components/AppCard.tsx so both pages
share it. The shared card accepts the API Application shape directly
(snake_case fields, launch_url for the link, icon_url with fallback to
the gradient + initial). Optional lastAccessedAt prop renders the
'Last accessed Xh ago' footer only on the dashboard's Recently Accessed
section.

New lib/applications.ts holds the Application TS type mirroring the
core JSON shape — same pattern as lib/auth.ts's Entity type.
Clicking a tile (dashboard Recently Accessed or full Applications grid)
now opens /applications/:id rather than launching the external app in a
new tab. The details page is where the user can see the app's metadata
and explicitly launch from there.

  components/AppCard becomes a <Link to=...>, chevron icon on hover
  pages/applications/ApplicationDetailsPage.tsx
    - useQuery GET /applications/:id
    - skeleton while loading, "not found" on miss
    - header with icon, name, description, Launch outline button
    - rows: client_id, launch URL, redirect URIs, registered date
…d vs browse behavior

Two click affordances are different concerns:
  - Dashboard (Recently Accessed) — user wants to jump back into the app
    they were using. LaunchAppCard opens launch_url in a new tab with
    the external-link icon as the hover affordance.
  - Applications page — user wants to inspect details (client_id,
    redirect URIs, last-launched, etc.) before launching. AppCard
    navigates to /applications/:id with a chevron-right hover icon.

Leaves room for both to diverge as we add more variant-specific UI
(launch counts on the dashboard card, ownership badges on the browse
card, etc.) without one component swelling into a config soup.
Backend
  Splits CreateOrUpdateApplication into:
    POST /applications  — create, response includes client_secret once
    PUT  /applications/:id — update name/description/icon_url/launch_url
  Create handler builds a createdApplicationResponse that embeds
  model.Application and adds a separate Secret field (json:client_secret)
  since model.Application JSON-skips client_secret on subsequent reads.

Web
  /applications/new      ApplicationNewPage — form, on success shows
                          client_id + client_secret in a dialog with
                          copy-to-clipboard buttons before routing to
                          the new app's details page
  /applications/:id/edit ApplicationEditPage — fetches existing, prefills
                          form, PUTs the edits, invalidates the index +
                          details queries
  /applications          gains "New application" CTA in the header
  /applications/:id      gains "Edit" button next to "Launch"

Redirect URI management is still TODO — we have add/remove endpoints
on the backend but no UI for them on the edit page yet.
Adds GET /applications/:id/secret (gated on aud=sentinel — first-party
only) so the secret can be retrieved on demand without leaking through
every app read. model.Application keeps json:- on ClientSecret so list
and by-id reads stay secretless.

Details page gains a Client Secret row with masked dots, an eye toggle
that triggers the secret fetch (useQuery enabled on toggle, 5min cache),
and a copy button. Client ID row gains a copy button to match.

Authz tightens later when we have application ownership — at that point
the gate becomes ownership OR sentinel:all instead of aud=sentinel.
Details page now reads as three cards instead of one bordered row stack:
  - OAuth credentials  (Client ID + masked secret with reveal/copy)
  - Redirect URIs      (list with copy buttons, link to edit when empty)
  - Metadata           (Launch URL, Registered, Last updated)
Header gets a wrap-friendly layout so the action buttons reflow on
narrow screens.

Edit page becomes two cards:
  - Basic info        (name, description, launch_url, icon_url + save)
  - Redirect URIs     (list with X-to-remove, inline form to add)
Redirect changes fire POST/DELETE /applications/:id/redirect-uris and
invalidate the by-id query so both edit and details refresh.

Adds a CopyableMono helper for the mono-font value + copy-button rows
that appear in three places now (client_id, secret, each redirect URI).
Backend
  CreateApplication now Requires a bearer and sets OwnerID from
  GetRequestTokenEntityID(c). The init job sets the bootstrap Sentinel
  app's OwnerID to the Sentinel core entity so it isn't orphaned.

Web
  Application TS type already had owner_id; Entity type gains the
  service_account variant (mirror of model.Entity.ServiceAccount, also
  omitempty server-side). Details page Metadata card adds a "Created by"
  row that fetches GET /entities/<owner_id> via useQuery (5min cache)
  and renders the user's full name, or the service account name when
  the owner is a service entity.

Existing rows in dev: backfill the Sentinel app manually with
  UPDATE application SET owner_id = '<sentinel_core_entity_id>'
  WHERE id = '<sentinel_app_id>' AND owner_id = ''
Reads (GET /applications, /applications/:id, /applications/:id/groups,
/applications/:id/redirect-uris):
  Require aud=sentinel OR applications:read scope

Writes (POST /applications, PUT /:id, DELETE /:id, group + redirect-uri
mutations):
  ApplicationWriteAuthorized helper:
    sentinel:all
    OR aud=sentinel AND bearer's entity_id == app.owner_id
    OR applications:write scope AND bearer's entity_id == app.owner_id
  Loaded application is fetched before the gate runs so the owner
  comparison has data.

Secrets (GET /applications/:id/secret):
  Stricter than reads — sentinel:all OR aud=sentinel + owner only.
  A third-party app with applications:read can't get any app's secret.

Create (POST /applications):
  aud=sentinel OR applications:write. No owner check (no app yet).

GET /applications/client/:clientID stays ungated — oauth's authorize
flow hits it over the docker network with no bearer. When we add
service-to-service client_credentials tokens, this can be locked down
behind sentinel:all.

ApplicationWriteAuthorized helper added at the bottom of application.go.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant