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.
- 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:credentialsorgoogle) 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/sessionnever returns it, and the server reads it only throughlib/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 userauth()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 toapi.pwnedpasswords.com;PWNED_PASSWORDS_CHECK=falseturns 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=falseturns 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, anAsyncLocalStorageon aSymbol.forglobal). A mobile app session is judged by its ownmfaVerifiedAt, 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 (verifyorenrol), 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.mjsverifies 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=falseturns 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
UserDevicetable: 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.
- 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
- 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_EMAILon the web and in the app, including through a Google account linked to that user earlier, andrequireSuperAdmin(with the guide's super-admin sections) accepts only a session whoseauthMethodiscredentials: a Google, app or older session never opens it. The super admin's password is set only from the server (scripts/vps-recover.shordocker/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.
- 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
EventPermissiongrants 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 throughcanAccessEvent/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'sMANAGE_REGISTRATIONSgrant, member or not; both are audited asATTENDEE_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_EMAILand fails as not found. Each super-admin page callsrequireSuperAdminMfa()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/organisationsremoves only the caller's own membership. It locks the organisation row and refuses to remove the last owner, and is audited asORGANISATION_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 otherEventPermissioncheck. A collaborator whose only grant isJUDGEsees 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. AJUDGEcollaborator is staff:resolveJudgeContextrequires the staff authenticator check for the grant path (members pass it throughgetMembership), and the cores' re-check (isEligibleJudge) fails closed for the person acting.
- 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/:eventSlugroute is framable only by exact HTTPS origins inALLOWED_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.
- 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 }withno-storeandno-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
clientMutationIdis 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_DOWNLOADEDwith 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 andno-referrer(header and meta). The proxy sends these pages and/ticket/{token}aprivate, no-cache, no-storeCache-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/:tokenisnoindex, uncached andno-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-scopedAttendanceCheckrow (a CHECK keeps the column a 64-character hex digest). The raw token exists only in the queued email (theattendance-checkoutbox 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 isnoindex,no-referrer(header and meta),private, no-storeand disallowed inrobots.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 inrobots.txt;crewis a reserved organisation slug). Sign-ups are rate limited (rsvpper IP and email; link photo uploadscrewPhotoUploadper 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 withMANAGE_REGISTRATIONSconverts a free solo registration, never a bought or held ticket. Crew tickets are free (database CHECKRSVP_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 thevolunteer_hours_entry_guardtrigger, 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 areprivate, no-storeandno-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-noticeemail 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.
- 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.authorizedis 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.
- 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
PaymentCheckoutAttemptwhose id is the reference sent to the provider (Razorpayreference_idmust 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
PENDINGrow 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/:orderIdlink. 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_DETAILSon the event. Refunds, cancellations, payments recorded by hand, complimentary tickets, payment links and paid guests added by hand needMANAGE_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
PaymentAmendmentrows; 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.
- Expense claim receipts are re-encoded WebP photos in private storage (
organisations/{id}/claim-receipts/, enforced by a CHECK onExpenseReceipt), never served by/api/uploads;/api/claims/receipts/:idserves 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
Assetis 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.
ClaimPayoutandPrizePayoutare 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
refundOrderwith its locks and limits.
- The guide's audience sections (
org-adminfor organisation owners and admins,super-adminfor 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 isprivate, no-store, and signed-in guide pages areno-storethrough the session-cookie rule inlib/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 withoutHELP_SCREENS_SEED=1, outside development, or against a non-local database, because it replaces its demo users and data.
- 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_DETAILSeditors. 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.
- 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 theSIGN_IN_NEW_DEVICEaudit; 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) andemail_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 asazp. - 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
sessionVersionand 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 withtotp, orPOST /auth/mfa), valid for 12 hours. Organiser routes refuse withSTAFF_MFA_REQUIRED/STAFF_MFA_ENROLMENT_REQUIREDuntil then, andgetMembership,canAccessEventandcanViewEventDashboardfail 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 (413PAYLOAD_TOO_LARGE), with or withoutContent-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 anIdempotency-Key; only HMACs (under a key derived fromMFA_ENCRYPTION_KEYfor this purpose) of the key and of the method, path and body, and the route's answer with everyticketTokenkept 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(likeJUDGE) 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.
- 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 inlib/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 dashboardloading.tsxmay 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 bytests/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 sentprivate, no-cache, no-storeby both proxies (lib/pageSecurity.ts). Order, verification, invitation, reset and email-verification links also get ano-referrerheader (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.
- 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 toPOST /api/feedbackwith 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 sentprivate, no-store, and is audited asACCOUNT_DATA_EXPORTED. - Operational alerts leave the instance, so
lib/opsAlerts.tsredacts 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 behttps: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 setsOPS_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-errorsaccepts only same-origin, size-capped, rate-limited reports and always answers an empty204. - 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 asJUDGING_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 asJUDGING_SUBMISSIONS_EXPORTED(filter names only). Every export action is listed on the super-admin Security page (SECURITY_AUDIT_ACTIONS). - Mentor Desk (Mentor Desk):
MENTORis an isolated grant likeJUDGEand 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.tsrejectsjavascript:,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 fromMFA_ENCRYPTION_KEY, and never logged or audited; audit metadata records the target host only. - Webhook targets are SSRF-restricted:
httpson 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=1relaxes this for development and tests only; production ignores it outside CI test mode andproduction:checkrefuses 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_changedcarries ids, status and stage, andapplications:readreturns 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-accountreportallowance (20 per 10 minutes,429beyond 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:readscope: both the attendance filter (attendance=checked_in|not_checked_in, which reveals who has checked in) and the nullable check-in timestamp (an explicitinclude=attendancerequest) are refused with403 INSUFFICIENT_SCOPEwithout 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.
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_*).
Production startup validates:
- matching HTTPS
AUTH_URLandNEXT_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.
- 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.comis 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.
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.