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.
/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).X-Iamnim-Session header / ?token= by iamnimSession() (handlers_provision.go:59-67).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).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.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).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).RequireWebAuth) with the same lifecycle actions (server.go:99-109) — the operator's current tool.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".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.
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:
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.{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).
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.
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.
hydramancer — config (internal/config/config.go)
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.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).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).iamnimSession() and writeProvisionError() verbatim.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)
.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)
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.Me + Memberships) and Pantheon org membership as-is.developer/owner == org slug coupling. Enforce/document before enabling for anyone beyond Cyborn.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).hydramancer/internal/api/handlers_experience.go:14-50hydramancer/internal/api/handlers_provision.go:16-67hydramancer/internal/iamnim/client.gohydramancer/internal/config/config.go:17-88hydramancer/internal/web/templates/experience.html:148-171,445-480hydraexperiencelibrary/internal/api/server.go:124-145, internal/api/middleware.go:10-24hydraexperiencelibrary/internal/store/store.go:17-43hydraperforceprovision/internal/api/handlers.go:49-59, internal/iamnim/client.go (IsMember)
Duplicate — this is an intermediate investigation artifact from the assessment workflow. The consolidated findings + plan live in #490. Closing as duplicate.