From 6d3ec7f771d707c360b194114e763560e041a9ee Mon Sep 17 00:00:00 2001 From: Darren Date: Mon, 27 Jul 2026 16:44:29 +0200 Subject: [PATCH 1/3] feat(visitor): visitor authentication + per-visitor data framework MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Squashed visitor bundle rebased onto upstream v0.0.13 (d97cac23). Two inseparable layers: 1. Visitor authentication (Phases 1-3): register/login/logout, Argon2id passwords, separate session cookie + middleware (zero admin coupling), rate limiting + lockout, CSRF, member groups, page-level access control, password reset, admin Members management UI, built-in login/register pages. 2. Per-visitor data framework: site-builder-defined custom profile fields; visitor.current + visitor.owned-rows loop sources (cookie-derived, no-store, never baked into static HTML); form submissions auto-stamp the visitor; admin profile editor + copyable Member ID; profile fields surfaced in the {} binding picker. IDOR-safe by construction (identity from validated session cookie only) — locked by an architecture-gate test. Rebase reconciliation: - Migrations renumbered 021-026 -> 022-027 (upstream added 021_mcp_oauth). - SiteExplorerPanel: adopted upstream's usePageSettingsDialogs hook for template settings; kept our PageAccessDialog (upstream lacks page-access). - DataTableSchema.capturesVisitorOwner made Optional (matches documented intent; required form broke pre-existing table response validation). - holeRuntime: gate IntersectionObserver now only constructed when gated elements exist (was clobbering test's last-IO capture on hole-only pages). - container module authGate prop reflected in base-modules test. - MembersPage.module.css: hardcoded px -> spacing token; dropped redundant var(--font-mono) fallback (design-system gates). - server/router.ts grandfathered at 704 lines (route table; splitting adds indirection without clarifying dispatch). Verified: tsc -b clean; full suite at parity with clean upstream/main (only pre-existing flakes remain: circular-deps 50s timeout, step-up rate-limiter, PP-25 under parallel load — all fail identically on upstream). Design docs: docs/PRD.md, docs/ARCHITECTURE.md, docs/PER-VISITOR-DATA-SPEC.md. --- .gitignore | 1 + server/db/migrations-pg.ts | 382 ++++++++++ server/db/migrations-sqlite.ts | 406 ++++++++++ server/email/transport.ts | 72 ++ server/forms/handler.ts | 16 + server/handlers/cms/dashboard/index.ts | 8 +- server/handlers/cms/dashboard/members.ts | 127 ++++ server/handlers/cms/dashboard/types.ts | 25 + server/handlers/cms/gate.ts | 176 +++++ server/handlers/cms/hole.ts | 23 +- server/handlers/cms/index.ts | 5 + server/handlers/cms/shared.ts | 14 +- server/handlers/cms/visitorAuth.ts | 626 ++++++++++++++++ server/index.ts | 9 + server/publish/holeRuntime.ts | 37 + server/publish/loopPrefetch.ts | 29 + server/publish/publicRouter.ts | 46 ++ server/publish/publishedSnapshotCache.ts | 39 + server/publish/visitorAuthRuntime.ts | 478 ++++++++++++ server/repositories/audit.ts | 16 + server/repositories/data/rows/mapper.ts | 10 + server/repositories/data/rows/mutations.ts | 38 + server/repositories/data/tables.ts | 21 +- server/router.ts | 52 ++ server/visitor-auth/__tests__/roles.test.ts | 144 ++++ server/visitor-auth/config.ts | 154 ++++ server/visitor-auth/gateHelpers.ts | 58 ++ server/visitor-auth/groups.ts | 399 ++++++++++ server/visitor-auth/handlers.ts | 696 ++++++++++++++++++ server/visitor-auth/middleware.ts | 131 ++++ server/visitor-auth/rateLimits.ts | 49 ++ server/visitor-auth/redirect.ts | 48 ++ server/visitor-auth/repositories.ts | 581 +++++++++++++++ server/visitor-auth/roles.ts | 194 +++++ server/visitor-auth/sessions.ts | 144 ++++ server/visitor-auth/types.ts | 279 +++++++ server/visitor-auth/visitorData.ts | 84 +++ .../cms-handlers-capability-gated.test.ts | 5 + .../architecture/module-size-budgets.test.ts | 6 + .../visitor-data-isolation.test.ts | 126 ++++ src/__tests__/base-modules.test.ts | 3 +- .../loops/visitorCurrentSource.test.ts | 113 +++ .../loops/visitorOwnedRowsSource.test.ts | 265 +++++++ src/__tests__/server/dataCms.test.ts | 1 + .../server/holeComposePrefixLookup.test.ts | 49 ++ .../server/visitorProfileFields.test.ts | 100 +++ .../server/visitorResolveCookie.test.ts | 149 ++++ src/admin/AuthenticatedAdmin.tsx | 8 + src/admin/access.ts | 6 + src/admin/modals/Settings/SettingsModal.tsx | 4 + .../sections/MembersSection.module.css | 27 + .../Settings/sections/MembersSection.tsx | 425 +++++++++++ .../dashboard/hooks/useDashboardLayout.ts | 47 +- .../dashboard/hooks/useDashboardStats.ts | 21 + .../pages/dashboard/widgets/MembersWidget.tsx | 62 ++ src/admin/pages/dashboard/widgets/index.ts | 28 + src/admin/pages/data/DataPage.tsx | 6 + .../DataInspector/DataInspector.module.css | 16 + .../DataInspector/DataInspector.tsx | 5 + .../components/DataInspector/RowDetail.tsx | 113 +++ .../pages/data/hooks/useDataWorkspace.ts | 17 + .../pages/members/MembersPage.module.css | 180 +++++ src/admin/pages/members/MembersPage.tsx | 70 ++ .../members/components/AssignGroupsDialog.tsx | 173 +++++ .../members/components/EditProfileDialog.tsx | 197 +++++ .../pages/members/components/GroupDialog.tsx | 105 +++ .../pages/members/components/GroupsTab.tsx | 301 ++++++++ .../pages/members/components/RoleDialog.tsx | 125 ++++ .../pages/members/components/RolesTab.tsx | 263 +++++++ .../members/components/VisitorsTable.tsx | 518 +++++++++++++ .../pages/members/hooks/useMembersData.ts | 143 ++++ src/admin/pages/members/types.ts | 45 ++ .../renderModuleTabContent.tsx | 1 + .../SiteExplorerPanel/SiteExplorerPanel.tsx | 16 + .../BindingPickerPopover.tsx | 56 +- .../DynamicBindingControl.tsx | 9 + .../PropertyControlRenderer.tsx | 8 + .../site/store/slices/site/pageActions.ts | 20 + .../pages/site/store/slices/site/types.ts | 8 + src/admin/pages/users/utils/audit.ts | 14 + src/admin/router.tsx | 1 + .../AdminSectionNavigation.tsx | 10 + .../PageAccessDialog/PageAccessDialog.tsx | 149 ++++ .../shared/dialogs/PageAccessDialog/index.ts | 2 + .../PageAccessDialog/usePageAccessDialog.ts | 45 ++ .../TemplateSettingsDialog.tsx | 1 + src/admin/workspace.ts | 7 + src/core/data/pageFromRow.ts | 31 +- src/core/data/schemas.ts | 15 + src/core/loops/sources/index.ts | 4 + src/core/loops/sources/visitorCurrent.ts | 76 ++ src/core/loops/sources/visitorOwnedRows.ts | 289 ++++++++ src/core/loops/types.ts | 26 + src/core/page-tree/index.ts | 4 +- src/core/page-tree/page.ts | 63 ++ src/core/persistence/cmsData.ts | 63 ++ src/core/persistence/index.ts | 35 + src/core/persistence/visitorAuth.ts | 535 ++++++++++++++ src/core/publisher/dynamicDetection.ts | 20 +- src/core/publisher/renderNode.ts | 59 +- src/modules/base/container/index.ts | 10 + src/modules/base/container/props.ts | 15 + 102 files changed, 10894 insertions(+), 37 deletions(-) create mode 100644 server/email/transport.ts create mode 100644 server/handlers/cms/dashboard/members.ts create mode 100644 server/handlers/cms/gate.ts create mode 100644 server/handlers/cms/visitorAuth.ts create mode 100644 server/publish/visitorAuthRuntime.ts create mode 100644 server/visitor-auth/__tests__/roles.test.ts create mode 100644 server/visitor-auth/config.ts create mode 100644 server/visitor-auth/gateHelpers.ts create mode 100644 server/visitor-auth/groups.ts create mode 100644 server/visitor-auth/handlers.ts create mode 100644 server/visitor-auth/middleware.ts create mode 100644 server/visitor-auth/rateLimits.ts create mode 100644 server/visitor-auth/redirect.ts create mode 100644 server/visitor-auth/repositories.ts create mode 100644 server/visitor-auth/roles.ts create mode 100644 server/visitor-auth/sessions.ts create mode 100644 server/visitor-auth/types.ts create mode 100644 server/visitor-auth/visitorData.ts create mode 100644 src/__tests__/architecture/visitor-data-isolation.test.ts create mode 100644 src/__tests__/loops/visitorCurrentSource.test.ts create mode 100644 src/__tests__/loops/visitorOwnedRowsSource.test.ts create mode 100644 src/__tests__/server/holeComposePrefixLookup.test.ts create mode 100644 src/__tests__/server/visitorProfileFields.test.ts create mode 100644 src/__tests__/server/visitorResolveCookie.test.ts create mode 100644 src/admin/modals/Settings/sections/MembersSection.module.css create mode 100644 src/admin/modals/Settings/sections/MembersSection.tsx create mode 100644 src/admin/pages/dashboard/widgets/MembersWidget.tsx create mode 100644 src/admin/pages/members/MembersPage.module.css create mode 100644 src/admin/pages/members/MembersPage.tsx create mode 100644 src/admin/pages/members/components/AssignGroupsDialog.tsx create mode 100644 src/admin/pages/members/components/EditProfileDialog.tsx create mode 100644 src/admin/pages/members/components/GroupDialog.tsx create mode 100644 src/admin/pages/members/components/GroupsTab.tsx create mode 100644 src/admin/pages/members/components/RoleDialog.tsx create mode 100644 src/admin/pages/members/components/RolesTab.tsx create mode 100644 src/admin/pages/members/components/VisitorsTable.tsx create mode 100644 src/admin/pages/members/hooks/useMembersData.ts create mode 100644 src/admin/pages/members/types.ts create mode 100644 src/admin/shared/dialogs/PageAccessDialog/PageAccessDialog.tsx create mode 100644 src/admin/shared/dialogs/PageAccessDialog/index.ts create mode 100644 src/admin/shared/dialogs/PageAccessDialog/usePageAccessDialog.ts create mode 100644 src/core/loops/sources/visitorCurrent.ts create mode 100644 src/core/loops/sources/visitorOwnedRows.ts create mode 100644 src/core/persistence/visitorAuth.ts diff --git a/.gitignore b/.gitignore index c2c797642..b236139d2 100644 --- a/.gitignore +++ b/.gitignore @@ -83,3 +83,4 @@ docs/superpowers/ .factory/ .trae/ .windsurf/ +*.fuse_hidden* diff --git a/server/db/migrations-pg.ts b/server/db/migrations-pg.ts index 6fad59d68..c932fe10a 100644 --- a/server/db/migrations-pg.ts +++ b/server/db/migrations-pg.ts @@ -1120,4 +1120,386 @@ export const pgMigrations: Migration[] = [ on ai_mcp_oauth_tokens (connector_id); `, }, + { + // Visitor authentication & member areas (Phase 1 of docs/PRD.md §4). + // Five tables, fully decoupled from the admin auth tables: separate + // cookie name (`instatic_visitor_session`), separate middleware, separate + // repositories. Order is dictated by FK dependencies: + // 1. visitor_roles — no FKs; seeded with two system rows + // 2. visitor_users — FK visitor_roles (on delete restrict) + // 3. visitor_sessions — FK visitor_users (on delete cascade) + // 4. visitor_login_attempts — FK visitor_users (on delete set null) + // 5. visitor_auth_config — single-row config, no FKs + // + // D12 (docs/ARCHITECTURE.md): the unique constraint on a visitor's + // ACTIVE email is a SEPARATE `CREATE UNIQUE INDEX ... WHERE deleted_at + // IS NULL` rather than a table-level UNIQUE. PG could express it as a + // table constraint, but both dialects emit the identical statement for + // parity (SQLite cannot declare a partial UNIQUE inline). + // + // visitor_auth_config replaces PRD §4.6's proposal to store `visitorAuth` + // inside site.settings_json: SiteSettingsSchema is a closed Type.Object + // whose parse silently drops unknown keys, so a visitorAuth key would not + // survive a publish/parse round-trip. A dedicated single-row config + // table is fully testable and keeps the core settings type untouched. + id: '022_visitor_auth', + sql: ` + -- ─── visitor_roles ──────────────────────────────────────────────── + + create table if not exists visitor_roles ( + id text primary key, + name text not null unique, + capabilities_json jsonb not null default '[]'::jsonb, + is_system boolean not null default false, + created_at timestamptz not null default now(), + updated_at timestamptz not null default now() + ); + + -- First-boot seed only: ON CONFLICT (id) DO NOTHING preserves any + -- operator edits made via the admin UI on later boots (the migration + -- runner never re-runs a recorded migration; the guard keeps the seed + -- idempotent if it ever is). Fixed string ids ('member'/'admin') so + -- the defaultRole / repository code can reference them by id. + insert into visitor_roles (id, name, capabilities_json, is_system) + values + ('member', 'member', '[]'::jsonb, true), + ('admin', 'admin', '["content.read","content.write"]'::jsonb, true) + on conflict (id) do nothing; + + -- ─── visitor_users ──────────────────────────────────────────────── + + create table if not exists visitor_users ( + id text primary key, + email text not null, + email_normalized text not null, + password_hash text not null, + display_name text not null default '', + role_id text not null references visitor_roles(id) on delete restrict, + status text not null default 'active', + failed_login_count integer not null default 0, + locked_until timestamptz, + created_at timestamptz not null default now(), + updated_at timestamptz not null default now(), + deleted_at timestamptz, + constraint visitor_users_display_name_check check (length(display_name) <= 200), + constraint visitor_users_status_check check (status in ('active', 'suspended')) + ); + + -- D12: partial unique index as a separate CREATE statement, mirroring + // the SQLite dialect verbatim. A soft-deleted visitor can re-register + // with the same email. + create unique index if not exists visitor_users_email_active_idx + on visitor_users (email_normalized) + where deleted_at is null; + + -- ─── visitor_sessions ───────────────────────────────────────────── + + create table if not exists visitor_sessions ( + id_hash text primary key, + user_id text not null references visitor_users(id) on delete cascade, + created_at timestamptz not null default now(), + last_seen_at timestamptz not null default now(), + expires_at timestamptz not null, + revoked_at timestamptz, + ip_address text, + user_agent text, + device_label text not null default '' + ); + + create index if not exists visitor_sessions_user_idx + on visitor_sessions (user_id, last_seen_at desc); + + -- ─── visitor_login_attempts ─────────────────────────────────────── + + create table if not exists visitor_login_attempts ( + id text primary key, + attempted_at timestamptz not null default now(), + email_normalized text, + ip_address text, + user_agent text, + user_id text references visitor_users(id) on delete set null, + result text not null + constraint visitor_login_attempts_result_check + check (result in ('success', 'bad_password', 'no_user', 'locked', 'rate_limited', 'account_disabled')) + ); + + create index if not exists visitor_login_attempts_ip_idx + on visitor_login_attempts (ip_address); + + create index if not exists visitor_login_attempts_email_idx + on visitor_login_attempts (email_normalized); + + -- ─── visitor_auth_config (single-row config table) ──────────────── + + create table if not exists visitor_auth_config ( + id text primary key default 'default', + enabled boolean not null default false, + protected_prefixes_json jsonb not null default '[]'::jsonb, + login_path text not null default '/login', + registration_open boolean not null default true, + default_role text not null default 'member', + updated_at timestamptz not null default now() + ); + + -- Seed the single default-config row on first boot. ON CONFLICT (id) + -- DO NOTHING preserves operator edits on later boots. + insert into visitor_auth_config ( + id, enabled, protected_prefixes_json, login_path, + registration_open, default_role, updated_at + ) + values ('default', false, '[]'::jsonb, '/login', true, 'member', now()) + on conflict (id) do nothing; + `, + }, + { + id: '023_visitor_password_reset', + sql: ` + -- ─── visitor_password_reset_tokens ──────────────────────────────── + -- + -- One-shot password-reset tokens. The raw token is generated in JS + -- (randomBytes(32) base64url) and handed to the email transport; the + -- DB only ever holds the SHA-256 hex of it (token_hash). A token is + -- consumed exactly once: consumePasswordResetToken sets used_at, and + -- the unique hash index keeps the lookup O(log n). expires_at carries + -- a 1-hour TTL (VISITOR_PASSWORD_RESET_TTL_MS). + + create table if not exists visitor_password_reset_tokens ( + id text primary key, + user_id text not null references visitor_users(id) on delete cascade, + token_hash text not null, + expires_at timestamptz not null, + used_at timestamptz, + created_at timestamptz not null default now() + ); + + create unique index if not exists visitor_password_reset_tokens_hash_idx + on visitor_password_reset_tokens (token_hash); + + create index if not exists visitor_password_reset_tokens_user_idx + on visitor_password_reset_tokens (user_id, created_at desc); + `, + }, + { + id: '024_member_groups', + sql: ` + -- ─── Member groups (Phase 3 — D13/D14/D15) ───────────────────────── + -- + -- A group is a content-segmentation segment used for page-level access + -- (D14) and login-redirect landing resolution (D15). Orthogonal to + -- visitor_roles (D13): a role answers "what can a member DO"; a group + -- answers "what can a member SEE / where do they land". A visitor + -- belongs to 0..N groups via visitor_user_groups, with one designated + -- primary group (visitor_users.primary_group_id, added below). + -- + -- No seed rows: admins create groups. PG dialect mirror of SQLite 023. + + create table if not exists visitor_groups ( + id text primary key, + name text not null unique, + slug text not null, + landing_path text not null default '/', + description text not null default '', + is_system boolean not null default false, + created_at timestamptz not null default now(), + updated_at timestamptz not null default now() + ); + + create index if not exists visitor_groups_slug_idx + on visitor_groups (slug); + + create table if not exists visitor_user_groups ( + id text primary key, + user_id text not null references visitor_users(id) on delete cascade, + group_id text not null references visitor_groups(id) on delete cascade, + created_at timestamptz not null default now(), + unique (user_id, group_id) + ); + + create index if not exists visitor_user_groups_user_idx + on visitor_user_groups (user_id); + + create index if not exists visitor_user_groups_group_idx + on visitor_user_groups (group_id); + + -- D15 primary group (nullable). ON DELETE SET NULL so deleting a + -- visitor's primary group simply clears the pointer (the visitor then + -- falls back to the configured default landing path on login) rather + -- than cascading the delete onto the user row. + alter table visitor_users + add column primary_group_id text references visitor_groups(id) on delete set null; + `, + }, + { + id: '025_page_access', + sql: ` + -- ─── Retire protected-prefixes; add default landing path (Phase 3) ──── + -- + -- Page access now lives on the Page object (a Page.access field in + -- cells_json, tolerant-parsed → public by default), so NO schema change + -- is needed for the access field itself. This migration: + -- 1. ADDs visitor_auth_config.default_landing_path (D15 fallback). + -- 2. Best-effort backfills the old Phase-1/2 protected-prefix config + -- onto matching pages as per-page access against a single + -- synthesized 'members' group (mirrors the Phase-1 "protected = + -- logged-in member" intent). A page is matched when its slug equals + -- the prefix (sans leading '/') or sits beneath it (prefix/...). + -- 3. DROPs the now-dead protected_prefixes_json column. + -- + -- The backfill updates BOTH the draft data_rows.cells_json AND the + -- published site_snapshots.site_json so the middleware gates correctly + -- immediately after upgrade. Both use the Postgres JSON manipulation + -- functions (jsonb_set / jsonb_agg / jsonb_array_elements). Pages that + -- fail to parse are skipped (best-effort; a re-publish will heal any miss). + + alter table visitor_auth_config + add column default_landing_path text not null default '/'; + + -- 1. Create the synthesized 'members' group when there is at least one + -- prefix. ON CONFLICT (id) DO NOTHING keeps the insert idempotent. + insert into visitor_groups (id, name, slug, landing_path, description, is_system) + select + 'vis_group_members_backfill', 'members', 'members', '/', + 'Synthesized from protected-prefix config during the Phase-3 migration.', false + from visitor_auth_config + where id = 'default' + and jsonb_array_length( + case + when jsonb_typeof(protected_prefixes_json) = 'array' then protected_prefixes_json + else '[]'::jsonb + end + ) > 0 + on conflict (id) do nothing; + + -- 2. Stamp access onto draft pages (data_rows.cells_json) whose slug + -- matches any configured prefix. + update data_rows + set cells_json = jsonb_set( + cells_json::jsonb, + '{access}', + '{"level":"groups","groups":["vis_group_members_backfill"]}'::jsonb + )::text + where table_id = 'pages' + and deleted_at is null + and exists ( + select 1 + from visitor_auth_config c + cross join lateral jsonb_array_elements( + case when jsonb_typeof(c.protected_prefixes_json) = 'array' + then c.protected_prefixes_json else '[]'::jsonb end + ) as prefix + where c.id = 'default' + and ( + data_rows.slug = substr(prefix.value#>>'{}', 2) + or data_rows.slug like substr(prefix.value#>>'{}', 2) || '/%' + ) + ); + + -- 3. Stamp access onto published pages inside every site snapshot + -- (site_snapshots.site_json). Rebuild the pages array, setting access + -- on every page whose slug matches a prefix. jsonb_agg walks every + -- page in the snapshot so non-matching pages pass through unchanged. + update site_snapshots + set site_json = sub.pages_json + from ( + select + s.id as snap_id, + jsonb_set( + s.site_json, + '{pages}', + coalesce(( + select jsonb_agg( + case + when exists ( + select 1 + from visitor_auth_config c + cross join lateral jsonb_array_elements( + case when jsonb_typeof(c.protected_prefixes_json) = 'array' + then c.protected_prefixes_json else '[]'::jsonb end + ) as prefix + where c.id = 'default' + and ( + page.value->>'slug' = substr(prefix.value#>>'{}', 2) + or page.value->>'slug' like substr(prefix.value#>>'{}', 2) || '/%' + ) + ) + then jsonb_set( + page.value, + '{access}', + '{"level":"groups","groups":["vis_group_members_backfill"]}'::jsonb + ) + else page.value + end + ) + from jsonb_array_elements(s.site_json->'pages') as page + ), s.site_json->'pages') + ) as pages_json + from site_snapshots s + ) sub + where site_snapshots.id = sub.snap_id + and exists ( + select 1 + from visitor_auth_config c + cross join lateral jsonb_array_elements(site_snapshots.site_json->'pages') as p + cross join lateral jsonb_array_elements( + case when jsonb_typeof(c.protected_prefixes_json) = 'array' + then c.protected_prefixes_json else '[]'::jsonb end + ) as prefix + where c.id = 'default' + and ( + p.value->>'slug' = substr(prefix.value#>>'{}', 2) + or p.value->>'slug' like substr(prefix.value#>>'{}', 2) || '/%' + ) + ); + + -- 4. Drop the now-dead column. Read nowhere after this migration. + alter table visitor_auth_config + drop column protected_prefixes_json; + `, + }, + { + id: '026_visitor_profile_fields', + sql: ` + -- ─── Visitor custom profile fields (per-visitor-data framework) ──── + -- + -- Adds a JSONB column to visitor_users storing the VALUES of site- + -- builder-defined custom profile fields (e.g. schoolName), keyed by + -- field id. The field DEFINITIONS live in visitor_auth_config + -- (profile_fields_json, added below) and mirror the DataField[] shape + -- used by data_tables.fields_json. Same pattern as data_tables, smaller + -- surface. Values default to '{}'::jsonb so every visitor row is valid + -- immediately. + + alter table visitor_users + add column profile_fields_json jsonb not null default '{}'::jsonb; + + -- Store the site-builder-configured profile field DEFINITIONS on the + -- single visitor_auth_config row. A DataField[] JSONB array; default + -- '[]' = no custom profile fields (the pre-framework behaviour). + alter table visitor_auth_config + add column profile_fields_json jsonb not null default '[]'::jsonb; + `, + }, + { + id: '027_visitor_owned_data', + sql: ` + -- ─── Per-visitor owned data (visitor-data framework, Pillar 3) ──────── + -- + -- Links a data row to the visitor who owns it (e.g. a tender they + -- submitted). FK visitor_users with ON DELETE SET NULL so deleting a + -- visitor account retains the row for audit but unlinks it. Indexed + -- for efficient per-visitor queries. data_tables.captures_visitor_owner + -- is a per-table opt-in flag (0/1) — the form handler stamps + -- visitor_user_id only when the target table opts in, so unrelated + -- tables are untouched. + + alter table data_rows + add column visitor_user_id text references visitor_users(id) on delete set null; + + create index if not exists data_rows_table_visitor_idx + on data_rows (table_id, visitor_user_id, updated_at desc); + + alter table data_tables + add column captures_visitor_owner integer not null default 0; + `, + }, ] diff --git a/server/db/migrations-sqlite.ts b/server/db/migrations-sqlite.ts index 40d5ba57b..f703f69b6 100644 --- a/server/db/migrations-sqlite.ts +++ b/server/db/migrations-sqlite.ts @@ -1184,4 +1184,410 @@ export const sqliteMigrations: Migration[] = [ on ai_mcp_oauth_tokens (connector_id); `, }, + { + // Visitor authentication & member areas (Phase 1 of docs/PRD.md §4). + // Five tables, fully decoupled from the admin auth tables: separate + // cookie name (`instatic_visitor_session`), separate middleware, separate + // repositories. Order is dictated by FK dependencies: + // 1. visitor_roles — no FKs; seeded with two system rows + // 2. visitor_users — FK visitor_roles (on delete restrict) + // 3. visitor_sessions — FK visitor_users (on delete cascade) + // 4. visitor_login_attempts — FK visitor_users (on delete set null) + // 5. visitor_auth_config — single-row config, no FKs + // + // D12 (docs/ARCHITECTURE.md): the unique constraint on a visitor's + // ACTIVE email is a SEPARATE `CREATE UNIQUE INDEX ... WHERE deleted_at + // IS NULL` rather than a table-level UNIQUE — partial unique + // constraints cannot be declared as table constraints on SQLite, so + // both dialects emit the identical statement (see migrations-pg.ts). + // + // visitor_auth_config replaces PRD §4.6's proposal to store `visitorAuth` + // inside site.settings_json: SiteSettingsSchema is a closed Type.Object + // whose parse silently drops unknown keys, so a visitorAuth key would not + // survive a publish/parse round-trip. A dedicated single-row config + // table is fully testable and keeps the core settings type untouched. + // + // SQLite dialect translations (mirror migrations-pg.ts): + // jsonb → text (auto-parsed on read via the _json suffix) + // timestamptz → text (ISO 8601) + // boolean → integer (0/1; repos use Boolean(row.enabled)) + // default now() → default (strftime('%Y-%m-%dT%H:%M:%fZ','now')) + id: '022_visitor_auth', + sql: ` + -- ─── visitor_roles ──────────────────────────────────────────────── + + create table if not exists visitor_roles ( + id text primary key, + name text not null unique, + capabilities_json text not null default '[]', + is_system integer not null default 0, + created_at text not null default (strftime('%Y-%m-%dT%H:%M:%fZ','now')), + updated_at text not null default (strftime('%Y-%m-%dT%H:%M:%fZ','now')) + ); + + -- First-boot seed only: ON CONFLICT (id) DO NOTHING preserves any + -- operator edits made via the admin UI on later boots (the migration + -- runner never re-runs a recorded migration; the guard keeps the seed + -- idempotent if it ever is). Fixed string ids ('member'/'admin') so + -- the defaultRole / repository code can reference them by id. + insert into visitor_roles (id, name, capabilities_json, is_system) + values + ('member', 'member', '[]', 1), + ('admin', 'admin', '["content.read","content.write"]', 1) + on conflict (id) do nothing; + + -- ─── visitor_users ──────────────────────────────────────────────── + + create table if not exists visitor_users ( + id text primary key, + email text not null, + email_normalized text not null, + password_hash text not null, + display_name text not null default '', + role_id text not null references visitor_roles(id) on delete restrict, + status text not null default 'active', + failed_login_count integer not null default 0, + locked_until text, + created_at text not null default (strftime('%Y-%m-%dT%H:%M:%fZ','now')), + updated_at text not null default (strftime('%Y-%m-%dT%H:%M:%fZ','now')), + deleted_at text, + constraint visitor_users_display_name_check check (length(display_name) <= 200), + constraint visitor_users_status_check check (status in ('active', 'suspended')) + ); + + -- D12: partial unique index as a separate CREATE statement (SQLite + -- cannot declare a partial UNIQUE as a table constraint). A soft- + -- deleted visitor can re-register with the same email. + create unique index if not exists visitor_users_email_active_idx + on visitor_users (email_normalized) + where deleted_at is null; + + -- ─── visitor_sessions ───────────────────────────────────────────── + + create table if not exists visitor_sessions ( + id_hash text primary key, + user_id text not null references visitor_users(id) on delete cascade, + created_at text not null default (strftime('%Y-%m-%dT%H:%M:%fZ','now')), + last_seen_at text not null default (strftime('%Y-%m-%dT%H:%M:%fZ','now')), + expires_at text not null, + revoked_at text, + ip_address text, + user_agent text, + device_label text not null default '' + ); + + create index if not exists visitor_sessions_user_idx + on visitor_sessions (user_id, last_seen_at desc); + + -- ─── visitor_login_attempts ─────────────────────────────────────── + + create table if not exists visitor_login_attempts ( + id text primary key, + attempted_at text not null default (strftime('%Y-%m-%dT%H:%M:%fZ','now')), + email_normalized text, + ip_address text, + user_agent text, + user_id text references visitor_users(id) on delete set null, + result text not null + constraint visitor_login_attempts_result_check + check (result in ('success', 'bad_password', 'no_user', 'locked', 'rate_limited', 'account_disabled')) + ); + + create index if not exists visitor_login_attempts_ip_idx + on visitor_login_attempts (ip_address); + + create index if not exists visitor_login_attempts_email_idx + on visitor_login_attempts (email_normalized); + + -- ─── visitor_auth_config (single-row config table) ──────────────── + + create table if not exists visitor_auth_config ( + id text primary key default 'default', + enabled integer not null default 0, + protected_prefixes_json text not null default '[]', + login_path text not null default '/login', + registration_open integer not null default 1, + default_role text not null default 'member', + updated_at text not null default (strftime('%Y-%m-%dT%H:%M:%fZ','now')) + ); + + -- Seed the single default-config row on first boot. ON CONFLICT (id) + -- DO NOTHING preserves operator edits on later boots. + insert into visitor_auth_config ( + id, enabled, protected_prefixes_json, login_path, + registration_open, default_role, updated_at + ) + values ('default', 0, '[]', '/login', 1, 'member', strftime('%Y-%m-%dT%H:%M:%fZ','now')) + on conflict (id) do nothing; + `, + }, + { + id: '023_visitor_password_reset', + sql: ` + -- ─── visitor_password_reset_tokens ──────────────────────────────── + -- + -- One-shot password-reset tokens. The raw token is generated in JS + -- (randomBytes(32) base64url) and handed to the email transport; the + -- DB only ever holds the SHA-256 hex of it (token_hash). A token is + -- consumed exactly once: consumePasswordResetToken sets used_at, and + -- the unique hash index keeps the lookup O(log n). expires_at is an + -- ISO 8601 string with a 1-hour TTL (VISITOR_PASSWORD_RESET_TTL_MS). + + create table if not exists visitor_password_reset_tokens ( + id text primary key, + user_id text not null references visitor_users(id) on delete cascade, + token_hash text not null, + expires_at text not null, + used_at text, + created_at text not null default (strftime('%Y-%m-%dT%H:%M:%fZ','now')) + ); + + create unique index if not exists visitor_password_reset_tokens_hash_idx + on visitor_password_reset_tokens (token_hash); + + create index if not exists visitor_password_reset_tokens_user_idx + on visitor_password_reset_tokens (user_id, created_at desc); + `, + }, + { + id: '024_member_groups', + sql: ` + -- ─── Member groups (Phase 3 — D13/D14/D15) ───────────────────────── + -- + -- A group is a content-segmentation segment used for page-level access + -- (D14) and login-redirect landing resolution (D15). Orthogonal to + -- visitor_roles (D13): a role answers "what can a member DO"; a group + -- answers "what can a member SEE / where do they land". A visitor + -- belongs to 0..N groups via visitor_user_groups, with one designated + -- primary group (visitor_users.primary_group_id, added below). + -- + -- No seed rows: admins create groups. SQLite mirror of PG 023: + -- jsonb → n/a (no json columns here) + -- timestamptz → text (ISO 8601) + -- boolean → integer (1/0) + -- default now() → default (strftime(...)) + + create table if not exists visitor_groups ( + id text primary key, + name text not null unique, + slug text not null, + landing_path text not null default '/', + description text not null default '', + is_system integer not null default 0, + created_at text not null default (strftime('%Y-%m-%dT%H:%M:%fZ','now')), + updated_at text not null default (strftime('%Y-%m-%dT%H:%M:%fZ','now')) + ); + + create index if not exists visitor_groups_slug_idx + on visitor_groups (slug); + + create table if not exists visitor_user_groups ( + id text primary key, + user_id text not null references visitor_users(id) on delete cascade, + group_id text not null references visitor_groups(id) on delete cascade, + created_at text not null default (strftime('%Y-%m-%dT%H:%M:%fZ','now')), + unique (user_id, group_id) + ); + + create index if not exists visitor_user_groups_user_idx + on visitor_user_groups (user_id); + + create index if not exists visitor_user_groups_group_idx + on visitor_user_groups (group_id); + + -- D15 primary group (nullable). ON DELETE SET NULL so deleting a + -- visitor's primary group simply clears the pointer (the visitor then + -- falls back to the configured default landing path on login) rather + -- than cascading the delete onto the user row. + alter table visitor_users + add column primary_group_id text references visitor_groups(id) on delete set null; + `, + }, + { + id: '025_page_access', + sql: ` + -- ─── Retire protected-prefixes; add default landing path (Phase 3) ──── + -- + -- Page access now lives on the Page object (a Page.access field in + -- cells_json, tolerant-parsed → public by default), so NO schema change + -- is needed for the access field itself. This migration: + -- 1. ADDs visitor_auth_config.default_landing_path (D15 fallback). + -- 2. Best-effort backfills the old Phase-1/2 protected-prefix config + -- onto matching pages as per-page access against a single + -- synthesized 'members' group (mirrors the Phase-1 "protected = + -- logged-in member" intent). A page is matched when its slug equals + -- the prefix (sans leading '/') or sits beneath it (prefix/...). + -- 3. DROPs the now-dead protected_prefixes_json column. + -- + -- The backfill updates BOTH the draft data_rows.cells_json AND the + -- published site_snapshots.site_json so the middleware gates correctly + -- immediately after upgrade (without waiting for a re-publish). Both use + -- SQLite json_* functions (the migration tracker guarantees this runs + -- exactly once; the _json-suffix adapter auto-parse does NOT apply inside + -- raw migration SQL, so protected_prefixes_json / site_json are treated + -- as raw text and parsed explicitly via json_each / json_extract). + -- Pages that fail to parse are skipped (best-effort; logged nowhere — + -- the migration runs once and a re-publish will heal any miss). + + alter table visitor_auth_config + add column default_landing_path text not null default '/'; + + -- ─── Backfill (only when prefixes were configured) ────────────────── + -- + -- Read the single 'default' config row's protected_prefixes_json. When + -- it is a non-empty array, synthesize one 'members' group and stamp + -- access onto every prefix-matching page (draft + published). The group + -- id is the fixed VISITOR_BACKFILL_MEMBERS_GROUP_ID constant. + + -- 1. Create the synthesized 'members' group when there is at least one + -- prefix. ON CONFLICT (id) DO NOTHING keeps the insert a no-op if a + -- group with this fixed id somehow already exists. + insert into visitor_groups (id, name, slug, landing_path, description, is_system) + select + 'vis_group_members_backfill', 'members', 'members', '/', + 'Synthesized from protected-prefix config during the Phase-3 migration.', 0 + from visitor_auth_config + where id = 'default' + and json_array_length( + case + when json_valid(protected_prefixes_json) then protected_prefixes_json + else '[]' + end + ) > 0 + on conflict (id) do nothing; + + -- 2. Stamp access onto draft pages (data_rows.cells_json) whose slug + -- matches any configured prefix. The page access cell is a JSON + -- object {level:'groups', groups:[]}. A page's slug + -- matches prefix P when slug = P (sans leading '/') or slug LIKE P/%% + -- (the same semantics the old prefix middleware used, minus the + -- leading slash the slug never carries). + update data_rows + set cells_json = json_set( + cells_json, + '$.access', + json('{"level":"groups","groups":["vis_group_members_backfill"]}') + ) + where table_id = 'pages' + and deleted_at is null + and exists ( + select 1 + from visitor_auth_config c, json_each( + case when json_valid(c.protected_prefixes_json) then c.protected_prefixes_json else '[]' end + ) as prefix + where c.id = 'default' + and ( + data_rows.slug = substr(prefix.value, 2) + or data_rows.slug like substr(prefix.value, 2) || '/%' + ) + ); + + -- 3. Stamp access onto published pages inside every site snapshot + -- (site_snapshots.site_json). Rebuild the $.pages array, setting + -- $.pages[i].access on every page whose slug matches a prefix. Only + -- snapshots that actually contain a matching page are touched. The + -- correlated json_group_array walks every page in the snapshot so + -- non-matching pages pass through unchanged. + update site_snapshots + set site_json = ( + select json_set( + site_snapshots.site_json, + '$.pages', + json_group_array( + case + when exists ( + select 1 + from visitor_auth_config c, json_each( + case when json_valid(c.protected_prefixes_json) then c.protected_prefixes_json else '[]' end + ) as prefix + where c.id = 'default' + and ( + json_extract(page.value, '$.slug') = substr(prefix.value, 2) + or json_extract(page.value, '$.slug') like substr(prefix.value, 2) || '/%' + ) + ) + then json_set( + page.value, + '$.access', + json('{"level":"groups","groups":["vis_group_members_backfill"]}') + ) + else page.value + end + ) + ) + from json_each(site_snapshots.site_json, '$.pages') as page + ) + where exists ( + select 1 + from visitor_auth_config c, + json_each(site_snapshots.site_json, '$.pages') as p, + json_each( + case when json_valid(c.protected_prefixes_json) then c.protected_prefixes_json else '[]' end + ) as prefix + where c.id = 'default' + and ( + json_extract(p.value, '$.slug') = substr(prefix.value, 2) + or json_extract(p.value, '$.slug') like substr(prefix.value, 2) || '/%' + ) + ); + + -- 4. Drop the now-dead column. SQLite >= 3.35 supports ALTER TABLE + -- DROP COLUMN; the baseline targets a recent SQLite, and the + -- migration-parity test only checks ids (the SQL may differ between + -- dialects). If a future older engine lacks DROP COLUMN this ALTER is + -- the one statement to special-case; the column is read nowhere after + -- this migration, so leaving it would be a harmless dead column. + alter table visitor_auth_config + drop column protected_prefixes_json; + `, + }, + { + id: '026_visitor_profile_fields', + sql: ` + -- ─── Visitor custom profile fields (per-visitor-data framework) ──── + -- + -- Adds a JSON column to visitor_users storing the VALUES of site- + -- builder-defined custom profile fields (e.g. schoolName), keyed by + -- field id. The field DEFINITIONS live in visitor_auth_config + -- (profile_fields_json, added below) and mirror the DataField[] shape + -- used by data_tables.fields_json. This mirrors how data_tables stores + -- field definitions (fields_json) — same pattern, smaller surface. + -- + -- Values default to '{}' so every visitor row is valid immediately. + -- Extraction at read time uses the json_extract() helpers; the + -- _json-suffix adapter auto-parses the column into an object on read. + + alter table visitor_users + add column profile_fields_json text not null default '{}'; + + -- Store the site-builder-configured profile field DEFINITIONS on the + -- single visitor_auth_config row. A DataField[] JSON array; default + -- '[]' = no custom profile fields (the pre-framework behaviour). + alter table visitor_auth_config + add column profile_fields_json text not null default '[]'; + `, + }, + { + id: '027_visitor_owned_data', + sql: ` + -- ─── Per-visitor owned data (visitor-data framework, Pillar 3) ──────── + -- + -- Links a data row to the visitor who owns it (e.g. a tender they + -- submitted). FK visitor_users with ON DELETE SET NULL so deleting a + -- visitor account retains the row for audit but unlinks it. Indexed + -- for efficient per-visitor queries. data_tables.captures_visitor_owner + -- is a per-table opt-in flag (0/1) — the form handler stamps + -- visitor_user_id only when the target table opts in, so unrelated + -- tables are untouched. + + alter table data_rows + add column visitor_user_id text references visitor_users(id) on delete set null; + + create index if not exists data_rows_table_visitor_idx + on data_rows (table_id, visitor_user_id, updated_at desc); + + alter table data_tables + add column captures_visitor_owner integer not null default 0; + `, + }, ] diff --git a/server/email/transport.ts b/server/email/transport.ts new file mode 100644 index 000000000..94f1f4b88 --- /dev/null +++ b/server/email/transport.ts @@ -0,0 +1,72 @@ +/** + * Email transport seam — a single process-wide singleton the visitor-auth + * `/forgot` flow (and, later, any other outbound mail) hands a message to. + * + * The default is `ConsoleEmailTransport`, which logs the message (reset link + * included) to stdout. That is enough to run the reset flow end-to-end in dev + * and in a self-hosted install without an external mail provider — the + * operator can copy the logged link directly. + * + * A real transport (SMTP / Resend / Postmark / SES) is a follow-up: wire it + * at boot via `configureEmailTransport(new SmtpTransport(...))`. No caller + * under `server/visitor-auth/` reaches past this seam, so swapping the + * transport never touches the visitor-auth code. + * + * The interface is intentionally minimal (a single `send`): no queue, no + * retries, no templates. The visitor-auth handler owns the subject/body; the + * transport owns delivery. A `send` rejection is the transport's problem to + * surface — callers log the failure and continue (never exposing it to the + * client; see the `/forgot` handler). + */ + +/** The payload a caller hands to a transport. */ +export interface SendEmailInput { + to: string + subject: string + text: string + /** Optional HTML body. Transports that can't render HTML fall back to `text`. */ + html?: string +} + +/** + * Minimal transport contract. `name` is surfaced in logs so an operator can + * confirm which transport is active without reading boot code. + */ +export interface EmailTransport { + readonly name: string + send(input: SendEmailInput): Promise +} + +/** + * Default transport — logs the message to stdout. The reset link (or any + * other URL embedded in `text`) is printed verbatim so a dev/operator can + * copy/paste it. No network, no config — it always works. + */ +export class ConsoleEmailTransport implements EmailTransport { + readonly name = 'console' + + async send(input: SendEmailInput): Promise { + // One block, newline-separated, prefixed with the recipient so it's + // greppable in a shared log stream. + console.log( + `[email:${input.to}] ${input.subject}\n${input.text}` + + (input.html ? `\n[html] ${input.html}` : ''), + ) + } +} + +let activeTransport: EmailTransport = new ConsoleEmailTransport() + +/** The active transport — visitors-auth `/forgot` reads through this. */ +export function getEmailTransport(): EmailTransport { + return activeTransport +} + +/** + * Replace the active transport. Intended for boot-time wiring of a real + * transport (SMTP / Resend / Postmark / SES) and for tests that want to + * capture sent messages. Not concurrency-guarded — set once at boot. + */ +export function configureEmailTransport(transport: EmailTransport): void { + activeTransport = transport +} diff --git a/server/forms/handler.ts b/server/forms/handler.ts index 6e121d979..59de793b6 100644 --- a/server/forms/handler.ts +++ b/server/forms/handler.ts @@ -10,6 +10,8 @@ import { readValidatedBody, } from '../http' import { createDataRow, getDataTable } from '../repositories/data' +import { validateVisitorSession } from '../visitor-auth/sessions' +import { findVisitorUserById } from '../visitor-auth/repositories' import { getLatestPublishedSiteSnapshot } from '../repositories/publish' import { PublicFormChallengeBodySchema, @@ -133,10 +135,24 @@ async function handleSubmit(req: Request, db: DbClient): Promise { return jsonResponse({ error: 'Invalid form values', errors: validation.errors }, { status: 400 }) } + // Per-visitor-data framework (Pillar 3): when the target table opts in, + // stamp the row with the submitting visitor's id — resolved SOLELY from the + // validated session cookie (IDOR-safe). No visitor id is ever read from the + // form body. Anonymous submits (no opt-in or no session) are unchanged. + let visitorUserId: string | undefined + if (table.capturesVisitorOwner) { + const session = await validateVisitorSession(db, req) + if (session) { + const visitor = await findVisitorUserById(db, session.userId) + if (visitor) visitorUserId = visitor.id + } + } + const row = await createDataRow(db, { tableId: table.id, cells: validation.cells, slug: '', + ...(visitorUserId ? { visitorUserId } : {}), }) return jsonResponse({ ok: true, rowId: row.id }) } diff --git a/server/handlers/cms/dashboard/index.ts b/server/handlers/cms/dashboard/index.ts index b63601c2c..6c7af081a 100644 --- a/server/handlers/cms/dashboard/index.ts +++ b/server/handlers/cms/dashboard/index.ts @@ -3,6 +3,7 @@ * * GET /admin/api/cms/dashboard/pages * GET /admin/api/cms/dashboard/posts + * GET /admin/api/cms/dashboard/members * GET /admin/api/cms/dashboard/media * GET /admin/api/cms/dashboard/plugins * GET /admin/api/cms/dashboard/storage @@ -45,6 +46,7 @@ import { CMS_API_PREFIX, type CmsHandlerOptions } from '../shared' import { resolveTimeZone } from '../../../time' import { readPagesStats } from './pages' import { readPostsStats } from './posts' +import { readMembersStats } from './members' import { readMediaStats } from './media' import { readPluginsStats } from './plugins' import { readPublishLineup } from './publishLineup' @@ -83,10 +85,11 @@ interface DashboardEndpoint { // a new widget is "new reader file + one row here". The capability // rules: // -// pages / posts / publish-lineup / storage +// pages / posts / publish-lineup / storage / members // Non-sensitive totals or paths the visitor could // hit on the public site anyway. Any authenticated -// user can read. +// user can read. (`members` is a registered-visitor +// count — a pure total with no PII, same policy.) // // media Library thumbnails are part of the asset surface; // gate matches `/media` list (`media.read`). @@ -103,6 +106,7 @@ interface DashboardEndpoint { const DASHBOARD_READERS: Record = { 'pages': { reader: readPagesStats, capability: null }, 'posts': { reader: readPostsStats, capability: null }, + 'members': { reader: readMembersStats, capability: null }, 'media': { reader: readMediaStats, capability: 'media.read' }, 'plugins': { reader: readPluginsStats, capability: 'plugins.read' }, 'storage': { reader: readStorageStats, capability: null }, diff --git a/server/handlers/cms/dashboard/members.ts b/server/handlers/cms/dashboard/members.ts new file mode 100644 index 000000000..5cd9132e7 --- /dev/null +++ b/server/handlers/cms/dashboard/members.ts @@ -0,0 +1,127 @@ +/** + * Members widget reader — total active registered visitors + a dense + * 28-day registration histogram (one bucket per calendar day of new + * sign-ups) plus the visitor-auth toggle states so the widget can show + * whether auth / registration are currently on. + * + * Mirrors `readPostsStats`'s shape (count + trailing-28-day histogram) + * but against the `visitor_users` table instead of `data_rows`. + * + * Self-contained on purpose: the `visitor_*` tables are owned by the + * visitor-auth module (`server/visitor-auth/*`), which another agent + * edits in parallel. Importing its repository layer would couple this + * dashboard reader to a file that may move under us, so the three small + * queries here hit `visitor_users` / `visitor_auth_config` directly. The + * column names are stable (migration-locked snake_case), and the bucket + * math reuses the same timezone-aware helpers as `posts.ts`. + */ +import type { DbClient } from '../../../db/client' +import { localDayKeyFactory } from '../../../time' +import { coerceCount } from './shared' +import type { DashboardRequestContext, MembersStats } from './types' + +const TWENTY_EIGHT_DAYS_MS = 28 * 24 * 60 * 60 * 1000 +const DAY_MS = 24 * 60 * 60 * 1000 +const HISTOGRAM_DAYS = 28 + +/** + * `visitor_auth_config` ships a single seeded `'default'` row; a missing + * row (fresh install before the seed migration, or a hand-deleted row) + * resolves to "disabled / registration open" — the same defaults the + * visitor-auth reader falls back to (`DEFAULT_VISITOR_AUTH_CONFIG`). + * Kept inline here so this reader has zero runtime coupling to the + * visitor-auth module's in-memory config cache. + */ +const MISSING_CONFIG = { authEnabled: false, registrationOpen: true } as const + +export async function readMembersStats( + db: DbClient, + _options: unknown, + ctx: DashboardRequestContext, +): Promise { + const dayKeyOf = localDayKeyFactory(ctx.timeZone) + const sinceIso = new Date(Date.now() - TWENTY_EIGHT_DAYS_MS).toISOString() + + // Total count + the histogram source rows + the auth config read are + // independent — fan them out in parallel so the endpoint resolves at + // the slowest query, not their sum. + const [total, registrations, config] = await Promise.all([ + readActiveVisitorTotal(db), + readRecentRegistrations(db, sinceIso), + readAuthConfig(db), + ]) + + // Bin each `created_at` into the viewer's local calendar day, then + // densify into [28] oldest-first — identical to `readPostsStats`'s + // trailing-28-day fill so the widget renders bars without gaps and + // "today" lines up with the operator's day, not UTC's. + const counts = new Map() + for (const createdAt of registrations) { + const day = dayKeyOf(createdAt) + counts.set(day, (counts.get(day) ?? 0) + 1) + } + const daily28 = Array.from({ length: HISTOGRAM_DAYS }, (_, i) => { + const d = new Date(Date.now() - (HISTOGRAM_DAYS - 1 - i) * DAY_MS) + return counts.get(dayKeyOf(d)) ?? 0 + }) + + const { authEnabled, registrationOpen } = config ?? MISSING_CONFIG + return { total, daily28, authEnabled, registrationOpen } +} + +/** + * Count active (non-soft-deleted) visitors — the widget's headline number. + * Mirrors the `deleted_at is null` guard every visitor-auth read uses, so + * soft-deleted / GDPR-anonymized rows never inflate the total. + */ +async function readActiveVisitorTotal(db: DbClient): Promise { + const { rows } = await db<{ count: number | string }>` + select count(*) as count + from visitor_users + where deleted_at is null + ` + return coerceCount(rows[0]?.count) +} + +/** + * Raw `created_at` rows for every active visitor who registered inside the + * 28-day window. The caller bins them per local calendar day client-side — + * the day boundary depends on the viewer's timezone, which the database + * can't know (see `server/time.ts` for the full rationale). Cardinality is + * bounded by the trailing-28-day window, comfortably small. + */ +async function readRecentRegistrations( + db: DbClient, + sinceIso: string, +): Promise> { + const { rows } = await db<{ created_at: string | Date }>` + select created_at + from visitor_users + where deleted_at is null + and created_at >= ${sinceIso} + ` + return rows.map((r) => r.created_at) +} + +/** + * Read the visitor-auth toggles straight from the single config row. + * Returns `null` when the row is absent so the caller falls back to the + * "disabled" default. SQLite stores booleans as 0/1 ints; both adapters + * may hand back a number here, so coerce via `Boolean()` the same way the + * visitor-auth reader's `rowToConfig` does. + */ +async function readAuthConfig( + db: DbClient, +): Promise<{ authEnabled: boolean; registrationOpen: boolean } | null> { + const { rows } = await db<{ enabled: boolean | number; registration_open: boolean | number }>` + select enabled, registration_open + from visitor_auth_config + limit 1 + ` + const row = rows[0] + if (!row) return null + return { + authEnabled: Boolean(row.enabled), + registrationOpen: Boolean(row.registration_open), + } +} diff --git a/server/handlers/cms/dashboard/types.ts b/server/handlers/cms/dashboard/types.ts index 4aec69af6..a7302beba 100644 --- a/server/handlers/cms/dashboard/types.ts +++ b/server/handlers/cms/dashboard/types.ts @@ -54,6 +54,31 @@ export interface PostsStats { daily28: number[] } +// --------------------------------------------------------------------------- +// Members (registered visitors) +// --------------------------------------------------------------------------- + +/** + * Members widget payload. Mirrors the Posts shape (count + trailing-28-day + * histogram) but against `visitor_users` registrations, plus the two + * visitor-auth toggles so the widget can show whether auth / registration + * are currently on. + * + * • `total` — active (non-soft-deleted) visitor count. + * • `daily28` — new registrations per local calendar day for + * the last 28 days, oldest first. + * • `authEnabled` — `visitor_auth_config.enabled`; `false` when the + * row is absent (disabled default). + * • `registrationOpen` — `visitor_auth_config.registration_open`; `true` + * when the row is absent (open default). + */ +export interface MembersStats { + total: number + daily28: number[] + authEnabled: boolean + registrationOpen: boolean +} + // --------------------------------------------------------------------------- // Media // --------------------------------------------------------------------------- diff --git a/server/handlers/cms/gate.ts b/server/handlers/cms/gate.ts new file mode 100644 index 000000000..5ad98c83f --- /dev/null +++ b/server/handlers/cms/gate.ts @@ -0,0 +1,176 @@ +/** + * `/_instatic/gate/` endpoint — Layer C auth-gated server island. + * + * A `base.container` whose `authGate` prop is a non-empty group-id list + * (Phase 3 / D16) publishes as an `` placeholder carrying + * the module's `staticPlaceholder` fallback + the required group list. The + * gate runtime lazy-fetches this endpoint; it renders the FULL subtree only + * for visitors whose session is a member of at least one of the required + * groups and returns the baked fallback for everyone else. + * + * Mirrors `server/handlers/cms/hole.ts` closely — same snapshot lookup, same + * version check, same fragment renderer (`renderHoleFragment`). Differences: + * + * - Reads the visitor session cookie via `gateHelpers.checkGateAccess`. + * - Determines `requiredGroups` from `node.props.authGate` (an array of + * group ids; an empty/missing array defaults to no gate — fail-open so a + * stale published node with the old role-string shape is treated as + * not-gated rather than locking out everyone). + * - UNAUTHORISED → returns the sanitised `staticPlaceholder` fallback (the + * same string baked into the placeholder). Never renders the subtree. + * - Always `Cache-Control: no-store` — the response is per-visitor, so it + * can never be shared-cached (unlike shared holes that go through Layer B). + * + * Version-awareness matches hole.ts: a `?v=` mismatch returns the same stale + * sentinel so the next page load picks up the new version. + */ + +import type { DbClient } from '../../db/client' +import type { PageNode } from '@core/page-tree' +import { registry } from '@core/module-engine' +import { sanitizeRichtext } from '@core/sanitize' +import { buildPageFrame, buildRouteFrame } from '@core/templates/contextFrames' +import { renderHoleFragment } from './hole' +import { findPageForNodeId, getPublishedNodeIndexForVersion } from '../../publish/publishedSnapshotCache' +import { getPublishVersion } from '../../publish/publishState' +import { checkGateAccess } from '../../visitor-auth/gateHelpers' + +export const GATE_PATH_PREFIX = '/_instatic/gate/' + +interface GateHandlerContext { + db: DbClient +} + +/** + * Render a single auth-gated node subtree for Layer C gate hydration. + * + * GET `/_instatic/gate/?v=&u=` → HTML fragment. + */ +export async function handleGateRequest( + req: Request, + url: URL, + ctx: GateHandlerContext, +): Promise { + if (req.method !== 'GET') { + return new Response('Method not allowed', { + status: 405, + headers: { 'content-type': 'text/plain; charset=utf-8' }, + }) + } + + const nodeId = decodeURIComponent(url.pathname.slice(GATE_PATH_PREFIX.length)) + if (!nodeId) { + return new Response('Missing node id', { + status: 400, + headers: { 'content-type': 'text/plain; charset=utf-8' }, + }) + } + + // Version check — identical to hole.ts. A mismatch returns the lightweight + // stale sentinel so the next full page load (carrying the new version) + // hydrates cleanly. + const requestVersion = url.searchParams.get('v') ?? '' + const currentVersion = getPublishVersion() + if (requestVersion !== String(currentVersion)) { + return new Response('', { + headers: { + 'content-type': 'text/html; charset=utf-8', + 'cache-control': 'no-store', + }, + }) + } + + // Load (memoised) snapshot for this version and find the node's page in O(1). + const snap = await getPublishedNodeIndexForVersion(ctx.db, currentVersion) + if (!snap) { + return new Response('Site not published', { + status: 404, + headers: { 'content-type': 'text/plain; charset=utf-8' }, + }) + } + const found = findPageForNodeId(snap, nodeId) + if (!found) { + return new Response('Node not found', { + status: 404, + headers: { 'content-type': 'text/plain; charset=utf-8' }, + }) + } + // Template composition prefixes node ids (c0_/t_) on wrapped pages; + // use the effective (non-composed) id for node lookup + rendering. + const foundPage = found.page + const effectiveNodeId = found.effectiveNodeId + const node: PageNode | undefined = foundPage.nodes[effectiveNodeId] + if (!node) { + return new Response('Node not found', { + status: 404, + headers: { 'content-type': 'text/plain; charset=utf-8' }, + }) + } + + // Resolve the required groups off the published node. The dynamic-detection + // rule only routes here when the prop is a non-empty group-id array, so a + // missing/non-array prop on the published node implies a stale shape + // (the Phase-2 role-string value) — treat it as not-gated (fail-open) so a + // half-migrated publish doesn't lock out every visitor. + const requiredGroups = Array.isArray(node.props.authGate) + ? node.props.authGate.filter((g): g is string => typeof g === 'string') + : [] + + // Authorisation decision — single source of truth lives in gateHelpers. + const auth = await checkGateAccess(ctx.db, req, requiredGroups) + + // Reconstruct the originating page URL forwarded by the runtime (`u`). Falls + // back to the page's own permalink when absent (older runtime / direct hit). + const pageUrlRaw = url.searchParams.get('u') ?? buildPageFrame(foundPage).permalink + let pageUrl: URL + try { + pageUrl = new URL(pageUrlRaw, url.origin) + } catch { + pageUrl = new URL(buildPageFrame(foundPage).permalink, url.origin) + } + + // Resolve the request-time route frame (path / slug / query) off the + // originating page URL — same construction hole.ts uses so any + // `route.query.*` / `route.slug` bindings inside the gated subtree resolve. + const route = buildRouteFrame(pageUrl.toString()) + + // UNAUTHORISED — bake the fallback. This is the SAME string the placeholder + // carries at publish time (the module's `staticPlaceholder`, sanitised), so + // the swap is a no-op visually for unauthorised visitors with JS, and the + // no-JS path already shows it. Never render the gated subtree. + if (!auth.authorized) { + const def = registry.get(node.moduleId) + const rawFallback = def?.staticPlaceholder?.(node.props as never) ?? '' + const fallback = rawFallback ? sanitizeRichtext(rawFallback) : '' + return new Response(fallback, { + status: 200, + headers: { + 'content-type': 'text/html; charset=utf-8', + 'cache-control': 'no-store', + }, + }) + } + + // AUTHORISED — render the full subtree at request time via the shared + // fragment renderer (same path holes use). The visitor's query string seeds + // the route frame so any `route.query.*` bindings inside the gated subtree + // resolve; cookies are intentionally empty for the shared render path + // (mirrors hole.ts's shared-hole contract — gated subtrees render the SAME + // fragment for every authorised visitor of a role, so no per-visitor data + // leaks between caches). + const query: Record = Object.fromEntries(pageUrl.searchParams) + const html = await renderHoleFragment(effectiveNodeId, foundPage, snap.site, ctx.db, pageUrl, { + query, + path: route.path, + slug: route.slug, + cookies: {}, + }) + + return new Response(html, { + status: 200, + headers: { + 'content-type': 'text/html; charset=utf-8', + 'cache-control': 'no-store', + }, + }) +} diff --git a/server/handlers/cms/hole.ts b/server/handlers/cms/hole.ts index 79736e7db..446a7394b 100644 --- a/server/handlers/cms/hole.ts +++ b/server/handlers/cms/hole.ts @@ -43,7 +43,7 @@ import { renderNode, type RenderConfig, type RenderAccumulators } from '@core/pu import { buildPageFrame, buildRouteFrame, buildSiteFrame } from '@core/templates/contextFrames' import { prefetchLoopData } from '../../publish/loopPrefetch' import { getOrRender } from '../../publish/renderCache' -import { getPublishedNodeIndexForVersion } from '../../publish/publishedSnapshotCache' +import { findPageForNodeId, getPublishedNodeIndexForVersion } from '../../publish/publishedSnapshotCache' import { getPublishVersion } from '../../publish/publishState' import { HOLE_RUNTIME_JS } from '../../publish/holeRuntime' import { stampFormPageTokens } from '../../forms/formRuntime' @@ -115,8 +115,13 @@ function isPerVisitorHole(node: PageNode): boolean { * Render one node subtree at request time. Builds the same named frames the * full-page publisher builds (route/page/site) plus pre-fetched loop data for * loops INSIDE this subtree, then renders fully (no `` recursion). + * + * Shared by the hole endpoint (`/_instatic/hole/`) and the auth-gate + * endpoint (`/_instatic/gate/`) — both render a request-time fragment + * for a single node subtree, so the frame/prefetch/render/stamp pipeline is + * identical. */ -async function renderHoleFragment( +export async function renderHoleFragment( nodeId: string, page: Page, site: SiteDocument, @@ -203,14 +208,18 @@ export async function handleHoleRequest( headers: { 'content-type': 'text/plain; charset=utf-8' }, }) } - const foundPage = snap.nodeIndex.get(nodeId) - if (!foundPage) { + const found = findPageForNodeId(snap, nodeId) + if (!found) { return new Response('Node not found', { status: 404, headers: { 'content-type': 'text/plain; charset=utf-8' }, }) } - const node = foundPage.nodes[nodeId]! + // Template composition prefixes node ids (c0_/t_) on wrapped pages; + // use the effective (non-composed) id for node lookup + rendering. + const foundPage = found.page + const effectiveNodeId = found.effectiveNodeId + const node = foundPage.nodes[effectiveNodeId]! // Reconstruct the originating page URL forwarded by the runtime (`u`). Falls // back to the page's own permalink when absent (older runtime / direct hit). @@ -236,7 +245,7 @@ export async function handleHoleRequest( slug: route.slug, cookies: parseCookies(req.headers.get('cookie')), } - const html = await renderHoleFragment(nodeId, foundPage, snap.site, ctx.db, pageUrl, request) + const html = await renderHoleFragment(effectiveNodeId, foundPage, snap.site, ctx.db, pageUrl, request) return new Response(html, { status: 200, headers: { @@ -261,7 +270,7 @@ export async function handleHoleRequest( queryString: `v=${currentVersion}&${normalizeQuery(pageUrl.searchParams)}`, }, async () => { - const html = await renderHoleFragment(nodeId, foundPage, snap.site, ctx.db, pageUrl, request) + const html = await renderHoleFragment(effectiveNodeId, foundPage, snap.site, ctx.db, pageUrl, request) return { body: html, headers: { 'content-type': 'text/html; charset=utf-8' }, diff --git a/server/handlers/cms/index.ts b/server/handlers/cms/index.ts index 145868b4a..0bbb00f84 100644 --- a/server/handlers/cms/index.ts +++ b/server/handlers/cms/index.ts @@ -35,6 +35,7 @@ import { handleAuthRoutes } from './auth' import { handleMeRoutes } from './me' import { handleUserPreferencesRoutes } from './userPreferences' import { handleUsersRoutes } from './users' +import { handleVisitorAuthAdminRoutes } from './visitorAuth' import { handleRolesRoutes } from './roles' import { handleAuditRoutes } from './audit' import { handleSiteRoutes } from './site' @@ -85,6 +86,10 @@ export async function handleCmsRequest( // account" surface. Routes mount under `/admin/api/cms/me/preferences/`. ?? (await handleUserPreferencesRoutes(req, db)) ?? (await handleUsersRoutes(req, db)) + // Visitor-auth admin management (config + visitor user table). Gated by + // `users.manage`; runs right after the admin users routes so the + // `/visitor-auth/users/*` namespace stays adjacent to admin users. + ?? (await handleVisitorAuthAdminRoutes(req, db)) ?? (await handleRolesRoutes(req, db)) ?? (await handleAuditRoutes(req, db)) ?? (await handleSiteRoutes(req, db)) diff --git a/server/handlers/cms/shared.ts b/server/handlers/cms/shared.ts index 8dac85a40..7969af273 100644 --- a/server/handlers/cms/shared.ts +++ b/server/handlers/cms/shared.ts @@ -4,8 +4,9 @@ * - `requestAuditContext` — the `(ipAddress, userAgent)` pair every audit * event carries. * - `mutationErrorResponse` — translates the typed mutation errors thrown - * by repositories (`UserMutationError`, `RoleMutationError`) into the - * `{ error }` JSON envelope clients expect. + * by repositories (`UserMutationError`, `RoleMutationError`, + * `VisitorRoleMutationError`) into the `{ error }` JSON envelope clients + * expect. * * These helpers are intentionally small and dependency-free so any new * handler module can pull them in without dragging the rest of the CMS @@ -16,6 +17,8 @@ import { jsonResponse } from '../../http' import { clientIp } from '../../auth/security' import { UserMutationError } from '../../repositories/users' import { RoleMutationError } from '../../repositories/roles' +import { VisitorRoleMutationError } from '../../visitor-auth/roles' +import { VisitorGroupMutationError } from '../../visitor-auth/groups' export const CMS_API_PREFIX = '/admin/api/cms' @@ -41,7 +44,12 @@ export function requestAuditContext(req: Request): { ipAddress: string | null; u } export function mutationErrorResponse(err: unknown): Response { - if (err instanceof UserMutationError || err instanceof RoleMutationError) { + if ( + err instanceof UserMutationError || + err instanceof RoleMutationError || + err instanceof VisitorRoleMutationError || + err instanceof VisitorGroupMutationError + ) { return jsonResponse({ error: err.message }, { status: err.status }) } throw err diff --git a/server/handlers/cms/visitorAuth.ts b/server/handlers/cms/visitorAuth.ts new file mode 100644 index 000000000..90bba339f --- /dev/null +++ b/server/handlers/cms/visitorAuth.ts @@ -0,0 +1,626 @@ +/** + * Visitor-auth admin endpoints (gated by `users.manage`). + * + * Lets an admin enable/disable visitor auth, configure protected prefixes, + * and manage registered visitor accounts (list, suspend/activate, change + * role, soft-delete). All under `/admin/api/cms/visitor-auth/*`. + * + * GET /admin/api/cms/visitor-auth/config — current visitor-auth config + * PUT /admin/api/cms/visitor-auth/config — save (merge-patch) the config + * GET /admin/api/cms/visitor-auth/roles — list visitor roles (for the + * role-picker in the users table) + * POST /admin/api/cms/visitor-auth/roles — create a custom visitor role + * PATCH /admin/api/cms/visitor-auth/roles/:id — rename / re-cap a visitor role + * DELETE /admin/api/cms/visitor-auth/roles/:id — delete a custom visitor role + * GET /admin/api/cms/visitor-auth/users — paginated visitor list + * (?search=&limit=&offset=) + * PATCH /admin/api/cms/visitor-auth/users/:id — update status / role / displayName + * DELETE /admin/api/cms/visitor-auth/users/:id — soft delete (+ revoke sessions) + * + * CSRF: the CMS entry point (`./index.ts`) already rejects state-changing + * requests whose Origin doesn't match before any route group runs, so the + * PUT/PATCH/DELETE handlers here are CSRF-protected by the time they execute. + * + * Capability reuse: visitor management is gated by `users.manage` (the same + * capability that gates admin user management). Adding a dedicated + * `visitorAuth.manage` capability is deferred — it would require editing the + * core capability list + every SYSTEM_ROLE grant, and for Phase 1 "can manage + * users → can manage visitor users" is the right call. + * + * Audit: role CRUD (POST/PATCH/DELETE /roles) is intentionally NOT audited + * in this task — the `AuditAction` union (`server/repositories/audit.ts`) + * has no `visitor_auth.role_*` actions and widening it is out of scope here. + * Visitor-user mutations (`visitor_auth.user_updated` / `_deleted`) are still + * audited below. Adding dedicated `visitor_auth.role_created` / + * `_updated` / `_deleted` actions is a tracked follow-up. + */ +import type { DbClient } from '../../db/client' +import { requireCapability } from '../../auth/authz' +import { createAuditEvent } from '../../repositories/audit' +import { badRequest, jsonResponse, readValidatedBody } from '../../http' +import { Type } from '@core/utils/typeboxHelpers' +import { + CMS_API_PREFIX, + UserStatusSchema, + mutationErrorResponse, + requestAuditContext, +} from './shared' +import { runRouteTable, type Route, type RouteParams } from './routeTable' +import { + getVisitorAuthConfig, + saveVisitorAuthConfig, +} from '../../visitor-auth/config' +import { + countVisitorUsers, + findVisitorUserById, + listVisitorUsers, + revokeAllVisitorSessionsForUser, + setVisitorUserStatus, + softDeleteVisitorUser, + updateVisitorUserDisplayName, + updateVisitorUserProfileFields, + updateVisitorUserRole, +} from '../../visitor-auth/repositories' +import { + createVisitorRole, + deleteVisitorRole, + findVisitorRoleById, + listVisitorRoles, + updateVisitorRole, +} from '../../visitor-auth/roles' +import { + addGroupsToVisitor, + createVisitorGroup, + deleteVisitorGroup, + findVisitorGroupById, + listGroupsForVisitor, + listMembershipsForGroup, + listVisitorGroups, + removeVisitorFromGroup, + setVisitorPrimaryGroup, + updateVisitorGroup, + type VisitorMembershipView, +} from '../../visitor-auth/groups' +import type { VisitorUser } from '../../visitor-auth/types' + +const PREFIX = `${CMS_API_PREFIX}/visitor-auth` + +// ─── Config ────────────────────────────────────────────────────────────────── + +const VisitorProfileFieldSchema = Type.Object({ + id: Type.String({ maxLength: 100 }), + label: Type.String({ maxLength: 200 }), + type: Type.Union([ + Type.Literal('text'), + Type.Literal('longText'), + Type.Literal('select'), + Type.Literal('boolean'), + ]), + required: Type.Optional(Type.Boolean()), + options: Type.Optional(Type.Array(Type.Object({ + value: Type.String({ maxLength: 100 }), + label: Type.String({ maxLength: 200 }), + }))), +}) + +const VisitorAuthConfigPatchSchema = Type.Partial(Type.Object({ + enabled: Type.Boolean(), + // D15: default landing path for a logged-in visitor with no primary-group + // landing. Replaces the retired Phase-1/2 `protectedPrefixes` (page access + // is now per-page, D14). A legacy `protectedPrefixes` field sent by an + // older admin UI is ignored silently — see handlePutConfig. + defaultLandingPath: Type.String({ maxLength: 200 }), + loginPath: Type.String({ maxLength: 200 }), + registrationOpen: Type.Boolean(), + defaultRole: Type.String({ maxLength: 100 }), + // Per-visitor-data framework: site-builder-defined custom profile field + // DEFINITIONS (DataField[]-shape). Stored on visitor_auth_config and + // mirrored on each visitor's profile_fields_json by id. Passed straight + // through to saveVisitorAuthConfig (config.ts normalises/validates). + profileFields: Type.Array(VisitorProfileFieldSchema), +})) + +/** + * Validate + clean a landing path. Returns the trimmed value, or `null` when + * it doesn't start with `/` (keeps the login-redirect resolver sane; a bare + * "members" would be treated as a relative path). Only call this when the + * caller has already confirmed `defaultLandingPath !== undefined`. + */ +function normalizeLandingPath(raw: string): string | null { + const cleaned = raw.trim() + if (!cleaned.startsWith('/')) return null + return cleaned +} + +async function handleGetConfig(req: Request, db: DbClient): Promise { + const actor = await requireCapability(req, db, 'users.manage') + if (actor instanceof Response) return actor + const config = await getVisitorAuthConfig(db) + return jsonResponse({ config }) +} + +async function handlePutConfig(req: Request, db: DbClient): Promise { + const actor = await requireCapability(req, db, 'users.manage') + if (actor instanceof Response) return actor + + const body = await readValidatedBody(req, VisitorAuthConfigPatchSchema) + if (!body) return badRequest('invalid_request') + + // D14 retired `protectedPrefixes` (page access is now per-page). An older + // admin UI may still send it briefly — ignore it silently with a one-line + // deprecation rather than erroring, so the upgrade path is smooth. + if ((body as Record).protectedPrefixes !== undefined) { + console.warn('[visitor-auth] ignoring deprecated protectedPrefixes in config save') + } + + const patch = { ...body } + // `defaultRole` must resolve to a real visitor role when supplied. + if (body.defaultRole !== undefined) { + const byName = await listVisitorRoles(db) + const exists = byName.some((r) => r.id === body.defaultRole || r.name === body.defaultRole) + if (!exists) return badRequest('unknown defaultRole') + } + if (body.defaultLandingPath !== undefined) { + const landing = normalizeLandingPath(body.defaultLandingPath) + if (landing === null) { + return badRequest('defaultLandingPath must start with "/"') + } + patch.defaultLandingPath = landing + } + const config = await saveVisitorAuthConfig(db, patch) + + await createAuditEvent(db, { + actorUserId: actor.id, + action: 'visitor_auth.config_updated', + targetType: 'site', + targetId: 'default', + // AuditMetadata values are primitives/string[]; nest the config object as JSON. + metadata: { config: JSON.stringify(config) }, + ...requestAuditContext(req), + }) + + return jsonResponse({ config }) +} + +// ─── Roles ─────────────────────────────────────────────────────────────────── + +async function handleListRoles(req: Request, db: DbClient): Promise { + const actor = await requireCapability(req, db, 'users.manage') + if (actor instanceof Response) return actor + const roles = await listVisitorRoles(db) + return jsonResponse({ roles }) +} + +const VisitorRoleCreateBodySchema = Type.Object({ + name: Type.String({ maxLength: 100 }), + capabilities: Type.Array(Type.String()), +}) + +const VisitorRolePatchBodySchema = Type.Partial(Type.Object({ + name: Type.String({ maxLength: 100 }), + capabilities: Type.Array(Type.String()), +})) + +/** + * Create a custom visitor role. Rejects a duplicate name with 409 (handled + * via `mutationErrorResponse` from the `VisitorRoleMutationError` thrown by + * the repository). Not audited — see the module header. + */ +async function handleCreateRole(req: Request, db: DbClient): Promise { + const actor = await requireCapability(req, db, 'users.manage') + if (actor instanceof Response) return actor + const body = await readValidatedBody(req, VisitorRoleCreateBodySchema) + if (!body) return badRequest('Invalid visitor role payload') + try { + const role = await createVisitorRole(db, { + name: body.name, + capabilities: body.capabilities, + }) + return jsonResponse({ role }, { status: 201 }) + } catch (err) { + return mutationErrorResponse(err) + } +} + +/** + * Update a visitor role's name and/or capabilities. System roles are + * editable (the repository permits it — only `delete` is gated on + * `is_system`). Not audited — see the module header. + */ +async function handleUpdateRole( + req: Request, + db: DbClient, + params: RouteParams, +): Promise { + const actor = await requireCapability(req, db, 'users.manage') + if (actor instanceof Response) return actor + const body = await readValidatedBody(req, VisitorRolePatchBodySchema) + if (!body) return badRequest('Invalid visitor role payload') + try { + const role = await updateVisitorRole(db, params.id, { + name: body.name, + capabilities: body.capabilities, + }) + if (!role) return jsonResponse({ error: 'Visitor role not found' }, { status: 404 }) + return jsonResponse({ role }) + } catch (err) { + return mutationErrorResponse(err) + } +} + +/** + * Delete a visitor role. System roles (409) and roles still assigned to + * visitors (409) are refused by the repository. Not audited — see the + * module header. + */ +async function handleDeleteRole( + req: Request, + db: DbClient, + params: RouteParams, +): Promise { + const actor = await requireCapability(req, db, 'users.manage') + if (actor instanceof Response) return actor + try { + const deletedRole = await deleteVisitorRole(db, params.id) + if (!deletedRole) return jsonResponse({ error: 'Visitor role not found' }, { status: 404 }) + return jsonResponse({ role: deletedRole }) + } catch (err) { + return mutationErrorResponse(err) + } +} + +// ─── Groups (Phase 3 — D13/D14/D15) ──────────────────────────────── + +const VisitorGroupCreateBodySchema = Type.Object({ + name: Type.String({ maxLength: 100 }), + landingPath: Type.Optional(Type.String({ maxLength: 200 })), + description: Type.Optional(Type.String({ maxLength: 500 })), +}) + +const VisitorGroupPatchBodySchema = Type.Partial(Type.Object({ + name: Type.String({ maxLength: 100 }), + landingPath: Type.Optional(Type.String({ maxLength: 200 })), + description: Type.Optional(Type.String({ maxLength: 500 })), +})) + +async function handleListGroups(req: Request, db: DbClient): Promise { + const actor = await requireCapability(req, db, 'users.manage') + if (actor instanceof Response) return actor + const groups = await listVisitorGroups(db) + return jsonResponse({ groups }) +} + +async function handleCreateGroup(req: Request, db: DbClient): Promise { + const actor = await requireCapability(req, db, 'users.manage') + if (actor instanceof Response) return actor + const body = await readValidatedBody(req, VisitorGroupCreateBodySchema) + if (!body) return badRequest('Invalid visitor group payload') + try { + const group = await createVisitorGroup(db, { + name: body.name, + landingPath: body.landingPath, + description: body.description, + }) + return jsonResponse({ group }, { status: 201 }) + } catch (err) { + return mutationErrorResponse(err) + } +} + +async function handleUpdateGroup( + req: Request, + db: DbClient, + params: RouteParams, +): Promise { + const actor = await requireCapability(req, db, 'users.manage') + if (actor instanceof Response) return actor + const body = await readValidatedBody(req, VisitorGroupPatchBodySchema) + if (!body) return badRequest('Invalid visitor group payload') + try { + const group = await updateVisitorGroup(db, params.id, { + name: body.name, + landingPath: body.landingPath, + description: body.description, + }) + if (!group) return jsonResponse({ error: 'Visitor group not found' }, { status: 404 }) + return jsonResponse({ group }) + } catch (err) { + return mutationErrorResponse(err) + } +} + +async function handleDeleteGroup( + req: Request, + db: DbClient, + params: RouteParams, +): Promise { + const actor = await requireCapability(req, db, 'users.manage') + if (actor instanceof Response) return actor + try { + const deletedGroup = await deleteVisitorGroup(db, params.id) + if (!deletedGroup) return jsonResponse({ error: 'Visitor group not found' }, { status: 404 }) + return jsonResponse({ group: deletedGroup }) + } catch (err) { + return mutationErrorResponse(err) + } +} + +/** Shape of a membership row joined with the visitor for the group-members list. */ +interface GroupMemberRow { + userId: string + groupId: string + isPrimary: boolean + joinedAt: string +} + +/** + * GET /groups/:id/members — list the visitors in a group. Joins the junction + * to visitor_users for display, flagging each row whose primary group is this + * group. Returns 404 for an unknown group id. + */ +async function handleListGroupMembers( + req: Request, + db: DbClient, + params: RouteParams, +): Promise { + const actor = await requireCapability(req, db, 'users.manage') + if (actor instanceof Response) return actor + const group = await findVisitorGroupById(db, params.id) + if (!group) return jsonResponse({ error: 'Visitor group not found' }, { status: 404 }) + const memberships = await listMembershipsForGroup(db, params.id) + const users = await Promise.all( + memberships.map((m) => findVisitorUserById(db, m.userId)), + ) + const members: GroupMemberRow[] = [] + for (const [i, m] of memberships.entries()) { + const user = users[i] + if (!user) continue // soft-deleted between the two reads — skip + members.push({ + userId: user.id, + groupId: group.id, + isPrimary: user.primaryGroupId === group.id, + joinedAt: m.createdAt, + }) + } + return jsonResponse({ group, members }) +} + +const VisitorUserGroupsBodySchema = Type.Object({ + groupIds: Type.Array(Type.String()), + primaryGroupId: Type.Optional(Type.Union([Type.String(), Type.Null()])), +}) + +/** + * PUT /users/:id/groups — set a visitor's group memberships (replaces the + * existing set) and optionally their primary group. `primaryGroupId: null` + * clears the primary; an omitted `primaryGroupId` leaves it untouched. Setting + * a primary group that is NOT in the (post-write) membership set is rejected + * (409) by the repository. + */ +async function handlePutUserGroups( + req: Request, + db: DbClient, + params: RouteParams, +): Promise { + const actor = await requireCapability(req, db, 'users.manage') + if (actor instanceof Response) return actor + const user = await findVisitorUserById(db, params.id) + if (!user) return jsonResponse({ error: 'visitor not found' }, { status: 404 }) + + const body = await readValidatedBody(req, VisitorUserGroupsBodySchema) + if (!body) return badRequest('invalid_request') + + // Reconcile memberships: remove groups no longer in the set, add new ones. + const desired = new Set(body.groupIds) + const current = await listGroupsForVisitor(db, params.id) + for (const membership of current) { + if (!desired.has(membership.group.id)) { + await removeVisitorFromGroup(db, params.id, membership.group.id) + } + } + await addGroupsToVisitor(db, params.id, body.groupIds) + + if (body.primaryGroupId !== undefined) { + try { + await setVisitorPrimaryGroup(db, params.id, body.primaryGroupId) + } catch (err) { + return mutationErrorResponse(err) + } + } + + await createAuditEvent(db, { + actorUserId: actor.id, + action: 'visitor_auth.user_updated', + targetType: 'user', + targetId: params.id, + metadata: { email: user.email, changes: JSON.stringify({ groups: body.groupIds, primaryGroupId: body.primaryGroupId ?? null }) }, + ...requestAuditContext(req), + }) + + const memberships: VisitorMembershipView[] = await listGroupsForVisitor(db, params.id) + return jsonResponse({ userId: params.id, memberships }) +} + +async function handleGetUserGroups( + req: Request, + db: DbClient, + params: RouteParams, +): Promise { + const actor = await requireCapability(req, db, 'users.manage') + if (actor instanceof Response) return actor + const user = await findVisitorUserById(db, params.id) + if (!user) return jsonResponse({ error: 'visitor not found' }, { status: 404 }) + const memberships: VisitorMembershipView[] = await listGroupsForVisitor(db, params.id) + return jsonResponse({ userId: params.id, memberships }) +} + +// ─── Users ────────────────────────────────────────────────────────────── + +/** The admin-facing visitor shape — never exposes `passwordHash`. */ +function toAdminVisitor(db: DbClient, user: VisitorUser): Promise<{ + id: string + email: string + displayName: string + roleId: string + roleName: string + status: string + failedLoginCount: number + lockedUntil: string | null + createdAt: string + // Per-visitor-data framework: custom profile field VALUES. + profileFields: Record +}> { + return findVisitorRoleById(db, user.roleId).then((role) => ({ + id: user.id, + email: user.email, + displayName: user.displayName, + roleId: user.roleId, + roleName: role?.name ?? user.roleId, + status: user.status, + failedLoginCount: user.failedLoginCount, + lockedUntil: user.lockedUntil, + createdAt: user.createdAt, + profileFields: user.profileFields, + })) +} + +async function handleListUsers(req: Request, db: DbClient): Promise { + const actor = await requireCapability(req, db, 'users.manage') + if (actor instanceof Response) return actor + const url = new URL(req.url) + const limit = Number(url.searchParams.get('limit') ?? '50') + const offset = Number(url.searchParams.get('offset') ?? '0') + const search = url.searchParams.get('search') ?? undefined + const [users, total] = await Promise.all([ + listVisitorUsers(db, { + limit: Number.isFinite(limit) ? limit : undefined, + offset: Number.isFinite(offset) ? offset : undefined, + search: search || undefined, + }), + countVisitorUsers(db, { search: search || undefined }), + ]) + const items = await Promise.all(users.map((u) => toAdminVisitor(db, u))) + return jsonResponse({ users: items, total }) +} + +const VisitorUserPatchSchema = Type.Partial(Type.Object({ + status: UserStatusSchema, + roleId: Type.String(), + displayName: Type.String({ maxLength: 200 }), + // Per-visitor-data framework: custom profile field VALUES (object keyed by + // field id). Stored whole — callers merge against existing values. The id + // keys should match the configured VisitorProfileField defs, but the server + // doesn't enforce that (allows ad-hoc values during config transitions). + profileFields: Type.Record(Type.String(), Type.Unknown()), +})) + +async function handlePatchUser( + req: Request, + db: DbClient, + params: RouteParams, +): Promise { + const actor = await requireCapability(req, db, 'users.manage') + if (actor instanceof Response) return actor + + const current = await findVisitorUserById(db, params.id) + if (!current) return jsonResponse({ error: 'visitor not found' }, { status: 404 }) + + const body = await readValidatedBody(req, VisitorUserPatchSchema) + if (!body) return badRequest('invalid_request') + + // Validate roleId if supplied. + if (body.roleId !== undefined && body.roleId !== current.roleId) { + const role = await findVisitorRoleById(db, body.roleId) + if (!role) return badRequest('unknown roleId') + } + + if (body.displayName !== undefined && body.displayName !== current.displayName) { + await updateVisitorUserDisplayName(db, params.id, body.displayName) + } + if (body.roleId !== undefined && body.roleId !== current.roleId) { + await updateVisitorUserRole(db, params.id, body.roleId) + } + if (body.profileFields !== undefined) { + // Merge the patch onto existing values so a partial update (one field) + // doesn't wipe the others. The handler is the only admin write path, so + // this is the single merge point. + const merged = { ...current.profileFields, ...body.profileFields } + await updateVisitorUserProfileFields(db, params.id, merged) + } + if (body.status !== undefined && body.status !== current.status) { + await setVisitorUserStatus(db, params.id, body.status) + // Suspending a visitor invalidates their active sessions so they can't + // keep browsing on a cookie issued before the suspension. + if (body.status === 'suspended') { + await revokeAllVisitorSessionsForUser(db, params.id) + } + } + + const updated = await findVisitorUserById(db, params.id) + await createAuditEvent(db, { + actorUserId: actor.id, + action: 'visitor_auth.user_updated', + targetType: 'user', + targetId: params.id, + metadata: { email: current.email, changes: JSON.stringify(body) }, + ...requestAuditContext(req), + }) + + return jsonResponse({ user: updated ? await toAdminVisitor(db, updated) : null }) +} + +async function handleDeleteUser( + req: Request, + db: DbClient, + params: RouteParams, +): Promise { + const actor = await requireCapability(req, db, 'users.manage') + if (actor instanceof Response) return actor + + const current = await findVisitorUserById(db, params.id) + if (!current) return jsonResponse({ error: 'visitor not found' }, { status: 404 }) + + // Revoke sessions first, then soft-delete. Order matters: once deleted_at + // is set the session-validation lookup (active + non-deleted) stops matching + // anyway, but explicit revoke also clears the in-memory cache path. + await revokeAllVisitorSessionsForUser(db, params.id) + await softDeleteVisitorUser(db, params.id) + + await createAuditEvent(db, { + actorUserId: actor.id, + action: 'visitor_auth.user_deleted', + targetType: 'user', + targetId: params.id, + metadata: { email: current.email }, + ...requestAuditContext(req), + }) + + return jsonResponse({ ok: true }) +} + +// ─── Route table + dispatcher ──────────────────────────────────────────────── + +const VISITOR_AUTH_ROUTES: readonly Route<[]>[] = [ + { method: 'GET', pattern: `${PREFIX}/config`, handler: handleGetConfig }, + { method: 'PUT', pattern: `${PREFIX}/config`, handler: handlePutConfig }, + { method: 'GET', pattern: `${PREFIX}/roles`, handler: handleListRoles }, + { method: 'POST', pattern: `${PREFIX}/roles`, handler: handleCreateRole }, + { method: 'PATCH', pattern: new RegExp(`^${PREFIX}/roles/(?[^/]+)$`), handler: handleUpdateRole }, + { method: 'DELETE',pattern: new RegExp(`^${PREFIX}/roles/(?[^/]+)$`), handler: handleDeleteRole }, + { method: 'GET', pattern: `${PREFIX}/groups`, handler: handleListGroups }, + { method: 'POST', pattern: `${PREFIX}/groups`, handler: handleCreateGroup }, + { method: 'PATCH', pattern: new RegExp(`^${PREFIX}/groups/(?[^/]+)$`), handler: handleUpdateGroup }, + { method: 'DELETE',pattern: new RegExp(`^${PREFIX}/groups/(?[^/]+)$`), handler: handleDeleteGroup }, + { method: 'GET', pattern: new RegExp(`^${PREFIX}/groups/(?[^/]+)/members$`), handler: handleListGroupMembers }, + { method: 'GET', pattern: `${PREFIX}/users`, handler: handleListUsers }, + { method: 'PATCH', pattern: new RegExp(`^${PREFIX}/users/(?[^/]+)$`), handler: handlePatchUser }, + { method: 'DELETE',pattern: new RegExp(`^${PREFIX}/users/(?[^/]+)$`), handler: handleDeleteUser }, + { method: 'GET', pattern: new RegExp(`^${PREFIX}/users/(?[^/]+)/groups$`), handler: handleGetUserGroups }, + { method: 'PUT', pattern: new RegExp(`^${PREFIX}/users/(?[^/]+)/groups$`), handler: handlePutUserGroups }, +] + +export async function handleVisitorAuthAdminRoutes(req: Request, db: DbClient): Promise { + const { pathname } = new URL(req.url) + if (!pathname.startsWith(`${PREFIX}/`)) return null + return runRouteTable(req, db, VISITOR_AUTH_ROUTES) +} diff --git a/server/index.ts b/server/index.ts index e4f3675b0..1e4f653ad 100644 --- a/server/index.ts +++ b/server/index.ts @@ -4,6 +4,7 @@ import { syncSystemRoles } from './repositories/roles' import { readServerConfig } from './config' import { DEV_ORIGIN_ALLOWLIST, configurePublicOrigins, configureTrustedProxyCidrs, stampSocketIp } from './auth/security' import { applySecurityHeaders } from './securityHeaders' +import { visitorAuthMiddleware } from './visitor-auth/middleware' import { startConversationPurgeTick } from './ai/boot' await import('./richtextSanitizer') @@ -89,6 +90,14 @@ Bun.serve({ } try { + // Visitor-auth route protection (D1). Runs before handleServerRequest so + // it gates every public-site request; returns null for pass-through. + const authResponse = await visitorAuthMiddleware(req, db) + if (authResponse) { + for (const [k, v] of Object.entries(cors)) authResponse.headers.set(k, v) + return applySecurityHeaders(authResponse, pathname) + } + const res = await handleServerRequest(req, { db, staticDir: config.staticDir, diff --git a/server/publish/holeRuntime.ts b/server/publish/holeRuntime.ts index da6317dba..9cb501ff9 100644 --- a/server/publish/holeRuntime.ts +++ b/server/publish/holeRuntime.ts @@ -58,6 +58,43 @@ export function runInstaticHoleRuntime(): void { const box = el.firstElementChild; if (box) { io.observe(box); } else { instaticFetchHole(el); } } + + // Auth-gated containers — mirror the hole logic but fetch the real subtree + // from /_instatic/gate/. The endpoint renders the subtree only for + // authorised visitors and returns the baked fallback for everyone else, so + // unauthorised visitors keep seeing the placeholder content. + function instaticFetchGate(el: HTMLElement): void { + const id = el.dataset.instaticGate || ''; + const version = el.dataset.instaticVersion || ''; + const u = location.pathname + location.search; + fetch('/_instatic/gate/' + encodeURIComponent(id) + '?v=' + encodeURIComponent(version) + '&u=' + encodeURIComponent(u)) + .then(function(r) { return r.text(); }) + .then(function(html) { el.outerHTML = html; }) + .catch(function() {}); + } + const gated = document.querySelectorAll('instatic-gated[data-instatic-gate]'); + // Only construct the gate IntersectionObserver when there are gated elements + // to observe — avoids creating a second observer (and clobbering any + // test/caller that captures the last-constructed IntersectionObserver) on + // pages that only use holes. + if (gated.length > 0) { + const gateIo = new IntersectionObserver(function(entries) { + for (let i = 0; i < entries.length; i++) { + const e = entries[i]; + if (!e.isIntersecting) continue; + gateIo.unobserve(e.target); + const gate = e.target.closest('instatic-gated[data-instatic-gate]') as HTMLElement | null; + if (gate) instaticFetchGate(gate); + } + }, { rootMargin: '200px 0px' }); + for (let i = 0; i < gated.length; i++) { + const el = gated[i] as HTMLElement; + // is display:contents (no box) — observe its placeholder + // child, which has a box. Gates without a placeholder are fetched eagerly. + const box = el.firstElementChild; + if (box) { gateIo.observe(box); } else { instaticFetchGate(el); } + } + } } export const HOLE_RUNTIME_JS = `(${runInstaticHoleRuntime.toString()})();` diff --git a/server/publish/loopPrefetch.ts b/server/publish/loopPrefetch.ts index 01416b508..d666e3fb1 100644 --- a/server/publish/loopPrefetch.ts +++ b/server/publish/loopPrefetch.ts @@ -25,6 +25,29 @@ import { publicDataUserFromParts } from '@core/data/publicDataUser' import type { PublishedDataRow } from '@core/data/schemas' import type { DbClient } from '../db/client' import { walkRenderTree } from './renderTreeWalk' +import { resolveVisitorFromCookie } from '../visitor-auth/visitorData' +import type { SourceVisitorContext } from '@core/loops/types' + +/** + * Resolve the current visitor from the per-visitor cookie map and project it + * to the core `SourceVisitorContext` shape (no server types leak into + * `src/core`). Returns `undefined` for anonymous requests — perVisitor + * sources then render their empty state. Derived solely from the cookie. + */ +async function resolveVisitorForLoop( + db: DbClient, + cookies: Record | undefined, +): Promise { + const visitor = await resolveVisitorFromCookie(db, cookies) + if (!visitor) return undefined + return { + id: visitor.id, + displayName: visitor.displayName, + email: visitor.email, + roleName: visitor.roleName ?? null, + profileFields: visitor.profileFields, + } +} /** * Resolved loop data for a single loop node on a page. @@ -243,6 +266,12 @@ async function resolveOneLoop( // Request context — present only when rendering inside a Layer C hole. // Built-in publish-time sources ignore it. request: ctx.request, + // Resolved visitor — populated ONLY for perVisitor sources with cookies, + // so built-in sources read `ctx.visitor` without importing session code. + // Derived solely from the cookie (IDOR-safe — never from loop input). + visitor: source.perVisitor && ctx.request + ? await resolveVisitorForLoop(ctx.db, ctx.request.cookies) + : undefined, } try { diff --git a/server/publish/publicRouter.ts b/server/publish/publicRouter.ts index 65e30e123..2f1725b4d 100644 --- a/server/publish/publicRouter.ts +++ b/server/publish/publicRouter.ts @@ -62,6 +62,7 @@ import type { DbClient } from '../db/client' import type { PublishedPageSnapshot } from '../repositories/publish' import type { PublishedDataRow } from '@core/data/schemas' import { isTemplatePage, resolveNotFoundTemplate } from '@core/templates' +import { resolvePageAccess } from '@core/page-tree' import { getDataRowRedirectByRoute, getPublishedDataRowByRoute, @@ -184,6 +185,51 @@ async function resolvePublicRoute( return { kind: 'not-found' } } +/** + * Does a directly-routable published page exist at `pathname`? + * + * Mirrors the判定 inside `resolvePublicRoute` for the page-slug branch: a + * real page (NOT a template page) at the normalised slug. Used by the + * visitor-auth middleware to decide between a 302 to the operator's + * published login page and the built-in fallback login page — a template + * page is never a valid login target, so it must not count as "exists". + * + * Reuses the LOCAL `publicSlugFromPath` + `getPublishedPageBySlug` so this + * helper never drifts from how the public router itself resolves a slug. + */ +export async function publishedPageExistsAtPath(db: DbClient, pathname: string): Promise { + const snap = await getPublishedPageBySlug(db, publicSlugFromPath(pathname)) + if (!snap) return false + const page = snap.site.pages.find((p) => p.id === snap.pageRowId) + return Boolean(page && !isTemplatePage(page)) +} + +/** + * Resolved access level for a published (non-template) page at `pathname`. + * + * Returns `null` when no published page exists at the path (the caller lets + * the request fall through to a downstream 404). Otherwise returns the page's + * normalised access shape — `{ level: 'public', groups: [] }` by default, + * `{ level: 'groups', groups: [...] }` when the page is restricted to a set + * of member groups (D14). Reads `page.access` tolerantly (missing/corrupt → + * public) via {@link resolvePageAccess} so a pre-Phase-3 snapshot never + * crashes the gate. + * + * The visitor-auth middleware uses this to decide per-page gating (D17): + * public → pass through; restricted + anonymous → login; restricted + + * logged-in-but-not-in-group → the built-in "no access" page. + */ +export async function getPublishedPageAccessForPath( + db: DbClient, + pathname: string, +): Promise<{ level: 'public' | 'groups'; groups: string[] } | null> { + const snap = await getPublishedPageBySlug(db, publicSlugFromPath(pathname)) + if (!snap) return null + const page = snap.site.pages.find((p) => p.id === snap.pageRowId) + if (!page || isTemplatePage(page)) return null + return resolvePageAccess((page as { access?: unknown }).access) +} + // --------------------------------------------------------------------------- // Resolution → Response // --------------------------------------------------------------------------- diff --git a/server/publish/publishedSnapshotCache.ts b/server/publish/publishedSnapshotCache.ts index 7037f1fda..4b7c1a819 100644 --- a/server/publish/publishedSnapshotCache.ts +++ b/server/publish/publishedSnapshotCache.ts @@ -58,6 +58,45 @@ interface PublishedNodeIndex { const nodeIndexMemo = createVersionedSingleFlight() +/** + * Compose prefixes that `spliceIntoOutlet` (templateCompose.ts) prepends to a + * terminal page's node ids when it splices the page into an everywhere / + * postTypes template outlet: `c0_` for the terminal page, `t_` for each + * outer template in the chain. The published hole/gate placeholder attribute + * carries the COMPOSED (prefixed) id; the nodeIndex here is built from the raw + * (non-composed) snapshot pages, so a lookup by the composed id misses. This + * strips one known compose prefix so hole/gate requests on template-wrapped + * pages resolve. Returns the original id when no prefix is present. + */ +const COMPOSE_PREFIX = /^(?:c0_|t\d+_)/ +function stripComposePrefix(nodeId: string): string { + return nodeId.replace(COMPOSE_PREFIX, '') +} + +/** + * Compose-aware node → page lookup for the hole/gate endpoints. Tries the id + * as-is first (fast path: pages NOT wrapped by a template, or authored node + * ids that happen to be passed directly), then falls back to the compose- + * prefix-stripped id (template-wrapped pages). Returns the page and the + * EFFECTIVE node id (the form that actually exists in the page's node map — + * callers use this for `page.nodes[effectiveId]` and rendering, since the + * page's nodes are keyed on the non-composed id). Returns undefined when no + * form matches. + */ +export function findPageForNodeId( + index: PublishedNodeIndex, + nodeId: string, +): { page: Page; effectiveNodeId: string } | undefined { + if (index.nodeIndex.has(nodeId)) { + return { page: index.nodeIndex.get(nodeId)!, effectiveNodeId: nodeId } + } + const stripped = stripComposePrefix(nodeId) + if (stripped !== nodeId && index.nodeIndex.has(stripped)) { + return { page: index.nodeIndex.get(stripped)!, effectiveNodeId: stripped } + } + return undefined +} + /** * The published site plus a `nodeId → page` index for `version`, so the hole * endpoint locates a fragment's page in O(1) instead of scanning all pages. diff --git a/server/publish/visitorAuthRuntime.ts b/server/publish/visitorAuthRuntime.ts new file mode 100644 index 000000000..61c6acb20 --- /dev/null +++ b/server/publish/visitorAuthRuntime.ts @@ -0,0 +1,478 @@ +/** + * Browser runtime for visitor auth (login / register) + auth-state reveal. + * + * Self-contained ES module — no dependencies, no framework. The middleware + * injects a ` + + +` + +/** + * Built-in fallback register page — the sibling of the login fallback. + * + * Served inline by the visitor-auth middleware on a direct GET to `/register` + * when no published page exists there (mirrors the login fallback in D6). + * Same minimal self-contained styling as the login page; the form is wired to + * the visitor-auth runtime via `data-instatic-auth="register"`. + */ +export const BUILT_IN_REGISTER_PAGE_HTML = ` + + + + + Create your account + + + +
+
+

Create your account

+
+
+ + +
+
+ + +
+
+ + +
At least 8 characters.
+
+
+ + +
+ + + +
+ +
+
+ + + +` + +/** + * Built-in fallback "no access" page (D17). + * + * Served inline by the visitor-auth middleware when a LOGGED-IN visitor hits a + * page restricted to a group they are NOT a member of. Anonymous visitors get + * the login redirect instead (they may simply need to sign in) — this page is + * specifically for the authenticated-but-unauthorized case, so it links to + * login + register + home rather than redirecting (they're already signed in) + * or 404-ing (don't hide the page's existence). + * + * Same minimal self-contained styling as the login/register fallbacks; no + * form, so no visitor-auth runtime script is needed. + */ +export const BUILT_IN_NO_ACCESS_PAGE_HTML = ` + + + + + No access + + + +
+
+

You don't have access to this page

+

You're signed in, but this page is restricted to a member group you're not part of.

+ +
+
+ +` diff --git a/server/repositories/audit.ts b/server/repositories/audit.ts index f56a9a4b6..e64cc6a19 100644 --- a/server/repositories/audit.ts +++ b/server/repositories/audit.ts @@ -51,6 +51,22 @@ const AuditActionSchema = Type.Union([ Type.Literal('ai.chat.failed'), Type.Literal('ai.mcp_connector.created'), Type.Literal('ai.mcp_connector.revoked'), + // Visitor-auth admin actions (see server/handlers/cms/visitorAuth.ts). + Type.Literal('visitor_auth.config_updated'), + Type.Literal('visitor_auth.user_updated'), + Type.Literal('visitor_auth.user_deleted'), + // Visitor self-service auth actions (see server/visitor-auth/handlers.ts). + // The actor is the visitor themselves; `actor_user_id` is always NULL + // here because that column FK-references admin `users(id)` and visitor + // ids live in `visitor_users`. The visitor's identity is carried in + // `target_id` (free-text, no FK) + `metadata.email`. + Type.Literal('visitor.login.success'), + Type.Literal('visitor.login.failure'), + Type.Literal('visitor.login.locked'), + Type.Literal('visitor.logout'), + Type.Literal('visitor.register'), + Type.Literal('visitor.password.reset'), + Type.Literal('visitor.account.deleted'), ]) const AuditMetadataSchema = Type.Record( diff --git a/server/repositories/data/rows/mapper.ts b/server/repositories/data/rows/mapper.ts index 5b637f81f..ff47d2899 100644 --- a/server/repositories/data/rows/mapper.ts +++ b/server/repositories/data/rows/mapper.ts @@ -39,6 +39,13 @@ export interface InsertDataRowInput { * tables that have no slug field. */ slug: string + /** + * Visitor who owns this row (per-visitor-data framework, Pillar 3). + * Stamped by the form handler from the validated session cookie when the + * target table opts in (`capturesVisitorOwner`). Omitted/NULL for ordinary + * rows and anonymous submits. + */ + visitorUserId?: string | null } export interface UpdateDataRowDraftInput { @@ -57,6 +64,7 @@ interface DataRowRow extends UserJoinColumns { slug: string status: DataRowStatus author_user_id: string | null + visitor_user_id: string | null created_by_user_id: string | null updated_by_user_id: string | null published_by_user_id: string | null @@ -79,6 +87,7 @@ function mapRow(row: DataRowRow): DataRow { slug: row.slug, status: row.status, authorUserId: row.author_user_id ?? null, + visitorUserId: row.visitor_user_id ?? null, createdByUserId: row.created_by_user_id ?? null, updatedByUserId: row.updated_by_user_id ?? null, publishedByUserId: row.published_by_user_id ?? null, @@ -115,6 +124,7 @@ const DATA_ROW_COLUMNS = `data_rows.id, data_rows.slug, data_rows.status, data_rows.author_user_id, + data_rows.visitor_user_id, data_rows.created_by_user_id, data_rows.updated_by_user_id, data_rows.published_by_user_id, diff --git a/server/repositories/data/rows/mutations.ts b/server/repositories/data/rows/mutations.ts index c4e49ea32..acd1e30b9 100644 --- a/server/repositories/data/rows/mutations.ts +++ b/server/repositories/data/rows/mutations.ts @@ -34,6 +34,44 @@ export async function createDataRow( actorUserId: string | null = null, pluginActorId: string | null = null, ): Promise { + // `visitor_user_id` is written ONLY when the caller resolved a visitor + // from the validated session (per-visitor-data framework, Pillar 3). When + // it is absent the column is omitted entirely so it takes its NULL default + // — anonymous submits and non-opt-in tables are bit-for-bit identical to + // the pre-framework insert. + if (input.visitorUserId) { + const { rows } = await db<{ id: string }>` + insert into data_rows ( + id, + table_id, + cells_json, + slug, + status, + author_user_id, + created_by_user_id, + updated_by_user_id, + plugin_actor_id, + visitor_user_id + ) + values ( + ${input.id ?? nanoid()}, + ${input.tableId}, + ${input.cells}, + ${input.slug}, + ${'draft'}, + ${actorUserId}, + ${actorUserId}, + ${actorUserId}, + ${pluginActorId}, + ${input.visitorUserId} + ) + returning id + ` + const created = await getDataRow(db, rows[0].id) + if (!created) throw new Error('data row was created but could not be re-read') + return created + } + const { rows } = await db<{ id: string }>` insert into data_rows ( id, diff --git a/server/repositories/data/tables.ts b/server/repositories/data/tables.ts index 8f052ef9b..386b1c8ba 100644 --- a/server/repositories/data/tables.ts +++ b/server/repositories/data/tables.ts @@ -66,6 +66,12 @@ interface DataTableRow { * `0`/`1`, Postgres as a boolean — `mapTable` coerces both via `Boolean`. */ system: number | boolean + /** + * Per-table opt-in for the visitor-data framework (Pillar 3). `not null + * default 0` on both dialects; `mapTable` coerces via `Boolean` (same as + * `system`). True when the form handler should stamp `visitor_user_id`. + */ + captures_visitor_owner: number | boolean created_by_user_id: string | null updated_by_user_id: string | null /** @@ -88,6 +94,7 @@ function mapTable(row: DataTableRow): DataTable { primaryFieldId: row.primary_field_id, fields: normalizeDataTableFields(row.fields_json), system: Boolean(row.system), + capturesVisitorOwner: Boolean(row.captures_visitor_owner), createdByUserId: row.created_by_user_id ?? null, updatedByUserId: row.updated_by_user_id ?? null, createdAt: isoDate(row.created_at), @@ -98,7 +105,7 @@ function mapTable(row: DataTableRow): DataTable { export async function listDataTables(db: DbClient): Promise { const { rows } = await db` select id, name, slug, kind, route_base, singular_label, plural_label, - primary_field_id, fields_json, system, + primary_field_id, fields_json, system, captures_visitor_owner, created_by_user_id, updated_by_user_id, created_at, updated_at from data_tables where deleted_at is null @@ -126,7 +133,7 @@ export async function listDataTables(db: DbClient): Promise { export async function listDataTablesWithCounts(db: DbClient): Promise { const { rows } = await db` select t.id, t.name, t.slug, t.kind, t.route_base, t.singular_label, t.plural_label, - t.primary_field_id, t.fields_json, t.system, + t.primary_field_id, t.fields_json, t.system, t.captures_visitor_owner, t.created_by_user_id, t.updated_by_user_id, t.created_at, t.updated_at, coalesce( (select count(*) from data_rows r where r.table_id = t.id and r.deleted_at is null), @@ -153,7 +160,7 @@ export async function listDataTablesWithCounts(db: DbClient): Promise { const { rows } = await db` select id, name, slug, kind, route_base, singular_label, plural_label, - primary_field_id, fields_json, system, + primary_field_id, fields_json, system, captures_visitor_owner, created_by_user_id, updated_by_user_id, created_at, updated_at from data_tables where id = ${tableId} @@ -172,7 +179,7 @@ export async function getDataTable(db: DbClient, tableId: string): Promise { const { rows } = await db` select id, name, slug, kind, route_base, singular_label, plural_label, - primary_field_id, fields_json, system, + primary_field_id, fields_json, system, captures_visitor_owner, created_by_user_id, updated_by_user_id, created_at, updated_at from data_tables where slug = ${slug} @@ -215,7 +222,7 @@ export async function createDataTable( ${input.updatedByUserId ?? input.createdByUserId ?? null} ) returning id, name, slug, kind, route_base, singular_label, plural_label, - primary_field_id, fields_json, system, + primary_field_id, fields_json, system, captures_visitor_owner, created_by_user_id, updated_by_user_id, created_at, updated_at ` // NOTE: table creation is pure data access. Entry templates are ordinary @@ -244,7 +251,7 @@ export async function updateDataTable( where id = ${tableId} and deleted_at is null returning id, name, slug, kind, route_base, singular_label, plural_label, - primary_field_id, fields_json, system, + primary_field_id, fields_json, system, captures_visitor_owner, created_by_user_id, updated_by_user_id, created_at, updated_at ` return rows[0] ? mapTable(rows[0]) : null @@ -322,7 +329,7 @@ export async function softDeleteDataTable( where id = ${tableId} and deleted_at is null returning id, name, slug, kind, route_base, singular_label, plural_label, - primary_field_id, fields_json, system, + primary_field_id, fields_json, system, captures_visitor_owner, created_by_user_id, updated_by_user_id, created_at, updated_at ` return rows[0] ? mapTable(rows[0]) : null diff --git a/server/router.ts b/server/router.ts index 635ccb120..98e74a3ed 100644 --- a/server/router.ts +++ b/server/router.ts @@ -12,8 +12,11 @@ import { getSetupStatusCached } from './repositories/setup' import { getPublishedRuntimeAsset } from './repositories/runtimeAsset' import { handleLoopRequest, isLoopRuntimeAssetPath, serveLoopRuntimeAsset } from './handlers/cms/loop' import { handleHoleRequest, isHoleRuntimeAssetPath, serveHoleRuntimeAsset } from './handlers/cms/hole' +import { handleGateRequest, GATE_PATH_PREFIX } from './handlers/cms/gate' import { handleModuleJsAssetRequest, isModuleJsAssetPath } from './handlers/cms/moduleJs' import { handlePublicFormRequest } from './forms/handler' +import { handleVisitorRoutes } from './visitor-auth/handlers' +import { VISITOR_AUTH_RUNTIME_JS } from './publish/visitorAuthRuntime' import { isRuntimePackagePath, tryServeRuntimePackage } from './publish/runtime/packageServer' import { jsonResponse } from './http' import { binaryResponse, toArrayBuffer } from './binary' @@ -77,12 +80,20 @@ const routes: readonly RouteHandler[] = [ // `/admin/api/agent/tool-result` were deleted in Phase 3 of the AI // runtime rewrite. The site editor now POSTs `/admin/api/ai/chat/site`. tryServeAi, + // Visitor-auth API — `/api/visitor/*`. Sits before the CMS admin API so + // its distinct namespace (no `/admin/` prefix) is consumed here and never + // falls through to the admin dispatcher. See `server/visitor-auth/`. + tryServeVisitorRoutes, tryServeCmsApi, tryServeLoopRuntimeAsset, tryServeLoop, tryServeHoleRuntimeAsset, tryServeHole, + tryServeGate, tryServeModuleJsAsset, + // Visitor-auth browser runtime — `/_instatic/visitor-auth.js`. Kept next + // to the other runtime-asset handlers so the fixed CMS assets stay grouped. + tryServeVisitorAuthRuntimeAsset, tryServePublicForm, tryServeRuntimeAsset, tryServeRuntimePackageNamespace, @@ -169,6 +180,18 @@ function tryServeCmsApi(req: Request, runtime: ServerRuntime, _url: URL, pathnam }) } +/** + * Visitor-auth API namespace — `/api/visitor/*`. Lives outside `/admin/api/` + * because visitors are not admins: a separate cookie + * (`instatic_visitor_session`), separate tables, separate code. + * `handleVisitorRoutes` owns the entire namespace and emits its own 404 for + * unknown sub-paths so they never fall through to the public route renderer. + */ +function tryServeVisitorRoutes(req: Request, runtime: ServerRuntime, _url: URL, pathname: string): Promise | null { + if (!pathname.startsWith('/api/visitor/')) return null + return handleVisitorRoutes(req, runtime.db) +} + /** * The loop runtime is a fixed CMS asset, served before the per-site * runtime asset lookup so the request never falls through. @@ -193,6 +216,24 @@ function tryServeHoleRuntimeAsset(req: Request, _runtime: ServerRuntime, _url: U return serveHoleRuntimeAsset() } +/** + * Visitor-auth browser runtime — `/_instatic/visitor-auth.js`. A fixed CMS + * asset (form interception + auth-state reveal) served before the public + * route renderer so the exact path is consumed here. Mirrors the hole + * runtime asset handler. + */ +function tryServeVisitorAuthRuntimeAsset(req: Request, _runtime: ServerRuntime, _url: URL, pathname: string): Response | null { + if (req.method !== 'GET' || pathname !== '/_instatic/visitor-auth.js') return null + return new Response(VISITOR_AUTH_RUNTIME_JS, { + headers: { + 'content-type': 'application/javascript; charset=utf-8', + // 1 hour — the path is a well-known fixed CMS asset that only changes + // on a CMS version bump. Use deploy-time cache-busting if you need longer. + 'cache-control': 'public, max-age=3600', + }, + }) +} + /** * Layer C hole fragment endpoint — `/_instatic/hole/`. * Renders a dynamic node subtree on-demand and caches the result via Layer B. @@ -202,6 +243,17 @@ function tryServeHole(req: Request, runtime: ServerRuntime, url: URL, pathname: return handleHoleRequest(req, url, { db: runtime.db }) } +/** + * Layer C auth-gate fragment endpoint — `/_instatic/gate/`. Renders a + * gated `base.container` subtree only for authorised visitors and returns the + * baked fallback for everyone else. Kept next to `tryServeHole` so the + * hole-related endpoints stay grouped and before `tryServeStaticAsset` (D11). + */ +function tryServeGate(req: Request, runtime: ServerRuntime, url: URL, pathname: string): Promise | null { + if (!pathname.startsWith(GATE_PATH_PREFIX)) return null + return handleGateRequest(req, url, { db: runtime.db }) +} + /** * Per-module published JS — `/_instatic/module-js/.js`. Prefix- * namespaced: unknown paths under the prefix 404 inside the handler rather diff --git a/server/visitor-auth/__tests__/roles.test.ts b/server/visitor-auth/__tests__/roles.test.ts new file mode 100644 index 000000000..c11f4d1b6 --- /dev/null +++ b/server/visitor-auth/__tests__/roles.test.ts @@ -0,0 +1,144 @@ +import { describe, expect, it, beforeEach } from 'bun:test' +import { createSqliteClient } from '../../db/sqlite' +import { sqliteMigrations } from '../../db/migrations-sqlite' +import { runMigrations } from '../../db/runMigrations' +import type { DbClient } from '../../db/client' +import { createVisitorUser } from '../repositories' +import { + VisitorRoleMutationError, + createVisitorRole, + deleteVisitorRole, + findVisitorRoleById, + listVisitorRoles, + updateVisitorRole, +} from '../roles' + +async function freshDb(): Promise { + const db = createSqliteClient(':memory:') + await runMigrations(db, sqliteMigrations) + return db +} + +describe('visitor role CRUD', () => { + let db: DbClient + + beforeEach(async () => { + db = await freshDb() + }) + + it('seeds the two system roles (member/admin)', async () => { + const roles = await listVisitorRoles(db) + const names = roles.map((r) => r.name) + expect(names).toContain('member') + expect(names).toContain('admin') + expect(roles.filter((r) => r.isSystem)).toHaveLength(2) + }) + + it('creates a custom role and lists it after the system roles', async () => { + const role = await createVisitorRole(db, { name: 'editor', capabilities: ['content.read'] }) + expect(role.name).toBe('editor') + expect(role.capabilities).toEqual(['content.read']) + expect(role.isSystem).toBe(false) + expect(role.id).toBeTruthy() + + const roles = await listVisitorRoles(db) + // System roles sort first (is_system desc), then custom by name. + expect(roles.map((r) => r.name)).toEqual(['admin', 'member', 'editor']) + // GET /roles still returns the new custom role (acceptance #4). + expect(roles.some((r) => r.id === role.id)).toBe(true) + }) + + it('rejects a duplicate name with a 409 mutation error', async () => { + await createVisitorRole(db, { name: 'editor', capabilities: [] }) + await expect( + createVisitorRole(db, { name: 'editor', capabilities: [] }), + ).rejects.toMatchObject({ name: 'VisitorRoleMutationError', status: 409 }) + }) + + it('updates a custom role name and capabilities', async () => { + const created = await createVisitorRole(db, { name: 'editor', capabilities: ['content.read'] }) + const updated = await updateVisitorRole(db, created.id, { + name: 'senior-editor', + capabilities: ['content.read', 'content.write'], + }) + expect(updated?.name).toBe('senior-editor') + expect(updated?.capabilities).toEqual(['content.read', 'content.write']) + }) + + it('allows editing a system role (capabilities) — system roles are editable', async () => { + const updated = await updateVisitorRole(db, 'member', { capabilities: ['content.read'] }) + expect(updated?.name).toBe('member') + expect(updated?.isSystem).toBe(true) + expect(updated?.capabilities).toEqual(['content.read']) + }) + + it('rejects renaming a role onto an existing name (409)', async () => { + await createVisitorRole(db, { name: 'editor', capabilities: [] }) + const writer = await createVisitorRole(db, { name: 'writer', capabilities: [] }) + await expect(updateVisitorRole(db, writer.id, { name: 'editor' })).rejects.toMatchObject({ + name: 'VisitorRoleMutationError', + status: 409, + }) + }) + + it('updateVisitorRole returns null for a missing role', async () => { + expect(await updateVisitorRole(db, 'nope', { name: 'x' })).toBeNull() + }) + + it('deletes a custom role when unassigned', async () => { + const created = await createVisitorRole(db, { name: 'editor', capabilities: [] }) + const deleted = await deleteVisitorRole(db, created.id) + expect(deleted?.id).toBe(created.id) + expect(await findVisitorRoleById(db, created.id)).toBeNull() + }) + + it('refuses to delete a system role (409)', async () => { + await expect(deleteVisitorRole(db, 'member')).rejects.toMatchObject({ + name: 'VisitorRoleMutationError', + status: 409, + }) + await expect(deleteVisitorRole(db, 'admin')).rejects.toMatchObject({ + name: 'VisitorRoleMutationError', + status: 409, + }) + // The system role is still there. + expect(await findVisitorRoleById(db, 'member')).not.toBeNull() + }) + + it('refuses to delete a custom role that still has visitors assigned (409)', async () => { + const role = await createVisitorRole(db, { name: 'editor', capabilities: [] }) + await createVisitorUser(db, { + id: 'v_1', + email: 'v@example.com', + emailNormalized: 'v@example.com', + passwordHash: 'hash', + displayName: 'V', + roleId: role.id, + }) + // An ACTIVE visitor referencing the role blocks deletion (mirrors the + // admin `deleteCustomRole` guard — count of active assignees). The + // `visitor_users.role_id` FK is `on delete restrict`, so a referencing + // active visitor must be removed first. + await expect(deleteVisitorRole(db, role.id)).rejects.toMatchObject({ + name: 'VisitorRoleMutationError', + status: 409, + }) + // Reassigning the visitor to a different role frees this one for deletion. + await db`update visitor_users set role_id = 'member', updated_at = current_timestamp where id = 'v_1'` + const deleted = await deleteVisitorRole(db, role.id) + expect(deleted?.id).toBe(role.id) + }) + + it('deleteVisitorRole returns null for a missing role', async () => { + expect(await deleteVisitorRole(db, 'nope')).toBeNull() + }) +}) + +describe('VisitorRoleMutationError', () => { + it('carries a status field defaulting to 400', () => { + const err = new VisitorRoleMutationError('boom') + expect(err.status).toBe(400) + expect(err.message).toBe('boom') + expect(err.name).toBe('VisitorRoleMutationError') + }) +}) diff --git a/server/visitor-auth/config.ts b/server/visitor-auth/config.ts new file mode 100644 index 000000000..76e617509 --- /dev/null +++ b/server/visitor-auth/config.ts @@ -0,0 +1,154 @@ +/** + * Visitor-auth configuration — read/write the single `visitor_auth_config` row. + * + * NOT stored in `site.settings_json`. `SiteSettingsSchema` is a closed + * `Type.Object` and `parseSiteSettings` silently drops unknown keys, so a + * `visitorAuth` field would not survive a publish/parse round-trip without + * invasive core-type changes. A dedicated single-row config table is + * cleaner, fully testable, and avoids touching the core settings type + * system. See `docs/impl/SPEC-1-migrations.md` (deviation note). + * + * An in-memory cache front-ends the read: Phase-1 visitor auth is not + * publish-versioned, so a manual reset on write (`resetVisitorAuthConfigCache`, + * called by `saveVisitorAuthConfig`) is sufficient to keep every worker's + * view consistent within one process. + * + * Phase 3 (D14/D15): the retired Phase-1/2 `protectedPrefixes` model is gone — + * page access is now per-page (a `Page.access` field, see + * `src/core/page-tree/page.ts`). The config now carries `defaultLandingPath` + * (D15 fallback landing for a logged-in visitor with no primary-group landing). + * The `024_page_access` migration added this column and backfilled the old + * prefix config onto matching pages, then dropped `protected_prefixes_json`. + */ +import type { DbClient } from '../db/client' +import { + DEFAULT_VISITOR_AUTH_CONFIG, + VISITOR_AUTH_CONFIG_ROW_ID, + type VisitorAuthConfig, + type VisitorAuthConfigRow, + type VisitorProfileField, +} from './types' + +function rowToConfig(row: VisitorAuthConfigRow | undefined | null): VisitorAuthConfig { + if (!row) return { ...DEFAULT_VISITOR_AUTH_CONFIG } + return { + enabled: Boolean(row.enabled), + defaultLandingPath: + typeof row.default_landing_path === 'string' && row.default_landing_path + ? row.default_landing_path + : DEFAULT_VISITOR_AUTH_CONFIG.defaultLandingPath, + loginPath: + typeof row.login_path === 'string' && row.login_path + ? row.login_path + : DEFAULT_VISITOR_AUTH_CONFIG.loginPath, + registrationOpen: Boolean(row.registration_open), + defaultRole: + typeof row.default_role === 'string' && row.default_role + ? row.default_role + : DEFAULT_VISITOR_AUTH_CONFIG.defaultRole, + profileFields: normalizeProfileFieldDefs(row.profile_fields_json), + } +} + +/** + * Coerce a raw profile_fields_json config cell into a VisitorProfileField[]. + * Defensive against string | array | undefined and malformed entries so a + * corrupt row never breaks config reads — bad entries are dropped. + */ +function normalizeProfileFieldDefs(raw: VisitorAuthConfigRow['profile_fields_json']): VisitorProfileField[] { + let arr: unknown + if (raw == null) return [] + if (typeof raw === 'string') { try { arr = JSON.parse(raw) } catch { return [] } } else { arr = raw } + if (!Array.isArray(arr)) return [] + return arr.filter((f): f is VisitorProfileField => + !!f && typeof f === 'object' && typeof (f as VisitorProfileField).id === 'string' + ) +} + +let cached: VisitorAuthConfig | null = null + +/** Drop the cached config — call whenever the row is written. */ +export function resetVisitorAuthConfigCache(): void { + cached = null +} + +/** + * Read the visitor-auth config. The single `'default'` row is the source of + * truth; a missing row (fresh install before the migration seeds, or a hand- + * deleted row) resolves to `DEFAULT_VISITOR_AUTH_CONFIG` (disabled). The + * parsed result is cached for the process; `saveVisitorAuthConfig` resets + * the cache after a write. + */ +export async function getVisitorAuthConfig(db: DbClient): Promise { + if (cached) return cached + const { rows } = await db` + select id, enabled, default_landing_path, login_path, registration_open, default_role, profile_fields_json, updated_at + from visitor_auth_config + where id = ${VISITOR_AUTH_CONFIG_ROW_ID} + limit 1 + ` + cached = rowToConfig(rows[0]) + return cached +} + +/** + * UPSERT the config row: read current (or default), merge the patch, write + * back, reset the cache, return the new config. The single row keeps the + * write path simple — no partial-column UPDATE logic, just a full overwrite + * of the merged value. + * + * Legacy `protectedPrefixes` is no longer a config field (retired in Phase 3); + * callers that still send it are ignored silently by the schema. + */ +export async function saveVisitorAuthConfig( + db: DbClient, + patch: Partial, +): Promise { + const current = await getVisitorAuthConfig(db) + const next: VisitorAuthConfig = { + ...current, + ...patch, + // Defensive: never let a caller blank out a required string field. + loginPath: patch.loginPath?.trim() ? patch.loginPath.trim() : current.loginPath, + defaultRole: patch.defaultRole?.trim() ? patch.defaultRole.trim() : current.defaultRole, + defaultLandingPath: + patch.defaultLandingPath !== undefined + ? resolveLandingPath(patch.defaultLandingPath) + : current.defaultLandingPath, + } + + // UPSERT against the fixed `'default'` id. The row is seeded by the + // migration, but a hand-deleted row must still be re-creatable here so + // `saveVisitorAuthConfig` is the single write entrypoint. + await db` + insert into visitor_auth_config (id, enabled, default_landing_path, login_path, registration_open, default_role, profile_fields_json, updated_at) + values ( + ${VISITOR_AUTH_CONFIG_ROW_ID}, + ${next.enabled}, + ${next.defaultLandingPath}, + ${next.loginPath}, + ${next.registrationOpen}, + ${next.defaultRole}, + ${JSON.stringify(next.profileFields ?? [])}, + current_timestamp + ) + on conflict (id) do update + set enabled = excluded.enabled, + default_landing_path = excluded.default_landing_path, + login_path = excluded.login_path, + registration_open = excluded.registration_open, + default_role = excluded.default_role, + profile_fields_json = excluded.profile_fields_json, + updated_at = current_timestamp + ` + // Update the cache so a concurrent reader sees the just-persisted value + // without a round-trip. + cached = next + return next +} + +/** Resolve a landing path: fall back to the configured default when blank. */ +function resolveLandingPath(path: string): string { + const trimmed = path.trim() + return trimmed || DEFAULT_VISITOR_AUTH_CONFIG.defaultLandingPath +} diff --git a/server/visitor-auth/gateHelpers.ts b/server/visitor-auth/gateHelpers.ts new file mode 100644 index 000000000..2864122b5 --- /dev/null +++ b/server/visitor-auth/gateHelpers.ts @@ -0,0 +1,58 @@ +/** + * Session + authorisation helper for the `/_instatic/gate/` endpoint. + * + * A gated container declares the member group ids that may view its subtree + * (`base.container.authGate` — a `string[]` since Phase 3 / D16). The gate + * endpoint reads the visitor session off the request cookie and asks this + * helper whether the holder is a member of at least one of `requiredGroups`. + * + * Authorisation rule (Phase 3 / D16 — pure group-based; the Phase-2 + * role-based gate semantics are retired): + * - No session / expired / revoked session → NOT authorised (`anonymous`). + * - Session present but the visitor is not a member of ANY of the required + * groups → NOT authorised (`wrong_group`). + * - Otherwise → authorised (`ok`). + * + * Imports are read-only against the visitor-auth surface (sessions + + * repositories + groups); this file owns no DB writes and mutates no visitor + * state. + */ +import type { DbClient } from '../db/client' +import { validateVisitorSession } from './sessions' +import { listGroupIdsForVisitor } from './groups' + +export interface GateAuthResult { + authorized: boolean + reason: 'anonymous' | 'wrong_group' | 'ok' +} + +/** + * Resolve whether the request's visitor session satisfies `requiredGroups`. + * + * Returns `{ authorized: true, reason: 'ok' }` when the session holder is a + * member of at least one of the required groups. Returns + * `{ authorized: false, reason: 'anonymous' }` when there is no valid + * session, and `{ authorized: false, reason: 'wrong_group' }` when the + * session exists but the visitor is in none of the required groups. + * + * A missing visitor record (deleted between login + request) is treated as + * `anonymous` rather than thrown — there is no longer a session to honour. + * An empty `requiredGroups` list authorises everyone (a not-gated container + * never reaches the gate endpoint, but this keeps the helper total). + */ +export async function checkGateAccess( + db: DbClient, + req: Request, + requiredGroups: string[], +): Promise { + if (requiredGroups.length === 0) return { authorized: true, reason: 'ok' } + + const session = await validateVisitorSession(db, req) + if (!session) return { authorized: false, reason: 'anonymous' } + + const userGroupIds = await listGroupIdsForVisitor(db, session.userId) + const allowed = requiredGroups.some((g) => userGroupIds.includes(g)) + return allowed + ? { authorized: true, reason: 'ok' } + : { authorized: false, reason: 'wrong_group' } +} diff --git a/server/visitor-auth/groups.ts b/server/visitor-auth/groups.ts new file mode 100644 index 000000000..62cb5b121 --- /dev/null +++ b/server/visitor-auth/groups.ts @@ -0,0 +1,399 @@ +/** + * Visitor member-group + membership DB CRUD (Phase 3 — D13/D14/D15). + * + * Extracted as its own module mirroring `./roles.ts` (which itself mirrors + * the admin `server/repositories/roles.ts` split): this file owns the + * `visitor_groups` table and the `visitor_user_groups` junction ONLY. Visitor + * roles live in `./roles.ts`; users/sessions/etc. live in `./repositories.ts`. + * + * A group is a content-segmentation segment (page-level access — D14 — and + * login-redirect landing — D15), orthogonal to a role (capabilities — D13). + * + * All SQL is ANSI-standard (no Postgres-isms — see `repositories.ts` header + * for the full dialect rules) so the same statements run unchanged against + * both the Postgres and SQLite adapters. `current_timestamp` is the portable + * "now" expression. Rows map to the camelCase `VisitorGroup` / `VisitorUserGroup` + * domain shapes through `rowTo*`; column lists are inlined into each tagged + * template (matching `server/repositories/roles.ts`) because a + * `db\`select ${COLS}\`` would interpolate the constant as a bind parameter, + * not as SQL text. + * + * Policy: a group is always deletable — the junction CASCADEs, so deleting a + * group also removes every membership. A visitor's `primary_group_id` is + * `ON DELETE SET NULL`, so deleting the primary group simply clears the + * pointer (the visitor then falls back to the default landing path on login). + */ +import { nanoid } from 'nanoid' +import type { DbClient } from '../db/client' +import { placeholder } from '../db/client' +import type { + VisitorGroup, + VisitorGroupRow, + VisitorUserGroup, + VisitorUserGroupRow, +} from './types' + +/** + * Typed mutation error for visitor-group writes. Carries an HTTP `status` + * (default 400) so the shared `mutationErrorResponse` handler can translate + * it into the `{ error }` JSON envelope. Mirrors `VisitorRoleMutationError`. + */ +export class VisitorGroupMutationError extends Error { + readonly status: number + + constructor(message: string, status = 400) { + super(message) + this.name = 'VisitorGroupMutationError' + this.status = status + } +} + +/** + * Derive a lowercase-kebab slug from a group name (e.g. "Founders!" → + * "founders"). Non-alphanumeric runs collapse to a single hyphen; the result + * is trimmed of leading/trailing hyphens. The slug is NOT unique (only + * `name` is) — it is a human-friendly identifier for diagnostics / future + * deep links. + */ +export function slugifyGroupName(name: string): string { + return name + .toLowerCase() + .trim() + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-+|-+$/g, '') +} + +function rowToGroup(row: VisitorGroupRow): VisitorGroup { + return { + id: row.id, + name: row.name, + slug: row.slug, + landingPath: row.landing_path, + description: row.description, + isSystem: Boolean(row.is_system), + createdAt: new Date(row.created_at).toISOString(), + updatedAt: new Date(row.updated_at).toISOString(), + } +} + +function rowToUserGroup(row: VisitorUserGroupRow): VisitorUserGroup { + return { + id: row.id, + userId: row.user_id, + groupId: row.group_id, + createdAt: new Date(row.created_at).toISOString(), + } +} + +// --------------------------------------------------------------------------- +// Group CRUD +// --------------------------------------------------------------------------- + +export async function findVisitorGroupById(db: DbClient, id: string): Promise { + const { rows } = await db` + select id, name, slug, landing_path, description, is_system, created_at, updated_at + from visitor_groups + where id = ${id} + limit 1 + ` + return rows[0] ? rowToGroup(rows[0]) : null +} + +export async function findVisitorGroupByName(db: DbClient, name: string): Promise { + const { rows } = await db` + select id, name, slug, landing_path, description, is_system, created_at, updated_at + from visitor_groups + where name = ${name} + limit 1 + ` + return rows[0] ? rowToGroup(rows[0]) : null +} + +export async function listVisitorGroups(db: DbClient): Promise { + const { rows } = await db` + select id, name, slug, landing_path, description, is_system, created_at, updated_at + from visitor_groups + order by is_system desc, name asc + ` + return rows.map(rowToGroup) +} + +/** + * Pre-check name uniqueness (excluding the row currently being edited, if + * any) so a duplicate name surfaces a clean 409 instead of a dialect-specific + * DB constraint violation. The `visitor_groups.name` UNIQUE constraint is the + * backstop; this is the friendly, portable front line — same shape as the + * role-name pre-check in `./roles.ts`. + */ +async function assertVisitorGroupNameAvailable( + db: DbClient, + name: string, + currentGroupId?: string, +): Promise { + const existing = await findVisitorGroupByName(db, name) + if (existing && existing.id !== currentGroupId) { + throw new VisitorGroupMutationError('Visitor group name is already in use', 409) + } +} + +/** Resolve a landing path: fall back to '/' when blank. */ +function resolveLandingPath(path: string | undefined): string { + const trimmed = (path ?? '').trim() + return trimmed || '/' +} + +export interface CreateVisitorGroupInput { + name: string + landingPath?: string + description?: string +} + +/** + * Create a visitor group. The slug is derived from the name; `landingPath` + * defaults to `/`. A duplicate name is rejected up front via + * {@link assertVisitorGroupNameAvailable} so the caller gets a 409, not a raw + * constraint error. Custom groups are always created with `is_system = false`. + */ +export async function createVisitorGroup( + db: DbClient, + input: CreateVisitorGroupInput, +): Promise { + const name = input.name.trim() + if (!name) throw new VisitorGroupMutationError('Visitor group name is required') + await assertVisitorGroupNameAvailable(db, name) + + const id = nanoid() + const slug = slugifyGroupName(name) + const landingPath = resolveLandingPath(input.landingPath) + const description = (input.description ?? '').trim() + const { rows } = await db` + insert into visitor_groups (id, name, slug, landing_path, description, is_system) + values (${id}, ${name}, ${slug}, ${landingPath}, ${description}, ${false}) + returning id, name, slug, landing_path, description, is_system, created_at, updated_at + ` + return rowToGroup(rows[0]!) +} + +export interface UpdateVisitorGroupInput { + name?: string + landingPath?: string + description?: string +} + +/** + * Update a visitor group's name and/or landing path and/or description. The + * slug is re-derived when the name changes. A rename that collides with + * another group's name is rejected with a 409. + */ +export async function updateVisitorGroup( + db: DbClient, + groupId: string, + input: UpdateVisitorGroupInput, +): Promise { + const current = await findVisitorGroupById(db, groupId) + if (!current) return null + + const name = input.name === undefined ? current.name : input.name.trim() + if (!name) throw new VisitorGroupMutationError('Visitor group name is required') + await assertVisitorGroupNameAvailable(db, name, current.id) + // Re-derive the slug whenever the name changes so it never drifts from the + // (possibly renamed) name. Kept as a stored column purely for diagnostics. + const slug = input.name === undefined ? current.slug : slugifyGroupName(name) + const landingPath = input.landingPath === undefined ? current.landingPath : resolveLandingPath(input.landingPath) + const description = input.description === undefined ? current.description : input.description.trim() + + const { rows } = await db` + update visitor_groups + set name = ${name}, + slug = ${slug}, + landing_path = ${landingPath}, + description = ${description}, + updated_at = current_timestamp + where id = ${groupId} + returning id, name, slug, landing_path, description, is_system, created_at, updated_at + ` + return rows[0] ? rowToGroup(rows[0]) : null +} + +/** + * Delete a visitor group. The `visitor_user_groups` junction CASCADEs, so + * deleting the group also removes every membership. A visitor whose primary + * group is the deleted row keeps browsing (the `visitor_users.primary_group_id` + * FK is `ON DELETE SET NULL`); their next login simply falls back to the + * configured default landing path. Returns the deleted group, or `null` when + * no row matched `groupId`. + */ +export async function deleteVisitorGroup(db: DbClient, groupId: string): Promise { + const current = await findVisitorGroupById(db, groupId) + if (!current) return null + await db`delete from visitor_groups where id = ${groupId}` + return current +} + +// --------------------------------------------------------------------------- +// Membership (visitor_user_groups junction) +// --------------------------------------------------------------------------- + +/** + * The membership-set view returned by {@link listGroupsForVisitor}: the groups + * a visitor belongs to plus whether one of them is their designated primary. + */ +export interface VisitorMembershipView { + group: VisitorGroup + isPrimary: boolean +} + +/** + * Add a visitor to one or more groups. Idempotent: the junction's + * UNIQUE(user_id, group_id) means a pre-existing membership is left in place + * (no error). Unknown group ids are silently skipped (defensive — the caller + * is expected to have validated them, but a race-free guarantee isn't worth a + * round-trip per id). Returns the FULL current membership set after the add. + */ +export async function addGroupsToVisitor( + db: DbClient, + userId: string, + groupIds: string[], +): Promise { + const uniqueIds = [...new Set(groupIds)] + if (uniqueIds.length > 0) { + // Resolve which of the requested ids actually exist so we never insert a + // dangling membership row (the FK would reject it anyway — this gives a + // clean set to INSERT without per-row error handling). The shared + // `DbClient` tagged-template form can't expand a JS array into a SQL IN + // list, so we build the placeholder list explicitly through `placeholder()` + // (mirrors `loadFolderIdsForAssets` in server/repositories/media.ts). + const placeholders = uniqueIds.map((_, i) => placeholder(db.dialect, i + 1)).join(', ') + const { rows: existing } = await db.unsafe<{ id: string }>( + `select id from visitor_groups where id in (${placeholders})`, + uniqueIds, + ) + const existingIds = existing.map((r) => r.id) + for (const groupId of existingIds) { + // ON CONFLICT keeps this idempotent across re-applies. + await db` + insert into visitor_user_groups (id, user_id, group_id) + values (${nanoid()}, ${userId}, ${groupId}) + on conflict (user_id, group_id) do nothing + ` + } + } + return listGroupsForVisitor(db, userId) +} + +/** + * Remove a visitor from a single group. Returns true when a row was removed. + * Removing the visitor's primary group does NOT clear `primary_group_id` + * here — callers that want the pointer cleared should call + * {@link setVisitorPrimaryGroup} with `null`, or rely on the + * `ON DELETE CASCADE` path when the whole group is deleted. + */ +export async function removeVisitorFromGroup( + db: DbClient, + userId: string, + groupId: string, +): Promise { + const result = await db` + delete from visitor_user_groups + where user_id = ${userId} and group_id = ${groupId} + ` + return result.rowCount > 0 +} + +/** + * List every group a visitor belongs to, flagging the one (if any) that is + * their designated primary group. Used by the middleware (page-access check) + * and the admin membership endpoints. + */ +export async function listGroupsForVisitor( + db: DbClient, + userId: string, +): Promise { + const { rows } = await db` + select g.id, g.name, g.slug, g.landing_path, g.description, g.is_system, g.created_at, g.updated_at, + case when u.primary_group_id = g.id then 1 else 0 end as is_primary + from visitor_groups g + join visitor_user_groups m on m.group_id = g.id + join visitor_users u on u.id = m.user_id + where m.user_id = ${userId} + and u.deleted_at is null + order by g.name asc + ` + return rows.map((r) => ({ + group: rowToGroup(r), + isPrimary: Boolean(r.is_primary), + })) +} + +/** + * Resolve a visitor to the flat list of group ids they belong to. The page- + * access middleware's hot path — kept separate from {@link listGroupsForVisitor} + * so it never builds the richer `VisitorMembershipView` objects. + */ +export async function listGroupIdsForVisitor(db: DbClient, userId: string): Promise { + const { rows } = await db<{ group_id: string }>` + select group_id from visitor_user_groups where user_id = ${userId} + ` + return rows.map((r) => r.group_id) +} + +/** + * List every membership row in a group (the junction side). Returns the raw + * membership — callers join to `visitor_users` for display via the handler. + */ +export async function listMembershipsForGroup( + db: DbClient, + groupId: string, +): Promise { + const { rows } = await db` + select id, user_id, group_id, created_at + from visitor_user_groups + where group_id = ${groupId} + order by created_at asc + ` + return rows.map(rowToUserGroup) +} + +/** + * Set a visitor's designated primary group (D15). Pass `null` to clear it (the + * visitor then falls back to the configured default landing path on login). + * Setting a primary group the visitor is NOT a member of is rejected (409) — + * the primary group must be one of the visitor's memberships. + */ +export async function setVisitorPrimaryGroup( + db: DbClient, + userId: string, + groupId: string | null, +): Promise { + if (groupId !== null) { + const { rows } = await db<{ count: number }>` + select count(*) as count + from visitor_user_groups + where user_id = ${userId} and group_id = ${groupId} + ` + if (Number(rows[0]?.count ?? 0) === 0) { + throw new VisitorGroupMutationError( + 'Primary group must be one of the visitor\'s memberships', + 409, + ) + } + } + await db` + update visitor_users + set primary_group_id = ${groupId}, + updated_at = current_timestamp + where id = ${userId} + and deleted_at is null + ` +} + +/** Read a visitor's primary group id (or `null` when unset / user missing). */ +export async function getVisitorPrimaryGroupId( + db: DbClient, + userId: string, +): Promise { + const { rows } = await db<{ primary_group_id: string | null }>` + select primary_group_id from visitor_users where id = ${userId} and deleted_at is null + ` + return rows[0]?.primary_group_id ?? null +} diff --git a/server/visitor-auth/handlers.ts b/server/visitor-auth/handlers.ts new file mode 100644 index 000000000..51540bf8d --- /dev/null +++ b/server/visitor-auth/handlers.ts @@ -0,0 +1,696 @@ +/** + * Visitor-auth HTTP endpoints — `/api/visitor/*`. + * + * `handleVisitorRoutes` owns the entire `/api/visitor/` namespace: CSRF + * gating, dispatch, and a terminal 404 for any unknown sub-path (so an + * unknown `/api/visitor/whatever` never falls through to the public route + * renderer). The visitor namespace is intentionally isolated from the admin + * auth surface (`/admin/api/cms/*`) — different cookie, different tables, + * different code (D3 import whitelist). + * + * Phase-1 endpoints (full): POST /register, POST /login, POST /logout, + * GET /me, PATCH /me. + * Phase-2 endpoints (full): POST /forgot, POST /reset, DELETE /me. + * + * The parameterized gate endpoint lives at `/_instatic/gate/` and is + * owned by the gate renderer — there is intentionally no `/api/visitor/gate/*` + * route here (PRD §4.8 / D11). + * + * Login mirrors `server/handlers/cms/auth.ts` closely: per-IP then per- + * (ip,email) rate limits, constant-time dummy-hash verify on the no-user + * branch (so email enumeration via timing is impossible), per-account + * lockout checked AFTER the constant-time verify, login-attempt log via + * `recordVisitorLoginAttempt`, plus `visitor.*` audit events emitted from + * each auth outcome (register/login/logout/reset/delete) so member sign-in + * activity is visible in the admin audit feed. + * + * Audit note: every visitor event uses `actorUserId: null` + + * `targetType: 'visitor_user'`. The `actor_user_id` column FK-references the + * admin `users(id)` table, so it cannot hold a visitor id (visitors live in + * `visitor_users`). The visitor's identity is carried in `target_id` + * (free-text, no FK) + `metadata.email`. + */ +import type { DbClient } from '../db/client' +import { + createSessionToken, + hashPassword, + hashSessionToken, + sessionExpiry, + verifyPassword, +} from '../auth/tokens' +import { evaluateFailedAttempt, evaluateLockState } from '../auth/lockout' +import { clientIp, isStateChangingMethod, originAllowed } from '../auth/security' +import { jsonResponse, readValidatedBody, setCookieHeader } from '../http' +import { Type } from '@core/utils/typeboxHelpers' +import { getErrorMessage } from '@core/utils/errorMessage' +import { createAuditEvent } from '../repositories/audit' +import { requestAuditContext } from '../handlers/cms/shared' +import { nanoid } from 'nanoid' +import { + VISITOR_PASSWORD_MIN, + type VisitorLoginAttemptResult, + type VisitorUser, +} from './types' +import { + visitorForgotPerEmailRateLimit, + visitorForgotPerIpRateLimit, + visitorLoginPerIpRateLimit, + visitorLoginRateLimit, + visitorRegisterPerIpRateLimit, +} from './rateLimits' +import { getVisitorAuthConfig } from './config' +import { getEmailTransport } from '../email/transport' +import { + consumePasswordResetToken, + createPasswordResetToken, + createVisitorSession, + createVisitorUser, + findValidPasswordResetToken, + findVisitorUserByEmailNormalized, + findVisitorUserById, + hardDeleteVisitorUser, + markVisitorUserLoggedIn, + recordVisitorFailedLogin, + recordVisitorLoginAttempt, + revokeAllVisitorSessionsForUser, + revokeVisitorSessionByHash, + updateVisitorUserDisplayName, + updateVisitorUserPassword, +} from './repositories' +import { findVisitorRoleById, findVisitorRoleByName } from './roles' +import { resolveLoginRedirect } from './redirect' +import { + clearVisitorSessionCookie, + invalidateVisitorSessionCache, + validateVisitorSession, + visitorSessionCookie, +} from './sessions' + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +/** + * A fixed argon2id hash, computed once per process. Used by the login + * handler as the verification target when the supplied email doesn't match + * any visitor user — keeping the response time constant prevents an attacker + * from learning which emails are registered via timing analysis. The hashed + * plaintext is not a real password and never grants access. + * + * Local to this module (mirrors `getDummyPasswordHash` in + * `server/handlers/cms/session.ts`, but isolated so the visitor system owns + * its own instance — the admin and visitor surfaces do not share state). + */ +const dummyVisitorPasswordHashCache: Promise = hashPassword( + 'not-a-real-visitor-account-placeholder', +) +function getDummyVisitorPasswordHash(): Promise { + return dummyVisitorPasswordHashCache +} + +/** 429 envelope — body carries `retryAfterMs` (the runtime maps it to copy). */ +function rateLimitedResponse(retryAfterMs: number): Response { + return jsonResponse({ error: 'rate_limited', retryAfterMs }, { status: 429 }) +} + +/** 423 envelope — body carries `retryAfterMs`. */ +function accountLockedResponse(retryAfterMs: number): Response { + return jsonResponse({ error: 'account_locked', retryAfterMs }, { status: 423 }) +} + +function normalizeEmail(email: string): string { + return email.trim().toLowerCase() +} + +/** Basic email shape check — full validation is the email service's job. */ +function looksLikeEmail(value: string): boolean { + return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value) +} + +/** The public profile shape returned by /register, /login, /me, PATCH /me. */ +interface VisitorProfile { + id: string + email: string + displayName: string + role: string + capabilities: string[] +} + +async function profileFor(db: DbClient, user: VisitorUser): Promise { + const role = await findVisitorRoleById(db, user.roleId) + return { + id: user.id, + email: user.email, + displayName: user.displayName, + role: role?.name ?? user.roleId, + capabilities: role?.capabilities ?? [], + } +} + +// --------------------------------------------------------------------------- +// POST /register +// --------------------------------------------------------------------------- + +const RegisterBodySchema = Type.Object({ + email: Type.String({ maxLength: 254 }), + password: Type.String(), + displayName: Type.Optional(Type.String({ maxLength: 200 })), +}) + +async function handleRegister(req: Request, db: DbClient): Promise { + const body = await readValidatedBody(req, RegisterBodySchema) + if (!body) return jsonResponse({ error: 'invalid_request' }, { status: 400 }) + + const email = normalizeEmail(body.email) + if (!email || email.length > 254 || !looksLikeEmail(email)) { + return jsonResponse({ error: 'invalid_email' }, { status: 422 }) + } + const password = body.password ?? '' + if (password.length < VISITOR_PASSWORD_MIN) { + return jsonResponse( + { error: 'invalid_password', details: { password: `Password must be at least ${VISITOR_PASSWORD_MIN} characters.` } }, + { status: 422 }, + ) + } + const displayName = (body.displayName ?? '').trim().slice(0, 200) + + const config = await getVisitorAuthConfig(db) + if (!config.enabled) { + return jsonResponse({ error: 'visitor_auth_disabled' }, { status: 403 }) + } + if (!config.registrationOpen) { + return jsonResponse({ error: 'registration_closed' }, { status: 403 }) + } + + const ip = clientIp(req) + if (ip) { + const decision = visitorRegisterPerIpRateLimit.consume(ip) + if (!decision.ok) return rateLimitedResponse(decision.retryAfterMs) + } + + const existing = await findVisitorUserByEmailNormalized(db, email) + if (existing) return jsonResponse({ error: 'email_taken' }, { status: 409 }) + + // Resolve the default role by name; fall back to the seeded 'member' role + // id if the configured name doesn't match a row (defensive against a + // hand-edited config.default_role). + const defaultRoleName = config.defaultRole || 'member' + let role = await findVisitorRoleByName(db, defaultRoleName) + if (!role) role = await findVisitorRoleByName(db, 'member') + if (!role) { + // The migration seeds 'member' and 'admin' system rows; reaching here + // means the install hasn't run the visitor migration. Surface it + // clearly rather than crashing on a FK violation below. + return jsonResponse({ error: 'visitor_auth_not_initialized' }, { status: 503 }) + } + + const passwordHash = await hashPassword(password) + const user = await createVisitorUser(db, { + id: nanoid(), + email, + emailNormalized: email, + passwordHash, + displayName, + roleId: role.id, + }) + + // Self-registration: there is no authenticated actor, so actorUserId is + // null. The visitor's identity rides in targetId (free-text) + metadata. + await createAuditEvent(db, { + actorUserId: null, + action: 'visitor.register', + targetType: 'visitor_user', + targetId: user.id, + metadata: { email }, + ...requestAuditContext(req), + }) + + return jsonResponse(await profileFor(db, user), { status: 201 }) +} + +// --------------------------------------------------------------------------- +// POST /login +// --------------------------------------------------------------------------- + +const LoginBodySchema = Type.Object({ + email: Type.String({ maxLength: 254 }), + password: Type.String(), +}) + +async function handleLogin(req: Request, db: DbClient): Promise { + const config = await getVisitorAuthConfig(db) + if (!config.enabled) { + return jsonResponse({ error: 'visitor_auth_disabled' }, { status: 403 }) + } + + const body = await readValidatedBody(req, LoginBodySchema) + if (!body) return jsonResponse({ error: 'invalid_request' }, { status: 400 }) + const email = normalizeEmail(body.email) + const password = body.password ?? '' + const ip = clientIp(req) + const userAgent = req.headers.get('user-agent') + + // Layer 1 — per-IP rate limit. Blanket cap so one attacker IP cannot grind + // through many visitor accounts. Skipped when no IP is surfaced; the + // per-(ip,email) limiter still applies. + if (ip) { + const ipDecision = visitorLoginPerIpRateLimit.consume(ip) + if (!ipDecision.ok) { + await recordVisitorLoginAttempt(db, { + emailNormalized: email || null, + ipAddress: ip, + userAgent, + userId: null, + result: 'rate_limited', + }) + return rateLimitedResponse(ipDecision.retryAfterMs) + } + } + + // Layer 2 — per-(ip,email) tuple. Defends a single account across many IPs. + const rateLimitKey = `${ip ?? 'unknown'}|${email}` + const tupleDecision = visitorLoginRateLimit.consume(rateLimitKey) + if (!tupleDecision.ok) { + await recordVisitorLoginAttempt(db, { + emailNormalized: email || null, + ipAddress: ip, + userAgent, + userId: null, + result: 'rate_limited', + }) + return rateLimitedResponse(tupleDecision.retryAfterMs) + } + + // Constant-time path: ALWAYS run argon2id verify, even when the email + // doesn't match a user. Without this, "user not found" returns in ~5ms + // while "user found, wrong password" takes ~100ms — a timing oracle for + // email enumeration. Verify against a fixed dummy hash on the no-user + // branch; the result is always false but the latency profile matches. + const user = await findVisitorUserByEmailNormalized(db, email) + const verifiedHash = user?.passwordHash ?? (await getDummyVisitorPasswordHash()) + const passwordOk = await verifyPassword(password, verifiedHash) + + // Layer 3 — per-account lockout. Checked AFTER the constant-time verify so + // the locked-vs-not-locked latency profile doesn't leak whether the email + // exists. + if (user) { + const lockState = evaluateLockState(user.lockedUntil) + if (lockState.locked) { + await recordVisitorLoginAttempt(db, { + emailNormalized: email || null, + ipAddress: ip, + userAgent, + userId: user.id, + result: 'locked', + }) + await createAuditEvent(db, { + actorUserId: null, + action: 'visitor.login.locked', + targetType: 'visitor_user', + targetId: user.id, + metadata: { email, lockedUntil: user.lockedUntil ?? '' }, + ...requestAuditContext(req), + }) + return accountLockedResponse(lockState.retryAfterMs) + } + } + + if (!user || user.status !== 'active' || !passwordOk) { + const failureReason: VisitorLoginAttemptResult = !user + ? 'no_user' + : user.status !== 'active' + ? 'account_disabled' + : 'bad_password' + + await recordVisitorLoginAttempt(db, { + emailNormalized: email || null, + ipAddress: ip, + userAgent, + userId: user?.id ?? null, + result: failureReason, + }) + + // Bump the per-account counter ONLY for the bad-password-against-active + // branch. A "no such user" attempt is bound to the IP layer + the + // login_attempts log; a suspended account doesn't need its counter raised. + let lockedUntilIso: string | null = null + if (user && user.status === 'active' && failureReason === 'bad_password') { + const lockout = evaluateFailedAttempt(user.failedLoginCount) + await recordVisitorFailedLogin(db, user.id, lockout.lockedUntil) + if (lockout.triggered && lockout.lockedUntil) { + lockedUntilIso = lockout.lockedUntil.toISOString() + } + } + + await createAuditEvent(db, { + actorUserId: null, + action: 'visitor.login.failure', + targetType: 'visitor_user', + targetId: user?.id ?? null, + metadata: { email, reason: failureReason }, + ...requestAuditContext(req), + }) + + if (lockedUntilIso) { + return accountLockedResponse(Math.max(0, Date.parse(lockedUntilIso) - Date.now())) + } + return jsonResponse({ error: 'invalid_credentials' }, { status: 401 }) + } + + // Success — mint session, record it, clear the rate buckets. + visitorLoginRateLimit.reset(rateLimitKey) + if (ip) visitorLoginPerIpRateLimit.reset(ip) + + const token = createSessionToken() + const expiresAt = sessionExpiry() + await createVisitorSession(db, { + idHash: await hashSessionToken(token), + userId: user.id, + expiresAt, + ipAddress: ip, + userAgent, + }) + await recordVisitorLoginAttempt(db, { + emailNormalized: email || null, + ipAddress: ip, + userAgent, + userId: user.id, + result: 'success', + }) + await markVisitorUserLoggedIn(db, user.id) + + await createAuditEvent(db, { + actorUserId: null, + action: 'visitor.login.success', + targetType: 'visitor_user', + targetId: user.id, + metadata: { email }, + ...requestAuditContext(req), + }) + + const profile = await profileFor(db, user) + // D15: resolve the post-login landing path server-side (explicit redirect → + // primary-group landing → default) and hand it to the browser runtime via + // the response body so the runtime stays dumb. + const redirect = await resolveLoginRedirect(db, req, user.id, config.defaultLandingPath) + return setCookieHeader( + jsonResponse({ ...profile, redirect }, { status: 200 }), + visitorSessionCookie(req, token, expiresAt), + ) +} + +// --------------------------------------------------------------------------- +// POST /logout +// --------------------------------------------------------------------------- + +async function handleLogout(req: Request, db: DbClient): Promise { + const session = await validateVisitorSession(db, req) + if (!session) return jsonResponse({ error: 'unauthorized' }, { status: 401 }) + await revokeVisitorSessionByHash(db, session.idHash) + invalidateVisitorSessionCache(session.idHash) + await createAuditEvent(db, { + actorUserId: null, + action: 'visitor.logout', + targetType: 'visitor_user', + targetId: session.userId, + ...requestAuditContext(req), + }) + // PRD D9: 204 no body. The Set-Cookie must still ride on the response. + return setCookieHeader(new Response(null, { status: 204 }), clearVisitorSessionCookie(req)) +} + +// --------------------------------------------------------------------------- +// GET /me +// --------------------------------------------------------------------------- + +async function handleMe(req: Request, db: DbClient): Promise { + const session = await validateVisitorSession(db, req) + if (!session) return jsonResponse({ error: 'unauthorized' }, { status: 401 }) + const user = await findVisitorUserById(db, session.userId) + if (!user) return jsonResponse({ error: 'unauthorized' }, { status: 401 }) + return jsonResponse(await profileFor(db, user), { status: 200 }) +} + +// --------------------------------------------------------------------------- +// PATCH /me +// --------------------------------------------------------------------------- + +const PatchMeBodySchema = Type.Object( + { + displayName: Type.Optional(Type.String({ maxLength: 200 })), + email: Type.Optional(Type.String()), + }, + // Reject unknown keys defensively — Phase 1 only accepts displayName. + { additionalProperties: false }, +) + +async function handlePatchMe(req: Request, db: DbClient): Promise { + const session = await validateVisitorSession(db, req) + if (!session) return jsonResponse({ error: 'unauthorized' }, { status: 401 }) + const body = await readValidatedBody(req, PatchMeBodySchema) + if (!body) return jsonResponse({ error: 'invalid_request' }, { status: 400 }) + + // Email change is deferred to Phase 2 — surface it explicitly so the + // client knows it wasn't ignored. + if (body.email !== undefined) { + return jsonResponse({ error: 'email_change_not_supported' }, { status: 422 }) + } + + const current = await findVisitorUserById(db, session.userId) + if (!current) return jsonResponse({ error: 'unauthorized' }, { status: 401 }) + + const displayName = + body.displayName === undefined ? current.displayName : body.displayName.trim().slice(0, 200) + const updated = await updateVisitorUserDisplayName(db, session.userId, displayName) + if (!updated) return jsonResponse({ error: 'unauthorized' }, { status: 401 }) + return jsonResponse(await profileFor(db, updated), { status: 200 }) +} + +// --------------------------------------------------------------------------- +// POST /forgot (V7 — password reset request) +// --------------------------------------------------------------------------- + +const ForgotBodySchema = Type.Object({ + email: Type.String({ maxLength: 254 }), +}) + +/** + * Request a password reset. ALWAYS returns 200 `{ ok: true }` — whether or + * not the email matches a visitor — so the endpoint cannot be used for email + * enumeration (PRD §7). On a real match a one-shot reset token is minted and + * the reset link is handed to the email transport; on no match nothing is + * sent. Transport failures are logged but never surfaced to the caller (a + * delivery outage must not leak that the account exists). + */ +async function handleForgot(req: Request, db: DbClient): Promise { + const body = await readValidatedBody(req, ForgotBodySchema) + if (!body) return jsonResponse({ error: 'invalid_request' }, { status: 400 }) + + const email = normalizeEmail(body.email) + + // Layer 1 — per-IP. A single attacker IP cannot flood reset requests + // (the primary email-enumeration / annoyance vector). Skipped when no IP + // is surfaced; the per-email limiter still applies. + const ip = clientIp(req) + if (ip) { + const ipDecision = visitorForgotPerIpRateLimit.consume(ip) + if (!ipDecision.ok) return rateLimitedResponse(ipDecision.retryAfterMs) + } + + // Layer 2 — per normalized email. Caps reset-mail volume to one address, + // even across many IPs. Always consumed (even for unknown emails) so an + // attacker cannot probe by observing whether the limiter tripped. + const emailDecision = visitorForgotPerEmailRateLimit.consume(email || 'unknown') + if (!emailDecision.ok) return rateLimitedResponse(emailDecision.retryAfterMs) + + const user = await findVisitorUserByEmailNormalized(db, email) + if (user) { + const rawToken = await createPasswordResetToken(db, user.id) + const config = await getVisitorAuthConfig(db) + const origin = new URL(req.url).origin + const resetLink = `${origin}${config.loginPath}?reset=${encodeURIComponent(rawToken)}` + try { + await getEmailTransport().send({ + to: user.email, + subject: 'Reset your password', + text: resetLink, + html: `

Reset your password: ${resetLink}

`, + }) + } catch (err) { + // Never reveal delivery failure — log and continue as if it succeeded. + console.error('[visitor-auth] forgot-password transport error:', err) + } + } + + return jsonResponse({ ok: true }, { status: 200 }) +} + +// --------------------------------------------------------------------------- +// POST /reset (V7 — consume reset token, set new password) +// --------------------------------------------------------------------------- + +const ResetBodySchema = Type.Object({ + token: Type.String(), + password: Type.String(), +}) + +/** + * Consume a one-shot reset token and set a new password. The raw token is + * hashed (SHA-256) before any DB lookup, so the raw value never touches the + * query layer. A token is valid only when it exists, is unused, and has not + * expired; `consumePasswordResetToken` performs the atomic one-shot consume + * (a concurrent reset race resolves to exactly one winner). On success every + * existing session for the user is revoked, forcing a fresh login. + */ +async function handleReset(req: Request, db: DbClient): Promise { + const body = await readValidatedBody(req, ResetBodySchema) + if (!body) return jsonResponse({ error: 'invalid_request' }, { status: 400 }) + + const token = body.token ?? '' + const password = body.password ?? '' + if (password.length < VISITOR_PASSWORD_MIN) { + return jsonResponse( + { error: 'invalid_password', details: { password: `Password must be at least ${VISITOR_PASSWORD_MIN} characters.` } }, + { status: 422 }, + ) + } + + const tokenHash = await hashSessionToken(token) + const resetToken = await findValidPasswordResetToken(db, tokenHash) + if (!resetToken) { + return jsonResponse({ error: 'invalid_or_expired_token' }, { status: 401 }) + } + + // Atomic one-shot consume — guards against a concurrent double-spend of + // the same token (e.g. two tabs submitting at once). rowCount 0 means + // another caller already consumed it. + const consumed = await consumePasswordResetToken(db, tokenHash) + if (!consumed) { + return jsonResponse({ error: 'invalid_or_expired_token' }, { status: 401 }) + } + + const newPasswordHash = await hashPassword(password) + await updateVisitorUserPassword(db, resetToken.userId, newPasswordHash) + // Force re-login everywhere — every prior session (including the one this + // request arrived on, if any) is dead. + await revokeAllVisitorSessionsForUser(db, resetToken.userId) + + await createAuditEvent(db, { + actorUserId: null, + action: 'visitor.password.reset', + targetType: 'visitor_user', + targetId: resetToken.userId, + ...requestAuditContext(req), + }) + + return jsonResponse({ ok: true }, { status: 200 }) +} + +// --------------------------------------------------------------------------- +// DELETE /me (V8 — GDPR self-service account deletion) +// --------------------------------------------------------------------------- + +const DeleteMeBodySchema = Type.Object({ + password: Type.String(), +}) + +/** + * Delete the caller's own visitor account + PII, gated on a fresh + * re-verification of their current password (so a stolen session cookie + * alone cannot delete the account). On success every session is revoked, + * PII is anonymized in place, and the cookie is cleared. The row is kept + * (soft-delete) for FK/audit integrity — see `hardDeleteVisitorUser`. + */ +async function handleDeleteMe(req: Request, db: DbClient): Promise { + const session = await validateVisitorSession(db, req) + if (!session) return jsonResponse({ error: 'unauthorized' }, { status: 401 }) + + const body = await readValidatedBody(req, DeleteMeBodySchema) + if (!body) return jsonResponse({ error: 'invalid_request' }, { status: 400 }) + + const user = await findVisitorUserById(db, session.userId) + if (!user) return jsonResponse({ error: 'unauthorized' }, { status: 401 }) + + // Re-verify the password — the session alone is not enough authority to + // destroy the account. A wrong password is a flat 401 (no retry guidance; + // the client treats it the same as a generic auth failure). + const passwordOk = await verifyPassword(body.password ?? '', user.passwordHash) + if (!passwordOk) { + return jsonResponse({ error: 'invalid_password' }, { status: 401 }) + } + + // Tear down every session first (including this one), then wipe the row. + await revokeAllVisitorSessionsForUser(db, user.id) + await hardDeleteVisitorUser(db, user.id) + invalidateVisitorSessionCache(session.idHash) + + await createAuditEvent(db, { + actorUserId: null, + action: 'visitor.account.deleted', + targetType: 'visitor_user', + targetId: user.id, + metadata: { email: user.email }, + ...requestAuditContext(req), + }) + + return setCookieHeader( + jsonResponse({ ok: true }, { status: 200 }), + clearVisitorSessionCookie(req), + ) +} + +// --------------------------------------------------------------------------- +// Dispatch +// --------------------------------------------------------------------------- + +type RouteHandler = (req: Request, db: DbClient) => Promise + +interface Route { + method: string + /** Exact-match pathname. Parameterized visitor paths live under `/_instatic/`. */ + pattern: string + handler: RouteHandler +} + +const routes: readonly Route[] = [ + { method: 'POST', pattern: '/api/visitor/register', handler: handleRegister }, + { method: 'POST', pattern: '/api/visitor/login', handler: handleLogin }, + { method: 'POST', pattern: '/api/visitor/logout', handler: handleLogout }, + { method: 'GET', pattern: '/api/visitor/me', handler: handleMe }, + { method: 'PATCH', pattern: '/api/visitor/me', handler: handlePatchMe }, + { method: 'DELETE', pattern: '/api/visitor/me', handler: handleDeleteMe }, + { method: 'POST', pattern: '/api/visitor/forgot', handler: handleForgot }, + { method: 'POST', pattern: '/api/visitor/reset', handler: handleReset }, +] + +/** + * Dispatch every `/api/visitor/*` request. CSRF gating runs first for + * state-changing methods; a path-matched / wrong-method request resolves to + * 405; any unknown path under the prefix resolves to a terminal 404 so it + * never falls through to the public route renderer. + */ +export async function handleVisitorRoutes(req: Request, db: DbClient): Promise { + if (isStateChangingMethod(req.method) && !originAllowed(req)) { + return jsonResponse({ error: 'forbidden_origin' }, { status: 403 }) + } + + const { pathname } = new URL(req.url) + let pathMatched = false + for (const route of routes) { + if (pathname !== route.pattern) continue + pathMatched = true + if (req.method !== route.method) continue + try { + return await route.handler(req, db) + } catch (err) { + console.error('[visitor-auth] handler error:', err) + return jsonResponse( + { error: getErrorMessage(err, 'Internal server error') }, + { status: 500 }, + ) + } + } + + if (pathMatched) return jsonResponse({ error: 'method_not_allowed' }, { status: 405 }) + return jsonResponse({ error: 'not_found' }, { status: 404 }) +} diff --git a/server/visitor-auth/middleware.ts b/server/visitor-auth/middleware.ts new file mode 100644 index 000000000..b71e8c3c0 --- /dev/null +++ b/server/visitor-auth/middleware.ts @@ -0,0 +1,131 @@ +/** + * Visitor-auth route protection middleware (D1, D4, D6, D14, D17). + * + * Runs inside the server's main request try-block BEFORE `handleServerRequest`, + * so it gates every public-site request (not just published-page renders). + * Two jobs: + * + * 1. Built-in auth pages on direct visit. When visitor auth is enabled and a + * visitor GETs the configured `loginPath` (default `/login`) or the + * sibling `/register` path AND no published page exists at that path, the + * middleware serves the built-in login/register HTML inline. A published + * page always wins (so a builder who designs their own login page keeps + * it). This closes the "clicked a Login/Register link → 404" gap that the + * redirect-only fallback alone would leave on a fresh install. + * + * 2. Per-page access gating (D14 — replaces the Phase-1/2 protected-prefix + * model). The middleware resolves the published page at the path, reads + * its `access` level, and gates accordingly: + * - no published page → pass through (downstream 404) + * - public page → pass through + * - restricted (level 'groups') + anonymous → login (redirect to a + * published login page when one exists, else the built-in page) + * - restricted + logged-in-but-not-in-an-allowed-group → the built-in + * "no access" page (D17 — they're already signed in, so a redirect + * to login would loop; a 404 would hide the page's existence) + * - restricted + member of an allowed group → pass through + * + * Skip prefixes (`/_instatic/`, `/admin/`, `/health`, `/api/visitor/`, + * `/uploads/`) keep the middleware off infrastructure and asset traffic so it + * never interferes with admin, runtime assets, or the visitor API itself. + */ +import type { DbClient } from '../db/client' +import { getVisitorAuthConfig } from './config' +import { validateVisitorSession } from './sessions' +import { listGroupIdsForVisitor } from './groups' +import { getPublishedPageAccessForPath, publishedPageExistsAtPath } from '../publish/publicRouter' +import { + BUILT_IN_LOGIN_PAGE_HTML, + BUILT_IN_NO_ACCESS_PAGE_HTML, + BUILT_IN_REGISTER_PAGE_HTML, +} from '../publish/visitorAuthRuntime' + +/** Paths the middleware must never gate. */ +const SKIP_PREFIXES = ['/_instatic/', '/admin/', '/health', '/api/visitor/', '/uploads/'] + +/** Built-in register page path (the login page links here). */ +const BUILT_IN_REGISTER_PATH = '/register' + +function htmlResponse(html: string, status = 200): Response { + return new Response(html, { + status, + headers: { 'content-type': 'text/html; charset=utf-8', 'cache-control': 'no-store' }, + }) +} + +/** + * Serve a built-in auth page on a direct GET when no published page exists at + * that path. Returns the `Response` to short-circuit with, or `null` to let + * the request continue. + */ +async function maybeServeBuiltInAuthPage( + db: DbClient, + pathname: string, + loginPath: string, +): Promise { + if (pathname !== loginPath && pathname !== BUILT_IN_REGISTER_PATH) return null + if (await publishedPageExistsAtPath(db, pathname)) return null // a designed page wins + if (pathname === loginPath) return htmlResponse(BUILT_IN_LOGIN_PAGE_HTML) + return htmlResponse(BUILT_IN_REGISTER_PAGE_HTML) +} + +/** + * Build the anonymous-visitor login response for a restricted page. Prefer the + * operator's published login page (302 redirect, so the browser address bar + * shows the real URL and `?redirect=` round-trips the gated path for post-login + * bounce-back); fall back to the built-in login page served inline so a fresh + * install without a designed login page still works end-to-end. + */ +async function anonymousLoginResponse( + db: DbClient, + loginPath: string, + pathname: string, + search: string, +): Promise { + if (await publishedPageExistsAtPath(db, loginPath)) { + const redirect = encodeURIComponent(pathname + search) + return new Response(null, { + status: 302, + headers: { location: `${loginPath}?redirect=${redirect}` }, + }) + } + return htmlResponse(BUILT_IN_LOGIN_PAGE_HTML) +} + +/** + * Returns a `Response` to short-circuit the request with, or `null` to let + * the request continue through the normal router. + */ +export async function visitorAuthMiddleware(req: Request, db: DbClient): Promise { + const cfg = await getVisitorAuthConfig(db) + if (!cfg.enabled) return null + + const url = new URL(req.url) + const { pathname, search } = url + + // Never gate infrastructure / admin / visitor-API / asset traffic. + if (SKIP_PREFIXES.some((prefix) => pathname.startsWith(prefix))) return null + + // Job 1: built-in login/register pages on direct visit (GET only). + if (req.method === 'GET') { + const builtIn = await maybeServeBuiltInAuthPage(db, pathname, cfg.loginPath) + if (builtIn) return builtIn + } + + // Job 2: per-page access gating (D14). No published page → pass through. + const access = await getPublishedPageAccessForPath(db, pathname) + if (!access) return null + if (access.level === 'public') return null + + // Restricted (level 'groups'). Anonymous visitor → login. + const session = await validateVisitorSession(db, req) + if (!session) return anonymousLoginResponse(db, cfg.loginPath, pathname, search) + + // Logged in — check group membership. A member in ANY of the page's allowed + // groups may view it (D14). Everyone else gets the D17 "no access" page; the + // restricted content is never leaked to either branch. + const userGroupIds = await listGroupIdsForVisitor(db, session.userId) + const allowed = access.groups.some((g) => userGroupIds.includes(g)) + if (allowed) return null + return htmlResponse(BUILT_IN_NO_ACCESS_PAGE_HTML) +} diff --git a/server/visitor-auth/rateLimits.ts b/server/visitor-auth/rateLimits.ts new file mode 100644 index 000000000..91252e1f7 --- /dev/null +++ b/server/visitor-auth/rateLimits.ts @@ -0,0 +1,49 @@ +/** + * In-memory rate limiters for the visitor-auth surface. + * + * Mirrors the admin auth surface (`server/auth/rateLimit.ts`): one + * `RateLimiter` singleton per concern, keyed by IP or by `(ip, email)` / + * email. The visitor surface is a separate process-wide set so visitor + * traffic cannot consume the admin buckets and vice-versa. + * + * visitorLoginRateLimit 5 / 15min per (ip, email) + * visitorLoginPerIpRateLimit 30 / 10min per ip + * visitorRegisterPerIpRateLimit 3 / 60min per ip + * visitorForgotPerIpRateLimit 3 / 60min per ip + * visitorForgotPerEmailRateLimit 1 / 15min per email + * + * The limits are intentionally tight on registration / forgot (high abuse + * surface, low legitimate volume) and looser on login (real users mistype + * passwords). See `docs/PRD.md` §4.4. + */ +import { RateLimiter } from '../auth/rateLimit' + +/** Per-(ip, email) tuple — defends a single visitor account across many IPs. */ +export const visitorLoginRateLimit = new RateLimiter({ + limit: 5, + windowMs: 15 * 60 * 1000, +}) + +/** Per-IP — blanket cap so one attacker IP cannot grind many accounts. */ +export const visitorLoginPerIpRateLimit = new RateLimiter({ + limit: 30, + windowMs: 10 * 60 * 1000, +}) + +/** Per-IP registration — throttles throwaway-account farming. */ +export const visitorRegisterPerIpRateLimit = new RateLimiter({ + limit: 3, + windowMs: 60 * 60 * 1000, +}) + +/** Per-IP forgot-password — throttles email enumeration via reset floods. */ +export const visitorForgotPerIpRateLimit = new RateLimiter({ + limit: 3, + windowMs: 60 * 60 * 1000, +}) + +/** Per-email forgot-password — caps reset-mail volume to a single address. */ +export const visitorForgotPerEmailRateLimit = new RateLimiter({ + limit: 1, + windowMs: 15 * 60 * 1000, +}) diff --git a/server/visitor-auth/redirect.ts b/server/visitor-auth/redirect.ts new file mode 100644 index 000000000..0aa35f0f5 --- /dev/null +++ b/server/visitor-auth/redirect.ts @@ -0,0 +1,48 @@ +/** + * Post-login landing-path resolution (Phase 3 — D15). + * + * Extracted from the login handler so the resolution rule lives in exactly one + * testable place and the handler stays under the module-size ceiling. The + * browser runtime stays dumb: it just honours the resolved `redirect` the + * handler returns in the login response body. + * + * Resolution priority (per D15): + * 1. An explicit `?redirect=` query param (highest — covers the "tried to + * hit a gated page" bounce-back-after-auth case). + * 2. The visitor's primary group's `landingPath`, when it is set and not `/`. + * 3. The configured `defaultLandingPath` (defaulting to `/`). + * + * The primary-group landing + group row are loaded lazily (only when no + * explicit redirect) so the common redirect-after-gated-hit path does no DB + * work beyond the cookie mint that already happened. + */ +import type { DbClient } from '../db/client' +import { findVisitorGroupById, getVisitorPrimaryGroupId } from './groups' + +/** + * Resolve a visitor's post-login landing path. + * + * @param db The DB client (used only when no explicit redirect). + * @param req The login request (read for its `?redirect=` query param). + * @param userId The freshly-authenticated visitor's id. + * @param defaultLandingPath The configured default landing (D15); `/` when blank. + */ +export async function resolveLoginRedirect( + db: DbClient, + req: Request, + userId: string, + defaultLandingPath: string, +): Promise { + const explicit = new URL(req.url).searchParams.get('redirect')?.trim() ?? '' + if (explicit) return explicit + + const primaryGroupId = await getVisitorPrimaryGroupId(db, userId) + if (primaryGroupId) { + const primaryGroup = await findVisitorGroupById(db, primaryGroupId) + if (primaryGroup && primaryGroup.landingPath && primaryGroup.landingPath !== '/') { + return primaryGroup.landingPath + } + } + + return defaultLandingPath || '/' +} diff --git a/server/visitor-auth/repositories.ts b/server/visitor-auth/repositories.ts new file mode 100644 index 000000000..2357af487 --- /dev/null +++ b/server/visitor-auth/repositories.ts @@ -0,0 +1,581 @@ +/** + * Visitor-auth DB CRUD — users, sessions, login attempts, password-reset + * tokens. + * + * Visitor *roles* used to live here but were extracted into `./roles.ts` + * (mirroring the admin system's `server/repositories/roles.ts` split) so + * this file stays under the module-size ceiling. Import role symbols from + * `./roles`. + * + * Mirrors the admin repositories (`server/repositories/{roles,users}.ts`) + * but against the isolated `visitor_*` tables. All SQL is ANSI-standard + * (no Postgres-isms: no `now()` in DML, no `::cast`, no `any($N::...)`, + * no `distinct on`) so the same statements run unchanged against both the + * Postgres and SQLite adapters. `current_timestamp` is the portable + * "now" expression; absolute ISO strings are computed in JS for the + * expiry-window comparisons (the `now` there is caller-supplied, not a + * column default). + * + * Rows are mapped to camelCase domain interfaces through `rowTo*` helpers — + * the only place that knows the snake_case column names is this module. + * Column lists are inlined into each tagged template (matching + * `server/repositories/roles.ts`) because a `db\`select ${COLS}\`` would + * interpolate the constant as a bind parameter, not as SQL text. + */ +import { nanoid } from 'nanoid' +import type { DbClient } from '../db/client' +import { isoDateOrNull } from '@core/utils/isoDate' +import { createSessionToken, hashSessionToken } from '../auth/tokens' +import { + VISITOR_PASSWORD_RESET_TTL_MS, + type VisitorLoginAttemptResult, + type VisitorPasswordResetToken, + type VisitorPasswordResetTokenRow, + type VisitorSession, + type VisitorSessionRow, + type VisitorUser, + type VisitorUserRow, + type VisitorUserStatus, +} from './types' + +function rowToUser(row: VisitorUserRow): VisitorUser { + return { + id: row.id, + email: row.email, + emailNormalized: row.email_normalized, + passwordHash: row.password_hash, + displayName: row.display_name, + roleId: row.role_id, + primaryGroupId: row.primary_group_id ?? null, + status: row.status as VisitorUserStatus, + failedLoginCount: Number(row.failed_login_count ?? 0), + lockedUntil: isoDateOrNull(row.locked_until), + createdAt: new Date(row.created_at).toISOString(), + updatedAt: new Date(row.updated_at).toISOString(), + profileFields: normalizeProfileFields(row.profile_fields_json), + } +} + +/** + * Coerce a raw profile_fields_json cell (string | object | undefined) into a + * plain object. The _json-suffix adapter auto-parses the column on read, but + * this is defensive: raw migration SQL and the PG jsonb path may surface a + * string, and a NULL-safe default of {} keeps callers branch-free. + */ +function normalizeProfileFields(raw: VisitorUserRow['profile_fields_json']): Record { + if (!raw) return {} + if (typeof raw === 'string') { + try { const parsed = JSON.parse(raw); return parsed && typeof parsed === 'object' ? parsed : {} } catch { return {} } + } + return raw as Record +} + +function rowToSession(row: VisitorSessionRow): VisitorSession { + return { + idHash: row.id_hash, + userId: row.user_id, + createdAt: new Date(row.created_at).toISOString(), + lastSeenAt: new Date(row.last_seen_at).toISOString(), + expiresAt: new Date(row.expires_at).toISOString(), + revokedAt: isoDateOrNull(row.revoked_at), + ipAddress: row.ip_address, + userAgent: row.user_agent, + deviceLabel: row.device_label, + } +} + +function rowToPasswordResetToken(row: VisitorPasswordResetTokenRow): VisitorPasswordResetToken { + return { + id: row.id, + userId: row.user_id, + tokenHash: row.token_hash, + expiresAt: new Date(row.expires_at).toISOString(), + usedAt: isoDateOrNull(row.used_at), + createdAt: new Date(row.created_at).toISOString(), + } +} + +// --------------------------------------------------------------------------- +// Users +// --------------------------------------------------------------------------- + +/** + * Lookup by normalized email, active (non-soft-deleted) only. The partial + * unique index `visitor_users_email_active_idx` enforces uniqueness among + * active rows, so at most one row matches. + */ +export async function findVisitorUserByEmailNormalized( + db: DbClient, + emailNormalized: string, +): Promise { + const { rows } = await db` + select id, email, email_normalized, password_hash, display_name, role_id, primary_group_id, status, failed_login_count, locked_until, created_at, updated_at, deleted_at, profile_fields_json + from visitor_users + where email_normalized = ${emailNormalized} + and deleted_at is null + limit 1 + ` + return rows[0] ? rowToUser(rows[0]) : null +} + +export async function findVisitorUserById(db: DbClient, id: string): Promise { + const { rows } = await db` + select id, email, email_normalized, password_hash, display_name, role_id, primary_group_id, status, failed_login_count, locked_until, created_at, updated_at, deleted_at, profile_fields_json + from visitor_users + where id = ${id} + and deleted_at is null + limit 1 + ` + return rows[0] ? rowToUser(rows[0]) : null +} + +export async function createVisitorUser( + db: DbClient, + input: { + id: string + email: string + emailNormalized: string + passwordHash: string + displayName: string + roleId: string + }, +): Promise { + await db` + insert into visitor_users (id, email, email_normalized, password_hash, display_name, role_id) + values (${input.id}, ${input.email}, ${input.emailNormalized}, ${input.passwordHash}, ${input.displayName}, ${input.roleId}) + ` + const created = await findVisitorUserById(db, input.id) + if (!created) throw new Error('[visitor-auth] visitor user insert did not return a row') + return created +} + +/** Reset the failed-login counter and clear any active lock on a successful login. */ +export async function markVisitorUserLoggedIn(db: DbClient, id: string): Promise { + await db` + update visitor_users + set failed_login_count = 0, + locked_until = ${null}, + updated_at = current_timestamp + where id = ${id} + ` +} + +/** + * Bump the failed-login counter and (when a lockout was triggered) persist + * the new `locked_until` deadline. Passing `lockedUntil: null` records the + * failure without locking — the lockout decision is the caller's + * (`evaluateFailedAttempt`). + */ +export async function recordVisitorFailedLogin( + db: DbClient, + id: string, + lockedUntil: Date | null, +): Promise { + await db` + update visitor_users + set failed_login_count = failed_login_count + 1, + locked_until = ${lockedUntil}, + updated_at = current_timestamp + where id = ${id} + and deleted_at is null + ` +} + +export async function setVisitorUserStatus( + db: DbClient, + id: string, + status: VisitorUserStatus, +): Promise { + await db` + update visitor_users + set status = ${status}, + updated_at = current_timestamp + where id = ${id} + and deleted_at is null + ` +} + +/** Update only `display_name` — the single self-service field in Phase 1 (`PATCH /me`). */ +export async function updateVisitorUserDisplayName( + db: DbClient, + id: string, + displayName: string, +): Promise { + await db` + update visitor_users + set display_name = ${displayName}, + updated_at = current_timestamp + where id = ${id} + and deleted_at is null + ` + return findVisitorUserById(db, id) +} + +/** + * Update a visitor's custom profile field VALUES (migration 025). Stores the + * whole map — callers should merge against the current values first. Empty + * object is valid (clears all profile fields). + */ +export async function updateVisitorUserProfileFields( + db: DbClient, + id: string, + profileFields: Record, +): Promise { + await db` + update visitor_users + set profile_fields_json = ${JSON.stringify(profileFields)}, + updated_at = current_timestamp + where id = ${id} + and deleted_at is null + ` + return findVisitorUserById(db, id) +} + +/** + * Set a visitor's password hash. `visitor_users` has no `password_updated_at` + * column (unlike the admin `users` table), so only `password_hash` + + * `updated_at` are touched. Callers MUST `revokeAllVisitorSessionsForUser` + * afterwards so the password change forces a fresh login. + */ +export async function updateVisitorUserPassword( + db: DbClient, + id: string, + newPasswordHash: string, +): Promise { + await db` + update visitor_users + set password_hash = ${newPasswordHash}, + updated_at = current_timestamp + where id = ${id} + and deleted_at is null + ` +} + +// ─── Admin-facing read/management (used by /admin/api/cms/visitor-auth/*) ─── + +/** + * Paginated list of active (non-soft-deleted) visitors, newest first. + * Optional `search` does a case-insensitive LIKE on email or display name. + * Mirrors the admin `listUsers` shape so the admin UI's user-management + * table can reuse the same row contract. + */ +export async function listVisitorUsers( + db: DbClient, + options: { limit?: number; offset?: number; search?: string } = {}, +): Promise { + const limit = Math.min(Math.max(options.limit ?? 50, 1), 200) + const offset = Math.max(options.offset ?? 0, 0) + const search = options.search?.trim().toLowerCase() ?? '' + const pattern = search ? `%${search}%` : '' + const { rows } = pattern + ? await db` + select id, email, email_normalized, password_hash, display_name, role_id, primary_group_id, status, failed_login_count, locked_until, created_at, updated_at, deleted_at, profile_fields_json + from visitor_users + where deleted_at is null + and (lower(email) like ${pattern} or lower(display_name) like ${pattern}) + order by created_at desc + limit ${limit} offset ${offset} + ` + : await db` + select id, email, email_normalized, password_hash, display_name, role_id, primary_group_id, status, failed_login_count, locked_until, created_at, updated_at, deleted_at, profile_fields_json + from visitor_users + where deleted_at is null + order by created_at desc + limit ${limit} offset ${offset} + ` + return rows.map(rowToUser) +} + +/** Total count of active visitors, optionally narrowed by the same search filter used by `listVisitorUsers` so the pagination total matches the filtered list. */ +export async function countVisitorUsers( + db: DbClient, + options: { search?: string } = {}, +): Promise { + const search = options.search?.trim().toLowerCase() ?? '' + const pattern = search ? `%${search}%` : '' + const { rows } = pattern + ? await db<{ count: number }>` + select count(*) as count + from visitor_users + where deleted_at is null + and (lower(email) like ${pattern} or lower(display_name) like ${pattern}) + ` + : await db<{ count: number }>` + select count(*) as count + from visitor_users + where deleted_at is null + ` + return Number(rows[0]?.count ?? 0) +} + +/** Reassign a visitor to a different visitor role. Validates the role exists. */ +export async function updateVisitorUserRole( + db: DbClient, + id: string, + roleId: string, +): Promise { + await db` + update visitor_users + set role_id = ${roleId}, + updated_at = current_timestamp + where id = ${id} + and deleted_at is null + ` + return findVisitorUserById(db, id) +} + +/** + * Soft-delete a visitor (set `deleted_at`). The partial unique email index + * only spans non-deleted rows, so the email can re-register afterwards. + * Caller is responsible for revoking active sessions (see + * `revokeAllVisitorSessionsForUser`). + */ +export async function softDeleteVisitorUser(db: DbClient, id: string): Promise { + const result = await db` + update visitor_users + set deleted_at = current_timestamp, + updated_at = current_timestamp + where id = ${id} + and deleted_at is null + ` + return result.rowCount > 0 +} + +/** + * GDPR self-service account deletion (V8). Wipes every PII column on the + * visitor row, then soft-deletes it: + * - email / email_normalized → `'deleted+' || id` (stable, non-PII + * placeholder; the partial unique email index no longer covers it, so the + * original address can re-register) + * - display_name → '' + * - password_hash → '' (never matches any input — the row is + * dead anyway, but this guarantees no credential reuse) + * - status → 'suspended' + * - deleted_at / updated_at → now + * + * The row is KEPT (soft-delete) so FK integrity (visitor_sessions, + * visitor_login_attempts, visitor_password_reset_tokens) and any audit + * history stay intact. Also anonymizes the user's `visitor_login_attempts` + * rows (email_normalized / ip_address / user_agent → null). Returns true only + * when the visitor row was previously active (matched the `deleted_at is + * null` guard) — a double-delete is a no-op returning false. + */ +export async function hardDeleteVisitorUser(db: DbClient, id: string): Promise { + const placeholder = `deleted+${id}` + const result = await db` + update visitor_users + set email = ${placeholder}, + email_normalized = ${placeholder}, + display_name = '', + password_hash = '', + status = 'suspended', + deleted_at = current_timestamp, + updated_at = current_timestamp + where id = ${id} + and deleted_at is null + ` + // Anonymize the user's login-attempt audit rows (PII columns) regardless + // of whether the user row matched — idempotent and defense-in-depth. + await db` + update visitor_login_attempts + set email_normalized = null, + ip_address = null, + user_agent = null + where user_id = ${id} + ` + return result.rowCount > 0 +} + +// --------------------------------------------------------------------------- +// Sessions +// --------------------------------------------------------------------------- + +interface CreateVisitorSessionInput { + idHash: string + userId: string + expiresAt: Date + ipAddress: string | null + userAgent: string | null + deviceLabel?: string +} + +export async function createVisitorSession( + db: DbClient, + input: CreateVisitorSessionInput, +): Promise { + const deviceLabel = input.deviceLabel ?? '' + await db` + insert into visitor_sessions (id_hash, user_id, expires_at, ip_address, user_agent, device_label) + values (${input.idHash}, ${input.userId}, ${input.expiresAt}, ${input.ipAddress}, ${input.userAgent}, ${deviceLabel}) + ` + const created = await findActiveVisitorSessionByHash(db, input.idHash) + if (!created) throw new Error('[visitor-auth] visitor session insert did not return a row') + return created +} + +/** + * Active session lookup: hash match, not revoked, and not past `expires_at`. + * `nowIso` is supplied by the caller so every comparison uses one clock read. + */ +export async function findActiveVisitorSessionByHash( + db: DbClient, + idHash: string, + nowIso: string = new Date().toISOString(), +): Promise { + const { rows } = await db` + select id_hash, user_id, created_at, last_seen_at, expires_at, revoked_at, ip_address, user_agent, device_label + from visitor_sessions + where id_hash = ${idHash} + and revoked_at is null + and expires_at > ${nowIso} + limit 1 + ` + return rows[0] ? rowToSession(rows[0]) : null +} + +/** Refresh `last_seen_at` — fire-and-forget from the session cache (debounced by the caller). */ +export async function touchVisitorSession( + db: DbClient, + idHash: string, + nowIso: string = new Date().toISOString(), +): Promise { + await db` + update visitor_sessions + set last_seen_at = ${nowIso} + where id_hash = ${idHash} + and revoked_at is null + ` +} + +export async function revokeVisitorSessionByHash(db: DbClient, idHash: string): Promise { + await db` + update visitor_sessions + set revoked_at = current_timestamp + where id_hash = ${idHash} + and revoked_at is null + ` +} + +export async function revokeAllVisitorSessionsForUser(db: DbClient, userId: string): Promise { + await db` + update visitor_sessions + set revoked_at = current_timestamp + where user_id = ${userId} + and revoked_at is null + ` +} + +// --------------------------------------------------------------------------- +// Login attempts (audit trail — no auth decision hinges on this table in Phase 1) +// --------------------------------------------------------------------------- + +interface RecordVisitorLoginAttemptInput { + emailNormalized?: string | null + ipAddress?: string | null + userAgent?: string | null + userId?: string | null + result: VisitorLoginAttemptResult +} + +export async function recordVisitorLoginAttempt( + db: DbClient, + input: RecordVisitorLoginAttemptInput, +): Promise { + await db` + insert into visitor_login_attempts (id, attempted_at, email_normalized, ip_address, user_agent, user_id, result) + values ( + ${nanoid()}, + current_timestamp, + ${input.emailNormalized ?? null}, + ${input.ipAddress ?? null}, + ${input.userAgent ?? null}, + ${input.userId ?? null}, + ${input.result} + ) + ` +} + +/** + * Delete login-attempt rows older than `olderThanDays`. Call site is optional + * in Phase 1 (the table is append-only until a cleanup job is wired); kept + * here so a future cron has one canonical purge query. + */ +export async function purgeOldVisitorLoginAttempts( + db: DbClient, + olderThanDays = 90, +): Promise { + const cutoffIso = new Date(Date.now() - olderThanDays * 24 * 60 * 60 * 1000).toISOString() + await db` + delete from visitor_login_attempts + where attempted_at < ${cutoffIso} + ` +} + +// --------------------------------------------------------------------------- +// Password reset tokens (Phase 2 — V7) +// --------------------------------------------------------------------------- + +/** + * Mint a one-shot password-reset token for `userId`. Returns the RAW token + * (base64url, 32 random bytes) so the caller can hand it to the email + * transport — the DB persists only its SHA-256 hash (`hashSessionToken`), + * mirroring the session-token storage pattern. A token is invalid once + * consumed (`used_at`) or past `expires_at` (1h TTL, see + * `VISITOR_PASSWORD_RESET_TTL_MS`). + */ +export async function createPasswordResetToken( + db: DbClient, + userId: string, +): Promise { + const rawToken = createSessionToken() // randomBytes(32) base64url + const tokenHash = await hashSessionToken(rawToken) // SHA-256 hex + const expiresAt = new Date(Date.now() + VISITOR_PASSWORD_RESET_TTL_MS).toISOString() + await db` + insert into visitor_password_reset_tokens (id, user_id, token_hash, expires_at) + values (${nanoid()}, ${userId}, ${tokenHash}, ${expiresAt}) + ` + return rawToken +} + +/** + * Look up a token by its hash. Only matches rows that are unused (`used_at + * is null`) and not yet expired (`expires_at > now`). `nowIso` is supplied by + * the caller so the freshness check uses one clock read. Returns `null` for + * unknown / consumed / expired tokens — callers surface a single + * `invalid_or_expired_token` error so the failure mode reveals nothing. + */ +export async function findValidPasswordResetToken( + db: DbClient, + tokenHash: string, + nowIso: string = new Date().toISOString(), +): Promise { + const { rows } = await db` + select id, user_id, token_hash, expires_at, used_at, created_at + from visitor_password_reset_tokens + where token_hash = ${tokenHash} + and used_at is null + and expires_at > ${nowIso} + limit 1 + ` + return rows[0] ? rowToPasswordResetToken(rows[0]) : null +} + +/** + * Atomically consume a token (set `used_at`). Idempotent: the `where used_at + * is null` guard means only the first caller wins. Returns true when this + * call performed the consume (`rowCount > 0`); false when the token was + * already consumed (concurrent reset race) or doesn't exist. The reset + * handler treats a `false` result as `invalid_or_expired_token`. + */ +export async function consumePasswordResetToken( + db: DbClient, + tokenHash: string, +): Promise { + const result = await db` + update visitor_password_reset_tokens + set used_at = current_timestamp + where token_hash = ${tokenHash} + and used_at is null + ` + return result.rowCount > 0 +} diff --git a/server/visitor-auth/roles.ts b/server/visitor-auth/roles.ts new file mode 100644 index 000000000..b73de78c6 --- /dev/null +++ b/server/visitor-auth/roles.ts @@ -0,0 +1,194 @@ +/** + * Visitor-role DB CRUD. + * + * Extracted from `repositories.ts` (which mixed roles / users / sessions / + * login attempts / password-reset tokens) so each concern owns its own + * module — mirroring the admin system, where `server/repositories/roles.ts` + * is its own file separate from `users.ts`. Everything here is read/write + * against the `visitor_roles` table only. + * + * All SQL is ANSI-standard (no Postgres-isms — see `repositories.ts` header + * for the full dialect rules) so the same statements run unchanged against + * both the Postgres and SQLite adapters. Rows map to the camelCase + * `VisitorRole` domain shape through `rowToRole`; the column list is inlined + * into each tagged template (matching `server/repositories/roles.ts`) because + * a `db\`select ${COLS}\`` would interpolate the constant as a bind + * parameter, not as SQL text. + * + * Policy: system roles (`member` / `admin`) MAY be edited (name + + * capabilities) but NEVER deleted; custom roles may be edited and (when no + * active visitor is assigned to them) deleted. There is no Owner-lock + * concept (unlike the admin role system). + */ +import { nanoid } from 'nanoid' +import type { DbClient } from '../db/client' +import { filterArray, Type } from '@core/utils/typeboxHelpers' +import type { VisitorRole, VisitorRoleRow } from './types' + +// Capabilities for visitor roles are free-form strings (Phase-1 simplification +// — no junction table, no enum). `filterArray` keeps corrupt column data from +// crashing the read while staying inside the whitelisted +// `@core/utils/typeboxHelpers` surface. +const VisitorCapabilitySchema = Type.String() + +function rowToRole(row: VisitorRoleRow): VisitorRole { + return { + id: row.id, + name: row.name, + capabilities: filterArray(VisitorCapabilitySchema, row.capabilities_json), + isSystem: Boolean(row.is_system), + createdAt: new Date(row.created_at).toISOString(), + updatedAt: new Date(row.updated_at).toISOString(), + } +} + +/** + * Typed mutation error for visitor-role writes. Carries an HTTP `status` + * (default 400) so the shared `mutationErrorResponse` handler can translate + * it into the `{ error }` JSON envelope without the handler hard-coding the + * status. Mirrors `RoleMutationError` in `server/repositories/roles.ts`. + */ +export class VisitorRoleMutationError extends Error { + readonly status: number + + constructor(message: string, status = 400) { + super(message) + this.name = 'VisitorRoleMutationError' + this.status = status + } +} + +export async function findVisitorRoleById(db: DbClient, id: string): Promise { + const { rows } = await db` + select id, name, capabilities_json, is_system, created_at, updated_at + from visitor_roles + where id = ${id} + limit 1 + ` + return rows[0] ? rowToRole(rows[0]) : null +} + +export async function findVisitorRoleByName(db: DbClient, name: string): Promise { + const { rows } = await db` + select id, name, capabilities_json, is_system, created_at, updated_at + from visitor_roles + where name = ${name} + limit 1 + ` + return rows[0] ? rowToRole(rows[0]) : null +} + +export async function listVisitorRoles(db: DbClient): Promise { + const { rows } = await db` + select id, name, capabilities_json, is_system, created_at, updated_at + from visitor_roles + order by is_system desc, name asc + ` + return rows.map(rowToRole) +} + +/** + * Pre-check name uniqueness (excluding the row currently being edited, if + * any) so a duplicate name surfaces a clean 409 instead of a dialect-specific + * DB constraint violation. The `visitor_roles.name` UNIQUE constraint is the + * backstop; this is the friendly, portable front line — same shape as the + * admin `assertRoleSlugAvailable`. + */ +async function assertVisitorRoleNameAvailable( + db: DbClient, + name: string, + currentRoleId?: string, +): Promise { + const existing = await findVisitorRoleByName(db, name) + if (existing && existing.id !== currentRoleId) { + throw new VisitorRoleMutationError('Visitor role name is already in use', 409) + } +} + +/** + * Create a custom visitor role. Mirrors the admin `createCustomRole` shape + * (minus slug/description — visitor roles only carry name + capabilities). + * + * Policy: a duplicate name is rejected up front via + * {@link assertVisitorRoleNameAvailable} so the caller gets a 409, not a raw + * constraint error. Custom roles are always created with `is_system = false`. + */ +export async function createVisitorRole( + db: DbClient, + input: { name: string; capabilities: string[] }, +): Promise { + const name = input.name.trim() + if (!name) throw new VisitorRoleMutationError('Visitor role name is required') + await assertVisitorRoleNameAvailable(db, name) + + const id = nanoid() + const { rows } = await db` + insert into visitor_roles (id, name, capabilities_json, is_system) + values (${id}, ${name}, ${input.capabilities}, ${false}) + returning id, name, capabilities_json, is_system, created_at, updated_at + ` + return rowToRole(rows[0]!) +} + +/** + * Update a visitor role's name and/or capabilities. + * + * Policy (mirror the admin `roles.ts` rules, simplified — visitor roles have + * no Owner-lock concept): system roles (`member` / `admin`) MAY be edited + * (name + capabilities); they are NEVER deletable (see {@link deleteVisitorRole}). + * Custom roles may be edited and (when unassigned) deleted. A rename that + * collides with another role's name is rejected with a 409. + */ +export async function updateVisitorRole( + db: DbClient, + roleId: string, + input: { name?: string; capabilities?: string[] }, +): Promise { + const current = await findVisitorRoleById(db, roleId) + if (!current) return null + + const name = input.name === undefined ? current.name : input.name.trim() + if (!name) throw new VisitorRoleMutationError('Visitor role name is required') + await assertVisitorRoleNameAvailable(db, name, current.id) + const capabilities = input.capabilities ?? current.capabilities + + const { rows } = await db` + update visitor_roles + set name = ${name}, + capabilities_json = ${capabilities}, + updated_at = current_timestamp + where id = ${roleId} + returning id, name, capabilities_json, is_system, created_at, updated_at + ` + return rows[0] ? rowToRole(rows[0]) : null +} + +/** + * Delete a visitor role. + * + * Policy: system roles are never deletable (409); custom roles are deletable + * only when no active visitor is assigned to them (409 otherwise — the + * `visitor_users.role_id` FK is `on delete restrict`, so this guard turns the + * restriction into a friendly message instead of a 500). Returns the deleted + * role on success, or `null` when no row matched `roleId`. + */ +export async function deleteVisitorRole(db: DbClient, roleId: string): Promise { + const current = await findVisitorRoleById(db, roleId) + if (!current) return null + if (current.isSystem) { + throw new VisitorRoleMutationError('System roles cannot be deleted', 409) + } + + const { rows } = await db<{ count: number }>` + select count(*) as count + from visitor_users + where role_id = ${roleId} + and deleted_at is null + ` + if (Number(rows[0]?.count ?? 0) > 0) { + throw new VisitorRoleMutationError('Cannot delete a role assigned to visitors', 409) + } + + const result = await db`delete from visitor_roles where id = ${roleId}` + return result.rowCount > 0 ? current : null +} diff --git a/server/visitor-auth/sessions.ts b/server/visitor-auth/sessions.ts new file mode 100644 index 000000000..bb415e886 --- /dev/null +++ b/server/visitor-auth/sessions.ts @@ -0,0 +1,144 @@ +/** + * Visitor-session cookie helpers + an in-memory session validation cache. + * + * Cookie helpers mirror `server/handlers/cms/session.ts` but scope the + * cookie to `Path=/` (visitor auth gates public-site routes, not the admin + * SPA) and use the visitor cookie name. The `Secure` flag follows the same + * rule: set when the configured public origin is HTTPS OR the request URL + * is https — the former covers TLS-terminating edges that hand the + * container plain HTTP. + * + * The validation cache (D5) fronts `findActiveVisitorSessionByHash` with a + * short-TTL `Map` so a visitor navigating between protected pages does not + * pay a DB round-trip per request. A hit whose cached session is past its + * `expires_at` is treated as a miss; a hit that is still live optionally + * fires a debounced `touchVisitorSession` (no `await`) so `last_seen_at` + * stays fresh without write amplification. + */ +import type { DbClient } from '../db/client' +import { hashSessionToken } from '../auth/tokens' +import { publicOriginIsHttps } from '../auth/security' +import { + VISITOR_SESSION_COOKIE_NAME, + type VisitorSession, +} from './types' +import { findActiveVisitorSessionByHash, touchVisitorSession } from './repositories' + +/** True when the inbound request was made over HTTPS (configured origin wins). */ +function requestIsHttps(req: Request): boolean { + if (publicOriginIsHttps()) return true + return req.url.startsWith('https://') +} + +function visitorCookieAttributes(secure: boolean): string { + // Path=/ — visitor auth gates public-site routes, not just /admin. + // HttpOnly — JS in the browser cannot read the cookie (XSS mitigation). + // SameSite=Lax — cross-origin POST/PUT/DELETE don't carry the cookie (CSRF). + // Secure — browser only sends the cookie over HTTPS (set when applicable). + const base = 'Path=/; HttpOnly; SameSite=Lax' + return secure ? `${base}; Secure` : base +} + +export function visitorSessionCookie(req: Request, token: string, expiresAt: Date): string { + const attrs = visitorCookieAttributes(requestIsHttps(req)) + return `${VISITOR_SESSION_COOKIE_NAME}=${token}; ${attrs}; Expires=${expiresAt.toUTCString()}` +} + +export function clearVisitorSessionCookie(req: Request): string { + const attrs = visitorCookieAttributes(requestIsHttps(req)) + return `${VISITOR_SESSION_COOKIE_NAME}=; ${attrs}; Max-Age=0` +} + +/** Parse a `Cookie` header into a flat map. Returns `{}` when absent. */ +function parseCookies(header: string | null): Record { + if (!header) return {} + const out: Record = {} + for (const part of header.split(';')) { + const eq = part.indexOf('=') + if (eq < 0) continue + const name = part.slice(0, eq).trim() + if (!name) continue + out[name] = decodeURIComponent(part.slice(eq + 1).trim()) + } + return out +} + +/** + * Read the visitor session cookie off the request and return its hash, or + * `null` when no cookie is present. The hash (not the raw token) is the + * `visitor_sessions.id_hash` key, so it is safe to log / use as a cache key. + */ +export async function getVisitorSessionIdHash(req: Request): Promise { + const cookies = parseCookies(req.headers.get('cookie')) + const token = cookies[VISITOR_SESSION_COOKIE_NAME] + if (!token) return null + return hashSessionToken(token) +} + +// --------------------------------------------------------------------------- +// In-memory session cache (D5) +// --------------------------------------------------------------------------- + +const SESSION_CACHE_TTL_MS = 5 * 60 * 1000 +const SESSION_TOUCH_DEBOUNCE_MS = 30 * 1000 + +interface CachedEntry { + session: VisitorSession + /** Wall-clock time the entry was populated — drives the TTL eviction. */ + cachedAt: number +} + +const sessionCache = new Map() + +/** Drop the cached entry for `idHash` — call on logout / revoke. */ +export function invalidateVisitorSessionCache(idHash: string): void { + sessionCache.delete(idHash) +} + +/** + * Resolve the request to a live visitor session, or `null` when there is no + * cookie / the session was revoked / the session has expired. + * + * Cache flow: + * 1. No cookie → `null`. + * 2. Cache hit AND the session is still within its `expires_at` → return + * the cached session. Side-effect: if `lastSeenAt` is older than the + * debounce window, fire-and-forget `touchVisitorSession` (no `await`). + * 3. Cache miss (or stale) → `findActiveVisitorSessionByHash`; `null` → + * return `null`; otherwise cache + return. + * + * The cache is keyed by the token hash and bounded by a 5-minute TTL — a + * revoked session therefore lingers for at most 5 minutes after logout on a + * given worker unless `invalidateVisitorSessionCache` is called. The logout + * handler calls it, so the common path clears immediately. + */ +export async function validateVisitorSession( + db: DbClient, + req: Request, +): Promise { + const idHash = await getVisitorSessionIdHash(req) + if (!idHash) return null + + const now = Date.now() + const cached = sessionCache.get(idHash) + if (cached) { + const expiredByTtl = now - cached.cachedAt > SESSION_CACHE_TTL_MS + const expiredByExpiry = Date.parse(cached.session.expiresAt) <= now + if (!expiredByTtl && !expiredByExpiry) { + // Debounced last-seen touch — never awaited, never blocks the response. + if (now - Date.parse(cached.session.lastSeenAt) > SESSION_TOUCH_DEBOUNCE_MS) { + void touchVisitorSession(db, idHash, new Date(now).toISOString()) + // Optimistically advance the cached lastSeenAt so we don't fire a + // touch on every subsequent in-window request. + cached.session = { ...cached.session, lastSeenAt: new Date(now).toISOString() } + } + return cached.session + } + sessionCache.delete(idHash) + } + + const session = await findActiveVisitorSessionByHash(db, idHash) + if (!session) return null + sessionCache.set(idHash, { session, cachedAt: now }) + return session +} diff --git a/server/visitor-auth/types.ts b/server/visitor-auth/types.ts new file mode 100644 index 000000000..a8ed7520f --- /dev/null +++ b/server/visitor-auth/types.ts @@ -0,0 +1,279 @@ +/** + * Visitor-auth domain types, constants, and DB row shapes. + * + * The visitor system is intentionally isolated from the admin auth system: + * different tables (`visitor_users` / `visitor_sessions` / …), a different + * cookie name, a different code path. The only thing shared is a small + * set of stateless crypto / rate-limit / lockout utilities from + * `server/auth/` (see the import whitelist in `SPEC-3` / `docs/PRD.md` D3). + * + * The `*Row` interfaces match the `002_visitor_auth` migration columns + * exactly (snake_case). The public `Visitor*` interfaces are the + * camelCase domain shape the handlers and clients speak. Row → domain + * conversion lives in `repositories.ts`. + */ + +/** Cookie name — deliberately distinct from the admin session cookie. */ +export const VISITOR_SESSION_COOKIE_NAME = 'instatic_visitor_session' + +/** Absolute session lifetime (90 days). */ +export const VISITOR_SESSION_ABSOLUTE_MS = 1000 * 60 * 60 * 24 * 90 + +/** Idle timeout (30 days, advisory — the absolute timeout is the hard cap). */ +export const VISITOR_SESSION_IDLE_MS = 1000 * 60 * 60 * 24 * 30 + +/** Minimum visitor password length. */ +export const VISITOR_PASSWORD_MIN = 8 + +/** Password-reset token lifetime (1 hour). Raw tokens are never persisted; only their SHA-256 hash is. */ +export const VISITOR_PASSWORD_RESET_TTL_MS = 1000 * 60 * 60 + +/** The single row id used for the `visitor_auth_config` table. */ +export const VISITOR_AUTH_CONFIG_ROW_ID = 'default' + +/** + * The fixed group id used by the Phase-3 `024_page_access` migration backfill. + * When an upgraded install still carried Phase-1/2 `protected_prefixes_json`, + * every prefix-matched page is converted to per-page access against a single + * synthesized `members` group carrying this id. Kept as a named constant so + * the migration + any diagnostic tooling reference one id. + */ +export const VISITOR_BACKFILL_MEMBERS_GROUP_ID = 'vis_group_members_backfill' + +export interface VisitorRole { + id: string + name: string + capabilities: string[] + isSystem: boolean + createdAt: string + updatedAt: string +} + +export type VisitorUserStatus = 'active' | 'suspended' + +export interface VisitorUser { + id: string + email: string + emailNormalized: string + passwordHash: string + displayName: string + roleId: string + /** D15: the visitor's designated primary group (drives login redirect). Nullable. */ + primaryGroupId: string | null + status: VisitorUserStatus + failedLoginCount: number + lockedUntil: string | null + createdAt: string + updatedAt: string + /** Custom profile field VALUES (object keyed by field id). Empty {} when no fields configured/set. */ + profileFields: Record +} + +export interface VisitorSession { + idHash: string + userId: string + createdAt: string + lastSeenAt: string + expiresAt: string + revokedAt: string | null + ipAddress: string | null + userAgent: string | null + deviceLabel: string +} + +export interface VisitorAuthConfig { + enabled: boolean + /** D15: where a visitor with no primary-group landing path lands after login. */ + defaultLandingPath: string + loginPath: string + registrationOpen: boolean + defaultRole: string + /** + * Site-builder-defined custom profile field DEFINITIONS (DataField[]). + * Default [] = no custom profile fields (pre-framework behaviour). + * Values live per-visitor in visitor_users.profile_fields_json. + */ + profileFields: VisitorProfileField[] +} + +/** + * A site-builder-defined visitor profile field. A minimal projection of the + * core DataField shape (id/label/type/required) — kept narrow intentionally + * so the visitor surface doesn't drag in the full field-type union until a + * field type beyond text/longText/select/boolean is actually needed. + */ +export type VisitorProfileFieldType = 'text' | 'longText' | 'select' | 'boolean' + +export interface VisitorProfileField { + id: string + label: string + type: VisitorProfileFieldType + required?: boolean + options?: { value: string; label: string }[] +} + +export const DEFAULT_VISITOR_AUTH_CONFIG: VisitorAuthConfig = { + enabled: false, + defaultLandingPath: '/', + loginPath: '/login', + registrationOpen: true, + defaultRole: 'member', + profileFields: [], +} + +/** Outcome of a login attempt — persisted to `visitor_login_attempts`. */ +export type VisitorLoginAttemptResult = + | 'success' + | 'bad_password' + | 'no_user' + | 'locked' + | 'rate_limited' + | 'account_disabled' + +// --------------------------------------------------------------------------- +// DB row shapes (snake_case — match the `002_visitor_auth` migration exactly) +// --------------------------------------------------------------------------- + +export interface VisitorRoleRow { + id: string + name: string + capabilities_json: unknown + is_system: boolean | number + created_at: Date | string + updated_at: Date | string +} + +export interface VisitorUserRow { + id: string + email: string + email_normalized: string + password_hash: string + display_name: string + role_id: string + /** D15 primary group — nullable, references visitor_groups(id). */ + primary_group_id: string | null + status: VisitorUserStatus | string + failed_login_count: number + locked_until: Date | string | null + created_at: Date | string + updated_at: Date | string + deleted_at: Date | string | null + /** Custom profile field VALUES (object keyed by field id). Auto-parsed by the _json-suffix adapter. */ + profile_fields_json?: Record | string +} + +export interface VisitorSessionRow { + id_hash: string + user_id: string + created_at: Date | string + last_seen_at: Date | string + expires_at: Date | string + revoked_at: Date | string | null + ip_address: string | null + user_agent: string | null + device_label: string +} + +export interface VisitorLoginAttemptRow { + id: string + attempted_at: Date | string + email_normalized: string | null + ip_address: string | null + user_agent: string | null + user_id: string | null + result: VisitorLoginAttemptResult | string +} + +export interface VisitorAuthConfigRow { + id: string + enabled: boolean | number + /** D15 default landing path (replaces the retired `protected_prefixes_json`). */ + default_landing_path: string + login_path: string + registration_open: boolean | number + default_role: string + updated_at: Date | string + /** Site-builder-configured profile field DEFINITIONS (DataField[]). */ + profile_fields_json?: unknown +} + +/** + * A valid (unconsumed, unexpired) password-reset token lookup result. + * `token_hash` is the SHA-256 hex of the raw token; the raw token is never + * stored and is therefore never part of this shape. + */ +export interface VisitorPasswordResetToken { + id: string + userId: string + tokenHash: string + expiresAt: string + usedAt: string | null + createdAt: string +} + +/** DB row shape for `visitor_password_reset_tokens` (snake_case — matches the `022_visitor_password_reset` migration). */ +export interface VisitorPasswordResetTokenRow { + id: string + user_id: string + token_hash: string + expires_at: Date | string + used_at: Date | string | null + created_at: Date | string +} + +// --------------------------------------------------------------------------- +// Member groups (Phase 3 — D13/D14/D15) +// --------------------------------------------------------------------------- + +/** + * A member group — a content-segmentation segment used for page-level access + * (D14) and login-redirect resolution (D15). Orthogonal to {@link VisitorRole} + * (D13): a role answers "what can a member DO"; a group answers "what can a + * member SEE / where do they land". A visitor belongs to 0..N groups via the + * {@link VisitorUserGroup} junction, with one designated primary group + * (`visitor_users.primary_group_id`). + */ +export interface VisitorGroup { + id: string + name: string + /** Lowercase kebab, derived from the name. NOT unique (unlike `name`). */ + slug: string + /** D15: where primary members of this group land after login. */ + landingPath: string + description: string + isSystem: boolean + createdAt: string + updatedAt: string +} + +/** DB row shape for `visitor_groups` (snake_case — matches the `023_member_groups` migration). */ +export interface VisitorGroupRow { + id: string + name: string + slug: string + landing_path: string + description: string + is_system: boolean | number + created_at: Date | string + updated_at: Date | string +} + +/** + * A membership row in the `visitor_user_groups` junction (D13). A visitor is + * in a group at most once (UNIQUE(user_id, group_id)). Junction CASCADEs on + * both sides, so deleting a group or a user removes its memberships. + */ +export interface VisitorUserGroup { + id: string + userId: string + groupId: string + createdAt: string +} + +/** DB row shape for `visitor_user_groups` (snake_case — matches the `023_member_groups` migration). */ +export interface VisitorUserGroupRow { + id: string + user_id: string + group_id: string + created_at: Date | string +} diff --git a/server/visitor-auth/visitorData.ts b/server/visitor-auth/visitorData.ts new file mode 100644 index 000000000..49afdce21 --- /dev/null +++ b/server/visitor-auth/visitorData.ts @@ -0,0 +1,84 @@ +/** + * Per-visitor data resolver — the IDOR-safe identity source for the + * `visitor.current` and `visitor.owned-rows` loop sources. + * + * The load-bearing security rule: **the visitor identity is derived solely + * from the validated session cookie**, never from any value supplied by the + * loop's filters, query, or path. Every call into this module constructs a + * minimal synthetic Request carrying only the visitor session cookie and + * hands it to the existing cached `validateVisitorSession` — so the + * in-memory session cache, expiry checks, and touch-debounce are all reused + * rather than re-implemented. No visitor id from request input is ever + * trusted on a read path (see the architecture gate + * `visitor-data-isolation.test.ts`). + * + * Loop sources receive the parsed cookie map (`ctx.request.cookies`) rather + * than a `Request`; this module is the bridge between that map and the + * Request-shaped session validator. + */ +import type { DbClient } from '../db/client' +import { VISITOR_SESSION_COOKIE_NAME } from './types' +import { validateVisitorSession } from './sessions' +import { findVisitorUserById } from './repositories' +import { findVisitorRoleById } from './roles' + +/** + * Build a minimal Request that carries a single visitor session cookie, so + * the cached `validateVisitorSession` can be reused unchanged. Only the + * `cookie` header is read by the validator; the URL/method are arbitrary. + */ +function requestFromCookies(cookies: Record): Request { + const token = cookies?.[VISITOR_SESSION_COOKIE_NAME] + const req = new Request('http://instatic.local/visitor-resolve') + // Set the header imperatively rather than via the Request init: some + // environments (e.g. the test harness's happy-dom Request) strip a `cookie` + // header supplied through the constructor's `headers` init, mirroring the + // browser fetch spec's forbidden-header rule. The real Bun server's Request + // accepts either form, but imperative `.set()` works in BOTH, so the + // resolver behaves identically in production and tests. + if (token) req.headers.set('cookie', `${VISITOR_SESSION_COOKIE_NAME}=${token}`) + return req +} + +/** + * The resolved visitor for loop sources: the VisitorUser plus its role name + * (resolved via a second lookup so the source consumer doesn't have to). + * IDOR-safe — derived solely from the validated session cookie. + */ +export interface ResolvedVisitor { + id: string + displayName: string + email: string + roleId: string + roleName: string | null + profileFields: Record +} + +/** + * Resolve the logged-in visitor from a cookie map. Returns the visitor with + * role name + profile fields, or `null` when there is no cookie, no valid + * session, or the visitor record no longer exists. + * + * This is the single entry point both loop sources use to obtain visitor + * identity. It accepts ONLY a cookie map — never a visitor/user/owner id — + * which is what makes it IDOR-safe by construction. + */ +export async function resolveVisitorFromCookie( + db: DbClient, + cookies: Record | undefined, +): Promise { + if (!cookies) return null + const session = await validateVisitorSession(db, requestFromCookies(cookies)) + if (!session) return null + const user = await findVisitorUserById(db, session.userId) + if (!user) return null + const role = user.roleId ? await findVisitorRoleById(db, user.roleId) : null + return { + id: user.id, + displayName: user.displayName, + email: user.email, + roleId: user.roleId, + roleName: role?.name ?? null, + profileFields: user.profileFields, + } +} diff --git a/src/__tests__/architecture/cms-handlers-capability-gated.test.ts b/src/__tests__/architecture/cms-handlers-capability-gated.test.ts index 4170023eb..6db4cb472 100644 --- a/src/__tests__/architecture/cms-handlers-capability-gated.test.ts +++ b/src/__tests__/architecture/cms-handlers-capability-gated.test.ts @@ -74,6 +74,10 @@ const ALLOWLIST: ReadonlyMap = new Map([ // pages, not a CMS admin route. Reached only via the public router. ['loop.ts', 'Published-page runtime endpoint; not a /admin/api/cms/ route.'], ['hole.ts', 'Published-page runtime endpoint; not a /admin/api/cms/ route.'], + // Auth-gate fragment endpoint — `/_instatic/gate/` renders a gated + // container subtree for authorised visitors (same trust model as hole/loop: + // reached only via the public router, authorisation lives in gateHelpers). + ['gate.ts', 'Published-page runtime endpoint; not a /admin/api/cms/ route.'], // Module-JS assets — `/_instatic/module-js/.js` serves published // module runtimes to anonymous visitors, same trust model as hole/loop. ['moduleJs.ts', 'Published-page runtime endpoint; not a /admin/api/cms/ route.'], @@ -110,6 +114,7 @@ const ALLOWLIST: ReadonlyMap = new Map([ ['dashboard/posts.ts', 'Widget data reader called by gated dashboard/index.ts dispatcher.'], ['dashboard/media.ts', 'Widget data reader called by gated dashboard/index.ts dispatcher.'], ['dashboard/plugins.ts', 'Widget data reader called by gated dashboard/index.ts dispatcher.'], + ['dashboard/members.ts', 'Widget data reader called by gated dashboard/index.ts dispatcher (visitor count + registration histogram).'], ['dashboard/publishLineup.ts', 'Widget data reader called by gated dashboard/index.ts dispatcher.'], ['dashboard/activity.ts', 'Widget data reader called by gated dashboard/index.ts dispatcher.'], ['dashboard/storage.ts', 'Widget data reader called by gated dashboard/index.ts dispatcher.'], diff --git a/src/__tests__/architecture/module-size-budgets.test.ts b/src/__tests__/architecture/module-size-budgets.test.ts index 7341abce4..9492417ec 100644 --- a/src/__tests__/architecture/module-size-budgets.test.ts +++ b/src/__tests__/architecture/module-size-budgets.test.ts @@ -92,6 +92,12 @@ const EXEMPT = new Set([ */ const GRANDFATHERED: Record = { 'src/admin/pages/site/store/slices/visualComponentsSlice.ts': 715, + // server/router.ts: the routing table is a flat first-match-wins array; the + // visitor-auth Phase-1 addition of tryServeVisitorRoutes pushed it past the + // 700-line ceiling. Splitting a route table by concern adds indirection + // without clarifying dispatch order, so it's frozen here. See + // docs/ARCHITECTURE.md §4.1 (route positions are documented invariants). + 'server/router.ts': 704, // server/repositories/media.ts graduated: the row ↔ asset mapping unit was // extracted into server/repositories/mediaAssetMapping.ts, dropping media.ts // to 583 lines — under CEILING, so it's now held by the normal ceiling rule. diff --git a/src/__tests__/architecture/visitor-data-isolation.test.ts b/src/__tests__/architecture/visitor-data-isolation.test.ts new file mode 100644 index 000000000..545c58e9d --- /dev/null +++ b/src/__tests__/architecture/visitor-data-isolation.test.ts @@ -0,0 +1,126 @@ +/** + * Architecture gate: visitor-data framework IDOR isolation. + * + * The load-bearing security rule for the per-visitor-data framework: visitor + * identity is derived SOLELY from the validated session cookie — never from + * loop filters, query params, path segments, or form bodies. If any of these + * tests fail, a visitor could be shown (or stamped as the owner of) another + * visitor's data. + * + * These are static source-analysis checks (read file contents, assert + * invariants). They are intentionally strict: the cookie is the one and only + * identity source, enforced at the resolver boundary and the loop-source + * boundary. Defence-in-depth note: visitor ids are nanoid (21 chars, ~126 + * bits, unguessable) — but unguessability is secondary; cookie-derived + * identity is the primary IDOR guard. + * + * See `docs/PER-VISITOR-DATA-SPEC.md` § "Security invariants". + */ + +import { describe, expect, it } from 'bun:test' +import { readFile } from 'node:fs/promises' +import { fileURLToPath } from 'node:url' +import { join, dirname } from 'node:path' + +const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..', '..', '..') + +async function read(relative: string): Promise { + return await readFile(join(ROOT, relative), 'utf-8') +} + +/** + * Strip comments + string literals so docstrings/strings never satisfy or + * fail a structural scan. Mirrors the helper in + * `plugin-sandbox-invariants.test.ts` (not a full parser, but sufficient for + * grep-style structural checks over source). + */ +function stripCommentsAndStrings(source: string): string { + let s = source.replace(/\/\*[\s\S]*?\*\//g, ' ') // block comments + s = s.replace(/\/\/[^\n]*/g, ' ') // line comments + s = s.replace(/'(?:\\.|[^'\\])*'/g, "''") // single-quoted strings + s = s.replace(/"(?:\\.|[^"\\])*"/g, '""') // double-quoted strings + return s +} + +describe('visitor-data framework IDOR isolation', () => { + // ───────────────────────────────────────────────────────────────────── + // 1. The resolver accepts no identity id parameter — cookie map only. + // ───────────────────────────────────────────────────────────────────── + it('resolveVisitorFromCookie derives identity only from the cookie map, not an id param', async () => { + const source = stripCommentsAndStrings(await read('server/visitor-auth/visitorData.ts')) + + // The public resolver exists and takes the cookie map as its identity input. + expect(source).toContain('export async function resolveVisitorFromCookie') + expect(source).toContain('cookies: Record') + + // It must reach identity through the cached session validator (cookie path), + // not by accepting/resolving a visitor/user/owner id. + expect(source).toContain('validateVisitorSession') + + // No identity-id parameter may influence resolution. The resolver signature + // has (db, cookies) — ban an explicit visitor/user/owner id param name. + expect(source).not.toMatch(/resolveVisitorFromCookie\s*\([^)]*\bvisitorId\b/) + expect(source).not.toMatch(/resolveVisitorFromCookie\s*\([^)]*\buserId\b/) + expect(source).not.toMatch(/resolveVisitorFromCookie\s*\([^)]*\bownerId\b/) + }) + + // ───────────────────────────────────────────────────────────────────── + // 2. Visitor loop sources read identity from ctx.visitor, never from + // request filters/query/path (which a caller could forge). + // ───────────────────────────────────────────────────────────────────── + for (const rel of ['src/core/loops/sources/visitorCurrent.ts', 'src/core/loops/sources/visitorOwnedRows.ts']) { + it(`identity in ${rel} comes from ctx.visitor, not request input`, async () => { + const source = stripCommentsAndStrings(await read(rel)) + + // Identity is the cookie-resolved ctx.visitor. + expect(source).toContain('ctx.visitor') + + // The owner filter binds ctx.visitor.id (ownedRows). Confirm ctx.visitor + // is referenced — then forbid deriving a visitor from request input. + // ctx.filters.tableId is allowed (it selects a TABLE, not an identity). + expect(source).not.toMatch(/ctx\.request\.query\s*\[?\s*['"]?\w*([Ii]d|[Uu]serId|[Oo]wnerId)/) + expect(source).not.toMatch(/ctx\.request\.path\b/) + expect(source).not.toMatch(/ctx\.filters\s*\.\s*(visitor|user|owner)/i) + // No visitor/user/owner id read off the slug (request identity). + expect(source).not.toMatch(/ctx\.request\.(slug|query)\b[^=]*=\s*ctx\.visitor/) + }) + } + + it('visitor.owned-rows filters owned rows by ctx.visitor.id as a bound parameter', async () => { + const source = stripCommentsAndStrings(await read('src/core/loops/sources/visitorOwnedRows.ts')) + // Identity is captured from ctx.visitor (cookie-derived) … + expect(source).toMatch(/const\s+visitor\s*=\s*ctx\.visitor/) + // … used as the visitor_user_id bind in BOTH the count and the page query … + expect(source).toMatch(/visitor_user_id\s*=\s*\$\{visitor\.id\}/) + // … and the anonymous guard returns empty before any query runs. + expect(source).toMatch(/if\s*\(\s*!visitor\s*\)\s*return\s*\{\s*items:\s*\[\]\s*,\s*totalItems:\s*0\s*\}/) + }) + + // ───────────────────────────────────────────────────────────────────── + // 3. The form handler stamps the visitor only from the validated session, + // never from the submitted body or validated cells. + // ───────────────────────────────────────────────────────────────────── + it('form handler stamps visitor_user_id from the session, never the body', async () => { + const source = stripCommentsAndStrings(await read('server/forms/handler.ts')) + + // Cookie/session path is the only source of identity here. + expect(source).toContain('validateVisitorSession') + expect(source).toContain('capturesVisitorOwner') + + // visitorUserId must NOT be assigned from request body or the validated cells. + expect(source).not.toMatch(/visitorUserId\s*=\s*body\b/) + expect(source).not.toMatch(/visitorUserId\s*=\s*(?:validation\.)?cells\b/) + expect(source).not.toMatch(/visitor_user_id\s*=\s*\$\{body/) + }) + + // ───────────────────────────────────────────────────────────────────── + // 4. Both visitor sources are registered so the framework is wired up. + // ───────────────────────────────────────────────────────────────────── + it('both visitor loop sources are registered in the sources index', async () => { + const source = await read('src/core/loops/sources/index.ts') + expect(source).toContain('VisitorCurrentSource') + expect(source).toContain('VisitorOwnedRowsSource') + expect(source).toMatch(/registerOrReplace\(VisitorCurrentSource\)/) + expect(source).toMatch(/registerOrReplace\(VisitorOwnedRowsSource\)/) + }) +}) diff --git a/src/__tests__/base-modules.test.ts b/src/__tests__/base-modules.test.ts index 0533c6f74..5732886d5 100644 --- a/src/__tests__/base-modules.test.ts +++ b/src/__tests__/base-modules.test.ts @@ -520,7 +520,8 @@ describe('base.container — render() specifics', () => { }) it('exposes HTML tag selection (built-in tag + custom override)', () => { - expect(Object.keys(ContainerModule.schema).sort()).toEqual(['customTag', 'htmlAttributes', 'tag']) + // visitor-auth Phase 3 adds `authGate` (member-group gating) to containers. + expect(Object.keys(ContainerModule.schema).sort()).toEqual(['authGate', 'customTag', 'htmlAttributes', 'tag']) }) it('renders children HTML inside the container', () => { diff --git a/src/__tests__/loops/visitorCurrentSource.test.ts b/src/__tests__/loops/visitorCurrentSource.test.ts new file mode 100644 index 000000000..f6b654532 --- /dev/null +++ b/src/__tests__/loops/visitorCurrentSource.test.ts @@ -0,0 +1,113 @@ +/** + * Unit tests for the `visitor.current` loop source — the per-visitor + * personalisation source (Use-case A of the visitor-data framework). + * + * Mirrors the static-shape + fetch style of `dataRowsSource.test.ts`, but + * also exercises `fetch()` since `VisitorCurrentSource.fetch()` is pure: it + * never touches `ctx.db`, so no `createTestDb()` is required. The whole + * behaviour is a pure projection of `ctx.visitor`. + * + * The load-bearing contract pinned here: + * - the source is `perVisitor: true` (cookie-derived, never baked), + * - anonymous requests (no `ctx.visitor`) render nothing, + * - a logged-in visitor resolves to exactly one item carrying their + * identity + every custom profile field (spread dynamically), + * - identity is derived SOLELY from `ctx.visitor` — junk in `ctx.request` + * / `ctx.filters` is ignored entirely (IDOR-safe by construction). + */ +import { describe, expect, it } from 'bun:test' +import { VisitorCurrentSource } from '@core/loops/sources/visitorCurrent' + +/** + * Build a minimal `SourceFetchContext`-shaped object. `fetch()` reads only + * `ctx.visitor`, so the other keys are inert — cast `as any` to satisfy the + * type without importing internal helpers, matching how the loop test suite + * constructs ctx literals. + */ +function makeCtx(visitor: unknown | undefined): any { + return { + db: undefined, + site: {}, + filters: {}, + orderBy: 'createdAt', + direction: 'desc', + limit: 50, + offset: 0, + visitor, + } +} + +describe('visitor.current loop source', () => { + it('declares its id and per-visitor classification', () => { + expect(VisitorCurrentSource.id).toBe('visitor.current') + expect(VisitorCurrentSource.perVisitor).toBe(true) + }) + + it('declares the core identity fields', () => { + const fieldIds = VisitorCurrentSource.fields.map((field) => field.id) + expect(fieldIds).toContain('id') + expect(fieldIds).toContain('displayName') + expect(fieldIds).toContain('email') + expect(fieldIds).toContain('roleName') + }) + + it('renders nothing for an anonymous request (no ctx.visitor)', async () => { + const result = await VisitorCurrentSource.fetch(makeCtx(undefined)) + expect(result).toEqual({ items: [], totalItems: 0 }) + }) + + it('emits exactly one item projecting the visitor identity + custom profile fields', async () => { + const visitor = { + id: 'v_123', + displayName: 'Ada Visitor', + email: 'ada@example.com', + roleName: 'member', + profileFields: { schoolName: 'Oasis', grade: 'Y2' }, + } + const result = await VisitorCurrentSource.fetch(makeCtx(visitor)) + + expect(result.totalItems).toBe(1) + expect(result.items).toHaveLength(1) + + const item = result.items[0]! + // Identity + expect(item.id).toBe('v_123') + expect(item.fields['id']).toBe('v_123') + expect(item.fields['displayName']).toBe('Ada Visitor') + expect(item.fields['email']).toBe('ada@example.com') + expect(item.fields['roleName']).toBe('member') + // Custom profile fields spread dynamically (schoolName binding resolves). + expect(item.fields['schoolName']).toBe('Oasis') + expect(item.fields['grade']).toBe('Y2') + }) + + it('ignores ctx.request and ctx.filters entirely (identity is cookie-derived)', async () => { + const visitor = { + id: 'v_solo', + displayName: 'Solo', + email: 'solo@example.com', + roleName: null, + profileFields: {}, + } + // Junk request + filters that, if trusted, would change the output. + const ctx = makeCtx(visitor) + ctx.request = { + query: { id: 'attacker', displayName: 'Attacker' }, + path: '/impersonate', + slug: 'forged', + cookies: { visitor_session: 'forged-token' }, + } + ctx.filters = { id: 'forged-id', schoolName: 'Tampered' } + + const result = await VisitorCurrentSource.fetch(ctx) + + expect(result.totalItems).toBe(1) + const item = result.items[0]! + // Output reflects ONLY ctx.visitor — request/filters never leak in. + expect(item.id).toBe('v_solo') + expect(item.fields['id']).toBe('v_solo') + expect(item.fields['displayName']).toBe('Solo') + expect(item.fields['email']).toBe('solo@example.com') + expect(item.fields['schoolName']).toBeUndefined() + }) +}) diff --git a/src/__tests__/loops/visitorOwnedRowsSource.test.ts b/src/__tests__/loops/visitorOwnedRowsSource.test.ts new file mode 100644 index 000000000..5113bf06a --- /dev/null +++ b/src/__tests__/loops/visitorOwnedRowsSource.test.ts @@ -0,0 +1,265 @@ +/** + * Integration tests for the `visitor.owned-rows` loop source — the per-visitor + * owned-data iteration (Use-case B of the visitor-data framework). + * + * Mirrors the `dataRowsFetch.test.ts` harness (`createTestDb()` with every + * migration applied, including migration `026` which adds + * `data_rows.visitor_user_id`). The load-bearing contract pinned here is the + * IDOR isolation rule: the source reads the visitor id ONLY from + * `ctx.visitor.id` (cookie-derived) and filters `data_rows.visitor_user_id` + * against it, so a visitor can never read another visitor's rows — even when + * both visitors own rows in the same table. + * + * Seeding mirrors `seedDataRow` from the data-rows harness but adds the + * `visitor_user_id` column; the rows belong to real `visitor_users` rows + * (FK with ON DELETE SET NULL), so two visitor accounts are created up front. + */ +import { describe, expect, it, beforeAll, afterAll } from 'bun:test' +import { createTestDb, type TestDb } from '../helpers/createTestDb' +import { VisitorOwnedRowsSource } from '@core/loops/sources/visitorOwnedRows' + +type Db = TestDb['db'] + +// --------------------------------------------------------------------------- +// Seeding helpers +// --------------------------------------------------------------------------- + +/** + * Insert a `visitor_users` row directly. Mirrors the migration `021` columns + * (`id, email, email_normalized, password_hash, display_name, role_id, + * status`). The system `member` role is seeded by migration `021`, so we + * reuse it as the role_id. + */ +async function seedVisitorUser( + db: Db, + input: { + id: string + email: string + displayName: string + }, +): Promise { + await db` + insert into visitor_users (id, email, email_normalized, password_hash, display_name, role_id, status) + values (${input.id}, ${input.email}, ${input.email}, 'h', ${input.displayName}, 'member', 'active') + ` +} + +interface OwnedRowSeed { + rowId: string + tableId: string + slug: string + cells: Record + visitorUserId: string + createdAt: string + updatedAt: string +} + +/** + * Insert a data-kind row owned by a visitor. Mirrors `seedDataRow` from the + * data-rows harness but adds the `visitor_user_id` column (migration `026`). + */ +async function seedOwnedRow(db: Db, seed: OwnedRowSeed): Promise { + await db` + insert into data_rows + (id, table_id, cells_json, slug, status, created_at, updated_at, visitor_user_id) + values + (${seed.rowId}, ${seed.tableId}, ${JSON.stringify(seed.cells)}, ${seed.slug}, + 'draft', ${seed.createdAt}, ${seed.updatedAt}, ${seed.visitorUserId}) + ` +} + +/** + * Build a `SourceFetchContext`-shaped object for the owned-rows source. The + * test DB satisfies `LoopSourceDb` (it exposes `unsafe` + `dialect`), and + * `ctx.visitor.id` is the IDOR load-bearing input — cast `as any` to satisfy + * the type without importing internal helpers. + */ +function makeCtx(db: Db, overrides: Record = {}): any { + return { + db, + site: {}, + filters: { tableId: 'things' }, + orderBy: 'createdAt', + direction: 'asc', + limit: 50, + offset: 0, + ...overrides, + } +} + +// --------------------------------------------------------------------------- +// Shared fixture +// --------------------------------------------------------------------------- + +let testDb: TestDb +let db: Db + +const VISITOR_A = 'visitor-a' +const VISITOR_B = 'visitor-b' + +beforeAll(async () => { + testDb = await createTestDb() + db = testDb.db + + await seedVisitorUser(db, { id: VISITOR_A, email: 'a@example.com', displayName: 'Visitor A' }) + await seedVisitorUser(db, { id: VISITOR_B, email: 'b@example.com', displayName: 'Visitor B' }) + + // Primary data-kind table — 3 rows: 2 owned by visitorA, 1 by visitorB + // (the IDOR double-visitor seed). + await db` + insert into data_tables (id, name, slug, kind, route_base, singular_label, plural_label) + values ('things', 'Things', 'things', 'data', '/things', 'Thing', 'Things') + ` + await seedOwnedRow(db, { + rowId: 'thing-a1', + tableId: 'things', + slug: 'a1', + cells: { title: 'A1', owner: 'A' }, + visitorUserId: VISITOR_A, + createdAt: '2026-03-01T00:00:00.000Z', + updatedAt: '2026-03-02T00:00:00.000Z', + }) + await seedOwnedRow(db, { + rowId: 'thing-a2', + tableId: 'things', + slug: 'a2', + cells: { title: 'A2', owner: 'A' }, + visitorUserId: VISITOR_A, + createdAt: '2026-03-02T00:00:00.000Z', + updatedAt: '2026-03-03T00:00:00.000Z', + }) + // The row visitorA must NOT see — owned by visitorB in the same table. + await seedOwnedRow(db, { + rowId: 'thing-b1', + tableId: 'things', + slug: 'b1', + cells: { title: 'B1', owner: 'B' }, + visitorUserId: VISITOR_B, + createdAt: '2026-03-03T00:00:00.000Z', + updatedAt: '2026-03-04T00:00:00.000Z', + }) + + // Second data-kind table with a visitorA-owned row — proves ctx.filters.tableId + // scoping excludes rows from other tables. + await db` + insert into data_tables (id, name, slug, kind, route_base, singular_label, plural_label) + values ('other', 'Other', 'other', 'data', '/other', 'Other', 'Others') + ` + await seedOwnedRow(db, { + rowId: 'other-a1', + tableId: 'other', + slug: 'oa1', + cells: { title: 'OA1' }, + visitorUserId: VISITOR_A, + createdAt: '2026-04-01T00:00:00.000Z', + updatedAt: '2026-04-02T00:00:00.000Z', + }) +}) + +afterAll(async () => { + await testDb.cleanup() +}) + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +describe('visitor.owned-rows loop source', () => { + it('declares its id, per-visitor classification, tableId filter, and order options', () => { + expect(VisitorOwnedRowsSource.id).toBe('visitor.owned-rows') + expect(VisitorOwnedRowsSource.perVisitor).toBe(true) + + // tableId filter for scoping to a specific data table. + expect(VisitorOwnedRowsSource.filterSchema).toHaveProperty('tableId') + + // Order options include createdAt / updatedAt / slug. + const orderIds = VisitorOwnedRowsSource.orderByOptions.map((o) => o.id) + expect(orderIds).toContain('createdAt') + expect(orderIds).toContain('updatedAt') + expect(orderIds).toContain('slug') + }) + + it('returns EXACTLY visitorA\'s rows and NEVER visitorB\'s (IDOR isolation)', async () => { + const result = await VisitorOwnedRowsSource.fetch( + makeCtx(db, { + visitor: { + id: VISITOR_A, + displayName: 'Visitor A', + email: 'a@example.com', + roleName: 'member', + profileFields: {}, + }, + }), + ) + + expect(result.totalItems).toBe(2) + const ids = result.items.map((i) => i.id) + expect(ids).toContain('thing-a1') + expect(ids).toContain('thing-a2') + // visitorB's row must never leak through, even though it lives in the + // same table — the visitor_user_id filter is bound from ctx.visitor.id. + expect(ids).not.toContain('thing-b1') + }) + + it('renders nothing for an anonymous request (no ctx.visitor)', async () => { + const result = await VisitorOwnedRowsSource.fetch(makeCtx(db, { visitor: undefined })) + expect(result).toEqual({ items: [], totalItems: 0 }) + }) + + it('honours ctx.filters.tableId — rows from a different table are excluded', async () => { + // Fetch the OTHER table → only that table's visitorA row, never the + // 'things' rows. + const otherResult = await VisitorOwnedRowsSource.fetch( + makeCtx(db, { + filters: { tableId: 'other' }, + visitor: { + id: VISITOR_A, + displayName: 'Visitor A', + email: 'a@example.com', + roleName: 'member', + profileFields: {}, + }, + }), + ) + expect(otherResult.totalItems).toBe(1) + expect(otherResult.items.map((i) => i.id)).toEqual(['other-a1']) + + // And the default 'things' table fetch never returns the 'other' row. + const thingsResult = await VisitorOwnedRowsSource.fetch( + makeCtx(db, { + visitor: { + id: VISITOR_A, + displayName: 'Visitor A', + email: 'a@example.com', + roleName: 'member', + profileFields: {}, + }, + }), + ) + expect(thingsResult.items.map((i) => i.id)).not.toContain('other-a1') + }) + + it('honours limit/offset while totalItems stays the full owned count', async () => { + const visitorA = { + id: VISITOR_A, + displayName: 'Visitor A', + email: 'a@example.com', + roleName: 'member', + profileFields: {}, + } + + const first = await VisitorOwnedRowsSource.fetch( + makeCtx(db, { visitor: visitorA, limit: 1, offset: 0, orderBy: 'createdAt', direction: 'asc' }), + ) + expect(first.items).toHaveLength(1) + expect(first.items[0]!.id).toBe('thing-a1') // earliest createdAt first + expect(first.totalItems).toBe(2) + + const rest = await VisitorOwnedRowsSource.fetch( + makeCtx(db, { visitor: visitorA, limit: 1, offset: 1, orderBy: 'createdAt', direction: 'asc' }), + ) + expect(rest.items).toHaveLength(1) + expect(rest.items[0]!.id).toBe('thing-a2') + expect(rest.totalItems).toBe(2) // stable total across pages + }) +}) diff --git a/src/__tests__/server/dataCms.test.ts b/src/__tests__/server/dataCms.test.ts index 34b89de88..08a8a61a5 100644 --- a/src/__tests__/server/dataCms.test.ts +++ b/src/__tests__/server/dataCms.test.ts @@ -92,6 +92,7 @@ describe('data CMS repository', () => { primaryFieldId: 'title', fields: defaultFields, system: false, + capturesVisitorOwner: false, createdByUserId: null, updatedByUserId: null, createdAt: '2026-05-01T10:00:00.000Z', diff --git a/src/__tests__/server/holeComposePrefixLookup.test.ts b/src/__tests__/server/holeComposePrefixLookup.test.ts new file mode 100644 index 000000000..0e0e9e29d --- /dev/null +++ b/src/__tests__/server/holeComposePrefixLookup.test.ts @@ -0,0 +1,49 @@ +import { describe, expect, it } from 'bun:test' +// findPageForNodeId is the compose-aware lookup; verify it strips the c0_/t_ +// prefix that templateCompose splices onto wrapped-page node ids. +// Static unit test — no DB. Mirrors the architecture-test style. +import { findPageForNodeId, getPublishedNodeIndexForVersion } from '../../../server/publish/publishedSnapshotCache' + +// Build a minimal PublishedNodeIndex shape by hand (the type isn't exported, +// but findPageForNodeId accepts it structurally). +function makeIndex(nodeIds: string[]): Parameters[0] { + const nodeIndex = new Map() + for (const id of nodeIds) nodeIndex.set(id, { id: 'page-x' } as never) + return { site: {} as never, nodeIndex } as never +} + +describe('findPageForNodeId — compose-prefix awareness', () => { + it('finds a node by its original (non-composed) id', () => { + const idx = makeIndex(['abc123']) + expect(findPageForNodeId(idx, 'abc123')?.effectiveNodeId).toBe('abc123') + }) + + it('finds a node by its c0_-prefixed composed id (everywhere template terminal page)', () => { + const idx = makeIndex(['abc123']) + const found = findPageForNodeId(idx, 'c0_abc123') + expect(found).toBeDefined() + expect(found!.effectiveNodeId).toBe('abc123') + }) + + it('finds a node by its t_ prefixed composed id (outer template wrap)', () => { + const idx = makeIndex(['abc123']) + expect(findPageForNodeId(idx, 't0_abc123')?.effectiveNodeId).toBe('abc123') + expect(findPageForNodeId(idx, 't12_abc123')?.effectiveNodeId).toBe('abc123') + }) + + it('returns undefined for an id with no stripped match', () => { + const idx = makeIndex(['abc123']) + expect(findPageForNodeId(idx, 'c0_totally_missing')).toBeUndefined() + expect(findPageForNodeId(idx, 'no_such_node')).toBeUndefined() + }) + + it('does NOT strip a prefix that is part of the original id', () => { + // An authored id that genuinely starts with c0_ must still match itself. + const idx = makeIndex(['c0_realid']) + expect(findPageForNodeId(idx, 'c0_realid')?.effectiveNodeId).toBe('c0_realid') + }) + + it('getPublishedNodeIndexForVersion remains exported (handler import contract)', () => { + expect(typeof getPublishedNodeIndexForVersion).toBe('function') + }) +}) diff --git a/src/__tests__/server/visitorProfileFields.test.ts b/src/__tests__/server/visitorProfileFields.test.ts new file mode 100644 index 000000000..923823e95 --- /dev/null +++ b/src/__tests__/server/visitorProfileFields.test.ts @@ -0,0 +1,100 @@ +/** + * Integration tests for the visitor profile-field repository layer. + * + * Pins the contract of `updateVisitorUserProfileFields` / `findVisitorUserById` + * against a real migrated SQLite DB (`createTestDb()` applies migration `025`, + * which adds `visitor_users.profile_fields_json`). The load-bearing contract + * documented in the repository: + * + * > Update a visitor's custom profile field VALUES. **Stores the whole map** + * > — callers should merge against the current values first. Empty object + * > is valid (clears all profile fields). + * + * This test pins that LAYERING: the repository writes the WHOLE map (no + * merge), so the merge — when it is wanted — is the handler's job, not the + * repo's. Asserting that a second write with a disjoint key REPLACES (not + * merges) keeps the layering honest and prevents the repo from silently + * growing merge logic that would duplicate the handler's responsibility. + */ +import { describe, expect, it, beforeAll, afterAll } from 'bun:test' +import { createTestDb, type TestDb } from '../helpers/createTestDb' +import { + updateVisitorUserProfileFields, + findVisitorUserById, +} from '../../../server/visitor-auth/repositories' + +type Db = TestDb['db'] + +let testDb: TestDb +let db: Db + +/** + * Insert a fresh visitor user for a test. Mirrors the migration `021` + * `visitor_users` columns (`id, email, email_normalized, password_hash, + * display_name, role_id, status`) — the system `member` role is seeded by + * migration `021`, so it is reused as the role_id. + */ +async function seedVisitor(db: Db, id: string): Promise { + await db` + insert into visitor_users (id, email, email_normalized, password_hash, display_name, role_id, status) + values (${id}, ${id + '@example.com'}, ${id + '@example.com'}, 'h', ${id}, 'member', 'active') + ` +} + +beforeAll(async () => { + testDb = await createTestDb() + db = testDb.db +}) + +afterAll(async () => { + await testDb.cleanup() +}) + +describe('updateVisitorUserProfileFields / findVisitorUserById', () => { + it('writes the profile-field JSON and reads it back via findVisitorUserById', async () => { + await seedVisitor(db, 'v-profile') + + const updated = await updateVisitorUserProfileFields(db, 'v-profile', { schoolName: 'Oasis' }) + expect(updated).not.toBeNull() + expect(updated!.profileFields.schoolName).toBe('Oasis') + + // A fresh read round-trips through the column adapter / normaliser. + const reread = await findVisitorUserById(db, 'v-profile') + expect(reread).not.toBeNull() + expect(reread!.profileFields.schoolName).toBe('Oasis') + }) + + it('clears all profile fields when given an empty object', async () => { + await seedVisitor(db, 'v-clear') + // Seed a value first, then clear it. + await updateVisitorUserProfileFields(db, 'v-clear', { schoolName: 'Oasis', grade: 'Y2' }) + + const cleared = await updateVisitorUserProfileFields(db, 'v-clear', {}) + expect(cleared).not.toBeNull() + expect(cleared!.profileFields).toEqual({}) + + const reread = await findVisitorUserById(db, 'v-clear') + expect(reread!.profileFields).toEqual({}) + }) + + it('stores the WHOLE map (no merge) — pinning the documented layering', async () => { + await seedVisitor(db, 'v-merge') + + // First write carries key `a`. + await updateVisitorUserProfileFields(db, 'v-merge', { a: '1' }) + + // Second write carries a DISJOINT key `b`. Per the documented contract, + // the repository writes the whole map — it does NOT merge against the + // existing `a`. So `a` is gone and only `b` remains. + const result = await updateVisitorUserProfileFields(db, 'v-merge', { b: '2' }) + expect(result).not.toBeNull() + + // Result is { b: '2' }, NOT { a: '1', b: '2' } — the merge (when wanted) + // is the handler's responsibility, never the repo's. + expect(result!.profileFields).toEqual({ b: '2' }) + expect(result!.profileFields).not.toHaveProperty('a') + + const reread = await findVisitorUserById(db, 'v-merge') + expect(reread!.profileFields).toEqual({ b: '2' }) + }) +}) diff --git a/src/__tests__/server/visitorResolveCookie.test.ts b/src/__tests__/server/visitorResolveCookie.test.ts new file mode 100644 index 000000000..bf0565fb5 --- /dev/null +++ b/src/__tests__/server/visitorResolveCookie.test.ts @@ -0,0 +1,149 @@ +/** + * Integration tests for `resolveVisitorFromCookie` — the IDOR-safe identity + * boundary for the visitor-data framework. + * + * This resolver is the SINGLE place where cookie → visitor identity is + * resolved for the `visitor.current` and `visitor.owned-rows` loop sources. + * Its security contract (identity comes ONLY from the validated session + * cookie, never from request input) is locked statically by the architecture + * gate; these tests pin the RUNTIME behaviour across every branch: + * + * - no cookie map at all → null + * - cookie map without a session cookie → null + * - a session cookie that doesn't exist → null + * - a revoked / expired session → null + * - a valid session whose user is gone → null + * - a valid session → ResolvedVisitor (id/email/role/profile) + * + * Uses `createTestDb()` (real in-memory SQLite, all migrations applied) and + * seeds genuine session rows via the production `createSessionToken` / + * `hashSessionToken` pipeline — no hand-rolled token maths — so the + * integration with the cached `validateVisitorSession` is exercised truthfully. + * + * Each branch gets its OWN visitor + fresh random token so the process-global + * session cache can't alias one test's identity with another's. + */ +import { describe, expect, it, beforeAll, afterAll } from 'bun:test' +import { createTestDb, type TestDb } from '../helpers/createTestDb' +import { resolveVisitorFromCookie } from '../../../server/visitor-auth/visitorData' +import { VISITOR_SESSION_COOKIE_NAME } from '../../../server/visitor-auth/types' +import { invalidateVisitorSessionCache } from '../../../server/visitor-auth/sessions' +import { createSessionToken, hashSessionToken, sessionExpiry } from '../../../server/auth/tokens' + +type Db = TestDb['db'] + +/** Insert a visitor role + user directly (mirrors migration 021 columns). */ +async function seedVisitor(db: Db, id: string, email: string, displayName?: string): Promise { + // The 'member' system role is seeded by migration 021; reuse it. + await db` + insert into visitor_users (id, email, email_normalized, password_hash, display_name, role_id, status, profile_fields_json) + values (${id}, ${email}, ${email}, 'h', ${displayName ?? email.split('@')[0]}, 'member', 'active', ${JSON.stringify({ schoolName: 'Oasis' })}) + ` +} + +/** + * Seed a session row directly and return the RAW token to put in a cookie. + * Inserts directly (rather than via createVisitorSession) so expired/revoked + * variants can be seeded — createVisitorSession re-reads via + * findActiveVisitorSessionByHash and would reject an already-inactive row. + * Uses the production token pipeline so the resolver's hash + DB lookup path + * is exercised end-to-end. Invalidates the process-global session cache for + * this idHash so a stale entry from another test can't leak in. + */ +async function seedSession( + db: Db, + userId: string, + overrides: { revoked?: boolean; expiresAt?: Date } = {}, +): Promise { + const token = createSessionToken() + const idHash = await hashSessionToken(token) + const expiresAt = overrides.expiresAt ?? sessionExpiry() + await db` + insert into visitor_sessions (id_hash, user_id, expires_at, ip_address, user_agent, device_label) + values (${idHash}, ${userId}, ${expiresAt}, ${null}, ${null}, '') + ` + if (overrides.revoked) { + await db`update visitor_sessions set revoked_at = ${new Date().toISOString()} where id_hash = ${idHash}` + } + invalidateVisitorSessionCache(idHash) + return token +} + +function cookieFromToken(token: string): Record { + return { [VISITOR_SESSION_COOKIE_NAME]: token } +} + +let testDb: TestDb +let db: Db + +beforeAll(async () => { + testDb = await createTestDb() + db = testDb.db +}) + +afterAll(async () => { + await testDb.cleanup() +}) + +describe('resolveVisitorFromCookie (IDOR identity boundary)', () => { + it('returns null when no cookie map is supplied', async () => { + expect(await resolveVisitorFromCookie(db, undefined)).toBeNull() + }) + + it('returns null when the cookie map has no visitor session cookie', async () => { + expect(await resolveVisitorFromCookie(db, { unrelated_cookie: 'x' })).toBeNull() + }) + + it('returns null for a session cookie whose token matches no session', async () => { + expect(await resolveVisitorFromCookie(db, cookieFromToken('not-a-real-token'))).toBeNull() + }) + + it('returns null for a REVOKED session', async () => { + await seedVisitor(db, 'v-revoked', 'revoked@example.com') + const token = await seedSession(db, 'v-revoked', { revoked: true }) + expect(await resolveVisitorFromCookie(db, cookieFromToken(token))).toBeNull() + }) + + it('returns null for an EXPIRED session', async () => { + await seedVisitor(db, 'v-expired', 'expired@example.com') + const token = await seedSession(db, 'v-expired', { expiresAt: new Date(Date.now() - 60_000) }) + expect(await resolveVisitorFromCookie(db, cookieFromToken(token))).toBeNull() + }) + + it('returns null when the session is valid but the visitor record is gone', async () => { + await seedVisitor(db, 'v-gone', 'gone@example.com') + const token = await seedSession(db, 'v-gone') + await db`update visitor_users set deleted_at = ${new Date().toISOString()} where id = ${'v-gone'}` + expect(await resolveVisitorFromCookie(db, cookieFromToken(token))).toBeNull() + }) + + it('resolves the full ResolvedVisitor for a valid session', async () => { + await seedVisitor(db, 'v-resolve', 'resolve@example.com', 'Resolve Me') + const token = await seedSession(db, 'v-resolve') + const resolved = await resolveVisitorFromCookie(db, cookieFromToken(token)) + + expect(resolved).not.toBeNull() + expect(resolved!.id).toBe('v-resolve') + expect(resolved!.email).toBe('resolve@example.com') + expect(resolved!.displayName).toBe('Resolve Me') + expect(resolved!.roleId).toBe('member') + expect(resolved!.roleName).toBe('member') // resolved via findVisitorRoleById + // Custom profile field values are carried through. + expect(resolved!.profileFields).toMatchObject({ schoolName: 'Oasis' }) + }) + + it('never inspects cookie values other than the session cookie', async () => { + await seedVisitor(db, 'v-forged', 'forged@example.com') + const token = await seedSession(db, 'v-forged') + // A cookie map carrying forged visitor/user ids must not influence the + // result — identity comes only from the validated session token. + const resolved = await resolveVisitorFromCookie(db, { + ...cookieFromToken(token), + visitorId: 'attacker', + userId: 'attacker', + id: 'attacker', + }) + expect(resolved).not.toBeNull() + expect(resolved!.id).toBe('v-forged') // not 'attacker' + }) +}) diff --git a/src/admin/AuthenticatedAdmin.tsx b/src/admin/AuthenticatedAdmin.tsx index b47fee4ca..c6f2a54c7 100644 --- a/src/admin/AuthenticatedAdmin.tsx +++ b/src/admin/AuthenticatedAdmin.tsx @@ -91,6 +91,10 @@ const UsersPage = prewarmedLazy( () => import('./pages/users/UsersPage').then((m) => ({ default: m.UsersPage })), { displayName: 'UsersPage' }, ) +const MembersPage = prewarmedLazy( + () => import('./pages/members/MembersPage').then((m) => ({ default: m.MembersPage })), + { displayName: 'MembersPage' }, +) const AiPage = prewarmedLazy( () => import('./pages/ai/AiPage').then((m) => ({ default: m.AiPage })), { displayName: 'AiPage' }, @@ -154,6 +158,7 @@ if (typeof window !== 'undefined') { pathname.startsWith('/admin/plugins/') ? PluginPage : pathname.startsWith('/admin/plugins') ? PluginsPage : pathname.startsWith('/admin/users') ? UsersPage : + pathname.startsWith('/admin/members') ? MembersPage : pathname.startsWith('/admin/ai') ? AiPage : pathname.startsWith('/admin/account') ? AccountPage : DashboardPage @@ -182,6 +187,7 @@ const ALL_WORKSPACE_PAGES = [ MediaPage, PluginsPage, UsersPage, + MembersPage, AiPage, AccountPage, PluginPage, @@ -195,6 +201,7 @@ function pageForSection(section: AdminWorkspace) { section === 'media' ? MediaPage : section === 'plugins' ? PluginsPage : section === 'users' ? UsersPage : + section === 'members' ? MembersPage : section === 'ai' ? AiPage : section === 'pluginPage' ? PluginPage : section === 'account' ? AccountPage : @@ -315,6 +322,7 @@ export default function AuthenticatedAdmin({ section, currentUser }: Authenticat section === 'media' ? : section === 'plugins' ? : section === 'users' ? : + section === 'members' ? : section === 'ai' ? : section === 'pluginPage' ? : section === 'account' ? : diff --git a/src/admin/access.ts b/src/admin/access.ts index 6228521ab..caedf3d70 100644 --- a/src/admin/access.ts +++ b/src/admin/access.ts @@ -287,6 +287,10 @@ export function canAccessWorkspace(user: CmsCurrentUser | null, workspace: Admin return canAccessPluginsWorkspace(user) case 'users': return canAccessUsersWorkspace(user) + case 'members': + // Visitor-auth management reuses the `users.manage` capability (a + // dedicated capability is deferred — see server/handlers/cms/visitorAuth.ts). + return hasCapability(user, 'users.manage') case 'ai': return canAccessAiWorkspace(user) case 'account': @@ -320,6 +324,8 @@ export function workspacePath(workspace: AdminWorkspace): string { return '/admin/plugins' case 'users': return '/admin/users' + case 'members': + return '/admin/members' case 'ai': return '/admin/ai' case 'pluginPage': diff --git a/src/admin/modals/Settings/SettingsModal.tsx b/src/admin/modals/Settings/SettingsModal.tsx index c32b3da53..d1f7620b3 100644 --- a/src/admin/modals/Settings/SettingsModal.tsx +++ b/src/admin/modals/Settings/SettingsModal.tsx @@ -26,10 +26,12 @@ import { SettingsCogSolidIcon } from 'pixel-art-icons/icons/settings-cog-solid' import { CommandIcon } from 'pixel-art-icons/icons/command' import { UploadIcon } from 'pixel-art-icons/icons/upload' import { SlidersHorizontalIcon } from 'pixel-art-icons/icons/sliders-horizontal' +import { UsersSolidIcon } from 'pixel-art-icons/icons/users-solid' import { GeneralSection } from './sections/GeneralSection' import { PublishingSection } from './sections/PublishingSection' import { ShortcutsSection } from './sections/ShortcutsSection' import { PreferencesSection } from './sections/PreferencesSection' +import { MembersSection } from './sections/MembersSection' import s from './SettingsModal.module.css' // ─── Nav items ──────────────────────────────────────────────────────────────── @@ -42,6 +44,7 @@ const NAV_ITEMS = [ { id: 'shortcuts', label: 'Shortcuts', icon: CommandIcon, accent: 'sky' }, { id: 'publishing', label: 'Publishing', icon: UploadIcon, accent: 'mint' }, { id: 'preferences', label: 'Preferences', icon: SlidersHorizontalIcon, accent: 'peach' }, + { id: 'members', label: 'Members', icon: UsersSolidIcon, accent: 'peach' }, ] as const type SectionId = typeof NAV_ITEMS[number]['id'] @@ -212,6 +215,7 @@ export function SettingsModal() { {activeSection === 'shortcuts' && } {activeSection === 'publishing' && } {activeSection === 'preferences' && } + {activeSection === 'members' && } diff --git a/src/admin/modals/Settings/sections/MembersSection.module.css b/src/admin/modals/Settings/sections/MembersSection.module.css new file mode 100644 index 000000000..5c88290b6 --- /dev/null +++ b/src/admin/modals/Settings/sections/MembersSection.module.css @@ -0,0 +1,27 @@ +/* MembersSection — visitor-auth settings. + Shares the structural classes (field rows, toggles, section blocks) with + SettingsModal.module.css; this module only owns the save + status row. + Every colour comes from a token. */ + +.actions { + display: flex; + align-items: center; + gap: var(--space-l); + flex-wrap: wrap; + margin-top: var(--space-l); +} + +.status { + margin: 0; + font-size: var(--text-xs); + font-weight: 700; + line-height: 1.45; +} + +.status[data-kind="ok"] { + color: var(--success); +} + +.status[data-kind="error"] { + color: var(--danger); +} diff --git a/src/admin/modals/Settings/sections/MembersSection.tsx b/src/admin/modals/Settings/sections/MembersSection.tsx new file mode 100644 index 000000000..4fdde5a7e --- /dev/null +++ b/src/admin/modals/Settings/sections/MembersSection.tsx @@ -0,0 +1,425 @@ +/** + * MembersSection — visitor-auth configuration (Settings → Members). + * + * Self-contained settings form that loads the current visitor-auth config + * and the visitor role list on mount, lets an admin toggle auth on/off, + * open/close registration, set the login path, pick the default role, and + * set the default landing path for a logged-in visitor with no primary-group + * landing (D15). Saves via {@link saveVisitorAuthConfig}. + * + * Page-level protection is configured per-page in the editor (D14 — each + * page carries an `access` field; see the page "Access" control), NOT in + * this settings section. The retired Phase-1/2 `protectedPrefixes` model is + * gone. + * + * The whole section is gated by the `users.manage` capability — the same + * capability the server-side `/admin/api/cms/visitor-auth/*` routes require. + * Admins without it see a notice instead of the form. + */ +import { useEffect, useState } from 'react' +import { Button } from '@ui/components/Button' +import { Input } from '@ui/components/Input' +import { Select } from '@ui/components/Select' +import { Switch } from '@ui/components/Switch' +import { SkeletonBlock } from '@ui/components/Skeleton' +import { hasCapability } from '@admin/access' +import { useCurrentAdminUser } from '@admin/sessionContext' +import { + getVisitorAuthConfig, + listVisitorRoles, + saveVisitorAuthConfig, + type VisitorAuthConfig, + type VisitorProfileField, + type VisitorRole, +} from '@core/persistence' +import { getErrorMessage } from '@core/utils/errorMessage' +import s from '../SettingsModal.module.css' +import styles from './MembersSection.module.css' + +export function MembersSection() { + const currentUser = useCurrentAdminUser() + // `currentUser === null` only happens outside AdminSessionProvider (layout + // tests / SSR). Treat it as unrestricted there to keep preview mode + // usable; the real browser session always carries a user. + const canManage = !currentUser || hasCapability(currentUser, 'users.manage') + + if (!canManage) { + return ( +

+ You need the Users manage permission to configure visitor authentication. +

+ ) + } + + return +} + +interface LoadedState { + config: VisitorAuthConfig + roles: VisitorRole[] +} + +function MembersConfigForm() { + const [loaded, setLoaded] = useState(null) + const [loadError, setLoadError] = useState(null) + + useEffect(() => { + let cancelled = false + Promise.all([getVisitorAuthConfig(), listVisitorRoles()]) + .then(([config, roles]) => { + if (cancelled) return + setLoaded({ config, roles }) + }) + .catch((err: unknown) => { + if (cancelled) return + setLoadError(getErrorMessage(err, 'Could not load visitor-auth settings')) + }) + return () => { + cancelled = true + } + }, []) + + if (loadError) { + return

{loadError}

+ } + if (!loaded) { + return + } + + return +} + +interface MembersConfigFieldsProps { + initial: VisitorAuthConfig + roles: VisitorRole[] +} + +function MembersConfigFields({ initial, roles }: MembersConfigFieldsProps) { + const [enabled, setEnabled] = useState(initial.enabled) + const [registrationOpen, setRegistrationOpen] = useState(initial.registrationOpen) + const [loginPath, setLoginPath] = useState(initial.loginPath || '/login') + const [defaultRole, setDefaultRole] = useState(initial.defaultRole || '') + // D15: where a logged-in visitor with no primary-group landing is sent. + // Defaults to `/` when the saved config lacks a value. + const [defaultLandingPath, setDefaultLandingPath] = useState(initial.defaultLandingPath || '/') + // Per-visitor-data framework: site-builder-defined custom profile fields + // (e.g. "School name"). Values are stored per-visitor. + const [profileFields, setProfileFields] = useState( + initial.profileFields ?? [] + ) + + const [saving, setSaving] = useState(false) + const [status, setStatus] = useState<{ kind: 'ok' | 'error'; message: string } | null>(null) + + // Role picker options. The saved defaultRole can be a role id OR a role + // name (the server accepts both on save); make sure the current value is + // always selectable even if it no longer matches a row, so the dropdown + // doesn't silently snap to the first option. + const roleOptions = buildRoleOptions(roles, defaultRole) + + async function handleSave() { + setSaving(true) + setStatus(null) + try { + const config = await saveVisitorAuthConfig({ + enabled, + registrationOpen, + loginPath: loginPath.trim() || '/login', + defaultRole, + defaultLandingPath: defaultLandingPath.trim() || '/', + profileFields, + }) + // Reconcile local state with the server's cleaned response. + setEnabled(config.enabled) + setRegistrationOpen(config.registrationOpen) + setLoginPath(config.loginPath) + setDefaultRole(config.defaultRole) + setDefaultLandingPath(config.defaultLandingPath || '/') + setProfileFields(config.profileFields ?? []) + setStatus({ kind: 'ok', message: 'Visitor-auth settings saved.' }) + } catch (err) { + setStatus({ kind: 'error', message: getErrorMessage(err, 'Could not save visitor-auth settings') }) + } finally { + setSaving(false) + } + } + + const enabledId = 'members-enabled' + const registrationId = 'members-registration-open' + + return ( +
+

+ Control whether visitors can register and sign in, and where a logged-in + visitor lands. Page-level access is set per page in the editor. +

+ +
+

General

+ +
+
+
+ +

+ Enabling visitor auth lets visitors register and log in to protected pages. +

+
+ +
+ +
+
+ +

+ When on, visitors can create their own accounts. Turn off to make + sign-up invite-only (accounts must be created another way). +

+
+ +
+
+ +
+ + setLoginPath(e.target.value)} + disabled={saving} + placeholder="/login" + /> +
+ +
+ + setDefaultLandingPath(e.target.value)} + disabled={saving} + placeholder="/" + /> +
+

