Cross-cutting platform concerns: reliability, accessibility, Swedish language, security hardening, analytics, feedback, PWA, admin token, rate limiting.
Part of the requirements index. Section IDs (02-§N.M) are stable and cited from code; they do not encode the file path.
Participants must be able to trust that:
The schedule is a shared coordination tool during the camp week.
The site must meet WCAG AA as a baseline:
4.5:1 for body text. alt text. The site is written entirely in Swedish.
This includes: all page content, navigation labels, form labels, error messages, confirmation messages, and accessibility text (alt, aria-label, etc.).
GitHub CodeQL static analysis reports six alerts across workflows, server code, and test files. All must be resolved so the repository reaches zero open CodeQL alerts.
ci.yml must declare an explicit permissions block with minimal
scope. The workflow only reads repository contents, so contents: read
is sufficient. deploy-reusable.yml must declare an explicit permissions block with
minimal scope. The workflow reads repository contents and deploys the
main site (sbsommar.se) to external shared hosting via FTP/SSH, so
contents: read is sufficient and no write-scoped token such as
pages: write is needed. The GitHub Pages documentation site (§97) is
served directly by GitHub from the docs/ folder and uses its own
built-in deployment, unaffected by this workflow’s permissions. slugify() function in source/api/github.js must not contain
regex patterns that CodeQL flags as polynomial-time backtracking risks.
The current /^-+|-+$/g alternation must be replaced with an equivalent
that avoids backtracking. tests/render.test.js and tests/github.test.js that
use bare includes('https://…') must be changed to match a surrounding
context (e.g. includes('href="https://…"') or
includes('link: https://…')). gh api repos/{owner}/{repo}/code-scanning/alerts?state=open
must return fewer open alerts (ideally zero). The event security scan (injection patterns, link protocol, length limits) currently runs only in CI as a post-commit check. This means malicious payloads can reach the git repository before being caught. Moving these checks into the API request validation layer rejects dangerous input at submission time — before any data is written to git.
This change does not remove the existing CI security scan; it adds an earlier, identical check. The CI scan will be removed in a future pipeline optimisation.
validateEventRequest / validateEditRequest) must
scan the free-text fields title, location, responsible, and description
for injection patterns before accepting the request. <script, javascript:,
event handler attributes (on*=), <iframe, <object, <embed,
data:text/html. link field is a non-empty string, the API must verify that it starts
with http:// or https:// (case-insensitive). Any other protocol or a missing
protocol must be rejected. source/api/validate.js and api/src/Validate.php. The site needs usage analytics to answer questions about traffic, visitor behaviour, and content effectiveness. Analytics must respect the static-site, no-backend, minimal-JS constraints.
/live.html) must also include the analytics
script, even though it has no shared layout. GOATCOUNTER_SITE_CODE) so it is not hardcoded in source. GoatCounter provides these automatically — no custom code required:
The following interactions must be tracked as GoatCounter custom events:
source/data/qr-codes.yaml) must list all
QR code identifiers. id and a description
field. ?ref=qr-affisch-01) so GoatCounter records it as a distinct
referrer. data-goatcounter-click attributes
where possible, minimising inline JavaScript. GOATCOUNTER_SITE_CODE to the build step so that rebuilt pages retain
the analytics script. A discreet feedback button in the navigation bar lets any visitor submit feedback that is automatically created as a GitHub Issue. The feature uses the same API patterns as add-event (Node.js + PHP dual implementation) and the same GitHub API primitives.
position: fixed; top: var(--space-xs); right: var(--space-sm));
on desktop it is positioned near the content column
edge. feedback:bug, feedback:suggestion, feedback:other.POST /feedback endpoint (Node.js) and POST /api/feedback
endpoint (PHP) must accept the feedback form data. [Feedback] {category}: {title}feedback:bug, feedback:suggestion, or feedback:other
depending on the selected category.{ success: true, issueUrl: "<URL>" } on success
so the client can link to the created issue. BUILD_ENV is neither production nor
qa), the API must not create a GitHub Issue. It must log the request
and return { success: true, issueUrl: "" } (empty
string). BUILD_ENV is qa), the API must create a
GitHub Issue just as in production, so that testers can give
feedback. 200 OK with { success: true } but does
not create an issue. role="dialog", aria-modal="true", and a
focus trap. aria-label="Ge feedback". <label> elements and
aria-required where applicable. githubRequest() / githubRequest()
primitives for the GitHub Issues API call. .form-error-msg: terracotta left border, light background). The site must be installable as a Progressive Web App so participants can add it to their home screen and use it in a standalone app-like experience. A service worker provides offline caching so that core pages and recent event data remain accessible without network connectivity.
app.webmanifest file at the site root. name to "SB Sommar" and short_name to
"SB Sommar". display to "standalone". start_url to "/". theme_color and background_color to values from
the design palette (07-DESIGN.md §2). icons array must include a "purpose": "any" entry. icons array must include at least one entry with
"purpose": "maskable" so the icon renders correctly in adaptive icon
contexts (Android home screen, etc.). <link rel="manifest" href="app.webmanifest">
in <head>. <meta name="theme-color"> with the same value
as the manifest theme_color. <meta name="mobile-web-app-capable"
content="yes">. <meta name="apple-mobile-web-app-status-bar-style"
content="default">. <link rel="apple-touch-icon" href="images/sbsommar-icon-192.png">. sw.js file at the site root. sw.js only when the browser supports service workers. install, the service worker pre-caches all site assets (HTML
pages, CSS, JS, images, events.json) so the full site is available
offline from the first launch. The pre-cache list is generated at
build time (see §92). fetch, the service worker must serve cached responses for navigation
and static-asset requests when the network is unavailable
(network-first with cache fallback for HTML, cache-first for CSS/JS/images). activate, the service worker must delete caches whose name does not
match the current version. /api/ paths) or
form-submission endpoints (/add-event, /edit-event,
/delete-event, /verify-admin). Form pages (lagg-till.html,
redigera.html) are pre-cached and served offline; an offline guard
(§92) disables submission when there is no network. http: or https:
schemes; all other schemes (e.g. chrome-extension:) must be
ignored. events.json using a
network-first strategy with cache fallback so that schedule data is
available offline. /offline.html) that tells the user they are offline
and lists which pages may be available from cache. sbsommar-icon-192.png (192×192) and sbsommar-icon-512.png (512×512) must
exist in the images directory. public/images/ alongside other image
assets. offline.html page at the site root. lagg-till.html). offline.html on install. images/sbsommar-icon-192.png) as
the browser favicon (<link rel="icon">). When an API call fails, the user must receive an error message that helps them understand whether the problem is actionable or not.
/add-event,
/add-events, and /edit-event. lagg-till.js) already displays
json.error — no client changes are needed. Chrome requires additional manifest fields to show a richer install prompt. Missing fields degrade the install experience or block the install prompt on newer browser versions.
id to "/". description to "Information och aktiviteter för SB Sommar-lägret". screenshots array with at least two
entries. form_factor set to "wide" with size
"1280x720" and type "image/png". form_factor set to "narrow" with size
"750x1334" and type "image/png". src paths must point to files in the images/ directory
and must be cache-busted by the existing build pipeline. Many users — especially on iPhone — do not notice that the site can be installed as an app. A discreet install button in the top bar helps them discover this without being intrusive.
beforeinstallprompt event
(Chrome/Edge on Android and desktop), the button must capture the
event and trigger the native install prompt when clicked. appinstalled
event), the button must be hidden. beforeinstallprompt), the button must show a tooltip or small
overlay with the instruction: “Tryck på Dela-ikonen och välj
‘Lägg till på hemskärmen’”. display-mode: standalone), the button must not be rendered at
all. beforeinstallprompt nor iOS Safari is
detected, the button must not be rendered. pwa-install.js). docs/07-DESIGN.md §7. The site uses a cookie-based ownership model where each participant can only edit events they created. During camp, one or two designated administrators need the ability to edit or delete any event — for example to correct mistakes, remove duplicates, or update events on behalf of participants who lost their cookie.
This requirement covers the token infrastructure: storage, activation, verification, and a visual status indicator. The edit/delete authorisation behaviour that uses this token is defined in §7, §18, and §89.
ADMIN_TOKENS. namn_uuid_epoch, where namn is a
lowercase identifier for the admin, uuid is a v4 UUID, and epoch
is a Unix timestamp (seconds) representing the token’s expiry
date. ADMIN_TOKENS. npm run admin:create generates a token with
60 days validity and prints instructions for where to store
it. ADMIN_TOKENS is unset or empty, all admin functionality is
disabled — the site behaves exactly as before. POST /verify-admin. { "token": "<string>" }. ADMIN_TOKENS, the response is
200 { "valid": true }. 403 { "valid": false }. /admin.html must allow an administrator to enter their
token. POST /verify-admin with the entered
token. valid: true:
localStorage under
the key sb_admin. The stored value is a JSON object:
{ "token": "<string>", "activated": <unix-ms> }.valid: false:
activated
timestamp. localStorage. localStorage: nothing is displayed. /admin.html. title attribute explaining its meaning in
Swedish (e.g. “Admin aktiv” / “Admin utgången”). docs/07-DESIGN.md §7. localStorage and sent explicitly in API request bodies or
headers. The PWA pre-caches every asset the build produces so the entire site works offline from the first launch after installation. Form pages and the feedback modal detect offline status and clearly communicate that submission requires an internet connection.
public/ after all post-processing
(cache-busting) is complete and generates a pre-cache URL
list. .htaccess, robots.txt, sw.js, version.json,
.ics files, .rss files, and per-event detail pages
(schema/*/index.html). sw.js by replacing a
placeholder token (/* __PRE_CACHE_URLS__ */). /images/hero.jpg,
/style.css). sw.js contains no remaining placeholder
tokens. PRE_CACHE_URLS array in sw.js is populated by the build-time
injection. There is no hand-maintained list. lagg-till.html and redigera.html. NO_CACHE_PATTERNS list contains only API and submission
endpoints: /add-event, /edit-event, /delete-event,
/verify-admin, /api/. It does not contain any .html
pages. cacheFirstThenNetwork strategy for static assets matches cache
entries as defined in §96.5. { ignoreSearch: true } as defined in §96.6. offline-guard.js detects offline status using
navigator.onLine and the online/offline events. lagg-till.html or redigera.html, an
alert banner appears at the top of the form area with the message:
“Du är offline. Formuläret kräver internetanslutning för att
skicka.” disabled attribute set). .form-error-msg styling from the design
system. lagg-till.html and
redigera.html. docs/07-DESIGN.md §7. offline.html) continues to function as a
last resort when a page is not in the cache. The API exposes four POST endpoints that either perform authorization
(/verify-admin) or accept ownership-gated writes (/edit-event,
/delete-event), plus the user-feedback channel (/feedback). CodeQL
flagged three of these as missing rate limiting (alerts #40, #41, #42,
rule js/missing-rate-limiting), which allows an attacker to brute-force
admin tokens or hammer the GitHub write path. The feedback endpoint
already enforces a per-IP rate limit through an in-memory / file-based
counter; this requirement extends the same protection to the remaining
authorization endpoints and consolidates the mechanism into a single
reusable implementation per runtime.
/verify-admin rejects more than 5 requests per IP per hour
with HTTP 429 and the Swedish error message “För många
förfrågningar. Försök igen senare.” /edit-event rejects more than 30 requests per IP per hour
with HTTP 429 and the same Swedish error message. /delete-event rejects more than 30 requests per IP per hour
with HTTP 429 and the same Swedish error message. /feedback continues to reject more than 5 requests per IP per
hour with HTTP 429 (no behavior change; see §73.14). req.ip in
Node — that is, Express’s trust-proxy-aware resolver (see §93.15) —
and from HTTP_X_FORWARDED_FOR with REMOTE_ADDR fallback in
PHP. app.js) uses the express-rate-limit middleware. Each guarded
route has its own rateLimit({ windowMs, limit, ... }) instance
configured with the per-endpoint limits in §93.2. Instances are named
so different endpoints do not share quotas. rateLimit() instance with
{ windowMs: 3_600_000, limit: 5 }, preserving the §73.14
behavior. api/index.php) calls SBSommar\RateLimit::isLimited($ip,
$namespace, $limit, $windowSeconds) from
api/src/RateLimit.php. Counter state lives in a single JSON file
under sys_get_temp_dir() with namespaced keys so endpoints do not
share quotas. Feedback::isRateLimited
no longer exists as a separate implementation; it delegates to
RateLimit::isLimited with the feedback namespace and
{ limit: 5, window: 3600 }. express-rate-limit’s
default in-memory store, which evicts expired entries automatically)
and in a local JSON file in PHP. Neither runtime coordinates across
processes. This is acceptable because each deployment uses a single
Node process or a single shared PHP host. express-rate-limit, used by app.js per §93.7. It is required so
that CodeQL’s js/missing-rate-limiting analysis can recognize the
counter, and so that the Node side gains standard Retry-After /
RateLimit-* response headers and automatic store cleanup — properties
the prior custom helper did not provide. app.js sets Express trust proxy to 'loopback'
so the middleware only honours X-Forwarded-For from trusted
loopback-connected reverse proxies. On non-loopback deployments the
trust boundary is the hosting environment’s proxy configuration,
consistent with the existing PHP feedback handler. CodeQL flagged four regex-related issues in the codebase:
js/polynomial-redos) in source/api/github.js at the
slugify() helper — the two-step .replace(/^-+/, '').replace(/-+$/, '')
pass depends on user-provided input and can backtrack polynomially on
--heavy strings.js/incomplete-sanitization) in
tests/scoped-headings.test.js — the ad-hoc escape
.replace(/\./g, '\\.').replace(/\s+/g, '\\s+') does not cover \,
*, +, ?, ^, $, {, }, (, ), |, [, ], so a
selector containing any of those characters would produce a malformed
pattern.Neither alert represents an active vulnerability — slug inputs come from authored camp data and the flagged test selectors are hardcoded — but both should be eliminated so the CodeQL queue stays actionable and future changes do not silently inherit the unsafe pattern.
slugify() in source/api/github.js strips leading and trailing -
characters in a single linear-time pass so its worst-case time on any
input is O(n). slugify(s) produces output identical to the previous implementation
for every input: lowercase, å/ä → a, ö → o, non-alphanumerics
collapsed to -, leading and trailing - removed, truncated to 48
characters. tests/helpers/regex-escape.js exports escapeRegExp(str) which
returns str with every regex metacharacter in the set
. * + ? ^ $ { } ( ) | [ ] \ prefixed by \, so the resulting
pattern matches only the literal input string. tests/scoped-headings.test.js uses escapeRegExp() at every site
where a container or heading value is interpolated into a
RegExp; the file contains no hand-rolled \./\s+ substitution for
regex construction. fixed on the next
scan after merge. Clients that had visited the site before a deploy could end up serving
a stale /style.css from the service worker’s Cache Storage even after
a new service worker activated. Two factors combined to cause this:
cache.addAll(PRE_CACHE_URLS) with default
fetch semantics, which respects the browser’s HTTP cache. When the
HTTP cache held a style.css copy from before the deploy (served
with Cache-Control: max-age=604800), that stale copy was pulled
directly into the new sb-sommar-v<N> cache.self.skipWaiting() or
self.clients.claim(), so a freshly installed worker stayed in the
waiting state until every existing client was closed. Users who kept
the site open (especially as an installed PWA) never saw the new
worker activate.The result was that CSS changes landed on the server but did not reach existing clients — the banners added in §94 rendered as unstyled inline links on devices that had visited the site within the last week.
This section defines the service-worker upgrade behaviour that removes the need for any user action (no “clear cache and data”) to recover from a stale cache.
sb-sommar-v6. install event handler calls self.skipWaiting() so that a new
worker moves straight from installed to activating without
waiting for all existing clients to close. install handler pre-caches every URL in PRE_CACHE_URLS using
new Request(url, { cache: 'reload' }), which bypasses the browser’s
HTTP cache and fetches each asset directly from the network, so a
stale HTTP-cache entry cannot be copied into the service-worker
cache. activate event handler deletes every cache whose name is not
equal to the current CACHE_NAME and then calls
self.clients.claim() so that the new worker immediately controls
every open tab without requiring a reload. cacheFirstThenNetwork strategy’s primary cache lookup
matches without ignoreSearch, so a request for
style.css?v=<newHash> does not satisfy from a cache entry keyed at
style.css?v=<oldHash> or style.css. When no exact match exists,
the request falls through to the network and the fresh response is
stored in the cache. A secondary ignoreSearch match is only
performed as an offline fallback when the network fetch itself
fails, so that pre-cached /style.css still serves when the user is
offline on a new hash. networkFirstThenCache and networkFirstWithOfflineFallback
strategies continue to use { ignoreSearch: true } when falling back
to the cache, so that a cache-busted HTML or events.json URL still
matches the previously stored entry during an offline fallback. PRE_CACHE_URLS list continues to contain
root-relative paths without the cache-busting query string
(e.g. /style.css, not /style.css?v=abc). ignoreSearch removed, stores fetched
style.css?v=<hash> responses as separate entries. The pre-cached
/style.css entry continues to serve as an offline fallback when the
hashed URL is not yet cached. sbsommar.se (or qa.sbsommar.se): the browser fetches sw.js
bypassing its HTTP cache, installs the new worker, applies
skipWaiting, and claims the open tab. sb-sommar-v5 cache and rebuilds
sb-sommar-v6 from fresh network responses. style.css with the §94 registration-banner rules. navigator.onLine is false, and offline.html remains the last-resort
fallback for navigation requests that are not in the cache.