Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
The table of contents is too big for display.
Diff view
Diff view
  •  
  •  
  •  
1 change: 1 addition & 0 deletions .claude/rules/backend/account-deletion.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,3 +13,4 @@ paths:
- Never delete `organizer_configurations` or `account_configuration` rows (shared plan rows) or `ticket_lookup_tokens` (buyer-email scoped across accounts). `stripe_customers` has no account foreign key; match it by Stripe account id.
- Users can belong to several accounts: a sole-account user is scrubbed and soft-deleted, and their `user_identities` rows are deleted so they can't sign back in with single sign-on; a shared user only loses the `account_users` row.
- An account's SSO connection, its domains and the `user_identities` tagged with the connection are deleted before `accounts` (`SsoConnectionAnonymizer` mirrors this).
- Anonymisation nulls the `organizer_settings` free text (`homepage_sections`, `website_url`, `location_details`, `social_media_handles`, `seo_*`). Event `homepage_sections` follow the event description retention rule and stay on the soft-deleted event.
2 changes: 1 addition & 1 deletion .claude/rules/backend/sso.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ paths:

# Single sign-on (OIDC)

- One generic OIDC client (`Services/Domain/Auth/Sso`) serves Google, Microsoft and the operator's `SSO_OIDC_*` provider; no Socialite. Providers exist only when their client id and secret are set.
- One generic OIDC client (`Services/Domain/Auth/Sso`) serves Google, Microsoft and the operator's `SSO_OIDC_*` provider; no Socialite. Providers exist, and their buttons show, only when their client id and secret are set; there is no separate visibility switch.
- Identities are keyed by (`issuer`, `subject`) in `user_identities`; Microsoft uses the `tid`-substituted issuer and `oid`, never `sub`, `email` or `upn`. Never write the provider email back to `users.email`.
- An email links an existing user only when the provider's email is trusted (`SsoIdentityClaimsMapper`) AND the user was verified in SaaS mode or password sign-in is disabled. Superadmins, untrusted emails and ambiguous matches get `SSO_LINK_REQUIRES_LOGIN` before any membership check, so responses can't reveal account state; never create a signup ticket for an email that already has a user. The first link by email while password sign-in is off clears the old password, trusted devices and sessions.
- Every step after `start` is bound to the browser: the state, the 2FA challenge, the account-choice ticket and the signup ticket all carry the binding hash (`SsoTicketService`, one cache entry per `SsoTicketType`). Keep that when adding a step.
Expand Down
3 changes: 3 additions & 0 deletions .claude/rules/backend/team-access.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ paths:
- "backend/app/DomainObjects/Enums/Permission.php"
- "backend/app/DomainObjects/Enums/AccountUserAccessScope.php"
- "backend/tests/Feature/Http/TeamAccess/**"
- "backend/app/Http/Request/EventSettings/**"
- "frontend/src/components/routes/event/Settings/Sections/PaymentSettings/**"
- "frontend/src/hooks/useAccess.ts"
- "frontend/src/components/common/TeamAccess*/**"
- "frontend/src/components/common/WriteAccess/**"
Expand All @@ -21,6 +23,7 @@ paths:
- A restricted member with no grants has no access, never full access. An event grant also gives read-only access to its organizer (`isOrganizerReadAuthorized`); an organizer grant covers its future events too.
- Treat any value that unlocks a public page as a credential. Staff responses return `orders.short_id` only to members with EDIT + VIEW_FINANCIALS; check-in list links are shareable with door staff on purpose.
- Deliberate, don't "fix": VIEW_FINANCIALS hides money in the dashboard but doesn't stop an EDIT member from configuring outbound channels (organizer email, email templates, analytics pixels) or running the door, which shows door sales.
- Free-text fields that tell buyers where to send money are payment settings. Changing an event's `offline_payment_instructions` needs MANAGE_PAYMENTS (Admins only by default) and a session: `UpdateEventSettingsRequest::after()` returns a 422 on the field for an API key or a member without it, on both PUT and PATCH. Re-sending the saved value (or one that purifies to it) always passes, so round-trips keep working; a PUT that omits it counts as clearing it. The payment settings form shows the instructions read-only to those members and re-sends the saved value. Same pattern as GTM containers in `PartialUpdateOrganizerSettingsRequest`.
- Public "same account" checks (draft previews, test orders, check-in staff view) use `AccountUserAccessService::currentMemberCanAccessEvent/Organizer`, never a raw account id comparison.
- `AccountUserAccessService` caches access per request keyed by `AuthUserService::getAuthenticatedTokenKey()` (the JWT string, or `api-key:{id}` for API keys); never key it on decoded payload claims (resources call `isPermitted()` hundreds of times per list).
- Frontend: 403s carrying `ACCESS_RESTRICTED`, `PERMISSION_REQUIRED` or `READ_ONLY_ROLE` must never send the user to login.
7 changes: 4 additions & 3 deletions .claude/rules/ee/enterprise-sso.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,12 +8,13 @@ paths:
# Enterprise single sign-on