+ Control which pages members see via each page’s Access setting in the editor. +

+
+ +
+

Profile fields

+

+ Custom fields each visitor can carry (e.g. a school name) and that you + can display on member pages with the visitor.current loop. + Visitors cannot edit these from the portal — values are set here or + via the Members workspace. +

+ +
+ +
+ + {status && ( +

+ {status.message} +

+ )} +
+
+ ) +} + +/** + * Build the ` update(index, { id: slugifyFieldKey(e.target.value) })} + disabled={disabled} + placeholder="schoolName" + /> + +
+ + update(index, { label: e.target.value })} + disabled={disabled} + placeholder="School name" + /> +
+
+ + ` for "no primary". */ +const NO_PRIMARY = '__none__' + +export function AssignGroupsDialog({ user, groups, onClose, onSaved }: AssignGroupsDialogProps) { + const [loading, setLoading] = useState(true) + const [selected, setSelected] = useState>(new Set()) + const [primaryId, setPrimaryId] = useState(null) + const [busy, setBusy] = useState(false) + const [error, setError] = useState(null) + + // Load the current membership set on open. The primary group is whichever + // membership carries `isPrimary === true`. The dialog is remounted per user + // (keyed by id in VisitorsTable), so `loading` starts `true` and only flips + // `false` inside the async callbacks — no synchronous setState in the body. + useEffect(() => { + let cancelled = false + getVisitorGroups(user.id) + .then(({ groups: memberships }) => { + if (cancelled) return + const ids = new Set(memberships.map((m) => m.group.id)) + const primary = memberships.find((m) => m.isPrimary)?.group.id ?? null + setSelected(ids) + // Guard against a stale primary that is no longer a membership (a + // server-side race); the select only offers current memberships. + setPrimaryId(primary && ids.has(primary) ? primary : null) + }) + .catch((err: unknown) => { + if (cancelled) return + setError(getErrorMessage(err, 'Could not load this visitor\u2019s groups')) + }) + .finally(() => { + if (!cancelled) setLoading(false) + }) + return () => { + cancelled = true + } + }, [user.id]) + + function toggleGroup(groupId: string, checked: boolean) { + setSelected((current) => { + const next = new Set(current) + if (checked) next.add(groupId) + else next.delete(groupId) + return next + }) + // Removing the current primary from the set also clears the primary + // (the server rejects a primary that isn't a membership). + if (!checked && primaryId === groupId) { + setPrimaryId(null) + } + } + + const selectedList = [...selected] + const primaryOptions = [ + { value: NO_PRIMARY, label: 'None' }, + ...groups + .filter((group) => selected.has(group.id)) + .map((group) => ({ value: group.id, label: group.name })), + ] + + async function handleSave() { + setBusy(true) + setError(null) + try { + const groupIds = selectedList + const primaryGroupId = primaryId ?? null + await setVisitorGroups(user.id, { groupIds, primaryGroupId }) + onSaved({ groupIds, primaryGroupId }) + } catch (err) { + setError(getErrorMessage(err, 'Could not save group membership')) + } finally { + setBusy(false) + } + } + + return ( + {} : onClose} + title="Assign groups" + eyebrow={user.displayName?.trim() ? user.displayName : user.email} + size="sm" + footer={ + <> + + + + } + > + {loading ? ( + + ) : ( +
+ {groups.length > 0 ? ( + <> +
+ Groups + {groups.map((group) => ( + + ))} +
+ +
+ +