diff --git a/.claude/skills/backend-api-explore/SKILL.md b/.claude/skills/backend-api-explore/SKILL.md index 78cc969f..0e25484d 100644 --- a/.claude/skills/backend-api-explore/SKILL.md +++ b/.claude/skills/backend-api-explore/SKILL.md @@ -225,15 +225,21 @@ row-count overview. things in order: 1. **Is the caller a confirmed participant?** Waitlisted users are refused by design (`charles` in H1). - 2. **Is it capability-gated?** `SetPreference`, `RemovePreference`, `Propose` - and submission RPCs need a capability that only - `HackathonService.SetCapabilities` can switch on — and `cmd/seed` creates - no `HackathonState` row, so **in seeded data these always refuse**. This is - configuration, not a bug. Reproducible today: + 2. **Is it capability-gated, and is that capability on _in this hackathon_?** + `SetPreference`, `RemovePreference`, `Propose` and the submission RPCs are + gated on a capability, which is really a casbin row written alongside a + `HackathonState` boolean. `cmd/seed` writes both (`seedCapabilities`) with + a **different set per hackathon**, so the same call succeeds in one seeded + hackathon and is refused in the next — configuration, not a bug. Read the + set instead of guessing: ``` - just rpc::as alice aliceandbob hackathon.ProjectService/SetPreference '{"projectId":""}' - → Code: PermissionDenied + just rpc::as alice aliceandbob hackathon.HackathonService/Get \ + '{"hackathonId":""}' | jq '.hackathon.state' ``` + As seeded: AI Innovation has register/propose/preferences/submissions on, + Climate Tech the same minus register, Internal Product Sprint only + vote/view-results. A hackathon created through `HackathonService.Create` + gets a state row with every capability `false`. 3. **Is the caller an Owner where the rule grants Member?** The casbin model has no role inheritance, so an owner is refused by a Member-scoped policy. diff --git a/.claude/skills/backend-seeding/SKILL.md b/.claude/skills/backend-seeding/SKILL.md index 39e835c2..6514b1fe 100644 --- a/.claude/skills/backend-seeding/SKILL.md +++ b/.claude/skills/backend-seeding/SKILL.md @@ -4,10 +4,10 @@ description: Adapting the Hackagon dev seed fixture (components/backend/cmd/seed) — adding or changing hackathons, users, projects, teams, submissions and phases, the casbin roles that must accompany every DB row, the sentinel-based idempotency - and how to force a re-seed, and the HackathonState gap that makes seeded - hackathons refuse capability-gated mutations. Use when asked to add or change - dev/test data, make a scenario reproducible locally, or fix a seeded hackathon - that nobody can act in. + and how to force a re-seed, and the HackathonState plus casbin rows that + decide what participants may actually do in each seeded hackathon. Use when + asked to add or change dev/test data, make a scenario reproducible locally, or + fix a seeded hackathon that nobody can act in. --- # Adapting the seed @@ -120,24 +120,42 @@ are hardcoded constants at the top of the file; the admin's comes from config. Adding a genuinely new user means adding them to Keycloak too (`tools/configs/keycloak/`), not just here. -## Known gap: no HackathonState row - -`cmd/seed` never creates a `HackathonState`. That row is what holds the six -capability booleans, and `HackathonService.SetCapabilities` is what flips them -_and_ writes the matching casbin policy. With no row and no call, **every -capability-gated mutation refuses in seeded data** — `SetPreference`, -`RemovePreference`, `Propose`, submissions. Confirmed live: alice, a confirmed -member of the seeded Climate Tech Hackathon, gets `PermissionDenied` from -`SetPreference`. - -This is the single biggest reason a seeded hackathon feels broken. If you're -asked to make the seed usable, this is the fix: create a `HackathonState` per -hackathon with capabilities appropriate to its phase, and write the matching -casbin rows — remembering that `SetCapabilities` grants to `Member`, and the -model has no inheritance, so an owner needs their own grant. - -Full write-up, including a partial-write bug in `SetCapabilities` when the state -row is missing: `mydocs/docs/backend-tickets/project-preferences-capability.md`. +## Capabilities: every hackathon needs `seedCapabilities` + +A capability is **two writes, not one**: the boolean on `HackathonState` and a +casbin policy row. The enforcer only ever reads the policy, so a state row on +its own grants nothing and a hackathon without both is one nobody can act in — +this used to be the single biggest reason a seeded hackathon felt broken. + +`seedCapabilities` (`main.go:67`) does both, and every hackathon must call it. +It copies the role each capability grants to from `SetCapabilities` +(`hackathon_service.go:616-653`) so seeded hackathons behave like ones created +through the API — registration granting to `*` rather than a role, since the +point is that a non-member can join, and `vote` writing **two** rows +(`Vote:Create` plus `VoteCategory:Read`, without which a member cannot see what +there is to vote on). It also takes the phase an organizer has declared current, +or nil. + +One row goes **beyond** the handler on purpose: team preferences are granted to +`Owner` as well as `Member`. The handler grants `Member` only and the model has +no inheritance, so an owner cannot express a preference — judged wrong and +tracked in `mydocs/docs/backend-tickets/project-preferences-capability.md` +(which also documents a partial-write bug in `SetCapabilities` when the state +row is missing). Drop that row if you would rather the fixture mirror the +handler exactly. + +The sets are chosen per hackathon so the fixture spans the interesting states: + +| Hackathon | On | Current phase | +| ----------------------- | ------------------------------------------- | ------------- | +| AI Innovation 2026 | register, propose, preferences, submissions | none | +| Climate Tech 2026 | propose, preferences, submissions | Hacking | +| Internal Product Sprint | vote, view-results | Demo | + +So "capability-gated mutation refuses" is now a question about _which_ +hackathon, not about the seed as a whole. Phase tags stay decorative: a phase +tagged `vote` in a hackathon whose `voting_enabled` is false is a legitimate +fixture — it says when voting is meant to happen, not that it is open. ## After changing the seed diff --git a/.claude/skills/backend-service-dev/SKILL.md b/.claude/skills/backend-service-dev/SKILL.md index f2b41b8c..17604187 100644 --- a/.claude/skills/backend-service-dev/SKILL.md +++ b/.claude/skills/backend-service-dev/SKILL.md @@ -170,10 +170,16 @@ auto-enable or disable any capability."_ `SetCurrentPhase` sets and never touches state or casbin. Advancing a phase changes what the UI displays, not what anyone may do. -**`cmd/seed` creates no `HackathonState` row at all.** Seeded hackathons -therefore report no capabilities and every capability-gated handler refuses. If -a mutation returns `PermissionDenied` in seeded data, check this before -suspecting your code. +**`cmd/seed` writes both halves itself.** `seedCapabilities` +(`cmd/seed/main.go:67`) creates each hackathon's `HackathonState` row _and_ the +casbin rows that go with it, copying the role each capability grants to from +`SetCapabilities` — plus one row `SetCapabilities` does not write: team +preferences to `Owner` as well as `Member`, so the fixture shows the intended +behaviour ahead of the handler (see +`mydocs/docs/backend-tickets/project-preferences-capability.md`). The set +differs per hackathon, so a `PermissionDenied` in seeded data means that +capability is off in _that_ hackathon — read `state` from `HackathonService.Get` +before suspecting your code. Known gaps are written up in `mydocs/docs/backend-tickets/` — start with `project-preferences-capability.md`, which traces the whole chain for diff --git a/CLAUDE.md b/CLAUDE.md index 90921120..76ad8290 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -130,9 +130,11 @@ These hold across the whole codebase; the skills explain the mechanisms. of surprising `PermissionDenied`. - **A phase does not control what participants may do.** `Phase.capabilities` is informational; `HackathonState` plus its casbin rows is what actually gates - anything, and `SetCurrentPhase` does not touch either. `cmd/seed` creates no - `HackathonState`, so capability-gated mutations refuse in seeded data. Details - in **backend-service-dev**; diagnosis in **backend-api-explore**. + anything, and `SetCurrentPhase` does not touch either. `cmd/seed` writes both + halves (`seedCapabilities`) with a **different capability set per hackathon**, + so a refused mutation in seeded data means that capability is off _there_ — + not that the fixture is unusable. Details in **backend-service-dev**; + diagnosis in **backend-api-explore**. - **Don't trust written status.** Determine what's implemented by asking the running server — see **backend-api-explore**. Any inventory committed to a markdown file is a snapshot that starts rotting immediately, this one diff --git a/components/frontend/.claude/skills/frontend-backend-wiring/SKILL.md b/components/frontend/.claude/skills/frontend-backend-wiring/SKILL.md index 80b056b3..9da7e2fc 100644 --- a/components/frontend/.claude/skills/frontend-backend-wiring/SKILL.md +++ b/components/frontend/.claude/skills/frontend-backend-wiring/SKILL.md @@ -172,17 +172,22 @@ not the DB). Server-only helpers of this kind live in `src/lib/server/hackathon/` (`capabilities.ts`, `projectEdit.ts`) — they read generated types, so a `.svelte` file must never import them. -## Capability-gated mutations fail in seeded data — expect it +## Capability-gated mutations depend on the hackathon — read the set first Several backend mutations are gated on a **capability**, not just a role: `SetPreference`/`RemovePreference` need `SET_TEAM_PREFERENCES`, `Propose` needs `PROPOSE_PROJECTS`, submissions need `CREATE_PROJECT_SUBMISSIONS`. A capability -is only "on" if `HackathonService.SetCapabilities` wrote both a `HackathonState` -boolean and a casbin policy row — and `cmd/seed` creates no `HackathonState` row -at all. Nothing in the frontend calls `setCapabilities`. - -So in a seeded hackathon these calls return `PERMISSION_DENIED` **by -configuration, not by a bug in your wiring**. Two consequences: +is "on" only when both a `HackathonState` boolean and a casbin policy row exist. +Two things write the pair: `HackathonService.SetCapabilities`, which the manage +hub's capability panel calls from the frontend +(`src/lib/server/hackathon/stateActions.ts`), and `cmd/seed` directly +(`seedCapabilities`), with a **different set per hackathon**. + +So the same call succeeds in one hackathon and returns `PERMISSION_DENIED` in +the next, **by configuration, not by a bug in your wiring** — and an organizer +can change the set under you. The set is already loaded: the `[id]` layout +passes `hackathon.state` through `enabledCapabilities` into +`hackathonState.enabled`. Read it before debugging. Two consequences: - Don't "fix" it frontend-side. Translate the error into something a user can read and move on. diff --git a/components/frontend/.claude/skills/frontend-dev/SKILL.md b/components/frontend/.claude/skills/frontend-dev/SKILL.md index 5bf28559..8232ffbd 100644 --- a/components/frontend/.claude/skills/frontend-dev/SKILL.md +++ b/components/frontend/.claude/skills/frontend-dev/SKILL.md @@ -26,7 +26,7 @@ Route groups carry the auth boundary: - `(app)/` — everything behind Keycloak login: `/dashboard`, `/manage/...`, `/hackathons/create`, and the member hackathon subtree `/my/hackathon/[id]/{overview,projects,teams,participants,submissions,timeline,pages/[pageId]}`. -- `(participant)/`, plus top-level `signin`, `signout`, `+error.svelte`. +- Top-level `signin`, `signout`, `+error.svelte`. There is no other route group. The public `/hackathon/[id]` and member `/my/hackathon/[id]/...` are **disjoint path spaces** on purpose; a signed-in visitor to the public page is redirected @@ -54,8 +54,8 @@ Use runes, not the Svelte 4 `export let` / reactive `$:` style: ## Components (`src/lib/components//`) -Areas: `hackathon/`, `dashboard/`, `layout/`, `forms/`, `profile/`. Prototype -for a list row is `hackathon/HackathonRow.svelte`. Conventions: +Areas: `hackathon/`, `dashboard/`, `layout/`, `forms/`. Prototype for a list row +is `hackathon/HackathonRow.svelte`. Conventions: - Type props inline in the `$props()` destructure; give optionals defaults. - Keep display props **generic strings** so a component is reusable — e.g. @@ -158,9 +158,17 @@ wanted anyway. Same for inspecting your work before a commit: one Bring the stack up with `just start` from the repo root, then open http://localhost:8081. -Auth-gated `(app)` pages can't be screenshotted headlessly — Keycloak redirects -— so either log in interactively as `alice`/`aliceandbob`, or verify the -underlying data directly with `just rpc::as alice aliceandbob /`. +Auth-gated `(app)` pages need a real session before they render anything — +Keycloak redirects an anonymous request away — and nothing in this repo captures +one today. That is a missing setup, not an impossibility: Playwright can drive +the Keycloak form (username → Sign In → password) and reuse the session, which +is how the `sketch/experimental-frontend-deployment` branch screenshots every +member tab at desktop and phone widths — see +`.claude/skills/hackathon-e2e/helpers/login.ts` **on that branch** if you decide +to port it (its realm config differs from main's, so the flow needs re-verifying +here). Until then, either log in interactively as `alice`/`aliceandbob`, or +verify the underlying data with `just rpc::as alice aliceandbob /`. + Public routes (`/`, `/hackathon/[id]`) render without a session and can be captured with whatever headless tool you have; if you take a screenshot, **look at the PNG** rather than assuming it rendered.