Skip to content

Security: SpaceWorks-Labs/SpaceWorks-Events

docs/SECURITY.md

Security model

Documentation index

This page documents implemented controls and security boundaries. It is not a substitute for an external penetration test, infrastructure review, or jurisdiction-specific privacy assessment.

Authentication

  • Passwords are bcrypt hashes and are never stored or logged in plaintext. New passwords require at least 12 characters and must fit bcrypt’s 72-byte UTF-8 limit; existing passwords remain usable for sign-in.
  • Auth.js sessions include a user sessionVersion; incrementing it invalidates issued sessions.
  • Each web sign-in records how it happened (authMethod: credentials or google) and a random 128-bit sign-in id (sid), both fixed for the session's life. The sid stays inside the encrypted session token: it is not on the session object, so /api/auth/session never returns it, and the server reads it only through lib/sessionSid.ts, which decodes the session cookie (joining Auth.js's chunks) with the same secret and cookie name and accepts it only for the user auth() validated. Sessions issued before sign-in ids existed are retired like revoked ones, so after that release every web user signs in once more, staff confirm their authenticator again, and the super admin uses the password.
  • Password reset uses expiring verification tokens and the transactional outbox.
  • Password accounts require a one-time, expiring inbox-verification link before sign-in or event creation. Tokens are stored only as hashes and consumed once.
  • Sign-up, password reset and password change refuse passwords found in Have I Been Pwned. The check uses k-anonymity: only the first five hex characters of the password's SHA-1 leave the server, with Add-Padding. Any failure or a 3-second timeout counts as not breached, so the check never blocks a sign-up. Self-hosted installs need outbound HTTPS to api.pwnedpasswords.com; PWNED_PASSWORDS_CHECK=false turns it off (air-gapped installs, tests).
  • Users can enable TOTP MFA and receive one-use recovery codes. TOTP use is claimed atomically in PostgreSQL, so a code cannot be replayed across replicas or between sign-in and the authenticator check on /verify-mfa. Wait for the next authenticator code when completing consecutive challenges. MFA enrollment is single-use, bound to the account session version, and cannot replace an active authenticator.
  • Organisation staff must use an authenticator app. Staff means anyone with an organisation membership (any role) or an event or series co-organiser grant; attendees and the super admin are exempt. Staff without an authenticator are sent to /account/security?mfa=required. Once one is set up, each session needs an authenticator check: given at the password sign-in or, after Google sign-in, on /verify-mfa. That page sets a signed, HttpOnly proof cookie (swe.mfa-proof.v1) for 12 hours, bound to the user, the session version and the session's sign-in id, so signing out everywhere or a password reset ends it, and a new sign-in in the same browser needs a new check. A session without a sign-in id gets no proof. Until then staff checks in actions (getMembership, canAccessEvent, canViewEventDashboard) return no access. STAFF_MFA_REQUIRED=false turns this off (local development and tests only).
  • A request that is not a browser with a cookie session runs as an explicit actor (lib/requestActor.ts, an AsyncLocalStorage on a Symbol.for global). A mobile app session is judged by its own mfaVerifiedAt, which counts for the same 12 hours as the proof cookie; a cookie session on the same request is ignored. A staff check with neither an actor nor a cookie session fails closed (verify or enrol), so a bearer route that forgets to declare its actor gets no staff access.
  • TOTP seeds are encrypted with AES-256-GCM using MFA_ENCRYPTION_KEY; recovery codes are stored as keyed hashes. Super-admin SMTP and Google client secrets, and the operational alerts webhook URL, are separately domain-encrypted from that key and never rendered back to the browser (only the webhook host is shown). scripts/check-instance-secrets.mjs verifies that the current key can decrypt them, the authenticator seeds and every organisation's secrets (mail server, chat channels, payment keys, station PINs), printing only counts: no values, ciphertexts or identifiers.
  • Organisation payment keys (Razorpay key and webhook secrets, Stripe secret key and signing secret) are encrypted with AES-256-GCM under their own HKDF subkey of MFA_ENCRYPTION_KEY, with the organisation id and column bound as associated data, so a value copied to another organisation or column does not decrypt. Only the owner can view or change them, changes need a recent sign-in, the page receives only "is set" flags, and the column names are on the split gateway's forbidden-field list. Audit metadata for payment settings is boolean only.
  • Sensitive account operations require recent authentication, revoke existing sessions, invalidate pending MFA setup, and write audit events. Password changes also invalidate outstanding reset links. A completed inbox password reset verifies that email while preserving any enabled MFA. Revoked JWTs return an anonymous session and are cleared by Auth.js.
  • Password sign-in protections:
    • An address with 20 failed sign-ins in an hour is blocked from signing in until the hour is up. Addresses read as "unknown" (no trusted proxy header) are never blocked, so set TRUSTED_PROXY_IP_HEADER.
    • An account with 5 failed sign-ins in 15 minutes needs an emailed code even with the right password, unless an authenticator code was given. At 15 failures codes are refused too, until the window passes.
    • A sign-in from a device the account hasn't used before (after its first) needs a six-digit code emailed to the account, unless an authenticator code was given. The code is valid for 10 minutes, single use, and stored only hashed; its outbox message (kind sign-in-code) is deleted after delivery, and at most 5 codes are sent an hour. LOGIN_NEW_DEVICE_CHECK=false turns the device code off (tests only).
    • A sign-in from a new device sends a "New sign-in to your account" alert email and is audited as SIGN_IN_NEW_DEVICE.
    • Devices are remembered in the UserDevice table: only a SHA-256 of the browser's random id, a short browser description and the last IP. Rows are removed with the account.
    • Account security lists "Signed in from a new device" and "Authenticator code confirmed" among recent security activity.
  • The super-admin panel opens with email and password only; there is no separate authenticator step-up. Google sign-in is refused for SUPER_ADMIN_EMAIL on the web and in the app, including through a Google account linked to that user earlier, and requireSuperAdmin (with the guide's super-admin sections) accepts only a session whose authMethod is credentials: a Google, app or older session never opens it. The super admin's password is set only from the server (scripts/vps-recover.sh or docker/vps/recover-super-admin.mjs); the app refuses its sign-up, forgot-password and password changes. The sign-in protections above still apply to it.
  • Google OAuth security, including MFA, is governed by the linked Google account; organisation staff still confirm an authenticator code on /verify-mfa. Linking to a password account requires that account’s email to be verified first; an unverified account must use inbox password recovery before linking. New Google links retain only provider identity fields, not reusable access/refresh/ID tokens. Previously stored provider tokens are not retroactively purged.
  • Super-admins can disable new password and Google account creation; existing users can continue to sign in.
  • External applications use high-entropy, tenant-bound API credentials under /api/v1; raw secrets are displayed once and only SHA-256 digests are stored.

Authorization and tenancy

  • Organisation membership is the tenant boundary.
  • Owner/admin/member permissions are checked inside Server Actions and server-rendered pages.
  • Hiding a control in the UI is never treated as authorization.
  • Event co-organisers hold exact EventPermission grants on one event or series (EDIT_DETAILS, MANAGE_REGISTRATIONS, MANAGE_INVITATIONS, CHECK_IN, PUBLISH_AND_SCHEDULE); a grant never implies another, and server checks go through canAccessEvent/canManageRegistrationsFor (lib/eventAccess.ts). No grant reaches publishing, short links, the Details and registration forms, emails, feedback, reports, the station PIN, the event address, cloning, embedding, deleting events or series, or managing co-organisers. The dashboards derive their controls from one server-computed capability object (lib/eventDashboard/capabilities.ts) that mirrors these checks; the matrix is in Permissions. The attendee CSV and the filtered Guests · People email export accept owners/admins or the event's MANAGE_REGISTRATIONS grant, member or not; both are audited as ATTENDEE_LIST_EXPORTED, and the email export records filter names only, never the search text. On team events the same holders can export the filtered Guests · Teams CSV, which contains team leaders' and members' names and emails (TEAM_LIST_EXPORTED, filter names only).
  • Super-admin access is checked server-side against SUPER_ADMIN_EMAIL and fails as not found. Each super-admin page calls requireSuperAdminMfa() itself, because a layout check does not re-run on client navigation; a unit test fails if any page omits it. Despite its name, it now checks super-admin access only.
  • Leaving an organisation from /account/organisations removes only the caller's own membership. It locks the organisation row and refuses to remove the last owner, and is audited as ORGANISATION_MEMBER_LEFT.
  • Database foreign keys and uniqueness constraints provide a second integrity layer.
  • Machine clients resolve their organisation from the authenticated credential and require an explicit endpoint scope. Caller-supplied organisation identifiers never select the tenant.
  • JUDGE (Hackathon judging) is isolated: it opens only the judging workspace and never satisfies any other EventPermission check. A collaborator whose only grant is JUDGE sees no guests, answers, emails, check-in, page editing, or settings. Judging settings, the rubric, assignments, disqualification, awards, and results are owner/admin only. A judge whose own account owns an RSVP on an entry (a member of the team, or the solo registrant) never sees that entry and cannot score it or be assigned it; the check runs server-side on every read and write. A JUDGE collaborator is staff: resolveJudgeContext requires the staff authenticator check for the grant path (members pass it through getMembership), and the cores' re-check (isEligibleJudge) fails closed for the person acting.

Input, output, and browser controls

  • Zod validates public/API and action inputs.
  • Registration and feedback answers are validated against current server-owned field definitions.
  • Uploads are size/signature/pixel checked; images are decoded, rotated, resized, metadata-stripped, and re-encoded. WebP dimension checks read lossy (VP8, little-endian), lossless (VP8L), and extended (VP8X) headers before decoding.
  • A per-request nonce protects scripts with a strict Content Security Policy. Normal application pages deny framing; the public /embed/:orgSlug/:eventSlug route is framable only by exact HTTPS origins in ALLOWED_EMBED_ORIGINS. It reuses the canonical public-event access checks and must not be used for ticket, account, dashboard, or administrative content.
  • Production responses include HSTS, frame denial, MIME sniffing protection, a permissions policy, and a strict referrer policy.
  • Server Actions default to same-origin requests, with an explicit trusted-origin allowlist when required.

Tokens and sensitive links

  • Check-in and certificate tokens are high-entropy random values.
  • A registration that changes hands (a ticket transfer, or an organiser changing a guest's email) gets a new ticket link, and every bearer link the previous holder had for it is retired in the same transaction: the attendance question, the solo submission link and a team leader's management link (retireHolderCapabilities, lib/rsvpHolder.ts).
  • Invite-only public registration (free registrations, team registration and series dates) is re-checked under the reservation's own Event or series lock: only a signed-in account whose email is on the invitation list registers, for its own single place, and a guest is refused. An audience switched to invite-only, or an invitation withdrawn, while a registration waits on that lock is therefore refused too. Organisers can still add guests by hand.
  • The public ticket status and live-event routes validate the token shape, return only { checkedIn, checkedInAt } with no-store and no-referrer, and answer unknown tokens with an empty 404. Realtime check-in channels are keyed by RSVP id, so ticket tokens never pass through Redis.
  • Offline check-in sync (web desk and mobile) is replay-safe: each queued check-in's clientMutationId is claimed per event in the same transaction as the check-in (commitOfflineCheckIn, OfflineCheckInMutation), with its outcome, including "already checked in" and status refusals. A retry returns that stored outcome and never checks a guest in again after an organiser's undo; the same id with another registration, time or override is refused. Offline check-ins older than 7 days are refused, and the records are deleted 8 days after the check-in time, so a deleted record can never be replayed.
  • Downloading the offline check-in roster (web desk and mobile alike) records CHECK_IN_ROSTER_DOWNLOADED with the actor, organisation, event and the row count only: never ticket tokens, contacts or answers.
  • Event short codes are random seven-character values containing at least one uppercase letter, so they cannot collide with a lowercase organisation slug. Only owners and admins can create or regenerate them (audited as EVENT_SHORT_LINK_CREATED/EVENT_SHORT_LINK_REGENERATED). Resolution redirects only published events with a released page, so a code never reveals a draft or hidden event's slug; unknown, unreleased, and rate-limited lookups return the same not-found response as an unknown organisation, and the destination page still applies its own privacy rules.
  • Venue check-in stations require an event-scoped, bcrypt-protected rotating PIN. PIN management/recovery requires a fresh administrator sign-in and recoverable PIN ciphertext uses a domain-separated authenticated-encryption key. A station can open only from 24 hours before the event through one hour after it ends. Successful entry creates a signed, HttpOnly proof bound to that event, expiry, and PIN version; rotation and disablement invalidate existing proofs immediately. Station PINs, proofs, attendee exports, and offline rosters are never exposed through the public station.
  • Team join links and team management links (Team registration) are random bearer capabilities. The management token is stored only as a SHA-256 hash and is shown once; regenerating either token retires the old link. Leader authority is re-checked under the event row lock on every mutation, a bare team id acts only for its signed-in leader, and the ticket (check-in) token reaches only leave and Looking for a team actions, never team management. The join page applies the event page's privacy, invite-only and release checks before revealing anything, and shows only the team name and size. Both pages are noindex, private-cache and no-referrer (header and meta). The proxy sends these pages and /ticket/{token} a private, no-cache, no-store Cache-Control, because its HTML header would otherwise replace Next's in production. A solo registrant joining a team by link is resolved from their ticket token, and the join token is matched only inside that registration's event, under the event lock, while the event and its page are published. Team audit metadata holds ids only.
  • Solo submission links (/submit/:token) are random bearer capabilities of at least 128 bits, stored only as a SHA-256 hash on the RSVP-scoped row; requesting a new link retires the old one. A link is emailed only to the registration's own address through the outbox (a capability kind, deleted after delivery), so holding a ticket QR is never enough to edit a project; requests are rate limited with a per-registration cooldown, and the action never returns the token. Team projects are edited through the team management link or by a signed-in team member. Entrant authority is re-checked inside each write transaction, the entrant pages exist only while the event is published and its website released, and /submit/:token is noindex, uncached and no-referrer (header and meta).
  • Attendance confirmation links (/attend/:token, Attendance confirmation) are random 256-bit bearer capabilities (43 base64url characters), stored only as a SHA-256 hash on the RSVP-scoped AttendanceCheck row (a CHECK keeps the column a 64-character hex digest). The raw token exists only in the queued email (the attendance-check outbox kind is a capability kind, deleted after delivery or expiry) or in the one response to an owner/admin who shares that guest's link by hand; it is never logged or audited, and view readers never select the digest. A reminder or a new shared link rotates the token, so the earlier link stops working and its queued email is dropped. Only organisation owners and admins (the broadcast rule, after the staff authenticator check) send, remind, share links or change the release switch; no co-organiser grant does, and every lookup is scoped to the organisation resolved from the slug. Only CONFIRMED registrations of one-off events are asked (attendanceEligibleRsvpWhere), never series dates or passes. The form shows only that registration's own name, email, team and answer. Replies are rate limited per link fingerprint and per address, accept only Yes or No and a note of at most 500 characters with control characters removed, and are re-checked against the current token inside the write. A No with release on uses only the guest's own paths: the self-cancel core for a free place, the buyer's own cancellation under the frozen refund policy for a paid single ticket they bought (never a full refund by default), or a refund request once the policy has closed; an order with several tickets or a ticket somebody else paid for is never cancelled, and organisers are told instead. The check-in desk and station show the reply, never the note; the Overview counts reach only owners and admins. The page is noindex, no-referrer (header and meta), private, no-store and disallowed in robots.txt.
  • Crew sign-up links (/crew/:token, Event crew) are random 256-bit bearer capabilities, stored as-is like team join tokens so organisers can show them again; rotating replaces the token and turning the link off deletes it, so an old link stops working at once. Malformed tokens are refused before any lookup, and the page shows only the event and organisation names and date (generic metadata, noindex, no-referrer, private no-store, disallowed in robots.txt; crew is a reserved organisation slug). Sign-ups are rate limited (rsvp per IP and email; link photo uploads crewPhotoUpload per IP and per hashed link, apart from the shared upload budget, and 200 a day per event; unsaved crew uploads are deleted after a day), closed for cancelled and ended events, and start hidden from the public Crew section. An email already on the crew or already registered gets the same answer and changes nothing, so the link neither enumerates attendees nor converts someone's registration; only an organiser with MANAGE_REGISTRATIONS converts a free solo registration, never a bought or held ticket. Crew tickets are free (database CHECK RSVP_crew_shape: confirmed, no team, order or ticket type), never count against capacity or seat holds, grant no dashboard access, and are refused by self-cancel, the Guests · People actions, checkout, transfers and every team and project path. Crew photos go through the safe WebP pipeline and badges draw only a same-organisation crew upload.
  • Volunteer shifts (Volunteer shifts): a crew kind (ORGANISER, VOLUNTEER, MENTOR, JUDGE) and an account link never grant authority; organisers need the crew rule (MANAGE_REGISTRATIONS) and, for hours and certificates, owner/admin, and nobody verifies their own hours or issues their own certificate (application check plus the volunteer_hours_entry_guard trigger, which matches the linked account and the crew email). The account link is set only from a verified account with the crew email, is never removed by an email change, and a crew email with verified hours or a certificate cannot change; the link opens only that crew ticket's own actions on mobile, while the emails still match. Volunteers act only on their own sign-ups through their crew ticket token, with the account signed in for an account-tied ticket, rate limited per ticket. Places are counted under the shift row lock and overlaps under the crew member row lock. Hours history and desk corrections are append-only in the database, and hours change only with a matching entry (volunteer_hours_guard). Contributor certificate tokens are 256-bit bearer capabilities shown only on the crew ticket, downloads are private, no-store and no-referrer, and a certificate is revoked when hours change. Hours are never money, the hours CSV carries names but no emails, tokens or reasons, volunteer-notice email is deleted after delivery, and no webhook or chat notice carries shift or hours data. Audit metadata is ids only.
  • Ticket, certificate, contributor certificate, invitation, password-reset, emailed sign-in code, team, submission, crew sign-up, attendance confirmation, and MFA enrollment values are secrets or bearer capabilities.
  • They must not appear in logs, audit metadata, monitoring tags, or analytics.
  • Readiness and scheduler secrets are compared in constant time and unauthorized calls return generic responses.

Payment webhooks

  • Razorpay and Stripe webhooks are verified over the exact raw request bytes (capped at 256 KB) with the addressed organisation's own secret, compared in constant time, before any parsing. Stripe signatures older than five minutes are refused. The random path id only selects the organisation; a valid signature from another organisation's secret fails.
  • Each verified event is recorded once (ProcessedWebhookEvent, unique per organisation, provider and event id) in the same transaction that applies it, so a failure is retried and a replay is acknowledged without effect. Raw payloads are never stored.
  • payment.authorized is never treated as paid; only captured payments (payment_link.paid, order.paid, payment.captured) and paid Stripe Checkout sessions count.
  • Webhooks are served only by the backend API host; the split frontend proxy refuses /api/webhooks/*.
  • PAYMENTS_FAKE_PROVIDER_URL (tests only) is refused in production.

Payment ledger

  • Money is integer minor units (BigInt) with an uppercase ISO 4217 code; each event has one currency, locked after its first sale. An order's lines and totals are priced by the application, and the provider only charges the order total, so behaviour is identical on Razorpay and Stripe.
  • Each hosted page is a PaymentCheckoutAttempt whose id is the reference sent to the provider (Razorpay reference_id must be unique per link), so retries never reuse a reference. Expiries stay inside each provider's window (Stripe Checkout 30 minutes to 24 hours, Razorpay links at least 15 minutes), and non-zero totals below the provider minimum (Razorpay ₹1, Stripe about US$0.50) are refused.
  • Paid tickets are sold as orders: under the event row lock, the order's lines are priced, each ticket type's sales window, per-order limits and capacity and the event capacity are checked (confirmed tickets plus live holds), and one registration is created per seat. A paid order holds its seats for 30 minutes; opening a provider page extends the hold to the provider's minimum page lifetime, never beyond 60 minutes in total. Approval, waitlist promotion and attendee self-service never confirm a ticket from a paid order; only its payment does.
  • Settlement matches only provider-echoed ids (checkout attempt, provider page, provider payment id) within the webhook's organisation and provider, under the event, order and payment row locks; a different amount or currency is never settled. Ended holds are released by the beat's one-minute payments job only after every open provider page is cancelled; if the buyer paid first, the order is settled. A payment that arrives after the seats were resold, or a second capture of a paid order, is recorded and refunded in full automatically.
  • Refunds are three steps: a PENDING row under the payment lock, the provider call outside any lock (idempotency key for Stripe; a lookup by our id before any retry for both providers), then settlement from the response or the refund webhook.
  • Neither the order access token nor ticket tokens are sent to a provider: checkouts return through a signed, expiring /api/payments/return/:orderId link. Payment emails queue only ids and resolve links at send time. Inside the embed widget, provider pages open in a new top-level tab, never in the frame.
  • Ticket types, prices, order limits, promo codes and the refund policy can be changed by organisation owners and admins and by collaborators with EDIT_DETAILS on the event. Refunds, cancellations, payments recorded by hand, complimentary tickets, payment links and paid guests added by hand need MANAGE_REGISTRATIONS (or owner/admin); every operation also checks that the order belongs to that event and organisation.
  • A paid order on an event that needs approval, or on a sold-out event or ticket, is a request: nothing is held or charged. Approval, a waitlist promotion, or a place freed by a cancellation offers the whole order a 24-hour hold with an emailed payment link; the provider page opens only when the buyer clicks Pay.
  • Settling an order by hand (paid in person, or complimentary) cancels its open provider pages first; if the buyer had already paid online, that payment stands instead. A payment captured on a page that opened afterwards is refunded automatically as a duplicate. Offline records are corrected only through append-only PaymentAmendment rows; the payment row stays as recorded.
  • Refunds of money taken in person are recorded as settled and never sent to a provider. Concurrent refunds are serialised on the event, order and payment locks, and the database refuses any total above the amount paid.
  • Promo codes are stored as a SHA-256 digest of the normalised code with a three-character hint, and priced under the event lock, so a limited code is never used more than allowed (confirmed orders, live holds and waiting requests count). The public code check is rate limited like registration and only previews the price.
  • Each paid order keeps a copy of the event's refund policy from when it was placed; after the first sale the policy may only become more generous. Buyers cancel online only within that copy's deadline, for the whole order, with the order access token as proof.
  • Database triggers keep settled payments' amount, currency, provider and status immutable, never replace a recorded provider payment id or checkout page, never reopen a finished checkout attempt, and keep pending plus succeeded refunds within the amount paid.

Event finance

  • Expense claim receipts are re-encoded WebP photos in private storage (organisations/{id}/claim-receipts/, enforced by a CHECK on ExpenseReceipt), never served by /api/uploads; /api/claims/receipts/:id serves them only to the organisation's owners and admins and the claim's own author, with an empty 404 otherwise.
  • Stored files do not outlive their records. Every object-storage upload is registered for deletion before its first byte and released only when its Asset is saved; deleting an organisation queues all of its files (receipts included) in the same transaction, and receipt photos no claim refers to are removed after a day. The queue (StorageDeletion) has no foreign key, records only keys, ids and short error codes, deletes only in the storage location each row was recorded for, and keeps a row until a later check finds the file gone (CONFIGURATION.md).
  • Deciding and paying claims, the budget, refund requests and the finance CSV are owner/admin only; members and co-organisers file and withdraw only their own claims. Participant claims need a confirmed registration, the event's switch, and the account behind an account-owned ticket; the per-person cap is checked under the event row lock.
  • ClaimPayout and PrizePayout are append-only ledgers guarded by database triggers: rows are never deleted except with their organisation, only a reference may be cleared when its target is deleted, and a reversal must match the payout it undoes (same organisation, amount and currency, never a reversal of a reversal).
  • A refund request is claimed before its refund runs (and released if the refund is refused), so concurrent approvals never refund twice; the refund itself is the normal refundOrder with its locks and limits.

In-app guide

  • The guide's audience sections (org-admin for organisation owners and admins, super-admin for the instance super admin) only decide what the guide lists; every feature the articles describe keeps its own server checks. Restricted articles are a 404 for other readers, the search index omits them and is private, no-store, and signed-in guide pages are no-store through the session-cookie rule in lib/pageSecurity.ts.
  • Article bodies load only through the generated map of static imports (content/help/bodies.generated.ts) for manifest slugs the viewer may read; no path is built from the URL.
  • Guide screenshots in public/help/ are public files: they come only from the seeded fictional demo data.
  • The screenshot seed (tests/help-screenshots/) refuses to run without HELP_SCREENS_SEED=1, outside development, or against a non-local database, because it replaces its demo users and data.

Ticket designs

  • Design documents are validated in full on every save (lib/ticketDesign/schema.ts: a strict schema, at most 60 elements and 256 KB) and rendered on the server; nothing in a design is executed or fetched from outside: images must be the organisation's own managed uploads (re-encoded WebP), text becomes glyph outlines, and colours are plain hex.
  • Every design keeps exactly one check-in QR code, at least 18 mm, upright, solid and on the ticket; it encodes only the ticket's own token. Editor previews use a sample QR, never a live ticket, and the ticket page stops showing the designed ticket once the guest has checked in.
  • Library templates and the organisation default belong to owners and admins; an event's designs to its EDIT_DETAILS editors. Database triggers keep an event's and a ticket type's design among that event's own, and the default among the organisation's templates.

Mobile sessions

  • The Space Works apps authenticate as ordinary users under /api/mobile/v1; they are never machine clients and never receive organisation-wide credentials. A signed-in mobile request runs as an explicit request actor (lib/requestActor.ts): auth() presents the app session, and every mutation runs the matching Server Action's own body, so role checks, payment holds, refunds, capacity transactions, rate limits, webhooks and audit are shared.
  • Password sign-in uses the web verifier (lib/credentialsAuth.ts) with every protection: address and account failure limits, the emailed code for a new install or an account under attack, the new sign-in alert (a missing or malformed device id counts as an unknown device for the alert and the SIGN_IN_NEW_DEVICE audit; only remembering a device needs one), and the authenticator. Google sign-in verifies the ID token's signature against Google's JWKS, issuer, expiry, audience (the web client id or the app client ids) and email_verified, then applies the web linking and account-creation policy. Mobile Google sign-in stays off until app client ids are configured, and a web-audience token must name one of them as azp.
  • Access tokens (15 minutes) and refresh tokens (60 days) are 32 random bytes; only SHA-256 digests are stored in MobileSession, with a SHA-256 of the install's id. Refresh tokens rotate on every use and must come with the install id the app signed in with; replaying any of the last three rotated tokens (the newest only after a 30-second retry grace), or presenting one with another install id, revokes the session (MOBILE_REFRESH_TOKEN_REUSED). The install id is client-supplied, so it stops a token replayed without it, not one copied together with it. All authentication failures return one generic 401.
  • Every session records the user's sessionVersion and is rejected once it changes, so password change or reset, authenticator enable/disable, "sign out everywhere" and operator recovery revoke app sessions with web sessions; those transactions also mark the rows revoked. "Sign out everywhere" from the app needs a sign-in in the last 10 minutes.
  • Staff authenticator: an app session carries its own mfaVerifiedAt (a sign-in with totp, or POST /auth/mfa), valid for 12 hours. Organiser routes refuse with STAFF_MFA_REQUIRED/STAFF_MFA_ENROLMENT_REQUIRED until then, and getMembership, canAccessEvent and canViewEventDashboard fail closed for the app session as well.
  • Responses are strict DTOs: no credential hashes, MFA material, order access tokens, team or submission links, provider ids or payment references, and a ticket token only on the caller's own confirmed ticket (tests/unit/mobile-secret-fields.test.ts). Order, claim and refund-request ids resolve only for their own buyer or the event in the path.
  • A mobile request without a bearer runs as an explicit anonymous actor, so auth() is null and a browser cookie never acts through /api/mobile/v1. Background work started during a mobile request (outbox timers, Redis clients and listeners, webhook queueing, live check-in dispatch) runs outside the actor (outsideRequestActor). Request bodies are streamed with a hard cap (413 PAYLOAD_TOO_LARGE), with or without Content-Length.
  • Every signed-in write from the app that moves money or creates or changes a record (refunds, settling, payment links, orders, claims, check-in, the Mentor Desk; exemptions in MOBILE_API.md, enforced by tests/unit/mobile-idempotency-coverage.test.ts) needs an Idempotency-Key; only HMACs (under a key derived from MFA_ENCRYPTION_KEY for this purpose) of the key and of the method, path and body, and the route's answer with every ticketToken kept as null (help text never), are kept, per user and route, for a day. Transient and sign-in refusals are never kept, and a key is never replayed for another path. MENTOR (like JUDGE) opens only its own console on mobile.
  • App-link association files are configured at runtime and keep bearer and staff paths (orders, member ID verification, teams, submissions, help) on the website.
  • Sign-in, sign-out, authenticator checks and refresh-token reuse are audited without token values. Mobile traffic has per-session and sign-in limits in addition to the proxy IP ceiling.

Abuse protection

  • Password login applies account and IP limits inside the credential verifier, in addition to proxy limits. Missing and OAuth-only identities perform dummy bcrypt work; signup spends its creation limit (per address and IP) before any lookup or hashing, and returns the same response for existing, newly created and rate-limited attempts; a limited attempt creates and sends nothing. Auth.js errors are logged without exception payloads.
  • Redis-backed limits cover organisation webhook changes (webhookManage, 30 per minute) and test sends/redeliveries (webhookTest, 20 per hour), APIs, authentication mutations, RSVP, feedback, search/discovery, uploads (event page images, 60 an hour, apart from the 12-an-hour budget for every other upload), Server Actions, object creation, invitations, broadcasts, attendance confirmation sends and reminders (attendanceCheckSend, 10 per hour, shared by both), shared attendance links (attendanceCheckLink, 60 per 10 minutes) and replies (attendanceCheckReply, 10 per minute per link and per address), check-in, personal data exports (per user), public ticket status and event streams (per IP), and short-link lookups (per IP). Bucket sizes live in lib/rateLimit.ts.
  • Machine API traffic has an additional per-client limit so credential rotation cannot multiply request capacity.
  • Security-sensitive production traffic fails closed when Redis is unavailable. Every Redis command on a request path (rate limits, realtime check-in) has a 500 ms deadline, and while the client is reconnecting the limiter answers at once instead of waiting on the backoff (lib/redisOptions.ts), so an outage refuses quickly rather than stalling requests.
  • Dashboard refusals are real HTTP statuses: no access, another organisation's or an unknown event, series, webhook endpoint, ID programme or ticket design, and a JUDGE-only collaborator on any non-judging page get a 404 (or a 307 to /dashboard, the organisation, or sign-in), never a 200 that streams the not-found page. A dashboard loading.tsx may only sit below a layout that has already awaited the wrapped page's access check (lib/views/dashboardGuards.ts, run on the backend in split hosting, pinned by tests/unit/dashboard-loading-boundaries.test.ts), so nothing of the protected page streams before the check. Pages still run their own checks.
  • Signed-in areas (/dashboard, /account, /super-admin, /verify-mfa), /login, invitations (/join/...), /reset-password, /verify-email, order links (/order/{token}), member ID verification (/verify/{token}), and any request carrying the session cookie are sent private, no-cache, no-store by both proxies (lib/pageSecurity.ts). Order, verification, invitation, reset and email-verification links also get a no-referrer header (next.config.ts).
  • Process-local fallback exists for development and an explicit CI-only production-build test mode.
  • Client IP attribution trusts only the configured header; the edge must overwrite that header and prevent direct-origin access.

Data privacy

  • The feedback form (web page, POST /api/feedback, and the app's form) follows the event page's access rule (resolvePublicEventAccess) on top of requiring a published event: nothing while the event's website is unreleased, and for an invite-only event only a signed-in guest whose verified email is invited. Submission re-reads privacy, release and the invitation inside its locked transaction. A certificate email is matched to a registration, never treated as sign-in. The app signs in to the form and to POST /api/feedback with its bearer; a bearer that does not sign in is refused.
  • Certificate-disabled feedback stores answers without email or RSVP identity and allows repeat anonymous submissions.
  • Certificate-enabled feedback requires an email matching a confirmed RSVP, links the response for certificate generation, and allows repeat submissions.
  • Feedback mode, eligibility, typed answers, and response creation are one locked transaction. PostgreSQL forbids partial identity/certificate linkage, while immutable question snapshots keep historical answers meaningful after form maintenance.
  • Registration counts are omitted from public payloads when the organiser disables count display.
  • The personal data export (GET /api/exports/account) resolves the user only from the session, returns allow-listed fields of the user's own profile, memberships, registrations (with the user's own attendance confirmation answers: when asked, the reply, when replied, and the note), feedback, co-organiser roles, hackathon projects (without any disqualification reason), applications (status, stage and the account's own answers, never reviews), and participation (lib/accountDataExport/participation.ts): crew places (food preference, bio, and whether a photo is on file, never its key), expense claims the user filed, volunteer shifts, hours and contributor certificates (never their tokens), mentor profiles (with the user's own affiliation), help requests and comments the user wrote, office-hour bookings the user made (time and place, never the mentor), team seeker profiles and join requests, and prize payouts to the user's projects (award, amount and date, never the payment reference or note); it excludes passwords, hashes, MFA material, and every bearer token, is sent private, no-store, and is audited as ACCOUNT_DATA_EXPORTED.
  • Operational alerts leave the instance, so lib/opsAlerts.ts redacts their text (URLs and connection strings, emails, bearer values, token=-style parameters, long opaque strings), reports route patterns instead of request paths, and keeps the fixed console log lines unchanged. Error detail goes only to the alert, never to stdout or audit metadata. Webhook URLs must be https: in production and may not embed user info. Any port is accepted, but the host must resolve only to public addresses (the organisation webhooks' address classifier, lib/net/publicAddress.ts) unless the operator sets OPS_WEBHOOK_ALLOW_PRIVATE=1, for example for a Mattermost on the same network. The host is checked when the URL is saved and again before every delivery, and the delivery connects only to the addresses just checked (lib/opsWebhookAddress.ts), so a DNS answer that changes in between can't reach a private host. The request is limited to the MFA-gated super-admin, follows no redirects, checks TLS against the host name, and never returns the response body. POST /api/client-errors accepts only same-origin, size-capped, rate-limited reports and always answers an empty 204.
  • Hackathon data (Hackathon judging): judges see project content only (title, summary, links, team or entrant display name), never entrant emails, answers, or ticket links. Judges' private comments and the disqualification reason are organiser-only; the entrant sees a neutral "not in judging" state and the reason is excluded from audit metadata and the personal data export. Public results and the project gallery apply the event page's access rules first and show no scores or comments. The scores CSV (GET /api/exports/events/:eventId/judging) is owner/admin only, formula-neutralised, private, no-store, contains no emails or comments, and is audited as JUDGING_SCORES_EXPORTED. The Judging → Submissions CSV (view=submissions, owner/admin only) includes the organiser-only disqualification reason and assigned judge names, no emails, and is audited as JUDGING_SUBMISSIONS_EXPORTED (filter names only). Every export action is listed on the super-admin Security page (SECURITY_AUDIT_ACTIONS).
  • Mentor Desk (Mentor Desk): MENTOR is an isolated grant like JUDGE and needs the staff authenticator check; every command re-checks the live grant inside its transaction, and queue and visit commands the mentor's availability. Help text and comments are visible only to the team, mentors and organisers and never reach webhooks, chat notifications, the outbox or audit metadata (audit events carry ids only); the CSV leaves the text and all contact details out. A mentor's affiliation is private to organisers and the mentor; the public Mentors section shows only mentors who opted in, with name, bio and skills. Participants prove themselves with the team management link or a signed-in CONFIRMED registration, never the ticket link alone. Nominations are an organiser-only shortlist and never enter judging scores. The help activity, comments, assignment runs, office-hour changes, check-ins and nominations are append-only (database trigger).
  • Super-admin insight pages never load tokens, credential hashes, registration answers, or message bodies; lists are capped and queries are parameterised.
  • Broadcast designs escape organiser values and accept only absolute http(s) image and link URLs. The ticket venue link is shown only for an http(s) map URL (lib/safeHttpUrl.ts rejects javascript:, data:, and other schemes).
  • Audit metadata is designed to exclude email addresses, answers, passwords, and bearer tokens.
  • Public and dashboard browser event DTOs use explicit selects that exclude check-in station secrets and unused relations. Production exception logging is category-only. Attendee answers and ticket capabilities require the exact registration-management permission; the check-in surface requires the separate exact check-in permission.
  • Organisation webhooks (docs/WEBHOOKS.md) are managed by owners and admins only (canManageWebhooks). Payloads are strict DTOs with ids, statuses, timestamps, event title/slug and attendee display names only: no email addresses, user ids, registration or feedback answers, or ticket, check-in, certificate or invitation tokens. Signing secrets are generated server-side, shown once, stored with AES-256-GCM under their own HKDF label from MFA_ENCRYPTION_KEY, and never logged or audited; audit metadata records the target host only.
  • Webhook targets are SSRF-restricted: https on 443/8443 without URL credentials, no local or internal host names, and every resolved address must be public unicast (private, loopback, link-local and metadata, CGNAT, multicast, reserved, documentation, IPv6 ULA/link-local, and IPv4-mapped/NAT64/6to4 forms of blocked IPv4 are refused). The check runs at save time and before every delivery, and the socket is pinned to the vetted address so DNS rebinding cannot redirect it. Redirects are never followed, requests time out after 10 s, bodies are capped at 256 KB, and only a short redacted response excerpt is stored. WEBHOOKS_ALLOW_PRIVATE_TARGETS=1 relaxes this for development and tests only; production ignores it outside CI test mode and production:check refuses it.
  • An organisation's own SMTP server is connected only by an address its host name resolves to that is public (never loopback, private, link-local, CGNAT or metadata ranges, checked for every resolved address), on ports 25, 465, 587 or 2525, with TLS checked against the host name. Its password is AES-GCM encrypted, bound to the organisation, and write-only. Connection errors are never echoed back. Account email never goes through an organisation's server.
  • Chat connections (webhook addresses, bot tokens, WhatsApp access tokens) are validated per service (Slack and Discord only on their own webhook hosts), AES-GCM encrypted bound to the organisation and the channel, and never shown again: the settings page shows only the host, chat id or last digits of numbers. A self-hosted Mattermost server is reached only by a checked public address pinned for the request, with TLS checked against its name. Discord messages disable mentions, so a guest's name can't ping a server. Chat messages carry names and titles only, never ticket links or contact details, and service replies are never echoed back.
  • Applications privacy (ADMISSION.md): withdrawn applications are kept with a snapshot name and one-way hashed person keys (no email in clear); the account export lists the account's own applications and answers (never reviews or scores); deleting an account scrubs the snapshot name and redacts answer revisions to their questions, keeping only the hashes so nobody applies twice.
  • Applications (ADMISSION.md): a place is never confirmed (or held) without an offer, enforced in the database (admission_status_guard), and a paid place only by its settled payment; the older approve/decline/promote/re-accept and waitlist paths refuse application registrations; reviewers (REVIEW_APPLICATIONS, staff MFA) see only their own assignments, blind stages hide who applied, and conflicts (own or teammate's application, having applied, declared) are never assigned; reviews, answer revisions, stage entries and released decisions are append-only; crew never become applications; applicants act by ticket token, rate limited. Emails carry ids only (the ticket link is added at send time), application.status_changed carries ids, status and stage, and applications:read returns counts only.
  • Machine order responses (orders:read) carry totals only: no buyer names or emails, order links, receipts, card details or payment provider IDs. The organiser's orders CSV and the event PDF are audited (ORDERS_EXPORTED, report downloads). The event and series-date report routes decide access before loading any metrics: a per-account report allowance (20 per 10 minutes, 429 beyond it), then the organisation owner/admin check and the report's availability, all answered with the same empty 404 (lib/eventReport/access.ts).
  • Machine participant responses intentionally exclude contact data, registration answers, user IDs, and ticket/check-in/certificate capabilities. Attendance data needs the separate participants:attendance:read scope: both the attendance filter (attendance=checked_in|not_checked_in, which reveals who has checked in) and the nullable check-in timestamp (an explicit include=attendance request) are refused with 403 INSUFFICIENT_SCOPE without it.
  • Asset buckets are private; only generated organisation-bound WebP derivatives are delivered through the application key allowlist. Uploads are signature- checked, decoded under a hard pixel limit, single-frame enforced, resized, metadata-stripped, and re-encoded before storage.
  • Capability-bearing outbox rows are deleted after delivery, expiry, or terminal failure. Queue/heartbeat failures persist only normalized error class/code, and retries use a stable non-sensitive Message-ID under lease-owned state transitions. Protected scheduler/readiness routes require one exact bearer.
  • Retention cleanup covers expired sessions, invitations, verification tokens, undo snapshots, old sent/failed non-capability outbox records, and webhook delivery history older than 30 days. Deployment operators must define broader legal retention and deletion procedures.

Audit trail

Application code creates descriptive business/security audit events. PostgreSQL triggers add a minimal PII-free fallback record for important organisation, membership, event, invitation, RSVP, and series mutations, including mutations that bypass ordinary application paths. Check-in and undo actions also have an immutable event history, and custom check-in lists keep their own (CheckInListEvent); creating, renaming, archiving, restoring and reordering lists is audited (CHECK_IN_LIST_*).

Transport and secrets

Production startup validates:

  • matching HTTPS AUTH_URL and NEXT_PUBLIC_BASE_URL;
  • PostgreSQL TLS through an accepted sslmode;
  • rediss:// for Redis;
  • HTTPS for a custom S3 endpoint;
  • authenticated SMTP unless an unauthenticated private relay is explicitly declared;
  • required secret lengths and base64 AES key sizes.

Store secrets in a deployment secrets manager. Use a least-privileged database role and IAM/workload identity for object storage where possible.

Known boundaries and follow-up work

  • Certificate eligibility currently proves knowledge of the confirmed attendee email, not control of that inbox. Add a one-time emailed verification link if certificates carry material value or personal information.
  • Google OAuth accounts do not use the application's sign-in TOTP challenge or the new-device code. Organisation staff signing in with Google confirm an authenticator code on /verify-mfa; attendees rely on Google's own policy.
  • The breached-password check fails open: when api.pwnedpasswords.com is unreachable, breached passwords are accepted.
  • Current automated coverage is broad in scope but low in percentage; expand authorization and mutation-path tests before making a high-assurance claim.
  • Application controls cannot replace CDN/WAF configuration, patch management, database backups, restore drills, alerting, access reviews, and incident response.
  • Conduct an independent security review before processing sensitive or regulated data.

Reporting a vulnerability

The repository does not yet define a public security contact or SECURITY.md policy at its root. Add a monitored private reporting channel before public deployment or open-source distribution.

There aren't any published security advisories