From 2a48c6f63d5226e53e10a22ae35b7167f020b759 Mon Sep 17 00:00:00 2001 From: countercheck Date: Sat, 30 May 2026 03:36:30 +0000 Subject: [PATCH 1/3] Fix ranking touch-drag on iOS (GH #49) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit survey-core defaults ranking's longTap to true, so on iOS Safari the first touch on a ranking item is treated as a scroll gesture and the page scrolls instead of starting the drag — reorder is unreachable on iPhone. SurveyJS's own docs recommend disabling longTap when users naturally swipe-to-drag. Disable it at the schema level via a one-shot Serializer side-effect (new surveyInit.ts) imported by both code paths that mount a Survey: the respondent runner and the admin preview. Idempotent and explicit about the shape it expects, so a future survey-core upgrade that renames the property fails loudly here rather than at the assignment site. Regression test asserts the live default — survey-core resetting it would silently re-break iPhone reordering. Co-Authored-By: Claude Opus 4.7 (1M context) --- frontend/src/SurveyRunner.tsx | 1 + frontend/src/admin/SurveyPreview.tsx | 1 + frontend/src/surveyInit.test.ts | 15 +++++++++++++++ frontend/src/surveyInit.ts | 19 +++++++++++++++++++ 4 files changed, 36 insertions(+) create mode 100644 frontend/src/surveyInit.test.ts create mode 100644 frontend/src/surveyInit.ts diff --git a/frontend/src/SurveyRunner.tsx b/frontend/src/SurveyRunner.tsx index 8614246..185b6c5 100644 --- a/frontend/src/SurveyRunner.tsx +++ b/frontend/src/SurveyRunner.tsx @@ -4,6 +4,7 @@ import { Survey } from 'survey-react-ui'; import { ApiError, fetchSurvey, submitResponse, type SurveyDetail } from './api'; import { RespondentLayout } from './RespondentLayout'; +import './surveyInit'; import { Card, CardBody, LoadingState } from './ui'; interface SurveyRunnerProps { diff --git a/frontend/src/admin/SurveyPreview.tsx b/frontend/src/admin/SurveyPreview.tsx index 062d389..d350429 100644 --- a/frontend/src/admin/SurveyPreview.tsx +++ b/frontend/src/admin/SurveyPreview.tsx @@ -2,6 +2,7 @@ import { useEffect, useMemo, useState } from 'react'; import { Model } from 'survey-core'; import { Survey } from 'survey-react-ui'; +import '../surveyInit'; import { Alert, Button } from '../ui'; interface Snapshot { diff --git a/frontend/src/surveyInit.test.ts b/frontend/src/surveyInit.test.ts new file mode 100644 index 0000000..b230137 --- /dev/null +++ b/frontend/src/surveyInit.test.ts @@ -0,0 +1,15 @@ +import { Serializer } from 'survey-core'; +import { describe, expect, it } from 'vitest'; + +import './surveyInit'; + +describe('surveyInit', () => { + // Regression test for GH #49: on iOS Safari the default longTap=true makes + // the initial touch on a ranking item scroll the page instead of starting + // a drag. Disabling longTap is what restores tap-and-drag reorder on mobile, + // so a future bump of survey-core that resets the default would silently + // re-break iPhone reordering — this test catches that. + it('defaults ranking longTap to false so touch drag starts without a long-press', () => { + expect(Serializer.findProperty('ranking', 'longTap').defaultValue).toBe(false); + }); +}); diff --git a/frontend/src/surveyInit.ts b/frontend/src/surveyInit.ts new file mode 100644 index 0000000..fddf25a --- /dev/null +++ b/frontend/src/surveyInit.ts @@ -0,0 +1,19 @@ +import { Serializer } from 'survey-core'; + +// Make ranking reorder respond to a normal drag on touch devices instead of +// requiring a 500ms long-press first. With the default longTap=true, iOS +// Safari treats the initial touch as a scroll gesture and the page scrolls +// instead of starting the drag (see GH #49). SurveyJS's own docs recommend +// disabling longTap when users naturally swipe-to-drag rather than long-press. +// +// Idempotent: re-importing this module does not re-toggle the default. +// Explicit null-check so a future survey-core upgrade that renames the +// property fails loudly here rather than with a confusing "Cannot set property +// 'defaultValue' of null" at the assignment site. +const longTap = Serializer.findProperty('ranking', 'longTap'); +if (!longTap) { + throw new Error( + "survey-core ranking property 'longTap' not found — has the API changed? See GH #49.", + ); +} +longTap.defaultValue = false; From da4306d72f9d08cb8c8595fb1ced8108cea3b784 Mon Sep 17 00:00:00 2001 From: countercheck Date: Sat, 30 May 2026 03:36:43 +0000 Subject: [PATCH 2/3] Document direct Postgres access from a laptop in infra/README MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Inline the analyst/reviewer connect flow that previously just pointed at docs/verification/m7.6-demo-to-prod.md: enable the opt-in TCP proxy, grab the role password from a tofu output, connect with psql. Calls out the choice between stele_analyst (marts) and stele_pii_reviewer (pii), and the gotchas worth knowing up front — the postgresql:// vs. postgresql+psycopg:// driver tag, the database_name default, and the NOINHERIT SET ROLE step for a per-user credential minted by the M3.5 provisioning CLI. Demo seed + secret rotation stay as a pointer. Co-Authored-By: Claude Opus 4.7 (1M context) --- infra/README.md | 54 +++++++++++++++++++++++++++++++++++++++++++------ 1 file changed, 48 insertions(+), 6 deletions(-) diff --git a/infra/README.md b/infra/README.md index 6c4dc8b..e290c16 100644 --- a/infra/README.md +++ b/infra/README.md @@ -116,13 +116,55 @@ it can't reach the private-network Postgres or the in-image entrypoint: railway ssh --service web # drops into a shell in the web container ``` -## Analyst access, demo seed, secret rotation +## Connecting to Postgres directly (analysts, reviewers) -External analyst/reviewer database access (the opt-in `enable_postgres_proxy` TCP -proxy), seeding the initial admin (`seed` entrypoint verb), and rotating the -generated role passwords (`scripts/rotate_role_password.py` + the -`*_password_override` variables) are covered in -`docs/verification/m7.6-demo-to-prod.md`, along with the full demo→prod checklist. +Railway's Postgres sits on the project's **private network**, so there is no +public connection string until the opt-in TCP proxy is on. It is **off by +default** — a standing public 5432 is a deliberate exposure, so flip it on +while you need access and back off when you're done. + +```bash +# 1. Enable the proxy and apply. +echo 'enable_postgres_proxy = true' >> terraform.tfvars +tofu apply +tofu output postgres_proxy # → : + +# 2. Grab the role password. +tofu output -raw stele_analyst_password # marts (the warehouse) +tofu output -raw stele_pii_reviewer_password # pii (free-text review) + +# 3. Connect. +psql "postgresql://stele_analyst:@:/stele" +``` + +Pick the role by what you need to read: `stele_analyst` reaches `marts` only, +`stele_pii_reviewer` reaches `pii` only — that one-schema-each ceiling is what +keeps a leaked credential low-stakes (CLAUDE.md *Schemas* table). Both group +roles are LOGIN and hold the schema grant directly, so no `SET ROLE` step is +needed. + +**Gotchas:** + +- Use the plain `postgresql://` driver tag for psql — *not* the + `postgresql+psycopg://` tag the app's env vars use (that one's + SQLAlchemy-only). +- Default DB name is `stele` (`variables.tf` `database_name`); check your + `terraform.tfvars` if you overrode it. +- If you're using a **per-user credential** minted by the M3.5 provisioning + CLI (a personal NOINHERIT login role), the credential is privilege-less + until you `SET ROLE stele_analyst;` (or `…_pii_reviewer`) after connecting, + using the one-time password you revealed. + +When you're done, set `enable_postgres_proxy = false` and `tofu apply` to close +the public endpoint. + +## Demo seed and secret rotation + +Seeding the initial admin (`seed` entrypoint verb), rotating the generated role +passwords (`scripts/rotate_role_password.py` + the `*_password_override` +variables), and per-user credential provisioning/revoke are covered in +`docs/verification/m7.6-demo-to-prod.md`, along with the full demo→prod +checklist. ## State holds secrets From a2b7dceafc1d90387294998f711a8b80f55f4996 Mon Sep 17 00:00:00 2001 From: countercheck Date: Sat, 30 May 2026 04:07:51 +0000 Subject: [PATCH 3/3] Address Copilot review on the direct-Postgres section MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Use `tofu apply -var 'enable_postgres_proxy=true/false'` for both flip and unflip so re-running stays idempotent (the prior `echo … >> terraform.tfvars` appended duplicate assignments on a second pass) and matches the house style in "Rolling out a new build" (-var deploy_latest=true). - Split into "Operator shortcut" + "Per-user logins" subsections. The former spells out that the group-role passwords from tofu state are operator-wide secrets with no per-person audit trail — don't hand them out. The latter promotes the M3.5 provisioning CLI flow from a buried gotcha to a named peer subsection, which is the auditable/revocable path for anyone else. Co-Authored-By: Claude Opus 4.7 (1M context) --- infra/README.md | 44 ++++++++++++++++++++++++++++---------------- 1 file changed, 28 insertions(+), 16 deletions(-) diff --git a/infra/README.md b/infra/README.md index e290c16..1e9f68e 100644 --- a/infra/README.md +++ b/infra/README.md @@ -121,12 +121,20 @@ railway ssh --service web # drops into a shell in the web container Railway's Postgres sits on the project's **private network**, so there is no public connection string until the opt-in TCP proxy is on. It is **off by default** — a standing public 5432 is a deliberate exposure, so flip it on -while you need access and back off when you're done. +while you need access and back off when you're done. Two paths in, by +audience: the operator shortcut below, or per-user logins for anyone else. + +### Operator shortcut (group-role login) + +As the deployer you already hold the role passwords (they're in tofu state), +so the fastest path is logging straight in as the group role. **Don't share +these** — they're operator-wide secrets with no per-person audit trail; for +anyone else, mint a per-user login (next subsection). ```bash -# 1. Enable the proxy and apply. -echo 'enable_postgres_proxy = true' >> terraform.tfvars -tofu apply +# 1. Enable the proxy for this session — -var keeps it out of tfvars, +# so re-running stays idempotent. +tofu apply -var 'enable_postgres_proxy=true' tofu output postgres_proxy # → : # 2. Grab the role password. @@ -135,28 +143,32 @@ tofu output -raw stele_pii_reviewer_password # pii (free-text review) # 3. Connect. psql "postgresql://stele_analyst:@:/stele" + +# 4. Close the public endpoint when you're done. +tofu apply -var 'enable_postgres_proxy=false' ``` Pick the role by what you need to read: `stele_analyst` reaches `marts` only, -`stele_pii_reviewer` reaches `pii` only — that one-schema-each ceiling is what -keeps a leaked credential low-stakes (CLAUDE.md *Schemas* table). Both group -roles are LOGIN and hold the schema grant directly, so no `SET ROLE` step is -needed. +`stele_pii_reviewer` reaches `pii` only — that one-schema-each ceiling is +what keeps a leaked credential low-stakes (CLAUDE.md *Schemas* table). Both +group roles are LOGIN and hold the schema grant directly, so no `SET ROLE` +step is needed. -**Gotchas:** +### Per-user logins (for colleagues, auditable, revocable) + +Mint a personal NOINHERIT login role with the M3.5 provisioning CLI, deliver +the one-time password, and they connect to the same proxy host:port. The +login is privilege-less until they `SET ROLE stele_analyst;` (or +`…_pii_reviewer`) after connecting. Full provision + revoke flow: +`docs/verification/m7.6-demo-to-prod.md`. + +### Gotchas - Use the plain `postgresql://` driver tag for psql — *not* the `postgresql+psycopg://` tag the app's env vars use (that one's SQLAlchemy-only). - Default DB name is `stele` (`variables.tf` `database_name`); check your `terraform.tfvars` if you overrode it. -- If you're using a **per-user credential** minted by the M3.5 provisioning - CLI (a personal NOINHERIT login role), the credential is privilege-less - until you `SET ROLE stele_analyst;` (or `…_pii_reviewer`) after connecting, - using the one-time password you revealed. - -When you're done, set `enable_postgres_proxy = false` and `tofu apply` to close -the public endpoint. ## Demo seed and secret rotation