Sign-in, Sessions and API Keys
How people, respondents and programs prove who they are in Sondely today, and how the TypeScript app does it. Read from the code on 5 November 2026: sondely/backend/app/api/auth/routes.py, backend/app/utils/auth.py, backend/app/utils/permissions.py, backend/app/core/security.py, backend/app/core/redis.py, backend/app/middleware/rate_limit.py, backend/app/api/api_keys/routes.py, backend/app/main.py, and the frontend in frontend/src. The target copies TourCockpit's src/lib/auth/* and src/lib/api/auth.ts at commit f952d0eea. Every route named here is in the API Route Map by its number (A1 to A17, K1 to K3); the tables are on Data Model.
How a request is identified today
Every protected route depends on get_current_user in backend/app/utils/auth.py. It reads one bearer token and tries three things in order. Nothing else is checked: no scope, no organization, no revocation list.
The organization on an API key is stored on the user object as _api_key_org_id (backend/app/utils/auth.py, line 68) and is read nowhere else in the backend. The JWT's scope claim (set to respondent by auto-enroll) is read nowhere either.
Sign-in methods today
| Method | Routes | Code | What it stores | What the caller gets | Notes |
|---|---|---|---|---|---|
| Password, register | A1 POST /api/v1/auth/register |
register, backend/app/api/auth/routes.py line 122 |
User with a bcrypt hash (passlib CryptContext(schemes=["bcrypt"]), backend/app/core/security.py); a verification token |
JWT and Redis session token, signed in at once | Minimum length 6 (UserCreate, backend/app/models/user.py). "Email already registered" tells anyone which emails have accounts. Signs the user in before the email is proven |
| Password, sign-in | A2 POST /api/v1/auth/login |
login, line 169 |
Redis session | JWT and session token | No rate limit. An unknown email returns before any bcrypt work, so the response time tells which emails exist |
| Email verification | A3 GET /api/v1/auth/verify-email, A4 resend |
verify_email line 184, resend_verification line 203 |
email_verifications: user id, token (secrets.token_hex(32), stored in plain text), 24 hours |
A redirect to /auth/signin?verified=true |
Nothing in the backend requires email_verified: an unverified account can do everything a verified one can |
| Password reset | A5 POST /forgot-password, A6 POST /reset-password |
lines 237, 263 | password_resets: token in plain text, 1 hour, used_at |
A message | A5 answers the same text whether the email exists. A6 does not end existing sessions |
| Passwordless code | A7 POST /request-otp, A8 POST /verify-otp |
lines 308, 340 | otp_codes: 6 digits, bcrypt hash, 10 minutes, 5 attempts per code, 5 codes per email per hour |
JWT and session token; a new email becomes an account, an email-only account is promoted and marked verified | The one path that proves the email before signing in |
A9 GET /auth/google, A10 GET /auth/google/callback |
lines 464, 501 | oauth_states (15 minutes, a validated return_origin); the user, linked by google_id, else by email |
A redirect to {origin}/auth/google/callback?access_token=...&session_token=... |
Links an existing account by email, including one whose password was set by someone else and never verified. Both tokens travel in the URL | |
| Apple | A11 GET /auth/apple, A12 POST /auth/apple/callback |
lines 621, 651 | oauth_states; the user, linked by apple_id, else by email |
A 303 redirect with both tokens in the URL | Reads the id_token with get_unverified_claims (line 688): the signature, audience and expiry are not checked. The token comes straight from Apple's token endpoint over TLS with Sondely's client secret, so this is a missing check, not an open door |
| Auto-enroll | A13 POST /auth/auto-enroll |
auto_enroll, line 753 |
A new email-only user and a RESPONDENT membership in the org named in the body | A 4-hour JWT with scope: respondent. For an email that already has an account, a token for that account |
survey_id in the body is never read. See the holes below |
| Upgrade | A14 POST /auth/upgrade |
upgrade_account, line 819 |
The password on an email-only account | JWT and session token | Needs no credential: the email comes from the body. "User not found" (404) tells anyone which emails exist |
| Sign-out | A15 POST /auth/logout |
logout, line 854 |
Nothing | A message | Revokes nothing. The JWT stays valid until it expires and the Redis session for 24 hours |
| Who am I | A16 GET /auth/me, A17 GET /auth/session |
lines 860, 889 | UserMeResponse with roles and is_admin |
is_admin carries the rule described under open decisions |
|
| Tenant enrollment | inside A1, A8, A10, A12 | _ensure_tenant_enrollment, line 94 |
A RESPONDENT membership in the org whose custom_domain matches, and assignments to its published surveys |
For A1 and A8 the host is the request's Host header, which a direct API call sets freely; for A10 and A12 it is the validated return_origin |
Tokens and sessions today
| Credential | Format and lifetime | Where it lives | Revocation |
|---|---|---|---|
| Access token | HS256 JWT, sub = user id, 30 minutes (ACCESS_TOKEN_EXPIRE_MINUTES, backend/app/core/config.py). Signed with SECRET_KEY from Key Vault in the dev and prod container apps (catalyzeup-devops/azure/main.tf); the code default is a placeholder used only when the variable is missing |
Browser localStorage (access_token), sent by both clients (frontend/src/api/client.ts, frontend/src/utils/api.ts) |
None |
| Session token | secrets.token_urlsafe(32) as a Redis key, 24 hours (_create_tokens_and_session, line 57; backend/app/core/redis.py) |
Browser localStorage (session_token), never sent by the frontend (inferred from a search of frontend/src: the token is stored, not read) |
None from the app. Without Redis, connect logs a warning and sessions are silently not stored |
| Respondent token | The same JWT with scope: respondent, 4 hours |
In memory in the take page (setSessionOnlyToken, frontend/src/api/client.ts) |
None |
| API key | ip_live_ plus 32 hex characters; first 12 characters stored as key_prefix and looked up with find_one, full key bcrypt-hashed; expires_at exists but K1 never sets it (backend/app/api/api_keys/routes.py) |
The integrator | revoked_at (K3) |
REFRESH_TOKEN_EXPIRE_MINUTES |
Defined in config.py, used nowhere |
Because the frontend sends only the 30-minute JWT and every 401 clears localStorage and redirects to /auth/signin, a person is signed out mid-task after 30 minutes regardless of the 24-hour session (inferred from the clients' 401 handlers, not observed).
The key prefix carries only 4 random hex characters (65,536 values) and the lookup is find_one by prefix: two live keys that share a prefix cannot both work, because only one document comes back to be verified (inferred from code, not run).
Target design
Credentials
| Credential | Who holds it | How it is presented | Resolves to |
|---|---|---|---|
| Session | A person in the browser | At cutover: Authorization: Bearer <token>, the same contract the Vite app uses. When the screens move to Next: an httpOnly cookie |
The user; the role is read per organization on each request |
| API key | A program | Authorization: Bearer <key> |
One organization and one scope; the actor in the audit is apikey:<id>, never the person who created it |
| Respondent grant | A person taking one survey without an account | Authorization: Bearer <grant> (in memory, as today) |
One survey in one organization, until it expires |
| Test seam | The e2e and contract suites | POST /api/v1/test/sign-in |
A real session, only when ENVIRONMENT=test |
All three stores hold a SHA-256 hash of the token and are looked up by that hash, as TourCockpit does with hashToken (src/lib/auth/tokens.ts) and hashApiKey (src/lib/api/auth.ts). The guard for each route comes from its registry Rbac (public, self, grant, member, admin, owner, test), defined on the API Route Map.
Roles
| Today | After |
|---|---|
Role enum in backend/app/models/enums.py has ADMIN and RESPONDENT; OWNER is a bare string used across the routes |
Prisma enum MemberRole { OWNER ADMIN RESPONDENT }, stored lower case in the API: owner, admin, respondent |
The survey creator passes every survey check without a role (get_survey_with_org_access), even after leaving the organization, and an admin creator can delete all responses, an owner-only action |
Roles only. The creator is recorded, not trusted (an open decision below) |
| Respondents read the member list and pending invitations (O9, O14) | Admin and owner only |
| An admin may remove another admin (O12); an owner may be demoted, leaving none (O13) | Removing an admin is owner-only; the last owner cannot be removed or demoted (LAST_OWNER) |
Passwords
- Argon2id through
@node-rs/argon2, with TourCockpit's parameters (19 MiB, 2 passes, 1 lane), copied as is fromsrc/lib/auth/password.ts. - bcrypt accepted once, then rehashed. TourCockpit's
verifyPasswordonly reads argon2 digests. Sondely's copy adds one branch: a digest starting$2a$,$2b$or$2y$is checked withbcryptjs(pure JavaScript, so the musl image needs no build tools); on success the password is hashed with argon2id and stored in the same transaction.needsRehashalready returns true for any digest that is not argon2id. A test signs in with a real passlib digest from a Python fixture and asserts the stored digest starts$argon2id$afterwards. - The unknown-email branch spends the same work as a real check (
verifyAgainstDummy), which closes today's timing oracle on A2. - Policy from
src/lib/auth/password-policy.ts: minimum 6 (today's rule, kept so no existing password is refused), maximum 256, the common-password and own-email checks.
Sessions in Postgres
- TourCockpit's
Sessionmodel:tokenHash,expiresAt,lastSeenAt, sliding expiry with an absolute ceiling (src/lib/auth/session.ts,src/lib/auth/config.ts). TourCockpit's windows are 90 days sliding and 180 absolute; Sondely's are set in its own config (proposed: 30 days sliding, 90 absolute, since owners hold respondent data). - Bearer contract at cutover. A1, A2, A8 and A14 keep returning
access_token,session_tokenandtoken_type: bearer. Both fields carry the same opaque session token. The frontend never decodes the token (no JWT parsing anywhere infrontend/src), so this changes nothing on screen and ends the 30-minute sign-outs. JWTs and Redis go away; everyone signs in again once at cutover, which falls inside the write freeze. - A15 deletes the session row. A6 (reset) and any password change call
destroyAllSessions. - Then the httpOnly cookie, when the screens move to Next: a cookie named for Sondely in place of
tp_session, httpOnly,Securein production,SameSite=Lax, host-only. 17 files infrontend/srcreadaccess_tokenfrom localStorage today; each stops when its screen moves. The bearer path for browsers is removed when the last screen has moved; bearer stays for API keys and grants. - Custom domains. A host-only cookie means a person signs in on the domain they use. Google and Apple keep one registered redirect URI on the canonical host, so the callback hands back to the tenant domain with a short-lived signed payload (
src/lib/auth/signed-payload.ts, the waysrc/lib/auth/google-return.tsdoes it) that the tenant host exchanges for its own cookie. Tokens never travel in a URL again.
One-time codes on the magic-link storage
TourCockpit's MagicLinkToken (tokenHash, kind with no default, expiresAt, consumedAt) holds every single-use secret:
| Kind | Today | After |
|---|---|---|
SIGN_IN_CODE (new) |
otp_codes with a bcrypt hash |
6 digits stored as an HMAC with a server secret (a bare SHA-256 of a 6-digit code is reversed offline in moments); plus email and attempts. 10 minutes, 5 attempts, codes per email and per IP limited |
VERIFY_EMAIL (new) |
email_verifications, plain text |
Hashed, 24 hours |
RESET |
password_resets, plain text, 1 hour |
Hashed, TourCockpit's RESET_LINK_TTL_MS (30 minutes) |
SET_PASSWORD |
not used | Kept from TourCockpit for invitations that set a password |
A3 stays a GET for the link in old emails, and consumes the token; A8 keeps today's behaviour (a new email becomes an account, an email-only account is promoted).
Google and Apple
- Google is ported from
src/lib/auth/google.tsandsrc/app/api/auth/google/callback/route.ts, keeping Sondely'sreturn_tovalidation (canonical hosts plus organization custom domains) and the rate limit TourCockpit puts on the callback. - Where Google sign-in starts and returns (fixed 9 November 2026, after "Continue with Google" answered 422 on platform.sondely.com). Behind the Container Apps ingress the app sees its own address,
http://localhost:8000, so the address the browser used is read from theHostheader, with the scheme fromX-Forwarded-ProtowhenTRUST_PROXYis true. A start on any host other thanAPP_BASE_URL's (a custom domain, the*.azurecontainerapps.ioaddress) is redirected to the canonical host first, because Google returns there and the binding cookie must be set there; hosts are compared, never full origins, so a missing forwarded scheme cannot loop. Without an explicitreturn_to, sign-in returns to the app: no custom-domain page exchanges the handoff yet, so returning to a custom domain would leave the person signed out. A handoff exchanged without anOriginheader is bound to the public host. Tests:tests/integration/review-oauth.test.ts. The Google client listshttps://platform.sondely.com/api/v1/auth/google/callbackand the dev equivalent as redirect URIs (added by Chip on 9 November 2026). - Apple is written new (TourCockpit has none): a
form_postcallback, the client secret as an ES256 JWT as today, and theid_tokenverified against Apple's published keys (issuer, audience, expiry, nonce) before any claim is read. The name arrives only on the first authorization and is saved then. Relay addresses (privaterelay.appleid.com) are treated as the account's email. - Linking by email only when the provider says the email is verified. When a provider or a code proves an email whose account was never verified, any password on that account is cleared and its sessions are ended, so someone who registered with another person's email before them loses that access.
API keys
| Today | After | |
|---|---|---|
| Bound to | Nothing: the key acts as its creator in every org the creator belongs to | One organization; a key on another org's resource gets 404 NOT_FOUND |
| Scope | Whatever the creator may do | read (GET only), draft (create and edit drafts; never publish, delete, approve a translation or send email), full |
| Storage | 12-character prefix plus bcrypt | SHA-256 of the whole key, unique, looked up directly (TourCockpit ApiKey.hashedKey) |
| Expiry | Column exists, never set | Optional at creation (K1) |
| Who manages keys | OWNER/ADMIN, and any key whose creator is OWNER/ADMIN | Owner or admin, signed in. A key never creates, lists or revokes keys |
| Audit | last_used_at, request_count |
lastUsedAt, and every write in Audit with actor apikey:<id> |
| Rate limit | 60 a minute, in memory, per replica (the container apps run 1 to 3 replicas, catalyzeup-devops/azure/main.tf) |
Per key, shared across replicas |
The new prefix is a decision below; the scope check lives in the registry guard, so a route with no key scope refuses every key.
Respondent grants
A grant replaces the auto-enroll token. Model RespondentGrant: id, tokenHash, organizationId, surveyId, email, userId (when the email has an email-only user), expiresAt (4 hours, as today), revokedAt, createdAt.
| Rule | Why |
|---|---|
| A13 mints a grant only for an email with no account or an email-only one, and only for a published survey in that organization | Today it hands any account's token to anyone who knows the email |
An email with a real account gets SIGN_IN_REQUIRED; the take page sends a code (A7) and signs in (A8) |
The existing passwordless path already proves the email |
| Accepted only by S10, S11, S12, SE1 to SE6, SE8, SE9 and A14, for its own survey | Everything else answers GRANT_OUT_OF_SCOPE |
| A grant reads only what it wrote: S12 and the session reads return rows stamped with the grant id, never rows matched by email | Today my-responses matches by the email typed, so typing someone's email shows their history |
| A14 (upgrade) needs the grant and a code sent to the grant's email; the email comes from the grant, never the body | A password is set only on an email that was proven |
| A response submitted under a grant carries a claim token returned by S10; S11 requires it | Today any signed-in person can claim any unclaimed response by id |
The take page already holds the token in memory and sends it on the session calls; A14 is the one call that must start sending it (frontend/src/pages/SurveyTakePage.tsx, line 113 posts without a bearer today).
Rate limiting
TourCockpit's limiter (src/lib/auth/rate-limit.ts) is a fixed window in memory, written for one replica. Sondely runs up to three, so its copy keeps the same interface over a shared store: a RateLimitBucket table with one upsert per hit, or an in-memory limiter behind a single-replica setting (a decision below).
| Route | Limit (proposed, TourCockpit's numbers where it has the same route) |
|---|---|
| A2 sign-in | 20 an hour per IP, 5 per 15 minutes per email |
| A5 forgot password | 20 an hour per IP, 3 an hour per email |
| A7 code request | 20 an hour per IP, 5 an hour per email (today's rule) |
| A8 code check | 5 attempts per code (today's rule), 30 an hour per IP |
| A1 register, A13 auto-enroll | 20 an hour per IP |
| A10, A12 callbacks | 30 an hour per IP |
| S10 respond | 30 an hour per IP per survey |
| API keys | 60 a minute per key (today's number) |
Every refusal is 429 RATE_LIMITED with Retry-After, class retry.
CORS
Today backend/app/main.py passes allow_origins=["http://localhost:6291", "http://127.0.0.1:6291", "*"] with allow_credentials=True, allow_headers=["*"] and expose_headers=["*", ...]. Starlette then echoes the caller's origin back with credentials allowed. The cors_origins setting in config.py is not used. Because the token sits in localStorage rather than a cookie, a hostile page cannot ride the session today; it would the day the cookie arrives.
After: the app is one origin per host (Next serves the screens and the API, custom domains included), so /api/v1 sends no CORS headers to browsers by default. An explicit allowlist from configuration covers anything that must call from another origin; * is never combined with credentials. API keys are server-to-server and need no CORS.
Security holes today and how the new design closes each
The first four are the ones named in Sondely as an Agent, step 0, which Chip has accepted for now. The rest were found reading the code for this page and have no decision yet.
| # | Hole | Where | What it allows | Closed by | Proven by |
|---|---|---|---|---|---|
| 1 | Auto-enroll returns a token for any existing account, and the respondent scope is never checked | A13, auto_enroll line 753; get_current_user |
Anyone who knows an owner's email and the org id (public on every survey page) acts as that owner for 4 hours | Grants are their own table and credential; an existing account gets SIGN_IN_REQUIRED; grants pass only grant routes |
A test calls A13 with an owner's email and asserts no session; a grant on S1 answers GRANT_OUT_OF_SCOPE |
| 2 | Upgrade sets a password from an email alone | A14, upgrade_account line 819 |
Taking over any email-only account, for example every respondent created by auto-enroll | Grant plus a code; email from the grant | A test upgrades without a grant (UNAUTHORIZED) and with a grant but no code (refused) |
| 3 | API keys act as their creator; the org on the key is never enforced | _resolve_api_key, backend/app/utils/auth.py; _api_key_org_id unused |
A key made in one org reaches every org its creator belongs to, with the creator's full rights, including creating more keys | Org-bound keys with scopes; keys cannot manage keys | The tenant isolation test runs every registry route with a key from another org and expects 404 |
| 4 | CORS allows * with credentials |
backend/app/main.py |
Any origin is answered as trusted; harmless while tokens are in localStorage, an open door once a cookie is used | Same-origin by default, explicit allowlist | A test sends a foreign Origin and asserts no Access-Control-Allow-Origin |
| 5 | Anyone can join any organization as a respondent, and respondents read the member list and pending invitations | A13 with any org id; _ensure_tenant_enrollment from a forged Host on A1 and A8; O9 and O14 allow any member |
Every member's and invitee's email and name, for any organization, plus draft surveys through S3 | Grants create no membership; tenant enrollment from the resolved host in Next middleware, never a raw header on an API call; O9 and O14 become admin only | A respondent and a grant on O9 and O14 get FORBIDDEN |
| 6 | Unverified accounts are fully usable, and Google, Apple and a code link to them by email | A1, A10, A12, A8 | Registering first with someone else's email keeps a password into the account they later use | A proven email clears a never-verified password and ends its sessions | A test registers, then signs in by Google with the same email, and asserts the first password no longer works |
| 7 | my-responses and history match by the email typed on the take page |
S10 stores respondent_email as free text; S12, S13, S17, S18 match on it |
With issue 1 or 2, reading or deleting another person's answers; anyone can file answers under another email | Grants read their own rows; a signed-in respondent's responses carry their user id | A grant cannot read a response it did not write |
| 8 | Any signed-in person can claim any unclaimed response | S11 | Attaching someone's anonymous answers to one's own account (ids are guessable cuids, below) | Claim token from S10 | S11 without the token is refused |
| 9 | The survey creator bypasses role checks | get_survey_with_org_access, backend/app/utils/permissions.py |
A creator removed from the org keeps full access to the survey and its responses; an admin creator can delete all responses | Roles only (open decision below) | A removed creator gets 404 on S3 |
| 10 | Sign-out revokes nothing | A15 | A copied token works until it expires | Session rows, deleted on A15 and on reset | Use after A15 is 401 |
| 11 | Tokens in URLs | A10, A12 redirects | Tokens land in browser history and any logging proxy | Signed payload exchanged for a cookie | No redirect URL carries a session token |
| 12 | Single-use tokens stored in plain text, and ids from a non-cryptographic generator | email_verifications, password_resets; generate_cuid in backend/app/utils/cuid.py uses random, and invitation tokens are cuids |
A database read yields live reset links; invitation tokens and public ids are more predictable than they look | Hashed tokens from crypto.randomBytes (TourCockpit generateToken); ids kept as data, never as secrets |
A unit test asserts no token column holds the raw value |
| 13 | The deployed dev app exposes every email it would have sent | /api/v1/dev/emails (M2) is unauthenticated and registered when ENVIRONMENT is development, which the impactpulse container app sets with external ingress (catalyzeup-devops/azure/main.tf); send_email intercepts every message there |
Reading any sign-in code or reset link on dev, so signing in as anyone there; whether dev holds real people's data is not known | Test routes only under ENVIRONMENT=test, which no deployed app sets |
The guard test in API Route Map |
| 14 | No rate limit on password sign-in, register, auto-enroll or respond, and the API key limit is per replica | backend/app/middleware/rate_limit.py covers keys only |
Password guessing and form flooding | Shared limiter with the table above | Limits asserted per route |
| 15 | Timing and messages tell which emails have accounts | A2 timing, A1 "Email already registered", A14 404 "User not found" | Listing who uses Sondely | Dummy verify, A14 behind a grant; A1 keeps EMAIL_TAKEN (the sign-up form needs it) but is rate limited |
Timing test as in TourCockpit |
| 16 | Apple's id_token is read without verification |
A12, line 688 | Low today (the token comes from Apple directly) | Verified against Apple's keys | Test with a token signed by another key |
Open decisions
Legacy ip_live_ keys
Every existing key is stored as a bcrypt hash keyed by a 12-character prefix, so it cannot be turned into the new SHA-256 hash without the key itself. Keys are also unscoped today, so any carried-over key needs an organization and a scope assigned.
| Option | What happens | Cost |
|---|---|---|
| Keep accepting them | ApiKey gets a nullable legacyPrefix and legacyBcrypt. A bearer starting ip_live_ is looked up by prefix and checked with bcrypt; on success the SHA-256 hash is written and the bcrypt columns cleared, so each key pays the slow path once. Org is the key's organization_id; scope full (what it can do today, narrowed to one org) |
One more branch in resolveApiAuth, and the prefix collision stays possible until each key has been used once |
| Rotate at cutover | Every key is revoked; owners and admins make new ones with a scope | Every integration breaks at cutover until someone pastes a new key. Count of live keys in production not checked (the dev seed creates one, ip_live_wehappers_dev_key_00001, in backend/app/main.py) |
Either way new keys get a new prefix (proposed sd_live_) so the two kinds are told apart at a glance.
"/me with no memberships means admin"
In code:
backend/app/api/auth/routes.py, lines 870 to 871, in A16:is_admin = any(r["role"] in ("OWNER", "ADMIN") for r in roles) or len(roles) == 0. The comment says "Admin if OWNER/ADMIN in any org, or new user with no memberships".backend/app/api/dashboard/routes.py, lines 35 to 38, in D1: the admin dashboard is shown to admins and owners "OR new users with no memberships"; the respondent dashboard only to users whose every role is RESPONDENT.frontend/src/contexts/AuthContext.tsx, lines 31 to 32, readsis_adminas is;AdminRouteinfrontend/src/App.tsx(lines 69 to 93) sends anyone without it to/dashboard.
What it does: a person who signs up at sondely.com belongs to no organization, so they get the admin screens and can create their first one (O1 accepts any signed-in user). It is a screen rule only: every server route still checks the role in the organization concerned, so it grants no data. Its side effect is that anyone with no membership, for example a person who signed in by code only to see their own results, lands on an empty admin dashboard instead of the respondent one.
| Option | Effect |
|---|---|
| Keep it | Same screens as today; the TypeScript /me reproduces the rule for parity |
| Replace it with an explicit state | /me returns roles and a separate canCreateOrganization (or an onboarding state); is_admin means owner or admin somewhere and nothing else. Screens decide from the state; respondents without an org see the respondent dashboard |
Smaller ones
| Decision | Options |
|---|---|
| Creator rule (hole 9) | Drop it (roles only), or keep it only while the creator is still owner or admin |
| Rate limit store | A Postgres table shared by all replicas, or pin Sondely to one replica and keep TourCockpit's in-memory limiter |
| When the cookie replaces the bearer for browsers | With the first screen that moves, or after the last |
| Session windows | Proposed 30 days sliding, 90 absolute; TourCockpit uses 90 and 180 |
| Fix holes 5 to 16 in Python first, like 1 to 4 in phase 0 of the migration plan, or only in the TypeScript app | 5, 6 and 13 are the ones with the widest reach |