From dbbc8c8104b09097e585d99af83525469b81e8f0 Mon Sep 17 00:00:00 2001 From: Hannah Casey <61227037+hanaCasey@users.noreply.github.com> Date: Thu, 13 Aug 2026 11:07:38 +0000 Subject: [PATCH 1/2] feat(frontend): static social preview cards from the platform default design MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit og-default.jpg was a stock photo, not the branded card Seo.svelte's og:image comment implies — replaced with the typographic design from Social Previews.dc.html section A: dark canvas, grid, lime accent, real SDSC wordmark, theme's mono type. square.png / story.png / banner.png are the same design at the mock's other three sizes, for manual posting (Instagram feed/story, profile banner) — not referenced by any code path, same as the mock's own export notes describe. URL line omitted from all four, same call as the per-event cards. Rendered once with satori + resvg-js in a throwaway script, not a project dependency — these are static assets, not generated at request time. --- components/frontend/static/og-default.jpg | 4 ++-- components/frontend/static/social/banner.png | 3 +++ components/frontend/static/social/square.png | 3 +++ components/frontend/static/social/story.png | 3 +++ 4 files changed, 11 insertions(+), 2 deletions(-) create mode 100644 components/frontend/static/social/banner.png create mode 100644 components/frontend/static/social/square.png create mode 100644 components/frontend/static/social/story.png diff --git a/components/frontend/static/og-default.jpg b/components/frontend/static/og-default.jpg index e5d9f5b0..cb52ee82 100644 --- a/components/frontend/static/og-default.jpg +++ b/components/frontend/static/og-default.jpg @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:1389f76bfd97f06259da982985aa1b213ee420a24f25a160927b74fa85fa76e4 -size 85099 +oid sha256:5e127edfd50e88be88caf5fbec5beb75a5070f45e895ae8349df04d1d92b4d2d +size 90689 diff --git a/components/frontend/static/social/banner.png b/components/frontend/static/social/banner.png new file mode 100644 index 00000000..9a45d749 --- /dev/null +++ b/components/frontend/static/social/banner.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:2cd93d78b3782e53cb83a9ce920edcee70405f31f6ed09865ad609577531ca09 +size 52143 diff --git a/components/frontend/static/social/square.png b/components/frontend/static/social/square.png new file mode 100644 index 00000000..ef237bdc --- /dev/null +++ b/components/frontend/static/social/square.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:f15994aaad6ac1d0727794360509db2fe142519fdd1bbea68e63d66b1b46d8d2 +size 73514 diff --git a/components/frontend/static/social/story.png b/components/frontend/static/social/story.png new file mode 100644 index 00000000..83b70e51 --- /dev/null +++ b/components/frontend/static/social/story.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:827cd4a7701c2b1183ca400c1c59bfead9b661d09d35d967bbac24154ec42e39 +size 124555 From 1be8424954288e6782b39c4812f4234a147be637 Mon Sep 17 00:00:00 2001 From: Hannah Casey <61227037+hanaCasey@users.noreply.github.com> Date: Thu, 13 Aug 2026 21:03:47 +0000 Subject: [PATCH 2/2] chore: unblock CI by formatting the tree and exempting .claude MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every PR into develop fails at the `format` stage before it reaches build or test, and the cause is not in any PR's diff: develop has never had a green CI run. The workflow only fires on pull_request and on pushes to main, so drift that lands on develop is not seen until someone opens a PR from it — at which point every branch cut from develop reports the same failure. Three things were wrong, and they are separable: 1. treefmt reformats `.claude/**`, 103 files of self-contained agent tooling that carries its own conventions (2-space shell, hand-wrapped markdown, a pnpm-owned lockfile). Excluded here for the same reason the codegen dirs and components/frontend/pnpm-lock.yaml already are: a vendored tree belongs to its own generator, and formatting it only makes the two styles fight. This also removes all 19 shellcheck findings, every one of which was in there. 2. shellcheck runs with no severity floor (the repo passes only `-e SC1091`), so it fails the whole run on an *info*-level finding. The one left outside .claude is a false positive: backticks quoting a config key inside a deliberately single-quoted string. Suppressed at the line, with the reason. 3. The remaining 44 files are genuine drift — buf's canonical import/option order, golines wrapping, and prettier at 80 columns — and are simply the formatter's own output, applied with no hand edits. `treefmt --ci` now exits 0 on this tree. Claude-Session: https://claude.ai/code/session_01RLLSy37SvMnHE5MUcNy6G5 --- .devcontainer/devcontainer.json | 2 +- .devcontainer/rustfs-init.sh | 5 +- api/proto/hackathon/config_service.proto | 8 +- api/proto/hackathon/entities/hackathon.proto | 2 +- api/proto/hackathon/hackathon_service.proto | 30 ++-- .../config_svc/override_window_request.proto | 7 +- .../config_svc/set_branding_request.proto | 4 +- .../set_email_templates_request.proto | 4 +- .../hackathon_svc/create_invite_request.proto | 4 +- .../create_invite_response.proto | 4 +- .../hackathon_svc/list_invites_request.proto | 4 +- .../hackathon_svc/list_invites_response.proto | 4 +- .../preview_invite_request.proto | 4 +- .../preview_invite_response.proto | 4 +- .../hackathon_svc/revoke_invite_request.proto | 4 +- .../backend/internal/service/capacity_test.go | 4 +- .../backend/internal/storage/sigv4_test.go | 18 ++- .../components/forms/MarkdownEditor.test.ts | 10 +- .../lib/components/layout/AppFooter.test.ts | 11 +- .../layout/SidebarNavSection.test.ts | 8 +- components/frontend/src/lib/gallery.test.ts | 4 +- components/frontend/src/lib/gallery.ts | 7 +- .../lib/server/hackathon/joinOffer.test.ts | 4 +- .../src/lib/server/hackathon/joinOffer.ts | 4 +- .../lib/server/hackathon/teamImport.test.ts | 140 ++++++++++++++---- .../src/lib/server/hackathon/teamImport.ts | 55 ++++--- components/frontend/src/lib/server/upload.ts | 7 +- components/frontend/src/lib/upload.ts | 3 +- .../src/lib/utils/markdownEdit.test.ts | 14 +- .../frontend/src/lib/utils/markdownEdit.ts | 8 +- .../src/lib/utils/markdownTable.test.ts | 5 +- .../frontend/src/lib/utils/phase.test.ts | 6 +- components/frontend/src/lib/utils/returnTo.ts | 8 +- .../(app)/manage/gallery/+page.server.ts | 4 +- .../my/hackathon/[id]/manage/tiles.test.ts | 20 ++- .../(app)/my/hackathon/[id]/media/+server.ts | 6 +- .../[id]/teams/manage/+page.server.ts | 9 +- .../teams/manage/template/[format]/+server.ts | 6 +- docs/TODO.md | 20 +-- docs/architecture.md | 6 +- docs/frontend/routes-and-auth.md | 45 +++--- docs/frontend/session-replay.md | 42 +++--- docs/storage.md | 78 +++++----- docs/testing.md | 22 +-- tools/nix/hackagon/pkgs/treefmt.nix | 4 + 45 files changed, 415 insertions(+), 253 deletions(-) diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index 1e1ee036..0b9535d8 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -11,7 +11,7 @@ "ghcr.io/devcontainers/features/nix:1": { "multiUser": true, "extraNixConfig": "experimental-features = nix-command flakes,sandbox = false" - }, + }, "ghcr.io/anthropics/devcontainer-features/claude-code:1.0": {} // Optional — uncomment to build/run containers inside the dev container: // ,"ghcr.io/devcontainers/features/docker-in-docker:2": {} diff --git a/.devcontainer/rustfs-init.sh b/.devcontainer/rustfs-init.sh index 7194468e..8871dddf 100644 --- a/.devcontainer/rustfs-init.sh +++ b/.devcontainer/rustfs-init.sh @@ -400,7 +400,10 @@ check_public_policy() { if [ "$failed" -ne 0 ]; then echo "FAIL — the public/private split is not what docs/storage.md says" >&2 - echo " (a prefix marked \`public: true\` in uploadRules must also be" >&2 + # The backticks quote a config key for a human reader; they are literal + # prose, so single quotes are the correct quoting here. + # shellcheck disable=SC2016 + echo ' (a prefix marked `public: true` in uploadRules must also be' >&2 echo " listed in put_public_policy above)" >&2 return 1 fi diff --git a/api/proto/hackathon/config_service.proto b/api/proto/hackathon/config_service.proto index 6a3ea88b..3791ff99 100644 --- a/api/proto/hackathon/config_service.proto +++ b/api/proto/hackathon/config_service.proto @@ -2,6 +2,10 @@ syntax = "proto3"; package hackathon; +import "hackathon/messages/config_svc/get_email_templates_request.proto"; +import "hackathon/messages/config_svc/get_email_templates_response.proto"; +import "hackathon/messages/config_svc/get_windows_request.proto"; +import "hackathon/messages/config_svc/get_windows_response.proto"; import "hackathon/messages/config_svc/override_window_request.proto"; import "hackathon/messages/config_svc/override_window_response.proto"; import "hackathon/messages/config_svc/set_branding_request.proto"; @@ -16,10 +20,6 @@ import "hackathon/messages/config_svc/set_voting_policy_request.proto"; import "hackathon/messages/config_svc/set_voting_policy_response.proto"; import "hackathon/messages/config_svc/set_windows_request.proto"; import "hackathon/messages/config_svc/set_windows_response.proto"; -import "hackathon/messages/config_svc/get_email_templates_request.proto"; -import "hackathon/messages/config_svc/get_email_templates_response.proto"; -import "hackathon/messages/config_svc/get_windows_request.proto"; -import "hackathon/messages/config_svc/get_windows_response.proto"; option go_package = "github.com/swissdatasciencecenter/hackagon/components/backend/internal/proto/hackathon"; diff --git a/api/proto/hackathon/entities/hackathon.proto b/api/proto/hackathon/entities/hackathon.proto index ff31ae02..05478562 100644 --- a/api/proto/hackathon/entities/hackathon.proto +++ b/api/proto/hackathon/entities/hackathon.proto @@ -7,11 +7,11 @@ import "google/protobuf/timestamp.proto"; import "hackathon/entities/capability.proto"; import "hackathon/entities/form_schema.proto"; import "hackathon/entities/hackathon_branding.proto"; -import "hackathon/entities/hackathon_voting_policy.proto"; import "hackathon/entities/hackathon_member.proto"; import "hackathon/entities/hackathon_settings.proto"; import "hackathon/entities/hackathon_state.proto"; import "hackathon/entities/hackathon_status.proto"; +import "hackathon/entities/hackathon_voting_policy.proto"; import "hackathon/entities/page.proto"; import "hackathon/entities/phase.proto"; import "hackathon/entities/project.proto"; diff --git a/api/proto/hackathon/hackathon_service.proto b/api/proto/hackathon/hackathon_service.proto index db9a528a..bc58f16d 100644 --- a/api/proto/hackathon/hackathon_service.proto +++ b/api/proto/hackathon/hackathon_service.proto @@ -8,6 +8,8 @@ import "hackathon/messages/hackathon_svc/advance_phase_request.proto"; import "hackathon/messages/hackathon_svc/advance_phase_response.proto"; import "hackathon/messages/hackathon_svc/approve_participant_request.proto"; import "hackathon/messages/hackathon_svc/approve_participant_response.proto"; +import "hackathon/messages/hackathon_svc/create_invite_request.proto"; +import "hackathon/messages/hackathon_svc/create_invite_response.proto"; import "hackathon/messages/hackathon_svc/create_request.proto"; import "hackathon/messages/hackathon_svc/create_response.proto"; import "hackathon/messages/hackathon_svc/delete_request.proto"; @@ -18,35 +20,33 @@ import "hackathon/messages/hackathon_svc/edit_request.proto"; import "hackathon/messages/hackathon_svc/edit_response.proto"; import "hackathon/messages/hackathon_svc/edit_settings_request.proto"; import "hackathon/messages/hackathon_svc/edit_settings_response.proto"; +import "hackathon/messages/hackathon_svc/get_registration_response_request.proto"; +import "hackathon/messages/hackathon_svc/get_registration_response_response.proto"; import "hackathon/messages/hackathon_svc/get_request.proto"; import "hackathon/messages/hackathon_svc/get_response.proto"; -import "hackathon/messages/hackathon_svc/create_invite_request.proto"; -import "hackathon/messages/hackathon_svc/create_invite_response.proto"; -import "hackathon/messages/hackathon_svc/list_invites_request.proto"; -import "hackathon/messages/hackathon_svc/list_invites_response.proto"; -import "hackathon/messages/hackathon_svc/preview_invite_request.proto"; -import "hackathon/messages/hackathon_svc/preview_invite_response.proto"; -import "hackathon/messages/hackathon_svc/revoke_invite_request.proto"; -import "hackathon/messages/hackathon_svc/revoke_invite_response.proto"; import "hackathon/messages/hackathon_svc/join_request.proto"; import "hackathon/messages/hackathon_svc/join_response.proto"; +import "hackathon/messages/hackathon_svc/list_invites_request.proto"; +import "hackathon/messages/hackathon_svc/list_invites_response.proto"; +import "hackathon/messages/hackathon_svc/list_registration_responses_request.proto"; +import "hackathon/messages/hackathon_svc/list_registration_responses_response.proto"; import "hackathon/messages/hackathon_svc/list_request.proto"; import "hackathon/messages/hackathon_svc/list_response.proto"; +import "hackathon/messages/hackathon_svc/preview_invite_request.proto"; +import "hackathon/messages/hackathon_svc/preview_invite_response.proto"; import "hackathon/messages/hackathon_svc/remove_owner_request.proto"; import "hackathon/messages/hackathon_svc/remove_owner_response.proto"; import "hackathon/messages/hackathon_svc/remove_participant_request.proto"; -import "hackathon/messages/hackathon_svc/submit_registration_form_request.proto"; -import "hackathon/messages/hackathon_svc/submit_registration_form_response.proto"; -import "hackathon/messages/hackathon_svc/get_registration_response_request.proto"; -import "hackathon/messages/hackathon_svc/get_registration_response_response.proto"; -import "hackathon/messages/hackathon_svc/list_registration_responses_request.proto"; -import "hackathon/messages/hackathon_svc/list_registration_responses_response.proto"; import "hackathon/messages/hackathon_svc/remove_participant_response.proto"; - +import "hackathon/messages/hackathon_svc/revoke_invite_request.proto"; +import "hackathon/messages/hackathon_svc/revoke_invite_response.proto"; import "hackathon/messages/hackathon_svc/set_capabilities_request.proto"; import "hackathon/messages/hackathon_svc/set_capabilities_response.proto"; import "hackathon/messages/hackathon_svc/set_current_phase_request.proto"; import "hackathon/messages/hackathon_svc/set_current_phase_response.proto"; +import "hackathon/messages/hackathon_svc/submit_registration_form_request.proto"; +import "hackathon/messages/hackathon_svc/submit_registration_form_response.proto"; + option go_package = "github.com/swissdatasciencecenter/hackagon/components/backend/internal/proto/hackathon"; service HackathonService { diff --git a/api/proto/hackathon/messages/config_svc/override_window_request.proto b/api/proto/hackathon/messages/config_svc/override_window_request.proto index ee07faab..a03e4224 100644 --- a/api/proto/hackathon/messages/config_svc/override_window_request.proto +++ b/api/proto/hackathon/messages/config_svc/override_window_request.proto @@ -12,7 +12,12 @@ option go_package = "github.com/swissdatasciencecenter/hackagon/components/backe message OverrideWindowRequest { string hackathon_id = 1 [(buf.validate.field).string.uuid = true]; // Which window to extend: "registration" or "submissions". - string window = 2 [(buf.validate.field).string = {in: ["registration", "submissions"]}]; + string window = 2 [(buf.validate.field).string = { + in: [ + "registration", + "submissions" + ] + }]; int32 extend_minutes = 3 [ (buf.validate.field).int32.gt = 0, (buf.validate.field).int32.lte = 1440 diff --git a/api/proto/hackathon/messages/config_svc/set_branding_request.proto b/api/proto/hackathon/messages/config_svc/set_branding_request.proto index a1f94bb1..1b6c0806 100644 --- a/api/proto/hackathon/messages/config_svc/set_branding_request.proto +++ b/api/proto/hackathon/messages/config_svc/set_branding_request.proto @@ -2,10 +2,10 @@ syntax = "proto3"; package hackathon.messages.config_svc; -option go_package = "github.com/swissdatasciencecenter/hackagon/components/backend/internal/proto/hackathon/messages/config_svc"; - import "buf/validate/validate.proto"; +option go_package = "github.com/swissdatasciencecenter/hackagon/components/backend/internal/proto/hackathon/messages/config_svc"; + message SetBrandingRequest { string hackathon_id = 1 [(buf.validate.field).string.uuid = true]; // CSS hex colours (#rgb or #rrggbb); validated server-side. diff --git a/api/proto/hackathon/messages/config_svc/set_email_templates_request.proto b/api/proto/hackathon/messages/config_svc/set_email_templates_request.proto index 0b4ed730..69c47f36 100644 --- a/api/proto/hackathon/messages/config_svc/set_email_templates_request.proto +++ b/api/proto/hackathon/messages/config_svc/set_email_templates_request.proto @@ -2,10 +2,10 @@ syntax = "proto3"; package hackathon.messages.config_svc; -option go_package = "github.com/swissdatasciencecenter/hackagon/components/backend/internal/proto/hackathon/messages/config_svc"; - import "buf/validate/validate.proto"; +option go_package = "github.com/swissdatasciencecenter/hackagon/components/backend/internal/proto/hackathon/messages/config_svc"; + // Notification COPY, stored per hackathon. Sending is a separate concern (no // notification service exists yet) — this pins the text organizers author so // it survives that gap. diff --git a/api/proto/hackathon/messages/hackathon_svc/create_invite_request.proto b/api/proto/hackathon/messages/hackathon_svc/create_invite_request.proto index a5794c8f..7b792d7a 100644 --- a/api/proto/hackathon/messages/hackathon_svc/create_invite_request.proto +++ b/api/proto/hackathon/messages/hackathon_svc/create_invite_request.proto @@ -2,10 +2,10 @@ syntax = "proto3"; package hackathon.messages.hackathon_svc; -option go_package = "github.com/swissdatasciencecenter/hackagon/components/backend/internal/proto/hackathon/messages/hackathon_svc"; - import "buf/validate/validate.proto"; +option go_package = "github.com/swissdatasciencecenter/hackagon/components/backend/internal/proto/hackathon/messages/hackathon_svc"; + message CreateInviteRequest { string hackathon_id = 1 [(buf.validate.field).string.uuid = true]; // Optional organizer-facing reminder of who the link was sent to. diff --git a/api/proto/hackathon/messages/hackathon_svc/create_invite_response.proto b/api/proto/hackathon/messages/hackathon_svc/create_invite_response.proto index c11fe209..5a83d36c 100644 --- a/api/proto/hackathon/messages/hackathon_svc/create_invite_response.proto +++ b/api/proto/hackathon/messages/hackathon_svc/create_invite_response.proto @@ -2,10 +2,10 @@ syntax = "proto3"; package hackathon.messages.hackathon_svc; -option go_package = "github.com/swissdatasciencecenter/hackagon/components/backend/internal/proto/hackathon/messages/hackathon_svc"; - import "hackathon/entities/hackathon_invite.proto"; +option go_package = "github.com/swissdatasciencecenter/hackagon/components/backend/internal/proto/hackathon/messages/hackathon_svc"; + message CreateInviteResponse { hackathon.entities.HackathonInvite invite = 1; } diff --git a/api/proto/hackathon/messages/hackathon_svc/list_invites_request.proto b/api/proto/hackathon/messages/hackathon_svc/list_invites_request.proto index 25e9afad..c8e3a041 100644 --- a/api/proto/hackathon/messages/hackathon_svc/list_invites_request.proto +++ b/api/proto/hackathon/messages/hackathon_svc/list_invites_request.proto @@ -2,10 +2,10 @@ syntax = "proto3"; package hackathon.messages.hackathon_svc; -option go_package = "github.com/swissdatasciencecenter/hackagon/components/backend/internal/proto/hackathon/messages/hackathon_svc"; - import "buf/validate/validate.proto"; +option go_package = "github.com/swissdatasciencecenter/hackagon/components/backend/internal/proto/hackathon/messages/hackathon_svc"; + message ListInvitesRequest { string hackathon_id = 1 [(buf.validate.field).string.uuid = true]; // Revoked links are hidden unless asked for. diff --git a/api/proto/hackathon/messages/hackathon_svc/list_invites_response.proto b/api/proto/hackathon/messages/hackathon_svc/list_invites_response.proto index 6870cc3b..66017015 100644 --- a/api/proto/hackathon/messages/hackathon_svc/list_invites_response.proto +++ b/api/proto/hackathon/messages/hackathon_svc/list_invites_response.proto @@ -2,10 +2,10 @@ syntax = "proto3"; package hackathon.messages.hackathon_svc; -option go_package = "github.com/swissdatasciencecenter/hackagon/components/backend/internal/proto/hackathon/messages/hackathon_svc"; - import "hackathon/entities/hackathon_invite.proto"; +option go_package = "github.com/swissdatasciencecenter/hackagon/components/backend/internal/proto/hackathon/messages/hackathon_svc"; + message ListInvitesResponse { repeated hackathon.entities.HackathonInvite invites = 1; } diff --git a/api/proto/hackathon/messages/hackathon_svc/preview_invite_request.proto b/api/proto/hackathon/messages/hackathon_svc/preview_invite_request.proto index c5271080..383e2539 100644 --- a/api/proto/hackathon/messages/hackathon_svc/preview_invite_request.proto +++ b/api/proto/hackathon/messages/hackathon_svc/preview_invite_request.proto @@ -2,10 +2,10 @@ syntax = "proto3"; package hackathon.messages.hackathon_svc; -option go_package = "github.com/swissdatasciencecenter/hackagon/components/backend/internal/proto/hackathon/messages/hackathon_svc"; - import "buf/validate/validate.proto"; +option go_package = "github.com/swissdatasciencecenter/hackagon/components/backend/internal/proto/hackathon/messages/hackathon_svc"; + // Redeeming side of an invite: exchanges the link secret for enough of the // hackathon to render its page. Deliberately takes ONLY the token, so it never // confirms whether a given hackathon id exists. diff --git a/api/proto/hackathon/messages/hackathon_svc/preview_invite_response.proto b/api/proto/hackathon/messages/hackathon_svc/preview_invite_response.proto index 712a9929..7b62ef68 100644 --- a/api/proto/hackathon/messages/hackathon_svc/preview_invite_response.proto +++ b/api/proto/hackathon/messages/hackathon_svc/preview_invite_response.proto @@ -2,10 +2,10 @@ syntax = "proto3"; package hackathon.messages.hackathon_svc; -option go_package = "github.com/swissdatasciencecenter/hackagon/components/backend/internal/proto/hackathon/messages/hackathon_svc"; - import "hackathon/entities/hackathon.proto"; +option go_package = "github.com/swissdatasciencecenter/hackagon/components/backend/internal/proto/hackathon/messages/hackathon_svc"; + message PreviewInviteResponse { // Shallow entity only — the invite grants visibility, not membership. hackathon.entities.Hackathon hackathon = 1; diff --git a/api/proto/hackathon/messages/hackathon_svc/revoke_invite_request.proto b/api/proto/hackathon/messages/hackathon_svc/revoke_invite_request.proto index d4351bf9..aa5271f8 100644 --- a/api/proto/hackathon/messages/hackathon_svc/revoke_invite_request.proto +++ b/api/proto/hackathon/messages/hackathon_svc/revoke_invite_request.proto @@ -2,10 +2,10 @@ syntax = "proto3"; package hackathon.messages.hackathon_svc; -option go_package = "github.com/swissdatasciencecenter/hackagon/components/backend/internal/proto/hackathon/messages/hackathon_svc"; - import "buf/validate/validate.proto"; +option go_package = "github.com/swissdatasciencecenter/hackagon/components/backend/internal/proto/hackathon/messages/hackathon_svc"; + message RevokeInviteRequest { string invite_id = 1 [(buf.validate.field).string.uuid = true]; } diff --git a/components/backend/internal/service/capacity_test.go b/components/backend/internal/service/capacity_test.go index 8a8cdd9a..3ace0100 100644 --- a/components/backend/internal/service/capacity_test.go +++ b/components/backend/internal/service/capacity_test.go @@ -223,7 +223,9 @@ var _ = Describe("Capacity", func() { _, cctx := newJoiner("freed-c") resp, err = client.Join(cctx, &msgs.JoinRequest{HackathonId: hid}) Expect(err).NotTo(HaveOccurred()) - Expect(resp.GetWaitlisted()).To(BeTrue(), "a free place with people waiting belongs to the queue") + Expect( + resp.GetWaitlisted(), + ).To(BeTrue(), "a free place with people waiting belongs to the queue") Expect(resp.GetQueuePosition()).To(BeInt32(2)) // The organizer hands the place out by hand. diff --git a/components/backend/internal/storage/sigv4_test.go b/components/backend/internal/storage/sigv4_test.go index 4cb51712..3c6e3d05 100644 --- a/components/backend/internal/storage/sigv4_test.go +++ b/components/backend/internal/storage/sigv4_test.go @@ -163,11 +163,25 @@ func TestPresignPutSignsSizeAndType(t *testing.T) { headers := http.Header{} headers.Set("Content-Type", "image/webp") headers.Set("Content-Length", "98028") - _, a := client.presign(http.MethodPut, "hackathons/abc/logo/x.webp", nil, headers, 15*time.Minute, at) + _, a := client.presign( + http.MethodPut, + "hackathons/abc/logo/x.webp", + nil, + headers, + 15*time.Minute, + at, + ) bigger := headers.Clone() bigger.Set("Content-Length", "98029") - _, b := client.presign(http.MethodPut, "hackathons/abc/logo/x.webp", nil, bigger, 15*time.Minute, at) + _, b := client.presign( + http.MethodPut, + "hackathons/abc/logo/x.webp", + nil, + bigger, + 15*time.Minute, + at, + ) if a == b { t.Error("signature did not change with the signed content-length") diff --git a/components/frontend/src/lib/components/forms/MarkdownEditor.test.ts b/components/frontend/src/lib/components/forms/MarkdownEditor.test.ts index 991930d7..f951bfab 100644 --- a/components/frontend/src/lib/components/forms/MarkdownEditor.test.ts +++ b/components/frontend/src/lib/components/forms/MarkdownEditor.test.ts @@ -297,9 +297,9 @@ describe("MarkdownEditor: paste a table", () => { mount() const { panel } = await openPanel() - expect( - panel.querySelector("button[disabled]")?.textContent?.trim(), - ).toBe("Insert table") + expect(panel.querySelector("button[disabled]")?.textContent?.trim()).toBe( + "Insert table", + ) }) it("inserts a table the Preview pane renders as a real ", async () => { @@ -429,7 +429,9 @@ describe("MarkdownEditor: paste a table", () => { await tick() await tick() - expect(field().value).toMatch(/^before\n\n\| a\s+\| b\s+\|\n\| -+ \| -+ \|\n\nafter$/) + expect(field().value).toMatch( + /^before\n\n\| a\s+\| b\s+\|\n\| -+ \| -+ \|\n\nafter$/, + ) }) it("closes on Escape and gives focus back to the control that opened it", async () => { diff --git a/components/frontend/src/lib/components/layout/AppFooter.test.ts b/components/frontend/src/lib/components/layout/AppFooter.test.ts index 51f4119e..6cc1e8cb 100644 --- a/components/frontend/src/lib/components/layout/AppFooter.test.ts +++ b/components/frontend/src/lib/components/layout/AppFooter.test.ts @@ -43,9 +43,14 @@ describe("AppFooter", () => { expect(internal.length).toBeGreaterThan(0) for (const href of internal) { - expect(["/", "/dashboard", "/hackathon", "/about", "/privacy", "/terms"]).toContain( - href, - ) + expect([ + "/", + "/dashboard", + "/hackathon", + "/about", + "/privacy", + "/terms", + ]).toContain(href) } }) diff --git a/components/frontend/src/lib/components/layout/SidebarNavSection.test.ts b/components/frontend/src/lib/components/layout/SidebarNavSection.test.ts index f85f5699..1d6e5cf4 100644 --- a/components/frontend/src/lib/components/layout/SidebarNavSection.test.ts +++ b/components/frontend/src/lib/components/layout/SidebarNavSection.test.ts @@ -203,7 +203,9 @@ describe("SidebarNavSection", () => { it("keeps the parent on the rail while its items are folded away", () => { render(SidebarNavSection, parented({ open: false })) - expect(screen.getByRole("link", { name: "hackathon" })).toBeInTheDocument() + expect( + screen.getByRole("link", { name: "hackathon" }), + ).toBeInTheDocument() expect(screen.queryByRole("link", { name: "teams" })).toBeNull() }) @@ -248,7 +250,9 @@ describe("SidebarNavSection", () => { collapsed: false, }) - expect(screen.getByRole("link", { name: "hackathon" })).toBeInTheDocument() + expect( + screen.getByRole("link", { name: "hackathon" }), + ).toBeInTheDocument() expect(screen.queryByRole("button")).toBeNull() }) diff --git a/components/frontend/src/lib/gallery.test.ts b/components/frontend/src/lib/gallery.test.ts index e73f28c4..c7275b1e 100644 --- a/components/frontend/src/lib/gallery.test.ts +++ b/components/frontend/src/lib/gallery.test.ts @@ -111,7 +111,9 @@ describe("originOfKey", () => { }) it("does not invent an event id for a key that has none", () => { - expect(originOfKey("hackathons/seed/x/cover.webp").hackathonId).toBeUndefined() + expect( + originOfKey("hackathons/seed/x/cover.webp").hackathonId, + ).toBeUndefined() expect(originOfKey("something/else.webp")).toEqual({ label: "Uploaded" }) }) }) diff --git a/components/frontend/src/lib/gallery.ts b/components/frontend/src/lib/gallery.ts index 51287f67..17006c63 100644 --- a/components/frontend/src/lib/gallery.ts +++ b/components/frontend/src/lib/gallery.ts @@ -57,7 +57,8 @@ export async function fetchStoredImages( ): Promise { const url = new URL(endpoint, "http://localhost") if (options.pageToken) url.searchParams.set("page", options.pageToken) - if (options.pageSize) url.searchParams.set("pageSize", String(options.pageSize)) + if (options.pageSize) + url.searchParams.set("pageSize", String(options.pageSize)) // Same-origin request, so only the path+query is sent — the base above exists // solely to let URLSearchParams do the encoding. const target = url.pathname + (url.search || "") @@ -70,7 +71,9 @@ export async function fetchStoredImages( } if (!response.ok) { const text = await response.text().catch(() => "") - throw new GalleryError(text.trim() || "Could not load what is already uploaded") + throw new GalleryError( + text.trim() || "Could not load what is already uploaded", + ) } let body: unknown diff --git a/components/frontend/src/lib/server/hackathon/joinOffer.test.ts b/components/frontend/src/lib/server/hackathon/joinOffer.test.ts index c43ef509..ec40fc4d 100644 --- a/components/frontend/src/lib/server/hackathon/joinOffer.test.ts +++ b/components/frontend/src/lib/server/hackathon/joinOffer.test.ts @@ -26,7 +26,9 @@ describe("joinIsOffered", () => { it("withholds a finished event whatever its capabilities say", () => { // Join checks `EndsAt` BEFORE the capability, so an open register flag on a // finished event still refuses. The button must agree. - expect(joinIsOffered(withRegister(CapabilityState.OPEN, FINISHED))).toBe(false) + expect(joinIsOffered(withRegister(CapabilityState.OPEN, FINISHED))).toBe( + false, + ) }) it("withholds a closed registration", () => { diff --git a/components/frontend/src/lib/server/hackathon/joinOffer.ts b/components/frontend/src/lib/server/hackathon/joinOffer.ts index c4b71123..98c2942a 100644 --- a/components/frontend/src/lib/server/hackathon/joinOffer.ts +++ b/components/frontend/src/lib/server/hackathon/joinOffer.ts @@ -34,7 +34,9 @@ const STATUS_FINISHED = 3 export function joinIsOffered(h: JoinableEntry): boolean { if (h.status === STATUS_FINISHED) return false - const register = h.capabilities.find((c) => c.capability === CAPABILITY_REGISTER) + const register = h.capabilities.find( + (c) => c.capability === CAPABILITY_REGISTER, + ) // Absent means no row governs it, which is UNGOVERNED, which PERMITS — the // same answer `capability.State.Allowed` gives on the server. Treating a // missing row as "closed" would hide the button on every event that never diff --git a/components/frontend/src/lib/server/hackathon/teamImport.test.ts b/components/frontend/src/lib/server/hackathon/teamImport.test.ts index 1d4382ec..a30bc10c 100644 --- a/components/frontend/src/lib/server/hackathon/teamImport.test.ts +++ b/components/frontend/src/lib/server/hackathon/teamImport.test.ts @@ -20,10 +20,20 @@ const CHARLES = "charles@mail.net" function world(): ImportWorld { return { participants: [ - { id: "u-alice", email: ALICE, name: "Alice Wonderland", isWaiting: false }, + { + id: "u-alice", + email: ALICE, + name: "Alice Wonderland", + isWaiting: false, + }, { id: "u-bob", email: BOB, name: "Bob Henderson", isWaiting: false }, { id: "u-admin", email: ADMIN, name: "Hackagon Admin", isWaiting: false }, - { id: "u-charles", email: CHARLES, name: "Charles Whitfield", isWaiting: true }, + { + id: "u-charles", + email: CHARLES, + name: "Charles Whitfield", + isWaiting: true, + }, ], projects: [ { id: "p-automl", title: "AutoML Pipeline Builder" }, @@ -36,7 +46,12 @@ function world(): ImportWorld { projectId: "p-automl", memberIds: ["u-alice", "u-admin"], }, - { id: "t-beta", name: "Team Beta", projectId: "p-chatbot", memberIds: [] }, + { + id: "t-beta", + name: "Team Beta", + projectId: "p-chatbot", + memberIds: [], + }, ], } } @@ -113,7 +128,9 @@ describe("the template", () => { }) it("leaves waitlisted participants out — they cannot be given a team yet", () => { - expect(templateRows(world()).map((r) => r.user_email)).not.toContain(CHARLES) + expect(templateRows(world()).map((r) => r.user_email)).not.toContain( + CHARLES, + ) }) it("falls back to example rows, on a REAL project, when nobody is confirmed", () => { @@ -194,9 +211,16 @@ describe("the template round trip", () => { describe("reading a CSV", () => { it("reads the three columns", () => { expect( - parsed(`user_email,project,team\n${BOB},Multilingual Chatbot,Team Beta\n`), + parsed( + `user_email,project,team\n${BOB},Multilingual Chatbot,Team Beta\n`, + ), ).toEqual([ - { row: 1, userEmail: BOB, project: "Multilingual Chatbot", team: "Team Beta" }, + { + row: 1, + userEmail: BOB, + project: "Multilingual Chatbot", + team: "Team Beta", + }, ]) }) @@ -222,7 +246,9 @@ describe("reading a CSV", () => { }) it("reads a semicolon-separated file, which is what Excel writes in Europe", () => { - const [row] = parsed(`user_email;project;team\r\n${BOB};Multilingual Chatbot;Team Beta\r\n`) + const [row] = parsed( + `user_email;project;team\r\n${BOB};Multilingual Chatbot;Team Beta\r\n`, + ) expect(row).toEqual({ row: 1, @@ -253,17 +279,30 @@ describe("reading a JSON", () => { it("reads a bare array", () => { expect( parsed( - JSON.stringify([{ user_email: BOB, project: "Multilingual Chatbot", team: "Team Beta" }]), + JSON.stringify([ + { + user_email: BOB, + project: "Multilingual Chatbot", + team: "Team Beta", + }, + ]), "roster.json", ), ).toEqual([ - { row: 1, userEmail: BOB, project: "Multilingual Chatbot", team: "Team Beta" }, + { + row: 1, + userEmail: BOB, + project: "Multilingual Chatbot", + team: "Team Beta", + }, ]) }) it("reads a { rows: [...] } wrapper and camelCase keys", () => { const [row] = parsed( - JSON.stringify({ rows: [{ userEmail: BOB, project: "X", teamName: "Y" }] }), + JSON.stringify({ + rows: [{ userEmail: BOB, project: "X", teamName: "Y" }], + }), "roster.json", ) @@ -290,25 +329,33 @@ describe("reading a JSON", () => { }) it("reads JSON out of a file named .csv rather than as one wide column", () => { - expect(parsed(JSON.stringify([{ user_email: BOB }]), "roster.csv")[0]?.userEmail).toBe(BOB) + expect( + parsed(JSON.stringify([{ user_email: BOB }]), "roster.csv")[0]?.userEmail, + ).toBe(BOB) }) it("says which row is not an object", () => { - expect(parseError(JSON.stringify([{ user_email: BOB }, "nope"]), "r.json")).toBe( - "row 2 is not an object", - ) + expect( + parseError(JSON.stringify([{ user_email: BOB }, "nope"]), "r.json"), + ).toBe("row 2 is not an object") }) it("refuses a project given as a list", () => { expect( - parseError(JSON.stringify([{ user_email: BOB, project: ["a", "b"] }]), "r.json"), + parseError( + JSON.stringify([{ user_email: BOB, project: ["a", "b"] }]), + "r.json", + ), ).toBe('row 1: "project" must be text, not a list') }) }) describe("resolving a file against the event", () => { it("plans a join onto an existing team", () => { - const plan = resolveImport(rows([BOB, "Multilingual Chatbot", "Team Beta"]), world()) + const plan = resolveImport( + rows([BOB, "Multilingual Chatbot", "Team Beta"]), + world(), + ) expect(plan.counts.errors).toBe(0) expect(forEmail(plan, BOB)).toMatchObject({ @@ -328,7 +375,10 @@ describe("resolving a file against the event", () => { world(), ) - expect(forEmail(plan, BOB)).toMatchObject({ status: "assign", target: "t-beta" }) + expect(forEmail(plan, BOB)).toMatchObject({ + status: "assign", + target: "t-beta", + }) }) it("creates a team the event does not have yet, ONCE for however many rows name it", () => { @@ -342,7 +392,11 @@ describe("resolving a file against the event", () => { expect(plan.counts.errors).toBe(0) expect(plan.creates).toEqual([ - { projectId: "p-chatbot", projectTitle: "Multilingual Chatbot", name: "Team Gamma" }, + { + projectId: "p-chatbot", + projectTitle: "Multilingual Chatbot", + name: "Team Gamma", + }, ]) expect(forEmail(plan, BOB)).toMatchObject({ status: "create", @@ -350,11 +404,17 @@ describe("resolving a file against the event", () => { detail: 'joins a new team "Team Gamma" under "Multilingual Chatbot"', }) // The admin is on Team Alpha today, so joining the new team means leaving it. - expect(forEmail(plan, ADMIN)).toMatchObject({ target: "new:0", leave: ["t-alpha"] }) + expect(forEmail(plan, ADMIN)).toMatchObject({ + target: "new:0", + leave: ["t-alpha"], + }) }) it("moves someone off their old team on the way to the new one", () => { - const plan = resolveImport(rows([ALICE, "Multilingual Chatbot", "Team Beta"]), world()) + const plan = resolveImport( + rows([ALICE, "Multilingual Chatbot", "Team Beta"]), + world(), + ) expect(forEmail(plan, ALICE)).toMatchObject({ status: "assign", @@ -416,13 +476,17 @@ describe("the rows a file can get wrong", () => { expect(forEmail(plan, "nobody@example.org")).toMatchObject({ status: "error", - detail: 'no participant of this hackathon has the email "nobody@example.org"', + detail: + 'no participant of this hackathon has the email "nobody@example.org"', }) expect(plan.counts.errors).toBe(1) }) it("names a project this event does not have", () => { - const plan = resolveImport(rows([BOB, "Quantum Blockchain", "Team Beta"]), world()) + const plan = resolveImport( + rows([BOB, "Quantum Blockchain", "Team Beta"]), + world(), + ) // A different answer to a different question: the person is fine, the // project is not, and "row failed" would leave the organiser guessing which. @@ -433,7 +497,10 @@ describe("the rows a file can get wrong", () => { }) it("names someone who is still on the waiting list", () => { - const plan = resolveImport(rows([CHARLES, "Multilingual Chatbot", "Team Beta"]), world()) + const plan = resolveImport( + rows([CHARLES, "Multilingual Chatbot", "Team Beta"]), + world(), + ) expect(forEmail(plan, CHARLES).detail).toBe( `${CHARLES} is on the waiting list — approve them before putting them on a team`, @@ -476,7 +543,10 @@ describe("the rows a file can get wrong", () => { }) it("refuses an empty email", () => { - const plan = resolveImport(rows(["", "Multilingual Chatbot", "Team Beta"]), world()) + const plan = resolveImport( + rows(["", "Multilingual Chatbot", "Team Beta"]), + world(), + ) expect(plan.rows[0]).toMatchObject({ status: "error", @@ -487,7 +557,10 @@ describe("the rows a file can get wrong", () => { it("refuses an ambiguous project title", () => { const w = world() w.projects.push({ id: "p-clone", title: "Multilingual Chatbot" }) - const plan = resolveImport(rows([BOB, "Multilingual Chatbot", "Team Beta"]), w) + const plan = resolveImport( + rows([BOB, "Multilingual Chatbot", "Team Beta"]), + w, + ) expect(forEmail(plan, BOB).detail).toBe( '2 projects are titled "Multilingual Chatbot" — rename one of them before importing', @@ -496,8 +569,16 @@ describe("the rows a file can get wrong", () => { it("refuses an ambiguous team name under one project", () => { const w = world() - w.teams.push({ id: "t-beta2", name: "Team Beta", projectId: "p-chatbot", memberIds: [] }) - const plan = resolveImport(rows([BOB, "Multilingual Chatbot", "Team Beta"]), w) + w.teams.push({ + id: "t-beta2", + name: "Team Beta", + projectId: "p-chatbot", + memberIds: [], + }) + const plan = resolveImport( + rows([BOB, "Multilingual Chatbot", "Team Beta"]), + w, + ) expect(forEmail(plan, BOB).detail).toBe( '2 teams under "Multilingual Chatbot" are named "Team Beta" — rename one of them before importing', @@ -505,7 +586,10 @@ describe("the rows a file can get wrong", () => { }) it("refuses a team name longer than the column can hold", () => { - const plan = resolveImport(rows([BOB, "Multilingual Chatbot", "x".repeat(256)]), world()) + const plan = resolveImport( + rows([BOB, "Multilingual Chatbot", "x".repeat(256)]), + world(), + ) expect(forEmail(plan, BOB).detail).toBe( "the team name is 256 characters; the limit is 255", diff --git a/components/frontend/src/lib/server/hackathon/teamImport.ts b/components/frontend/src/lib/server/hackathon/teamImport.ts index f6e3ec1f..fac72a0a 100644 --- a/components/frontend/src/lib/server/hackathon/teamImport.ts +++ b/components/frontend/src/lib/server/hackathon/teamImport.ts @@ -344,19 +344,18 @@ function parseJson(text: string): ParseResult { // Accept both the bare array the template writes and a `{ rows: [...] }` // wrapper, which is what anyone hand-rolling an export tends to produce. - const list = - Array.isArray(parsed) ? parsed - : ( - parsed !== null && - typeof parsed === "object" && - Array.isArray((parsed as { rows?: unknown }).rows) - ) ? - ((parsed as { rows: unknown[] }).rows as unknown[]) - : null + const list = Array.isArray(parsed) + ? parsed + : parsed !== null && + typeof parsed === "object" && + Array.isArray((parsed as { rows?: unknown }).rows) + ? ((parsed as { rows: unknown[] }).rows as unknown[]) + : null if (!list) { return { ok: false, - message: 'the JSON must be an array of rows, or an object with a "rows" array', + message: + 'the JSON must be an array of rows, or an object with a "rows" array', } } if (list.length === 0) return { ok: false, message: "the file has no rows" } @@ -493,7 +492,9 @@ export function resolveImport( }) if (email === "") { - planned.push(err("user_email is empty — every row must name a participant")) + planned.push( + err("user_email is empty — every row must name a participant"), + ) continue } const key = fold(email) @@ -564,7 +565,9 @@ export function resolveImport( } if (teamName.length > MAX_TEAM_NAME) { planned.push( - err(`the team name is ${teamName.length} characters; the limit is ${MAX_TEAM_NAME}`), + err( + `the team name is ${teamName.length} characters; the limit is ${MAX_TEAM_NAME}`, + ), ) continue } @@ -617,11 +620,11 @@ export function resolveImport( ...base, status: "assign", detail: - leave.length === 0 ? - `joins "${team.name}" (${project.title})` - : alreadyThere ? - `stays on "${team.name}" and leaves ${quoteList(leave.map((t) => t.name))}` - : `moves from ${quoteList(leave.map((t) => t.name))} to "${team.name}"`, + leave.length === 0 + ? `joins "${team.name}" (${project.title})` + : alreadyThere + ? `stays on "${team.name}" and leaves ${quoteList(leave.map((t) => t.name))}` + : `moves from ${quoteList(leave.map((t) => t.name))} to "${team.name}"`, userId: person.id, target: team.id, leave: leave.map((t) => t.id), @@ -647,9 +650,9 @@ export function resolveImport( ...base, status: "create", detail: - current.length === 0 ? - `joins a new team "${teamName}" under "${project.title}"` - : `moves from ${quoteList(currentNames)} into a new team "${teamName}" under "${project.title}"`, + current.length === 0 + ? `joins a new team "${teamName}" under "${project.title}"` + : `moves from ${quoteList(currentNames)} into a new team "${teamName}" under "${project.title}"`, userId: person.id, target: `new:${at}`, leave: current.map((t) => t.id), @@ -763,7 +766,8 @@ export function templateRows(world: ImportWorld): TemplateRow[] { // the format exists to express. The project title is a REAL one when the event // has any; the emails are obviously placeholders, and the importer will say so // by name if they are left in. - const example = world.projects[0]?.title ?? "Project title, exactly as in this event" + const example = + world.projects[0]?.title ?? "Project title, exactly as in this event" const team = world.projects[0] ? `Team ${initialsOf(example)}` : "Team name" return [ @@ -773,13 +777,18 @@ export function templateRows(world: ImportWorld): TemplateRow[] { } /** The downloadable file, in the format asked for. */ -export function buildTemplate(world: ImportWorld, format: ImportFormat): string { +export function buildTemplate( + world: ImportWorld, + format: ImportFormat, +): string { const rows = templateRows(world) if (format === "json") return `${JSON.stringify(rows, null, 2)}\n` const lines = [ IMPORT_COLUMNS.map(csvCell).join(","), - ...rows.map((r) => [r.user_email, r.project, r.team].map(csvCell).join(",")), + ...rows.map((r) => + [r.user_email, r.project, r.team].map(csvCell).join(","), + ), ] // CRLF, which is what RFC 4180 specifies and what Excel expects. diff --git a/components/frontend/src/lib/server/upload.ts b/components/frontend/src/lib/server/upload.ts index e975b91b..85ff4e82 100644 --- a/components/frontend/src/lib/server/upload.ts +++ b/components/frontend/src/lib/server/upload.ts @@ -115,7 +115,12 @@ export async function listStoredImages( Number.isFinite(requested) && requested > 0 ? Math.trunc(requested) : 0 try { - const result = await storage.listObjects({ scope, ownerId, pageSize, pageToken }) + const result = await storage.listObjects({ + scope, + ownerId, + pageSize, + pageToken, + }) return json({ objects: result.objects.map((o) => ({ diff --git a/components/frontend/src/lib/upload.ts b/components/frontend/src/lib/upload.ts index d5ca25f0..d2aa751c 100644 --- a/components/frontend/src/lib/upload.ts +++ b/components/frontend/src/lib/upload.ts @@ -160,7 +160,8 @@ export async function uploadImage( } catch { throw new UploadError("Could not reach the object store") } - if (!put.ok) throw new UploadError(`Storage rejected the upload (${put.status})`) + if (!put.ok) + throw new UploadError(`Storage rejected the upload (${put.status})`) return { ...signed, file } } diff --git a/components/frontend/src/lib/utils/markdownEdit.test.ts b/components/frontend/src/lib/utils/markdownEdit.test.ts index 32a3556c..07a4b27b 100644 --- a/components/frontend/src/lib/utils/markdownEdit.test.ts +++ b/components/frontend/src/lib/utils/markdownEdit.test.ts @@ -105,9 +105,9 @@ describe("toggleLinePrefix", () => { }) it("bullets every line the selection touches", () => { - expect(show(toggleLinePrefix(state("[one\ntwo\nthree]"), BULLET_LIST))).toBe( - "[- one\n- two\n- three]", - ) + expect( + show(toggleLinePrefix(state("[one\ntwo\nthree]"), BULLET_LIST)), + ).toBe("[- one\n- two\n- three]") }) it("numbers a list from one, per line", () => { @@ -192,9 +192,7 @@ describe("insertBlock", () => { it("separates the block from a paragraph above it", () => { // Glued to the paragraph, markdown reads the table as more paragraph. - expect(insertBlock(state("intro|"), TABLE).text).toBe( - `intro\n\n${TABLE}\n`, - ) + expect(insertBlock(state("intro|"), TABLE).text).toBe(`intro\n\n${TABLE}\n`) }) it("does not add a blank line that is already there", () => { @@ -204,9 +202,7 @@ describe("insertBlock", () => { }) it("separates the block from what follows it", () => { - expect(insertBlock(state("|outro"), TABLE).text).toBe( - `${TABLE}\n\noutro`, - ) + expect(insertBlock(state("|outro"), TABLE).text).toBe(`${TABLE}\n\noutro`) }) it("leaves the caret after the block", () => { diff --git a/components/frontend/src/lib/utils/markdownEdit.ts b/components/frontend/src/lib/utils/markdownEdit.ts index 0cb1038b..baf9e705 100644 --- a/components/frontend/src/lib/utils/markdownEdit.ts +++ b/components/frontend/src/lib/utils/markdownEdit.ts @@ -148,10 +148,7 @@ function lineBounds(text: string, start: number, end: number) { * prefix — a partial selection gets levelled up rather than half-toggled, * which is the behaviour that makes a drag over mixed lines predictable. */ -export function toggleLinePrefix( - state: EditState, - op: LinePrefix, -): EditState { +export function toggleLinePrefix(state: EditState, op: LinePrefix): EditState { const { text, start, end } = state const { from, to } = lineBounds(text, start, end) const lines = text.slice(from, to).split("\n") @@ -291,7 +288,8 @@ export function toggleCodeBlock(state: EditState): EditState { `${FENCE}\n\n${FENCE}`, ) // Caret on the blank line between the fences. - const inside = result.text.indexOf(`${FENCE}\n\n${FENCE}`) + FENCE.length + 1 + const inside = + result.text.indexOf(`${FENCE}\n\n${FENCE}`) + FENCE.length + 1 return { ...result, start: inside, end: inside } } diff --git a/components/frontend/src/lib/utils/markdownTable.test.ts b/components/frontend/src/lib/utils/markdownTable.test.ts index 20d6a77a..09a93206 100644 --- a/components/frontend/src/lib/utils/markdownTable.test.ts +++ b/components/frontend/src/lib/utils/markdownTable.test.ts @@ -267,7 +267,10 @@ describe("tableFromPaste", () => { */ describe("the generated markdown renders as a real table", () => { /** Render a paste through the real pipeline and read the table back. */ - const render = (input: string, options?: Parameters[1]) => { + const render = ( + input: string, + options?: Parameters[1], + ) => { const converted = tableFromPaste(input, options) if (!converted) throw new Error("nothing was converted") diff --git a/components/frontend/src/lib/utils/phase.test.ts b/components/frontend/src/lib/utils/phase.test.ts index 0699ed4f..55d2bf90 100644 --- a/components/frontend/src/lib/utils/phase.test.ts +++ b/components/frontend/src/lib/utils/phase.test.ts @@ -236,9 +236,9 @@ describe("toDateTimeLocal", () => { describe("formatPhaseRange", () => { it("reads out both ends when a phase is fully dated", () => { - expect( - formatPhaseRange(new Date(2026, 2, 1), new Date(2026, 2, 3)), - ).toBe("Mar 1, 2026 – Mar 3, 2026") + expect(formatPhaseRange(new Date(2026, 2, 1), new Date(2026, 2, 3))).toBe( + "Mar 1, 2026 – Mar 3, 2026", + ) }) // All four combinations, because both dates are optional in the schema and a diff --git a/components/frontend/src/lib/utils/returnTo.ts b/components/frontend/src/lib/utils/returnTo.ts index 6f535d42..74aab265 100644 --- a/components/frontend/src/lib/utils/returnTo.ts +++ b/components/frontend/src/lib/utils/returnTo.ts @@ -38,7 +38,9 @@ export function safeReturnTo(value: string | null | undefined): string | null { export function loginUrlFor(target: string | null | undefined): string { const safe = safeReturnTo(target) - return safe ? `${LOGIN_PATH}?returnTo=${encodeURIComponent(safe)}` : LOGIN_PATH + return safe + ? `${LOGIN_PATH}?returnTo=${encodeURIComponent(safe)}` + : LOGIN_PATH } /** @@ -49,9 +51,7 @@ export function loginUrlFor(target: string | null | undefined): string { * and "the dashboard", so a change that breaks one cannot silently pass the * other — both the guard's round trip and the plain "Log in" button call it. */ -export function loginDestination( - returnTo: string | null | undefined, -): string { +export function loginDestination(returnTo: string | null | undefined): string { const target = safeReturnTo(returnTo) if (!target) return DEFAULT_LOGIN_DESTINATION diff --git a/components/frontend/src/routes/(app)/manage/gallery/+page.server.ts b/components/frontend/src/routes/(app)/manage/gallery/+page.server.ts index 2d59a09c..f62633af 100644 --- a/components/frontend/src/routes/(app)/manage/gallery/+page.server.ts +++ b/components/frontend/src/routes/(app)/manage/gallery/+page.server.ts @@ -51,7 +51,9 @@ export const load: PageServerLoad = async (event) => { let eventNames: Record = {} try { const events = await hackathon.list({}) - eventNames = Object.fromEntries(events.hackathons.map((h) => [h.id, h.name])) + eventNames = Object.fromEntries( + events.hackathons.map((h) => [h.id, h.name]), + ) } catch { eventNames = {} } diff --git a/components/frontend/src/routes/(app)/my/hackathon/[id]/manage/tiles.test.ts b/components/frontend/src/routes/(app)/my/hackathon/[id]/manage/tiles.test.ts index d29806c8..6cd3d7d3 100644 --- a/components/frontend/src/routes/(app)/my/hackathon/[id]/manage/tiles.test.ts +++ b/components/frontend/src/routes/(app)/my/hackathon/[id]/manage/tiles.test.ts @@ -242,13 +242,21 @@ describe("the hub's one phase action", () => { }) it("starts the first phase still to come when nothing is running", () => { - mount({ declared: false, currentPhase: null, nextPhase: phase("p2", "Judging") }) + mount({ + declared: false, + currentPhase: null, + nextPhase: phase("p2", "Judging"), + }) expect(submitLabel()).toMatch(/Start Judging/) }) it("offers nothing to advance to past the last phase", () => { - mount({ declared: true, currentPhase: phase("p1", "Wrap-up"), nextPhase: null }) + mount({ + declared: true, + currentPhase: phase("p1", "Wrap-up"), + nextPhase: null, + }) expect(submitLabel()).toBeUndefined() }) @@ -257,11 +265,15 @@ describe("the hub's one phase action", () => { // change that leaves the page looking exactly as it did. it("offers Clear the marker only against a declaration", () => { mount({ declared: true, currentPhase: phase("p1", "Hacking") }) - expect(screen.getByRole("button", { name: "Clear the marker" })).toBeInTheDocument() + expect( + screen.getByRole("button", { name: "Clear the marker" }), + ).toBeInTheDocument() cleanup() mount({ declared: false, currentPhase: phase("p1", "Hacking") }) - expect(screen.queryByRole("button", { name: "Clear the marker" })).toBeNull() + expect( + screen.queryByRole("button", { name: "Clear the marker" }), + ).toBeNull() }) // The one screen where the difference is actionable, so it is the one screen diff --git a/components/frontend/src/routes/(app)/my/hackathon/[id]/media/+server.ts b/components/frontend/src/routes/(app)/my/hackathon/[id]/media/+server.ts index a0e8142b..5d768fa7 100644 --- a/components/frontend/src/routes/(app)/my/hackathon/[id]/media/+server.ts +++ b/components/frontend/src/routes/(app)/my/hackathon/[id]/media/+server.ts @@ -31,4 +31,8 @@ export const POST: RequestHandler = (event) => * `users//avatar/` — no scope can; see ObjectScope in the proto. */ export const GET: RequestHandler = (event) => - listStoredImages(event, ObjectScope.OBJECT_SCOPE_HACKATHON_MEDIA, event.params.id) + listStoredImages( + event, + ObjectScope.OBJECT_SCOPE_HACKATHON_MEDIA, + event.params.id, + ) diff --git a/components/frontend/src/routes/(app)/my/hackathon/[id]/teams/manage/+page.server.ts b/components/frontend/src/routes/(app)/my/hackathon/[id]/teams/manage/+page.server.ts index 5a935706..38e9f477 100644 --- a/components/frontend/src/routes/(app)/my/hackathon/[id]/teams/manage/+page.server.ts +++ b/components/frontend/src/routes/(app)/my/hackathon/[id]/teams/manage/+page.server.ts @@ -405,7 +405,9 @@ export const actions: Actions = { const text = String(form.get("fileText") ?? "") const filename = String(form.get("filename") ?? "import.csv") if (text.trim() === "") { - return fail(400, { importError: "Nothing to apply — preview a file first." }) + return fail(400, { + importError: "Nothing to apply — preview a file first.", + }) } const parsed = parseRosterFile(text, filename) @@ -451,9 +453,8 @@ export const actions: Actions = { let applied = 0 for (const row of plan.rows) { if (row.status === "unchanged" || row.status === "error") continue - const targetId = - row.target?.startsWith("new:") ? - newTeamIds[Number(row.target.slice(4))] + const targetId = row.target?.startsWith("new:") + ? newTeamIds[Number(row.target.slice(4))] : (row.target ?? null) if (row.target?.startsWith("new:") && !targetId) { // Its team failed to be created; the failure is already reported above diff --git a/components/frontend/src/routes/(app)/my/hackathon/[id]/teams/manage/template/[format]/+server.ts b/components/frontend/src/routes/(app)/my/hackathon/[id]/teams/manage/template/[format]/+server.ts index 08fc4f04..d738fb48 100644 --- a/components/frontend/src/routes/(app)/my/hackathon/[id]/teams/manage/template/[format]/+server.ts +++ b/components/frontend/src/routes/(app)/my/hackathon/[id]/teams/manage/template/[format]/+server.ts @@ -38,9 +38,9 @@ export const GET: RequestHandler = async (event) => { return new Response(buildTemplate(world, format), { headers: { "content-type": - format === "json" ? - "application/json; charset=utf-8" - : "text/csv; charset=utf-8", + format === "json" + ? "application/json; charset=utf-8" + : "text/csv; charset=utf-8", "content-disposition": `attachment; filename="teams-${event.params.id}.${format}"`, }, }) diff --git a/docs/TODO.md b/docs/TODO.md index ac00da1a..d5b5661a 100644 --- a/docs/TODO.md +++ b/docs/TODO.md @@ -220,8 +220,8 @@ which closes it for a single-instance deployment (post-fix: 12 of 12 rounds, exactly one row); the journey pins it with `act7.race.doublevote` + `act7.race.check`. A partial unique index on `(category, voter) WHERE vote_type = 'single_choice'` remains the multi-instance -fix, but ent cannot express one, so it would have to be hand-written SQL -outside the schema. +fix, but ent cannot express one, so it would have to be hand-written SQL outside +the schema. **The last-organizer guard raced the same way** — `RemoveOwner` read the owner list, checked it, then removed, so two organizers demoting each other @@ -243,14 +243,14 @@ Pinned by `act5.race.owner.*` (mutual demotion → exactly one owner survives). confirmed participant (the member list is built from that table, so a role granted outside it makes an owner absent from the roster) - [x] F4 — `returnTo` consumed (with an open-redirect guard; the old ping-pong - protection replaced by an explicit `sessionUsable` flag). - **Reopened and closed properly 2026-08-12**: only the SERVER half had been - built. `redirectHandle` forwarded a logged-in caller from `/?returnTo=X` to - X, but nothing ever put a logged-in caller back on `/` carrying the query — - `NavBar`'s "Log in" button computed its own `callbackUrl` from the pathname - and never read `returnTo`, so the one control the visitor was being asked - to press is what dropped the deep link. Both guards now park on - `/signin?returnTo=…` (an interstitial that says what happened before it + protection replaced by an explicit `sessionUsable` flag). **Reopened and + closed properly 2026-08-12**: only the SERVER half had been built. + `redirectHandle` forwarded a logged-in caller from `/?returnTo=X` to X, + but nothing ever put a logged-in caller back on `/` carrying the query — + `NavBar`'s "Log in" button computed its own `callbackUrl` from the + pathname and never read `returnTo`, so the one control the visitor was + being asked to press is what dropped the deep link. Both guards now park + on `/signin?returnTo=…` (an interstitial that says what happened before it goes), the destination is resolved once by `loginDestination`, and the button reads the same query. `tests/smoke/23-login-destination.spec.ts` follows an anonymous deep link through Keycloak and asserts the final URL diff --git a/docs/architecture.md b/docs/architecture.md index bf9fbef7..79d2a75e 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -79,9 +79,9 @@ Configuration for each component is loaded from a config directory passed with It exists so that pointing this checkout at a machine-specific hostname never edits a tracked file. The Cloudflare quick-tunnel wiring (`.claude/skills/cloudflare-tunnel/scripts/auth-wire.sh`) writes the tunnel - issuer here; it used to rewrite `config.yaml` itself, and a hostname that - dies with the tunnel got committed. `internal/config/config_test.go` asserts - both tracked configs still name `localhost`. + issuer here; it used to rewrite `config.yaml` itself, and a hostname that dies + with the tunnel got committed. `internal/config/config_test.go` asserts both + tracked configs still name `localhost`. ## Request flow diff --git a/docs/frontend/routes-and-auth.md b/docs/frontend/routes-and-auth.md index 6c26b793..c9a5daed 100644 --- a/docs/frontend/routes-and-auth.md +++ b/docs/frontend/routes-and-auth.md @@ -30,23 +30,23 @@ The public and member views of a hackathon live in **disjoint path spaces**: ## Route map -| Route | Purpose | Data loaded (server) | Access | -| ----------------------------- | ---------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------- | +| Route | Purpose | Data loaded (server) | Access | +| ----------------------------- | ---------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------- | | `/` | Marketing home: hero, trending hackathon list, winners, carousel, features | `(public)/+page.server.ts` — `publicHackathonClient.list({ visibilityFilter: VISIBILITY_PUBLIC })` + `locals.session` | Public, signed in or not. A logged-in caller carrying a legacy `?returnTo=` is forwarded to it | -| `/hackathon/[id]` | Public hackathon landing page; "News & Pages" section from backend pages | `(public)/hackathon/[id]/+page.server.ts` — `publicPageClient.list({ hackathonId })`, failures swallowed to `[]` | Anonymous only in practice: any signed-in visitor is 302'd to `/my/hackathon/[id]/overview` | -| `/signin` | Sign-in interstitial (GET) + the Auth.js `signIn` form action (POST) | `(public)/signin/+page.server.ts` — resolves `?returnTo=` to a validated destination; forwards a signed-in caller straight to it | Public (matches `PUBLIC_ROUTE_PATTERNS`) — it is where the guards SEND anonymous visitors | -| `/signout` | POST-only form action wrapping Auth.js `signOut` | none | Public | -| `/auth/*` | Auth.js endpoints (signin, callback, csrf, session), mounted by `authHandle` | n/a | Public | -| `/dashboard` | "Your hackathons" / "Other hackathons" + notification sidebar | `(app)/dashboard/+page.server.ts` — `Promise.all([hackathon.list({visibilityFilter: PUBLIC}), hackathon.list({participantId})])` | Signed in | -| `/manage/users` | Platform user table (name, Keycloak ID, created) | `(app)/manage/users/+page.server.ts` — `user.list({})` | Signed in **and** global `Admin` on the backend (see gaps below) | -| `/my/hackathon/[id]` (layout) | Member shell: sub-nav, compact hero, phase timeline | `(app)/my/hackathon/[id]/+layout.server.ts` — `hackathon.get({ hackathonId })`, derives `myMembership` from `members` | Confirmed participant, hackathon owner, or global admin (enforced by `HackathonService.Get`) | -| `…/overview` | Participation card, hackathon description, proposal preview | Layout data only; cards are still hard-coded placeholders | as layout | -| `…/participants` | Searchable participant list | none — a hard-coded demo array in the component | as layout | -| `…/proposals` | Project proposals with status label | `parent().hackathon.projects`, mapped to `{id,title,description,status}` | as layout | -| `…/teams` | Team cards with members and project title | `team.list({ hackathonId })` + project titles from `parent()` | as layout | -| `…/submissions` | Submission table (team, project, status, result) | `team.list` then `Promise.all(teams.map(t => team.listSubmissions({teamId: t.id})))` | as layout | -| `…/timeline` | Phases sorted by `startsAt` | `parent().hackathon.phases` | as layout | -| `…/webinars`, `…/photos` | Placeholders (`HackathonUnderConstruction`) | none | as layout | +| `/hackathon/[id]` | Public hackathon landing page; "News & Pages" section from backend pages | `(public)/hackathon/[id]/+page.server.ts` — `publicPageClient.list({ hackathonId })`, failures swallowed to `[]` | Anonymous only in practice: any signed-in visitor is 302'd to `/my/hackathon/[id]/overview` | +| `/signin` | Sign-in interstitial (GET) + the Auth.js `signIn` form action (POST) | `(public)/signin/+page.server.ts` — resolves `?returnTo=` to a validated destination; forwards a signed-in caller straight to it | Public (matches `PUBLIC_ROUTE_PATTERNS`) — it is where the guards SEND anonymous visitors | +| `/signout` | POST-only form action wrapping Auth.js `signOut` | none | Public | +| `/auth/*` | Auth.js endpoints (signin, callback, csrf, session), mounted by `authHandle` | n/a | Public | +| `/dashboard` | "Your hackathons" / "Other hackathons" + notification sidebar | `(app)/dashboard/+page.server.ts` — `Promise.all([hackathon.list({visibilityFilter: PUBLIC}), hackathon.list({participantId})])` | Signed in | +| `/manage/users` | Platform user table (name, Keycloak ID, created) | `(app)/manage/users/+page.server.ts` — `user.list({})` | Signed in **and** global `Admin` on the backend (see gaps below) | +| `/my/hackathon/[id]` (layout) | Member shell: sub-nav, compact hero, phase timeline | `(app)/my/hackathon/[id]/+layout.server.ts` — `hackathon.get({ hackathonId })`, derives `myMembership` from `members` | Confirmed participant, hackathon owner, or global admin (enforced by `HackathonService.Get`) | +| `…/overview` | Participation card, hackathon description, proposal preview | Layout data only; cards are still hard-coded placeholders | as layout | +| `…/participants` | Searchable participant list | none — a hard-coded demo array in the component | as layout | +| `…/proposals` | Project proposals with status label | `parent().hackathon.projects`, mapped to `{id,title,description,status}` | as layout | +| `…/teams` | Team cards with members and project title | `team.list({ hackathonId })` + project titles from `parent()` | as layout | +| `…/submissions` | Submission table (team, project, status, result) | `team.list` then `Promise.all(teams.map(t => team.listSubmissions({teamId: t.id})))` | as layout | +| `…/timeline` | Phases sorted by `startsAt` | `parent().hackathon.phases` | as layout | +| `…/webinars`, `…/photos` | Placeholders (`HackathonUnderConstruction`) | none | as layout | `src/routes/+layout.server.ts` returns `{ session: locals.session }` for every route, which is what `NavBar` uses to decide between "Log in" and the avatar @@ -56,7 +56,6 @@ button. 1. **Where the flow starts.** Two entry points, and they agree on the destination because they call the same helper: - - **The "Log in" button** — `NavBar.svelte` calls the client helper `signIn('keycloak', { callbackUrl: loginReturn })` from `@auth/sveltekit/client`, which POSTs to Auth.js at @@ -143,12 +142,12 @@ Two different identities are in play, and they are not interchangeable: `src/hooks.server.ts` composes five handles with `sequence()`, run in this order on every request: -| # | Handle | Responsibility | -| --- | -------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| 1 | `setupHandle` | Lazily builds the `ConfigLoader` singleton (config dir from `--config-dir`), sets up the pino logger, and puts `AppConfig` on `locals.config`. | -| 2 | `loggerHandle` | Per-request child logger with `requestId`/`method`/`path` on `locals.logger`; logs start/finish with duration, at `warn` when status ≥ 400. | -| 3 | `authHandle` | The Auth.js handle re-exported from `src/auth.ts`. Mounts `/auth/*` and makes `locals.auth()` available. | -| 4 | `sessionSetupHandle` | One `locals.auth()` call per request → sanitized `locals.session`; then, for protected routes only: guard, build `locals.grpc`, and resolve `locals.platformUser`. | +| # | Handle | Responsibility | +| --- | -------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| 1 | `setupHandle` | Lazily builds the `ConfigLoader` singleton (config dir from `--config-dir`), sets up the pino logger, and puts `AppConfig` on `locals.config`. | +| 2 | `loggerHandle` | Per-request child logger with `requestId`/`method`/`path` on `locals.logger`; logs start/finish with duration, at `warn` when status ≥ 400. | +| 3 | `authHandle` | The Auth.js handle re-exported from `src/auth.ts`. Mounts `/auth/*` and makes `locals.auth()` available. | +| 4 | `sessionSetupHandle` | One `locals.auth()` call per request → sanitized `locals.session`; then, for protected routes only: guard, build `locals.grpc`, and resolve `locals.platformUser`. | | 5 | `redirectHandle` | A logged-in user landing on `/?returnTo=X` is forwarded (303) to the validated `X`. Nothing produces that shape any more — the guards park on `/signin` — so this is a backstop for pasted or bookmarked legacy links. | Outside the chain: `init` runs once at server startup and pings the backend with diff --git a/docs/frontend/session-replay.md b/docs/frontend/session-replay.md index 19027985..12d62f2d 100644 --- a/docs/frontend/session-replay.md +++ b/docs/frontend/session-replay.md @@ -31,14 +31,14 @@ them is an owner's decision to make explicitly, not a default to drift into. ## On whose say-so -| | | -| ----------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| **Who decides** | The person using the browser. Not an organiser, not an admin — there is no setting anywhere that turns recording on for somebody else. | +| | | +| ----------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| **Who decides** | The person using the browser. Not an organiser, not an admin — there is no setting anywhere that turns recording on for somebody else. | | **When they are asked** | On the first page load of a deployment that has replay configured. A banner appears at the bottom of every page until it is answered — pinned to the viewport so it is seen, and taking up its own space at the end of the document so it never covers a control (it was `fixed` once, and the bottom of every page was unclickable for exactly the people who had not answered yet). | -| **What happens before they answer** | Nothing is recorded. The server does not send the browser an ingest endpoint or a project key at all, so there is nothing for the page to start — this is a property of what was transmitted, not of what a script decided. | -| **How to change it** | `/account` → **Session recording**. Withdrawing takes effect on the same click: the response is a redirect, so the recording page is replaced by one that was never given the tracker's configuration. | -| **How long a "yes" lasts** | 180 days, then the banner returns. | -| **Do Not Track** | A browser sending DNT (or Global Privacy Control) is never recorded, even if it has said yes. The tracker SDK is not even downloaded. | +| **What happens before they answer** | Nothing is recorded. The server does not send the browser an ingest endpoint or a project key at all, so there is nothing for the page to start — this is a property of what was transmitted, not of what a script decided. | +| **How to change it** | `/account` → **Session recording**. Withdrawing takes effect on the same click: the response is a redirect, so the recording page is replaced by one that was never given the tracker's configuration. | +| **How long a "yes" lasts** | 180 days, then the banner returns. | +| **Do Not Track** | A browser sending DNT (or Global Privacy Control) is never recorded, even if it has said yes. The tracker SDK is not even downloaded. | The decision is stored in a first-party, `httpOnly` cookie (`hackagon_replay_consent`) and **nothing about it reaches the backend**. That @@ -180,7 +180,7 @@ An absent or incomplete block parses to `{enabled: false}`, so no deployment starts recording because somebody forgot a flag. **That overlay has a second writer** — the Cloudflare tunnel's `auth-wire.sh` -owns `oidc` in the same file — so `--restore` removes the `replay` *block*, not +owns `oidc` in the same file — so `--restore` removes the `replay` _block_, not the file. Deleting the file would drop the tunnel's issuer, and a tunnel with no issuer keeps serving pages: only signing in breaks, which nobody notices until they try. `.claude/skills/lib/config-overlay.sh` does the per-key edit. @@ -193,19 +193,19 @@ measures **bytes on the wire**, because "the component checked a variable" is a statement about our code and "nothing left the browser" is a statement about the visitor. -| Spec | Proves | -| ----------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `consent.spec.ts` | A fresh browser records **zero bytes** and is not even sent the project key; clicking _Allow_ in the real banner starts it — the two halves in one run, so the zero cannot be a broken measurement. Withdrawing at `/account` stops it. A DNT browser with consent granted records nothing and never fetches the SDK. The cookie is unreachable from page scripts. | -| `masking.spec.ts` | A sentinel typed into the registration form is absent from the captured bytes — preceded by an **unmasked control run** that finds its own sentinel, because a zero-hit grep otherwise reads identically to "nothing was recorded". Also: the signed-in user's display name is absent (the attribute hole), and no page path is present (the URL decision), each with its own positive control. | +| Spec | Proves | +| ------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `consent.spec.ts` | A fresh browser records **zero bytes** and is not even sent the project key; clicking _Allow_ in the real banner starts it — the two halves in one run, so the zero cannot be a broken measurement. Withdrawing at `/account` stops it. A DNT browser with consent granted records nothing and never fetches the SDK. The cookie is unreachable from page scripts. | +| `masking.spec.ts` | A sentinel typed into the registration form is absent from the captured bytes — preceded by an **unmasked control run** that finds its own sentinel, because a zero-hit grep otherwise reads identically to "nothing was recorded". Also: the signed-in user's display name is absent (the attribute hole), and no page path is present (the URL decision), each with its own positive control. | | `playable.spec.ts` | That a recording can be **watched**. Everything above measures the browser's side of the wire and cannot see the far end at all: a batch OpenReplay's reader rejects is counted, answered `200`, and then discarded whole, so a completely dead pipeline reads exactly like a healthy one from here. This records a real session, reads its id off the tracker's own start response, and waits for the mob file to exist and come back through the two hops the player uses. It records that session on `/invite/` and greps the token out of **both** the wire and the decompressed stored file, so the two properties — playable, and no credential in it — are asserted on the same session. | The last one is why the others are not enough on their own. Recordings were -unplayable for three days while every spec above stayed green, because all -three faults were on OpenReplay's side of the ingest endpoint: its `sink` -container was not running (a service that is absent looks nothing like a -service that is unhealthy), its object store answered `NoSuchBucket` to every -request — PUT included — for a bucket sitting on its own disk until it was -restarted, and `ender` logged `batch meta not at the start of batch` once per -session. That last one is upstream and survives the fix; it costs a batch, not -the recording. `openreplay-stack/scripts/doctor.sh` now names a compose service -with no container, which is the only one of the three that a preflight can see. +unplayable for three days while every spec above stayed green, because all three +faults were on OpenReplay's side of the ingest endpoint: its `sink` container +was not running (a service that is absent looks nothing like a service that is +unhealthy), its object store answered `NoSuchBucket` to every request — PUT +included — for a bucket sitting on its own disk until it was restarted, and +`ender` logged `batch meta not at the start of batch` once per session. That +last one is upstream and survives the fix; it costs a batch, not the recording. +`openreplay-stack/scripts/doctor.sh` now names a compose service with no +container, which is the only one of the three that a preflight can see. diff --git a/docs/storage.md b/docs/storage.md index d88889c6..b1f67cda 100644 --- a/docs/storage.md +++ b/docs/storage.md @@ -137,9 +137,9 @@ Two shapes on the server side are worth keeping: Anything that stores a picture must accept the root-relative path back. `UserService.EditProfile` validated `avatar_url` as http/https only, so the -upload worked and *saving the result* answered `InvalidArgument`; `checkImageRef` -now takes an absolute link or a `/objects/…` path, and still refuses -`javascript:`, `data:`, `//host` and `/\host`. +upload worked and _saving the result_ answered `InvalidArgument`; +`checkImageRef` now takes an absolute link or a `/objects/…` path, and still +refuses `javascript:`, `data:`, `//host` and `/\host`. **Where upload is offered:** the event logo and every markdown editor inside an event (pages, phases, tracks, the event description), the platform pages CMS, @@ -171,11 +171,11 @@ Admin role.** Three consequences, stated rather than left to be discovered: by another name — exactly what "keys, not URLs" avoids. - **Public from the moment it is uploaded, including for a draft page.** The object store's policy is per-prefix, not per-row, so an image pasted into an - unpublished About page is readable at its `/objects/…` path before the page is. - This is already true of a hackathon whose page is hidden; the protection is - that the path contains a v4 UUID nobody can enumerate, so the exposure is "the - link leaks if it is shared", not "the draft is browsable". Anything that must - stay unreadable until publication needs a private kind and + unpublished About page is readable at its `/objects/…` path before the page + is. This is already true of a hackathon whose page is hidden; the protection + is that the path contains a v4 UUID nobody can enumerate, so the exposure is + "the link leaks if it is shared", not "the draft is browsable". Anything that + must stay unreadable until publication needs a private kind and `CreateDownloadUrl`, not this one. The ceiling is 15 MiB and the allowlist is `imageTypes` — deliberately identical @@ -198,11 +198,11 @@ authorization rule from it, and a client-supplied prefix is never trusted. prefix.** Not a parallel rule that has to be kept in agreement with the upload table — the identical check `authorizeUpload` makes. -| scope | prefixes | who | -| ----------------- | --------------------------- | ---------------------- | -| `HACKATHON_MEDIA` | `hackathons//` | hackathon `write` | -| `SITE_MEDIA` | `site/media/` | global `Admin` | -| `ALL_MEDIA` | `hackathons/`, `site/media/`| global `Admin` | +| scope | prefixes | who | +| ----------------- | ---------------------------- | ----------------- | +| `HACKATHON_MEDIA` | `hackathons//` | hackathon `write` | +| `SITE_MEDIA` | `site/media/` | global `Admin` | +| `ALL_MEDIA` | `hackathons/`, `site/media/` | global `Admin` | `HACKATHON_MEDIA` covers the event's `logo/` and `media/` folders together, because someone picking a picture wants everything the event has and both take @@ -217,11 +217,12 @@ rather than a prefix string.** easy to do with someone's photograph. A global admin fixing one profile still reaches it from that profile. So the account page's picker has no browse half at all, and the absence is asserted (with a positive control on a surface that - DOES have one, or "no gallery tab" would pass on a dialog that never rendered). -- **`teams//submissions/`** — private by bucket policy. Those objects have no - stable readable path, so a picker row for one would be a broken image; and the - KEYS alone would say which teams turned work in and how much, to anyone allowed - to list any scope. + DOES have one, or "no gallery tab" would pass on a dialog that never + rendered). +- **`teams//submissions/`** — private by bucket policy. Those objects have + no stable readable path, so a picker row for one would be a broken image; and + the KEYS alone would say which teams turned work in and how much, to anyone + allowed to list any scope. **The answer is bounded, and says when it is.** Keys end in a v4 uuid, so the store's lexicographic order is noise — the listing is re-sorted newest-first, @@ -232,11 +233,11 @@ how someone concludes their upload failed. The cursor is therefore an offset into the sorted order, not the store's continuation token — that token would resume a different sequence than the caller was reading. -Only objects whose extension is on `imageTypes` come back (derived from that map, -not restated). Every listable prefix is an imagery prefix, so this only filters -strays — `rustfs-init.sh` leaves a `_selftest/probe.txt` under each public prefix -while it proves the bucket policy — but a gallery is a grid of `` and a row -that can only render broken is worse than no row. +Only objects whose extension is on `imageTypes` come back (derived from that +map, not restated). Every listable prefix is an imagery prefix, so this only +filters strays — `rustfs-init.sh` leaves a `_selftest/probe.txt` under each +public prefix while it proves the bucket policy — but a gallery is a grid of +`` and a row that can only render broken is worse than no row. **Authorization is answered BEFORE "is storage configured".** Otherwise an anonymous caller learns something about the deployment in place of the @@ -245,13 +246,13 @@ server with no store (which is what the unit-test config is). ### One picker, two ways in -`components/forms/ImagePickerDialog.svelte` replaced the bare `` behind every uploader. A native `` opened with +`components/forms/ImagePickerDialog.svelte` replaced the bare +`` behind every uploader. A native `` opened with `showModal()`, so the platform owns the focus trap, the Esc key and the inertness of the page behind it. Two halves: **upload**, with a visible drop target that is a region and not the whole page, and **choose from what is -already uploaded**, rendered only when the caller passes a `browseEndpoint` -(a tab that can only ever be empty is worse than one tab). +already uploaded**, rendered only when the caller passes a `browseEndpoint` (a +tab that can only ever be empty is worse than one tab). One trap it introduced, worth not re-learning: **the dialog's heading is its accessible name, and a closed `` is `display:none` but still in the @@ -273,11 +274,11 @@ reachable only by typing its URL and that is not happening twice. It says on screen that avatars and submission files are deliberately absent, because a gallery that quietly omitted them would read as a complete inventory. -**There is deliberately no single-object delete, and that follows from "keys, not -URLs".** An image can be referenced from any page's markdown, any event's `logo` -column and any prize row, and NOTHING records which. Deleting one would break -those references silently — the row keeps its path and the page renders a hole. -A safe delete needs a reference scan across every markdown field in the +**There is deliberately no single-object delete, and that follows from "keys, +not URLs".** An image can be referenced from any page's markdown, any event's +`logo` column and any prize row, and NOTHING records which. Deleting one would +break those references silently — the row keeps its path and the page renders a +hole. A safe delete needs a reference scan across every markdown field in the database, which is a manifest by another name: exactly what this design avoids. The deletion that exists is still the one whose scope is an entity nobody points at any more — `HackathonService.Delete` and `UserService.DeleteAccount` purging @@ -289,10 +290,9 @@ by prefix. by the PROPOSER, who is a plain Member — and `HACKATHON_MEDIA` authorizes on hackathon `Write`, which Members do not have. Offering upload there means a project-scoped kind, not a new form. -- **Submission attachments.** `UPLOAD_KIND_SUBMISSION_ATTACHMENT` is - authorized and keyed, but `owner_id` is the SUBMISSION, so a file can only be - attached to a submission that already exists — today's form fixes the - structured answers at create. It also needs somewhere to keep the key - (`Submission.form` is `map[string]string`, so a `file` field could hold one) - and a link that mints a `CreateDownloadUrl`, which is still the one RPC with - no caller. +- **Submission attachments.** `UPLOAD_KIND_SUBMISSION_ATTACHMENT` is authorized + and keyed, but `owner_id` is the SUBMISSION, so a file can only be attached to + a submission that already exists — today's form fixes the structured answers + at create. It also needs somewhere to keep the key (`Submission.form` is + `map[string]string`, so a `file` field could hold one) and a link that mints a + `CreateDownloadUrl`, which is still the one RPC with no caller. diff --git a/docs/testing.md b/docs/testing.md index c99cd9db..12795565 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -70,20 +70,20 @@ rather than spare capacity — `CreateSubmission`/`EditSubmission`/ `api/proto/**/*_service.proto` does. The denominator moves whenever a service gains a method (107 at `833a7388`), so the list below is the part worth keeping current, not the ratio. Reproduce per method with -`grep -rn '\.(' components/frontend/src --include='*.ts' ---include='*.svelte'`, ignoring hits under `src/lib/server/grpc/generated/`. +`grep -rn '\.(' components/frontend/src --include='*.ts' --include='*.svelte'`, +ignoring hits under `src/lib/server/grpc/generated/`. The seven, and why each is deliberate: -| RPC | Why nothing calls it | -| --- | --- | -| `HackathonService.SetCurrentPhase` | aliases the `AdvancePhase` the timeline calls | -| `VoteService.GetVoteCategory` | covered by the list endpoint already driving the UI | -| `VoteService.ListVotes` | same | -| `TeamService.GetSubmission` | same | -| `VoteService.SuggestResults` | computes a tally the UI records by hand with `CreateVoteResult` | -| `StorageService.CreateDownloadUrl` | waits for something private to serve | -| `ProjectService.RemovePreference` | there is no un-prefer control to call it | +| RPC | Why nothing calls it | +| ---------------------------------- | --------------------------------------------------------------- | +| `HackathonService.SetCurrentPhase` | aliases the `AdvancePhase` the timeline calls | +| `VoteService.GetVoteCategory` | covered by the list endpoint already driving the UI | +| `VoteService.ListVotes` | same | +| `TeamService.GetSubmission` | same | +| `VoteService.SuggestResults` | computes a tally the UI records by hand with `CreateVoteResult` | +| `StorageService.CreateDownloadUrl` | waits for something private to serve | +| `ProjectService.RemovePreference` | there is no un-prefer control to call it | `PageService.SetOrder` left this list on 2026-08-12: drag-and-drop (and the keyboard pick-up beside it) on Manage Pages sends the whole sequence in one diff --git a/tools/nix/hackagon/pkgs/treefmt.nix b/tools/nix/hackagon/pkgs/treefmt.nix index 5c131568..3dbfeae7 100644 --- a/tools/nix/hackagon/pkgs/treefmt.nix +++ b/tools/nix/hackagon/pkgs/treefmt.nix @@ -13,6 +13,10 @@ _: { "components/backend/internal/proto/**" "components/backend/ent/**" "components/frontend/src/lib/server/grpc/generated/**" + # Self-contained agent tooling (skills + their vendored deps). It carries + # its own conventions — 2-space shell, hand-wrapped markdown, a pnpm-owned + # lockfile — and reformatting it here only makes the two styles fight. + ".claude/**" ]; };