- Only per-account connections are ee (`backend/ee/Sso`, `frontend/src/ee/sso`); social login and the operator's `SSO_OIDC_*` provider stay in core (`.claude/rules/backend/sso.md`). Core calls into ee directly (`LoginService`, `EnsureTokenNotRevoked`, `CreateAccountHandler`, `SsoProviderRegistry`).
- An account's connection is a provider with key `connection-{id}` resolved by `SsoProviderRegistry::get`, so the phase 1 start/exchange flow serves it; it only accepts the LOGIN, REAUTH and TEST intents, and inactive connections only TEST.
- An account's connection is a provider with key `connection-{id}` resolved by `SsoProviderRegistry::get`, so the phase 1 start/exchange flow serves it; it only accepts the LOGIN, LINK, REAUTH and TEST intents, and inactive connections only TEST.
- A connection binds tokens to itself through `aud` plus `tid` (Entra) or `hd` (Workspace). Its emails are trusted only when the provider marks them verified (`email_verified`, Entra `xms_edov`) AND the domain is verified and not suspended for that account. Never trust them through the phase 1 resolver; connection logins go through `EnterpriseSsoResolver`.
- A connection never email-links an account owner or a superadmin, and its identities can't confirm identity (REAUTH) for users who belong to other accounts: otherwise an admin who re-points the connection at their own provider could take over other users.
- A connection never email-links an account owner or a superadmin, and its identities can't confirm identity (REAUTH) for users who belong to other accounts: otherwise an admin who re-points the connection at their own provider could take over other users. A superadmin never links, signs in or confirms identity through a connection, even with an identity on it (`SsoConnectionLinkService::isSuperAdmin`): a tenant's identity provider must never authenticate platform staff. `SSO_LINK_REQUIRES_LOGIN` carries `context.email` (so the callback says to connect it in Security settings) only when the email is trusted and the single matching user could link this connection; otherwise the context is empty and the message says to use the password.
- Members link a connection themselves from Security settings (LINK, after `IdentityConfirmationService`), the owner included, so the owner can sign in through it without the break-glass email. `SsoConnectionLinkService::assertCanLink` requires an ACTIVE membership of the connection's account and refuses superadmins, at start and again at exchange (`SSO_PROVIDER_NOT_CONFIGURED` otherwise); linking never grants membership or changes a role. `GET /users/me/sso-identities` lists the linkable connections in `meta.linkable_connections` (none for a superadmin).
- Every outbound request to a provider goes through `SsrfGuard` (https, public addresses only, pinned with `CURLOPT_RESOLVE`) unless `SSO_ALLOW_PRIVATE_ISSUERS` is set; never set it on multi-tenant installs.
- Domain status is derived from `verified_at` and `failing_since` (suspended seven days after the daily check starts failing); one account per verified domain, conflicts are resolved with `sso:verify-domain`.
- Enforcement is `account_sso_connections.enforce_sso` plus the `account_users.sso_enforced` snapshot (set at enable and when someone joins, cleared only by an admin's exempt toggle). It's checked in `LoginService::completeLogin` and `EnsureTokenNotRevoked`; tokens must carry the account's `sso_connection_id`, and a token issued through a connection dies when that connection is turned off or deleted. A member the connection can't sign in (owner of any account or superadmin, with no identity on it) is not enforced, so they keep their password; the preview reports them as `sso_available: false`. A verified domain can't be removed while enforcing. The owner is never enforced: their password sign-in always asks for 2FA, emails every admin (the owner gets their own copy), and neither they nor support can turn their 2FA off while enforcing.
- Enforcement is `account_sso_connections.enforce_sso` plus the `account_users.sso_enforced` snapshot (set at enable and when someone joins, cleared only by an admin's exempt toggle). It's checked in `LoginService::beginLogin` (members only, before a 2FA challenge is issued), `LoginService::completeLogin` and `EnsureTokenNotRevoked`; tokens must carry the account's `sso_connection_id`, and a token issued through a connection dies when that connection is turned off or deleted. `usersWhoCannotUseConnection` is the one answer to who the connection can't sign in: every superadmin, and an owner of any account without an identity on it (for Workspace, a shared Google identity counts only when its email is on the hosted domain). Those members still get the snapshot but are not enforced: enabling or requiring them doesn't revoke their sessions or API keys, they keep their password until they link it, the preview reports `sso_available: false` and the Team page shows `PASSWORD`. A verified domain can't be removed while enforcing. The owner is never enforced: their password sign-in always asks for 2FA, emails every admin (the owner gets their own copy), and neither they nor support can turn their 2FA off while enforcing.
- Forgot-password silently sends nothing when `isPasswordResetAllowed` is false; `/users/me` exposes the same answer as `password_reset_allowed` so Security settings doesn't offer a password link that never arrives.
- Domain capture: while a connection enforces, nobody can sign up with an email on its usable domains. `SsoEnforcementService::assertSignupAllowed` throws `SsoRequiredException` from `CreateAccountHandler` (password and social signup) and before a social signup ticket is issued; the frontend sends them to SSO discovery. Invitations to other accounts are not captured.
- Tokens issued through a connection use its session length and can't be refreshed (`SSO_SESSION_EXPIRED`); the frontend re-authenticates with `prompt=none`.
Expand Down
3 changes: 2 additions & 1 deletion .claude/rules/features/lineup.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,4 +15,5 @@ paths:
- People and stages match by `nameKey` on the frontend and `ImportLineupHandler::key` on the backend; keep the two identical or the preview lies.
- The import preview mirrors every server validation limit so one bad cell never fails the whole import. Change both sides together.
- `ImportLineupHandler` writes through repositories in bulk; don't route it through the per-row upsert handlers (far too slow for 1,000 slots).
- Per-date lineups for recurring events are derived from slot times (`lineupForOccurrence`), never stored.
- Per-date lineups for recurring events are derived from slot times (`lineupForOccurrence`), never stored. When the Schedule section is hidden the public payload has no slots, so on recurring events only each entry carries `schedule_days` (dates only, computed on read) and `lineupForOccurrence` filters on those. Single-date events never get `schedule_days`: a hidden schedule must not reveal which day each act plays.
- Ticket-email lineup highlights (caps, headliner/featured fallback, preferred slots) are picked by `LineupHighlightSelector`, which the spam check reuses to label that content `ticket email …` and put it first in the prompt. Change the selection there, never in a copy.
4 changes: 3 additions & 1 deletion .claude/rules/features/widget.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,9 @@ paths:
- Attributes reach the iframe as PascalCase params (`data-hievents-continue-button-text` → `ContinueButtonText`). `resolveWidgetConfig` maps them onto the snake_case keys that saved widgets store. Host-page `aff`, `promo_code` and `utm_*` are forwarded as-is.
- Language: a `data-hievents-locale` attribute wins, then a saved widget's `locale` (the SSR loader sets `widgetLocale` on the request context; the client reads it from the hydrated loader data), then the visitor's language. Old snippets hardcode `data-hievents-locale="en"` with `data-hievents-widget-version="1.0"`; that pair is ignored.
- A saved widget's embed carries only what the host page needs (`HOST_KEYS` in `WidgetEditor/embedCode.ts`); the rest comes from `/public/widgets/{short_id}`, and attributes override it. The Button type's text, colours and radius are drawn by `widget.js` before anything loads, so they live in the embed code; the editor tells users to re-copy the code when a saved widget's embed attributes change. Orders store `widget_id` for stats (seat-picker orders too). Views count anonymous loads, deduped per IP and written in batches (`WidgetViewIncrementService`), never one UPDATE per load.
- Widget routes report the height of `WidgetFrame`'s inner wrapper, never `document.scrollHeight`, so they can shrink. Dialogs inside auto-resized embeds are placed near the click and clamped to fit (`EmbeddedDialogOffset`); Mantine's default sizing uses `100dvh`, which is the whole content height in an auto-resized iframe.
- Saving a widget looks up every newly sent product, category and date id on the event and returns 422 on a miss. Ids already stored on the widget that have since been deleted are dropped silently, so the widget can still be saved or renamed; when that empties a selection the widget shows all tickets (or any date), and the editor previews this before saving (`savedSettings`).
- A missing event or organizer sets `notFound` in the loader data; widget components read it from `useWidgetRuntime()` and turn their queries off, so server and client render the same message.
- Widget routes report the height of `WidgetFrame`'s inner wrapper, never `document.scrollHeight`, so they can shrink. Dialogs inside auto-resized embeds are placed near the click, inside the part of the iframe the visitor can see (measured with an `IntersectionObserver`), and `WidgetFrame` reports extra height while one is open so the iframe can grow (`EmbeddedDialogOffset` + `embeddedDialogPlacement.ts`); Mantine's default sizing uses `100dvh`, which is the whole content height in an auto-resized iframe.
- Widget routes render real content on the server, so any input that appears before hydration must stay disabled until `useIsHydrated()` (see `NumberSelector`), or the visitor's input is wiped.
- The editor preview loads the real widget route with `Preview=true` and pushes settings over `postMessage`; preview mode never creates orders.
- Widgets run on third-party pages: keep widget routes free of heavy imports, and let the SSR loaders prefetch everything the first paint needs.
27 changes: 27 additions & 0 deletions .github/workflows/e2e.yml
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,33 @@ concurrency:
cancel-in-progress: ${{ github.event_name == 'pull_request' }}

jobs:
unit:
name: e2e unit specs
runs-on: ubuntu-latest
timeout-minutes: 10

permissions:
contents: read

steps:
- name: Checkout code
uses: actions/checkout@v4

- name: Set up Node
uses: actions/setup-node@v4
with:
node-version: 22
cache: npm
cache-dependency-path: e2e/package-lock.json

- name: Install dependencies
working-directory: e2e
run: npm ci

- name: Run pure-logic specs
working-directory: e2e
run: npx playwright test --config tests '\.unit\.spec\.ts$' --forbid-only --reporter=github,list

plan:
runs-on: ubuntu-latest
outputs:
Expand Down
31 changes: 15 additions & 16 deletions .github/workflows/tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -4,14 +4,29 @@ on:
push:
branches: [main, develop, 'v[0-9]+.[0-9]+.[0-9]+-*']
paths:
- 'VERSION'
- 'backend/**'
- '.github/workflows/tests.yml'
pull_request:
paths:
- 'VERSION'
- 'backend/**'
- '.github/workflows/tests.yml'

jobs:
version:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4

- name: Check backend/VERSION matches VERSION
run: |
if [ "$(cat VERSION)" != "$(cat backend/VERSION)" ]; then
echo "::error file=backend/VERSION::backend/VERSION ($(cat backend/VERSION)) does not match VERSION ($(cat VERSION))"
exit 1
fi

tests:
runs-on: ubuntu-latest

Expand All @@ -20,9 +35,6 @@ jobs:
php-versions: ['8.3', '8.4', '8.5']

services:
# Postgres is started for the Feature suite. The Unit suite does not need
# a live connection — it only reads DB_DATABASE for the _test guard check
# in CreatesApplication — so it runs in parallel with Postgres warming up.
postgres:
image: postgres:15
env:
Expand All @@ -37,9 +49,6 @@ jobs:
--health-timeout 5s
--health-retries 5

# Job-level env. .env.testing supplies the rest, but the DB host on a CI
# runner is 127.0.0.1 (service container exposes its port on the runner),
# not the docker network alias used locally — override here.
env:
DB_HOST: 127.0.0.1
DB_PORT: 5432
Expand Down Expand Up @@ -78,16 +87,9 @@ jobs:
run: cd backend && composer install --prefer-dist --no-progress --no-interaction

- name: Stage .env for testing
# Laravel auto-loads .env.testing when APP_ENV=testing, but artisan
# commands run outside that flow read .env directly. Copy .env.testing
# to .env so both paths see the same config.
run: cp backend/.env.testing backend/.env

- name: Run Unit test suite
# Pure unit tests — no DB connection, no migrations. The CreatesApplication
# bootstrap detects the absence of DatabaseTransactions / RefreshDatabase
# traits and skips migrate:fresh entirely. Runs in parallel with the
# Postgres service container coming up.
run: cd backend && ./vendor/bin/phpunit --testsuite=Unit --no-coverage

- name: Wait for Postgres
Expand All @@ -102,7 +104,4 @@ jobs:
exit 1

- name: Run Feature test suite
# Integration tests against the real PostgreSQL test database. The first
# test that boots Laravel triggers migrate:fresh once per process via
# CreatesApplication::ensureTestDatabaseIsMigrated.
run: cd backend && ./vendor/bin/phpunit --testsuite=Feature --no-coverage
2 changes: 1 addition & 1 deletion VERSION
Original file line number Diff line number Diff line change
@@ -1 +1 @@
2.0.0-beta
2.0.0-rc.3
7 changes: 5 additions & 2 deletions backend/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ APP_LICENCE_KEY=
APP_TRUSTED_PROXIES=*
# Optional. Only needed when APP_TRUSTED_PROXIES doesn't include the frontend server; set the same value on the frontend
APP_SSR_SHARED_SECRET=
# Optional. API requests a minute per user, shared by the web app and all of that user's API keys
# APP_API_RATE_LIMIT_PER_MINUTE=180
APP_SAAS_STRIPE_APPLICATION_FEE_PERCENT=1.5
APP_HOMEPAGE_VIEWS_UPDATE_BATCH_SIZE=8
APP_DISABLE_REGISTRATION=false
Expand Down Expand Up @@ -111,9 +113,10 @@ GOOGLE_MAPS_API_KEY=
# Only set this when the provider verifies every email address it issues
# SSO_OIDC_TRUST_EMAIL=false
# Turn off email and password sign-in for everyone except super admins
# Users who have not linked SSO can only get in if the provider vouches for their email; for SSO_OIDC_* that needs
# SSO_OIDC_TRUST_EMAIL=true. Preview how many users are affected before turning it on:
# SSO_PASSWORD_LOGIN_DISABLED=true php artisan sso:status
# SSO_PASSWORD_LOGIN_DISABLED=false
# Hide the sign-in buttons until launch; add ?sso=1 to the sign-in page URL to see them
# SSO_BUTTONS_VISIBLE=true
# Let identity providers on private networks or plain http be used, for example an internal Keycloak.
# Never set this on an install where people you don't trust can manage an account's single sign-on.
# SSO_ALLOW_PRIVATE_ISSUERS=false
2 changes: 1 addition & 1 deletion backend/VERSION
Original file line number Diff line number Diff line change
@@ -1 +1 @@
2.0.0-alpha.1
2.0.0-rc.3
Loading
Loading