The contained runner that drove the 2026-09 security audit, the README review and four rounds of design ideation lived only inside the ignored notebook. It moves to scripts/agent-runner/: the prepare, tools, audit, ideation and teardown scripts, the compose and container files, the allowlist proxy and the prompt templates, with the paths that assumed the notebook fixed and findings written to a caller-named directory. Run outputs, transcripts and the round-specific sheet scripts stay behind. docs/agent-runner.md states the method: the disposable git archive copy and the fail-closed proxy, how a task is shaped, union rather than intersection of findings across models, separate adjudication of every finding against the source, and the evidence a finding must carry.
5.6 KiB
member-console — system context for the auditor
Supplied because you cannot infer it from a slice of code. These are facts about this system, with locations you can verify. Nothing here is a finding.
What the application is
A Go web application for worker-cooperative membership: members buy plans,
which confer entitlements, which provision resources (federated wiki sites,
forum access, custom domains). Server-rendered html/template with HTMX;
PostgreSQL via sqlc/pgx; Valkey for sessions; Temporal for workflows;
Keycloak as the OIDC identity provider; Stripe for payments.
Module git.coopcloud.tech/wiki-cafe/member-console.
Two surfaces, one role
internal/server/shell.godefines the member and operator surfaces.- The only privileged role is
OperatorRole = "operator-member", a literal ininternal/server/operator_partials.go:33. There is no admin role yet. - Roles originate in the OIDC ID token and are stored in the session at
login.
HasRolereads that session copy, not a live token (internal/auth/auth.go:757-782). - There are only three non-test
HasRolecall sites:internal/server/server.go:723,:754, andinternal/server/operator.go:165.
Request pipeline, in order
internal/server/server.go:668-699 builds preAuthStack:
RequestID → Logging → Recovery → Timeout(32s) → MaxBodySize(1MB) →
SecureHeaders → CORS → CSRF → Compress. That wraps routeAware
(:688), which applies the authentication middleware only to requests that
match a registered route pattern.
Server timeouts: Read 4s, Write 8s, Idle 16s, MaxHeaderBytes 1MB.
Authentication middleware
internal/auth/auth.go:126+. In order:
- Public paths pass straight through:
/login,/callback,/logout,/logout-callback,/register,/favicon.ico, plus every path passed in asselfAuthenticatedPaths. - Any path under
/static/passes through. - Session must carry
authenticated. - The session's person is looked up in the database on every authenticated
request; if the row is gone the session is ended and the request bounces
to
/login. This is deliberate (a session can outlive its person after an identity merge, purge, or demo-database reset).
The exemption coupling — important and deliberate
One list, csrfExemptPaths, feeds both CSRF bypass and authentication
bypass:
- It is seeded with
/domains/ask(internal/server/server.go:404), which is unauthenticated by design: a TLS-terminating proxy calls it mid-handshake and holds no session and no CSRF token. - Each integration appends its own via
RouteMount.CSRFExemptPaths(server.go:411). - It flows to
csrfConfig.Ignore(server.go:561-564) and toauthConfig.Middleware(csrfExemptPaths...)(server.go:688).
So declaring a path CSRF-exempt also makes it unauthenticated. Current
declarations: Stripe /webhooks/stripe
(internal/integrations/stripe/stripe.go:79,147); Discourse web.WebhookPath
(internal/integrations/discourse/discourse.go:300); FedWiki declares none
(internal/integrations/fedwiki/fedwiki.go:297).
Response headers
internal/middleware/security.go sets Cache-Control: no-store,
X-Content-Type-Options: nosniff, X-Frame-Options: DENY,
Strict-Transport-Security: max-age=3600; includeSubDomains,
Referrer-Policy: no-referrer, X-XSS-Protection, Cross-Origin-Embedder-Policy: require-corp, Cross-Origin-Opener-Policy: same-origin,
Cross-Origin-Resource-Policy: same-origin, and this CSP:
default-src 'self'; script-src 'self' https://unpkg.com/htmx.org@*;
style-src 'self'; img-src 'self' data:; font-src 'self'; connect-src 'self';
object-src 'none'; frame-ancestors 'none'; form-action 'self'; base-uri 'self';
upgrade-insecure-requests is appended only when env == "production".
Project intent, stated in its own docs, is a strict CSP with no inline
scripts and self-hosted front-end assets (see NOTICE and
internal/embeds/static/).
CSRF
internal/middleware/csrf.go wraps a gorilla-style CSRF with a 32-byte secret
that must persist across restarts, per-path Ignore predicates, configurable
TrustedOrigins, and configurable cookie Secure/HttpOnly/SameSite.
Browser writes send the token in an X-CSRF-Token header from the page, because
native form POSTs were failing an Origin check under Referrer-Policy: no-referrer.
Tenancy model
Person → organization → workspace. A person's session carries person, org, and
workspace ids (auth.go:722-747). Grants form an append-only ledger; what is
"currently delivering" lives on pool_provisions.status, not on grants.status.
Cross-tenant isolation is the property that matters: a member of org A must not
read or mutate anything belonging to org B.
Trust boundaries
| Boundary | Direction | Authenticated by |
|---|---|---|
| Browser ↔ app | inbound | session cookie + CSRF token |
| Keycloak ↔ app | inbound identity | OIDC authorization code flow; ID token |
| Stripe ↔ app | inbound webhook | provider signature (no session, no CSRF) |
| Discourse ↔ app | inbound webhook | provider secret (no session, no CSRF) |
| TLS proxy ↔ app | inbound /domains/ask |
nothing, by design |
| app → FedWiki farm | outbound | admin bearer token |
| app → Stripe API | outbound | restricted key from a file |
| app → Postgres / Valkey / Temporal | outbound | connection credentials |
What is deliberately test-only
Everything under test/ is fixtures. test/mc-config.yaml carries
TEST-ONLY-* secrets on purpose and test/secrets/ is gitignored. Do not
report these as leaked credentials. They matter only if the same value or
pattern reaches a production code path.