HydraIssues

Self-service experience management for external creators (Cyborn / Gallo-Romeins) via hydramancer
closed unclassified Project: hydramancer Reporter: 15 Aug 2026 11:51

Description

Goal

Give external creators (first case: Cyborn, developer of the gallo-romeins-museum experience) a self-service view of their experience's lifecycle state plus scoped controls (stage/promote/rollback/pause/resume), delivered through the hydramancer creator portal. Today only an operator can drive the lifecycle, using the experience-library admin bearer token. This closes the last manual gate in the otherwise end-to-end pipeline (Perforce submit -> watcher -> notify -> library registers a development build -> operator promotes -> live).

This issue is assessment + plan only. No code, deploy, or live-state change is proposed here.

What already exists to reuse

hydramancer (the portal) — github.com/cederikdotcom/hydramancer, live at hydramancer.experiencenet.com

  • iamnim sign-in flow already wired on /experience:
    • GET /experience/login -> redirects to iamnim /login?redirect_uri=https://<domain>/experience/authed (internal/api/handlers_experience.go:14-18).
    • GET /experience/authed -> captures ?token=, stores it in the iamnim_session cookie (HttpOnly, Secure, SameSite=Lax, 24h) (handlers_experience.go:22-36).
    • GET /experience/logout -> clears the cookie (handlers_experience.go:39-50).
    • Session is read from cookie / X-Iamnim-Session header / ?token= by iamnimSession() (handlers_provision.go:59-67).
  • Identity + membership resolution via the iamnim client (internal/iamnim/client.go): Me(session) -> GET /api/me, Memberships(session) -> GET /api/me/memberships returning {organization_slug, organization_name}. The session is forwarded as the iamnim_session cookie (client.go:61-76).
  • The privileged-proxy pattern (the template to copy): POST /api/v1/provision/perforce -> handleProvisionPerforce (handlers_provision.go:16-55). It (1) requires an iamnim session, (2) forwards it downstream as X-Iamnim-Session, (3) the portal holds NO privileged credentials — the downstream (hydraperforceprovision) does the authn + membership check + privileged action, (4) upstream status/body are streamed straight back. Upstream base URL comes from config (internal/config/config.go:17-20, PerforceURL), settable by env HYDRAMANCER_PROVISION_PERFORCE_URL (config.go:81-88); empty URL disables the panel.
  • The access panel UI on internal/web/templates/experience.html:148-171: gated on .CanProvision, shows signed-in email + logout, an org <select> populated from .Orgs, a button, and a result <pre>. The webExperience handler builds experienceData{SignedIn, Email, Orgs, CanProvision} (server.go:71-94). Client JS posts to the proxy and renders the result (experience.html:445-480).

hydraexperiencelibrary — live at hydraexperiencelibrary.experiencenet.com (a scale behind Traefik)

  • Full lifecycle JSON API, all under requireAuth (single admin bearer token, internal/api/middleware.go:10-24): GET /api/v1/experiences, GET /api/v1/experiences/{name}, PUT /api/v1/experiences/{name}, POST .../{promote,rollback,pause,resume,retire}, POST .../stream, GET .../builds (internal/api/server.go:124-145).
  • A cookie-session admin web UI (RequireWebAuth) with the same lifecycle actions (server.go:99-109) — the operator's current tool.
  • Experience record fields (internal/store/store.go:17-43): name, label, status, owner, developer, watch_name, development_build, staging_build, production_build, previous_build, districts[], .... The gallo-romeins-museum experience has developer="cyborn", owner="gallo-romeins-museum".

The central gap: the library API authorizes by ONE admin token, not by org

requireAuth compares the bearer against a single adminToken (middleware.go:17-21). There is no per-org scoping: anyone with the token sees and controls every experience. So a creator cannot be handed the token, and the library cannot today tell "Cyborn" apart from any other org. A scoping layer must sit between the creator and that privileged token.

Two options

Option A (recommended) — scope in the hydramancer proxy; library stays token-privileged

Mirror the Perforce provision proxy. hydramancer holds the library admin token (config/env, never exposed to the browser) and enforces org-scoping before every privileged call:

  • List: authenticate the session (iam.Me), fetch the user's org slugs (iam.Memberships), call the library GET /api/v1/experiences with the admin token, then filter server-side to experiences whose developer OR owner is one of the user's org slugs. Only the filtered set reaches the browser.
  • Action on {name}: first GET /api/v1/experiences/{name} with the admin token, read that record's developer/owner, and confirm the signed-in user is a member of THAT org (iam.IsMember). Only then forward the action. Crucially, the required org is derived from the experience record, not from a client-supplied field — the browser names an experience, never an org, so it cannot widen its own scope. (This is a deliberate tightening over the provision proxy, where the client names org_slug and we check membership; here the experience pins the org.)

Pros: zero change to the library; reuses the exact, already-deployed proxy + session-forwarding pattern; the scoping boundary lives in one small, auditable handler. Cons: the library API stays a blunt all-or-nothing token; any second consumer would re-implement scoping. hydramancer becomes a trusted privileged client (as it already is for Perforce).

Option B — make the library API org-aware

Give the library its own iamnim integration: accept a forwarded X-Iamnim-Session, resolve identity + memberships, and filter/authorize by the experience's developer/owner inside requireAuth (or a new requireOrgAuth). hydramancer would then be a thin pass-through like the current provision proxy.

Pros: scoping lives with the data it protects; any future consumer (CLI, another portal) inherits it; no privileged token handed to a proxy. Cons: larger change touching a live scale (new iamnim client, config, middleware, dual-auth so the webhook + operator token still work); more surface to get wrong on a production service. Higher blast radius for the same near-term outcome.

Recommendation: Option A now, Option B later. Ship the creator-facing capability behind the proven proxy pattern with no change to the live library. Treat Option B (org-aware library auth) as the durable follow-up once a second consumer appears.

The authorization boundary (think-hard note)

Scoping key = the experience's developer/owner string must equal the iamnim organization_slug. For Cyborn this holds: developer="cyborn" and the provisioning org slug is cyborn (confirmed by hydraperforceprovision's own membership check, internal/api/handlers.go:49-59, iamnim/client.go IsMember). This coupling is an assumption to make explicit and enforce: either adopt the convention "experience.developer/owner == iamnim org slug" as a documented invariant, or add an explicit map in hydramancer config. Without it, scoping silently breaks (a creator sees nothing, or worse, a slug collision leaks another org's experience). Decide developer-only vs developer OR owner for the visibility set: an agency (Cyborn) develops for a client org (the museum). Recommend: a creator may act on an experience if EITHER its developer OR its owner slug is in their memberships, so both the building agency and the owning venue can self-serve; document this.

Implementation plan (Option A)

hydramancer — config (internal/config/config.go)

  • Add an ExperienceLibrary config block: base_url and admin_token (the library's privileged bearer). Env overrides HYDRAMANCER_EXPERIENCELIBRARY_URL and HYDRAMANCER_EXPERIENCELIBRARY_TOKEN (matches the existing env-override style, config.go:81-88). Empty base_url disables the panel, exactly like PerforceURL.

hydramancer — server (internal/api/server.go)

  • NewServer gains experienceLibraryURL + experienceLibraryToken; store on Server.
  • Register routes in registerRoutes (server.go:42-51):
    • GET /api/v1/experiences — list, org-filtered.
    • POST /api/v1/experiences/{name}/{action} — action proxy, org-checked. Actions: promote, rollback, pause, resume (retire/stream optional, gate carefully).
  • Extend experienceData with CanManageExperiences bool and render the new panel (webExperience, server.go:82-94). The list itself can be fetched client-side after load to keep the page fast.

hydramancer — new handler file internal/api/handlers_experiences.go (mirrors handlers_provision.go)

  • handleListExperiences: session -> iam.Me (401 if invalid) -> iam.Memberships -> GET library list with admin token -> filter by developer/owner in memberships -> return filtered JSON.
  • handleExperienceAction: session -> iam.Me -> GET the one experience with admin token -> resolve required org from developer/owner -> iam.IsMember (403 if not) -> forward POST .../{action} with admin token, streaming status/body back (same shape as handleProvisionPerforce, handlers_provision.go:34-54).
  • Reuse iamnimSession() and writeProvisionError() verbatim.
  • Add IsMember(session, slug) to the hydramancer iamnim client (copy from hydraperforceprovision iamnim/client.go), or filter inline against Memberships.

hydramancer — template (internal/web/templates/experience.html)

  • Add a "Manage your experiences" panel gated on .CanManageExperiences + .SignedIn, styled like .access. Render a table per experience: label, status, development/staging/production build numbers, and action buttons wired to POST /api/v1/experiences/{name}/{action}. Reuse the existing fetch+result JS pattern (experience.html:445-480). Show a friendly empty state when the filtered list is empty (mirrors the "not a member of any org" note, experience.html:163-164).

Deployment (per land/scale conventions — not part of this issue)

  • Set HYDRAMANCER_EXPERIENCELIBRARY_URL (mesh/internal URL of the library scale) and HYDRAMANCER_EXPERIENCELIBRARY_TOKEN on the hydramancer scale via landconfigregistry env, same mechanism as HYDRAMANCER_PROVISION_PERFORCE_URL.

Scope, risks, non-goals

  • Non-goal: re-implementing auth. Reuse iamnim (Me + Memberships) and Pantheon org membership as-is.
  • Risk: the developer/owner == org slug coupling. Enforce/document before enabling for anyone beyond Cyborn.
  • Risk: the library admin token now also lives on the hydramancer scale (already true in spirit for Perforce). Keep it env-only, never in the image, never sent to the browser.
  • Guard rails: consider excluding destructive retire from self-service initially; restrict actions to states the creator should own (promote a staged build, rollback, pause/resume). create and first stage remain operator/CLI per current docs (experience.html:253-256).
  • Follow-up: Option B (org-aware library auth) as the durable path once a second consumer needs scoping.

Key file references

  • Portal sign-in: hydramancer/internal/api/handlers_experience.go:14-50
  • Portal proxy pattern: hydramancer/internal/api/handlers_provision.go:16-67
  • Portal iamnim client: hydramancer/internal/iamnim/client.go
  • Portal config: hydramancer/internal/config/config.go:17-88
  • Portal panel template: hydramancer/internal/web/templates/experience.html:148-171,445-480
  • Library routes + token auth: hydraexperiencelibrary/internal/api/server.go:124-145, internal/api/middleware.go:10-24
  • Experience record fields: hydraexperiencelibrary/internal/store/store.go:17-43
  • Membership-check reference: hydraperforceprovision/internal/api/handlers.go:49-59, internal/iamnim/client.go (IsMember)

Comments (1)

claude-ops 15 Aug 2026 12:02

Duplicate — this is an intermediate investigation artifact from the assessment workflow. The consolidated findings + plan live in #490. Closing as duplicate.