From dc6ac143857ca131d4e307665896fb6bac04f244 Mon Sep 17 00:00:00 2001 From: Todd Hebebrand Date: Mon, 6 Jul 2026 10:30:04 -0600 Subject: [PATCH 01/28] docs: add Quick Support one-time code session design spec Co-Authored-By: Claude Fable 5 --- ...26-07-06-one-off-support-session-design.md | 152 ++++++++++++++++++ 1 file changed, 152 insertions(+) create mode 100644 docs/superpowers/specs/2026-07-06-one-off-support-session-design.md diff --git a/docs/superpowers/specs/2026-07-06-one-off-support-session-design.md b/docs/superpowers/specs/2026-07-06-one-off-support-session-design.md new file mode 100644 index 000000000..c66ba7a2d --- /dev/null +++ b/docs/superpowers/specs/2026-07-06-one-off-support-session-design.md @@ -0,0 +1,152 @@ +# Quick Support: One-Time Code Ad-Hoc Remote Sessions — Design + +**Date:** 2026-07-06 +**Status:** Approved (brainstorm with Todd) +**Related:** installer bootstrap tokens (`2026-04-19-a-installer-bootstrap-tokens.sql`, #2161), remote session consent (`2026-06-19-remote-session-consent-notification-design.md`), remote desktop stack (`agent/internal/remote/desktop/`) + +## Problem + +Techs need to remote into machines that do **not** run a permanently enrolled Breeze agent — a customer's home PC, a prospect's laptop, a machine not yet onboarded. Today the only options are enrolling the device for real or bolting on a third-party tool via the Remote Access provider launcher. Competitors (NinjaOne Quick Access, ScreenConnect ad-hoc support) make this a one-time-code flow. + +## Decision summary + +- **Flow direction:** tech generates the code in Breeze and sends it to the end user (not user-generated IDs). +- **Architecture:** **ephemeral enrollment** — the support client is the existing Go agent in a `--support` mode that enrolls a short-lived device row, so the entire existing remote-desktop stack (remote_sessions, WebRTC broker, TURN, consent, Tauri viewer, audit) is reused unchanged. Rejected alternative: a purpose-built agentless client with a parallel signaling path — 2-3× the new code, and the required temp-service/UAC capability drags in most of the agent anyway. +- **Elevation:** ScreenConnect-style — user-mode by default, optional one-UAC-prompt temporary service install for full control. +- **Platforms v1:** Windows and macOS. +- **Tenancy:** one hidden **Quick Support org per partner** (lazily created), plus an optional informational attribution to a real customer org. + +## User flows + +### Technician + +1. Clicks **Quick Support** (Remote page / topbar) → dialog with optional attribution (customer org picker + freetext label, e.g. "Contoso — CFO laptop"). Nothing else required. +2. Gets a short one-time code (e.g. `KTM-4H7-P2X`), a copyable link (`https://.2breeze.app/quick/`), and a live status panel. +3. Sends link/code to the end user (phone, email, chat). +4. Status panel: *waiting → client downloaded → connected*. **Connect** starts a normal remote desktop session in the existing Tauri viewer. +5. May disconnect/reconnect multiple times within the session window. **End session** tears everything down. + +### End user + +1. Opens the link → minimal public landing page: "Your technician wants to help — Download for Windows / Mac", with plain-language copy about what runs and that it removes itself. +2. Runs the download. Code rides in the download filename where possible (MSI-filename pattern; mind the bracket-collision gotcha from #1956); manual code entry is the fallback. +3. Client shows a small status window: "Connected — waiting for technician", with an always-visible **Stop sharing** button. Running the code-bearing client **is** the consent act; the window is the ongoing indicator. +4. Optionally approves one UAC prompt (Windows) / admin prompt (macOS) for elevated control. +5. On session end (tech ends it, user clicks Stop, or TTL reaper fires) the client removes its temporary service and deletes itself. + +## Session lifecycle + +``` +pending ──redeem──▶ claimed ──enroll+WS──▶ ready ◀──disconnect── active + │ 15-min code TTL │ ──connect──▶ + ▼ ▼ +expired ended (reason: tech | end_user | expired | error) +``` + +Hard cap `hard_expires_at` (default 8h; partner-configurable later) guarantees no session outlives the day. + +## Data model + +### `support_sessions` (new table — Shape 1 direct-org RLS) + +| Column | Notes | +|---|---| +| `id` uuid PK | | +| `org_id` NOT NULL FK organizations | the partner's hidden Quick Support org | +| `created_by_user_id` FK users | | +| `code_hash` | SHA-256; plaintext shown once at creation, never stored | +| `code_expires_at` | default now + 15 min | +| `failed_attempts` int | per-code lockout counter | +| `status` | `pending / claimed / ready / active / ended / expired` | +| `hard_expires_at` | default now + 8h | +| `device_id` nullable FK devices | set when ephemeral enrollment completes | +| `attributed_org_id` nullable FK organizations | reporting only, no tenancy effect | +| `attribution_label` text nullable | freetext, e.g. "Contoso — CFO laptop" | +| `claimed_at`, `claimed_from_ip`, `ended_at`, `ended_reason`, `created_at` | | + +RLS: `breeze_has_org_access(org_id)` select/insert/update/delete policies in the **same migration** that creates the table. Shape 1 is auto-discovered by `rls-coverage.integration.test.ts` — no allowlist entry. + +### `organizations.kind` (new column) + +`'customer'` (default) | `'quick_support'`. One hidden org per partner, lazily created on first session, with one default site (enrollment keys require `site_id`). + +**Exclusion sweep (highest-risk item):** every org enumeration must exclude `kind = 'quick_support'` — org list endpoints/UI, billing & device-count queries, reports, AI tools, alert scopes, anywhere else surfaced by a repo-wide sweep. The implementation plan carries this as an explicit checklist with its own tests (at minimum: device-count/billing query test). + +### `devices.is_ephemeral` (new column) + +Boolean default false. Ephemeral devices: excluded from partner `maxDevices` license counts; never targeted by policies/automations/patching (belt-and-suspenders — the hidden org has none). Purged by the reaper after session end. + +## API surface + +### Authenticated (tech) — mounted under existing `/remote` guards: auth + `remote:access` + `requireMfa()` + +- `POST /support-sessions` — create; generates code, lazily provisions hidden org + site, audits `support_session_created`. +- `GET /support-sessions`, `GET /support-sessions/:id` — list / live status (status panel polls). +- `POST /support-sessions/:id/end` — pushes `support_end` command over the agent WS, revokes device tokens, marks ended, audits. +- **Connecting reuses `POST /remote/sessions` unchanged** — the ephemeral device is a real device row, so offer/answer/ICE/consent/viewer paths need zero changes. + +### Public, unauthenticated (the code is the auth — mirrors `installer.ts` bootstrap redemption) + +- `GET /quick/:code` — download landing page (web app route). +- `GET /support/download/:platform` — serves the client via existing `binarySource` machinery, code embedded in filename. +- `POST /support/redeem` — rate-limited (per-IP like `/agents/enroll` + per-code attempt lockout). Atomically flips `pending → claimed`; returns a **single-use child enrollment key** scoped to the hidden org (exact installer-bootstrap pattern). Client then calls the normal `POST /agents/enroll`. + +**Enrollment chain guards:** support-derived child keys are flagged so (a) devices born from them are `is_ephemeral = true`, in the hidden org only, and linked back to the support session (`device_id` set; status → `ready` when the agent WS connects); (b) regular enrollment keys can never set the ephemeral flag; (c) support keys can never create persistent devices. + +## Support client (Go agent `--support` mode) + +One new top-level mode in the existing agent binary — no separate codebase. Skips persistent config, redeems the code, enrolls ephemerally, runs the agent loop with everything disabled except remote desktop + heartbeat, and shows a small status window with **Stop sharing**. + +### Elevation tiers (Windows) + +- **Tier 1 (default):** runs as the logged-in user, no install. Capture + input in-process in the user's session. UAC prompts / secure desktop are not visible to the tech. + - *Open verification item:* the capture stack currently runs via the SYSTEM desktop helper; the in-process user-session path must be confirmed or added. +- **Tier 2 (elevated):** at launch, or when the tech requests it mid-session, the client triggers one UAC prompt; on approval it installs itself as a **temporary Windows service** (session-scoped service name) and relaunches under it — full existing agent semantics including the SYSTEM desktop helper, so UAC and the secure desktop are controllable. Service removed at teardown. + +### macOS + +Ships as a signed + notarized **`.app` wrapper** — TCC attributes Screen Recording/Accessibility permissions to the responsible bundle, so a bare CLI binary is not viable. Status window includes a guided-permissions screen. Uses the agent's existing macOS capture path. Tier 2 = one admin-password prompt installing a temporary LaunchDaemon. Distinct workstream; the largest platform-specific effort. + +## Cleanup — three independent layers + +1. **Cooperative:** `support_end` command (or Stop button / process exit) → agent stops + deletes its temp service, wipes files, exits. Windows self-delete uses the standard detached delayed-delete trampoline. +2. **Server reaper (BullMQ):** at `hard_expires_at`, or after prolonged agent-offline, force-end the session, revoke device tokens (agent WS drops, cannot reconnect), purge ephemeral device rows a few hours after end. Audit logs survive (no FK to devices). +3. **Client dead-man switch:** support-mode agent self-cleans and exits if it cannot reach the API for N minutes or learns its session ended — nothing lingers even if the server disappears. + +## Security + +- **Code:** ≥40 bits entropy, human-typeable alphabet (no ambiguous chars), hashed at rest, single-use, 15-min TTL, per-IP rate limiting + per-code failed-attempt lockout. +- **Access:** creating sessions requires `remote:access` + MFA. Session ownership rules follow existing remote_sessions semantics. +- **Containment:** ephemeral tokens revoked at end; hard 8h cap regardless of tech action; support keys ⇄ persistent keys are mutually exclusive (see enrollment chain guards). +- **Consent:** running the code-bearing client is the consent act; the status window is the persistent indicator; per-session desktop connects still flow through the existing consent-prompt config (default for support sessions: notify/off — the user already consented). +- **Audit:** new `support_session_created / claimed / connected / ended / expired` actions via `logSessionAudit`, plus all existing `session_*` actions from the reused remote path. + +## Web UI + +- Quick Support button + create dialog (attribution org picker + label). +- Live status panel polling `GET /support-sessions/:id`; Connect button fires the existing viewer deep-link flow. +- Support sessions list (active + recent) with connect/end actions. +- All mutation handlers wrapped in `runAction` per repo standard. +- Public landing page for `/quick/`. + +## Testing + +- **API route tests** (Vitest + Drizzle mocks): create/redeem/end happy paths + guards (expired code, lockout, wrong status transitions, RBAC/MFA, cross-tenant). +- **RLS integration tests:** new table auto-discovered by coverage test; forge cross-tenant redeem/enroll as `breeze_app` — must fail. +- **Integration test:** the full redeem → child-key → ephemeral-enroll chain against real Postgres, proving flags and linkage. +- **Go tests:** table-driven tests for `--support` mode argument handling, lifecycle state machine, self-clean/dead-man logic. +- **Validator tests:** new Zod schemas in `packages/shared`. +- **Exclusion sweep tests:** device-count/billing queries proven to ignore `quick_support` orgs and ephemeral devices. + +## Phasing + +1. **Phase 1:** Windows, Tiers 1+2, end-to-end (schema, API, web UI, agent support mode, cleanup layers). +2. **Phase 2:** macOS `.app` packaging, notarization, TCC guided flow, LaunchDaemon tier. +3. **Phase 3:** niceties — mid-session elevation request UX polish, partner-configurable TTLs, session history reporting by attributed org. + +## Out of scope (v1) + +- User-generated codes / anonymous session queue (TeamViewer-style). +- Temporary time-boxed access to already-enrolled devices (the session/code model doesn't preclude it later). +- Linux end-user client. +- Terminal / file-transfer tools in support sessions (desktop only; the ephemeral device technically supports them — explicitly disabled in v1 to keep the consent story simple). From b2ef0e0bda55ea83dd60b96ab5b0ee0f2d522c18 Mon Sep 17 00:00:00 2001 From: Todd Hebebrand Date: Mon, 6 Jul 2026 10:31:30 -0600 Subject: [PATCH 02/28] docs: add public code-entry page (/quick) and soft-check endpoint to Quick Support spec Co-Authored-By: Claude Fable 5 --- .../specs/2026-07-06-one-off-support-session-design.md | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/docs/superpowers/specs/2026-07-06-one-off-support-session-design.md b/docs/superpowers/specs/2026-07-06-one-off-support-session-design.md index c66ba7a2d..93cdc722c 100644 --- a/docs/superpowers/specs/2026-07-06-one-off-support-session-design.md +++ b/docs/superpowers/specs/2026-07-06-one-off-support-session-design.md @@ -28,7 +28,7 @@ Techs need to remote into machines that do **not** run a permanently enrolled Br ### End user -1. Opens the link → minimal public landing page: "Your technician wants to help — Download for Windows / Mac", with plain-language copy about what runs and that it removes itself. +1. Opens the link → minimal public landing page: "Your technician wants to help — Download for Windows / Mac", with plain-language copy about what runs and that it removes itself. Alternatively (phone-call scenario), the user browses to `https://.2breeze.app/quick` — a public code-entry page — and types the code the tech reads out; a valid code routes to the same landing page. 2. Runs the download. Code rides in the download filename where possible (MSI-filename pattern; mind the bracket-collision gotcha from #1956); manual code entry is the fallback. 3. Client shows a small status window: "Connected — waiting for technician", with an always-visible **Stop sharing** button. Running the code-bearing client **is** the consent act; the window is the ongoing indicator. 4. Optionally approves one UAC prompt (Windows) / admin prompt (macOS) for elevated control. @@ -87,7 +87,9 @@ Boolean default false. Ephemeral devices: excluded from partner `maxDevices` lic ### Public, unauthenticated (the code is the auth — mirrors `installer.ts` bootstrap redemption) -- `GET /quick/:code` — download landing page (web app route). +- `GET /quick` — code-entry page (web app route): single code input; on submit routes to `/quick/:code`. +- `GET /quick/:code` — download landing page (web app route). Soft-validates the code first via `GET /support/check/:code` and shows a clear "code invalid or expired" state on failure so typos are caught before any download. +- `GET /support/check/:code` — rate-limited public soft-check; returns only valid/invalid + platform hints, never session details, and does **not** consume the code. Guessing-oracle risk accepted: ≥40-bit codes, 15-min TTL, per-IP rate limit, per-code lockout. - `GET /support/download/:platform` — serves the client via existing `binarySource` machinery, code embedded in filename. - `POST /support/redeem` — rate-limited (per-IP like `/agents/enroll` + per-code attempt lockout). Atomically flips `pending → claimed`; returns a **single-use child enrollment key** scoped to the hidden org (exact installer-bootstrap pattern). Client then calls the normal `POST /agents/enroll`. From 24c561b34f50a49801640e59bfcb0bc494029a1e Mon Sep 17 00:00:00 2001 From: Todd Hebebrand Date: Mon, 6 Jul 2026 10:53:56 -0600 Subject: [PATCH 03/28] docs: quick support phase 1 implementation plan + spec implementation notes Co-Authored-By: Claude Fable 5 --- .../plans/2026-07-06-quick-support-phase1.md | 996 ++++++++++++++++++ ...26-07-06-one-off-support-session-design.md | 11 + 2 files changed, 1007 insertions(+) create mode 100644 docs/superpowers/plans/2026-07-06-quick-support-phase1.md diff --git a/docs/superpowers/plans/2026-07-06-quick-support-phase1.md b/docs/superpowers/plans/2026-07-06-quick-support-phase1.md new file mode 100644 index 000000000..7a288d8ee --- /dev/null +++ b/docs/superpowers/plans/2026-07-06-quick-support-phase1.md @@ -0,0 +1,996 @@ +# Quick Support Phase 1 (Windows) Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Tech generates a one-time code; end user runs a downloaded client (the Go agent in `support` mode) that enrolls an ephemeral device in a hidden per-partner org; the existing remote-desktop stack connects to it; everything cleans itself up. + +**Architecture:** Ephemeral enrollment (approved spec `docs/superpowers/specs/2026-07-06-one-off-support-session-design.md`). New `support_sessions` table (Shape-1 org RLS) + one-time code redemption modeled on installer bootstrap tokens; redemption mints a single-use child enrollment key flagged with `support_session_id`; `/agents/enroll` marks the device `is_ephemeral` and links it back. The tech connects via the unchanged `POST /remote/sessions` + Tauri viewer flow. + +**Tech Stack:** Hono + Drizzle + BullMQ (API), Astro + React islands (web), Go + cobra (agent), Vitest / go test. + +## Global Constraints + +- Windows end-user client only in Phase 1 (macOS = Phase 2). Tech-side viewer already cross-platform. +- Code format: 9 chars from alphabet `ABCDEFGHJKMNPQRSTVWXYZ23456789` (30 chars, no I/L/O/0/1 → ~44 bits), displayed `XXX-XXX-XXX`, stored as SHA-256 hex. +- TTLs: code redemption 15 min; session hard cap 8 h; ephemeral device purge 6 h after end; client dead-man switch 10 min offline; reaper interval 5 min. +- New tenant-scoped table uses RLS Shape 1 (direct `org_id`), policies in the same migration. Migration naming `2026-07-06-.sql`, idempotent, no inner `BEGIN;`/`COMMIT;` (autoMigrate wraps each file in a transaction). +- All web mutations via `runAction` (`apps/web/src/lib/runAction.ts`). +- v1 requires `auth.scope === 'partner'` (or `system`) to create support sessions — org-scoped tokens can't reach the hidden org (documented limitation). +- BullMQ job ids: use `-`, never `:`. +- Never derive Zod enums from Drizzle `pgEnum.enumValues` (breaks schema mocks). + +## Deviations from spec (implementation adaptations — spec updated alongside this plan) + +1. `organizations.kind` → reuse existing `org_type` pg enum; add value `'quick_support'`. +2. `failed_attempts` column dropped: codes are looked up by hash, so an unknown code has no row to count against. Per-IP sliding-window rate limits + 44-bit entropy + 15-min TTL cover guessing. +3. `active` status is **derived** (live `remote_sessions` rows for the device) rather than stored — avoids hooking remote-session create/end. Stored states: `pending / claimed / ready / ended / expired`. +4. v1 status "window" = console window with status lines + Ctrl+C to stop (the agent has no GUI framework; a native window is Phase 3 polish). +5. Landing URL is `/quick?code=` (Astro static pages can't do dynamic `/quick/:code` paths without SSR). +6. End-user-initiated stop is detected via agent-offline (reaper marks `ended/end_user` after 5 min offline) rather than a dedicated API call — no new agent-auth surface in v1. + +--- + +### Task 1: DB migration + Drizzle schema + +**Files:** +- Create: `apps/api/migrations/2026-07-06-quick-support-sessions.sql` +- Create: `apps/api/src/db/schema/supportSessions.ts` +- Modify: `apps/api/src/db/schema/orgs.ts` (orgTypeEnum ~line 8) +- Modify: `apps/api/src/db/schema/devices.ts` (devices table) +- Modify: `apps/api/src/db/schema/orgs.ts` (enrollmentKeys table ~line 115) +- Modify: `apps/api/src/db/schema/index.ts` (barrel export) + +**Interfaces:** +- Produces: `supportSessions` table object, `supportSessionStatusEnum` (values `['pending','claimed','ready','ended','expired']`), `devices.isEphemeral: boolean`, `enrollmentKeys.supportSessionId: uuid | null`, org type value `'quick_support'`. + +- [ ] **Step 1: Write the migration** + +```sql +-- 2026-07-06: Quick Support — one-time code ad-hoc sessions. +-- Spec: docs/superpowers/specs/2026-07-06-one-off-support-session-design.md +-- support_sessions is RLS Shape 1 (direct org_id) — auto-discovered by the +-- rls-coverage integration test, no allowlist entry needed. +-- Fully idempotent. NOTE: no BEGIN/COMMIT — autoMigrate wraps the file. + +-- New org type for the hidden per-partner Quick Support org. +-- PG12+: ADD VALUE is allowed inside a transaction as long as the new value +-- is not used later in the SAME transaction — nothing below uses it. +ALTER TYPE org_type ADD VALUE IF NOT EXISTS 'quick_support'; + +-- Exactly one hidden org per partner. +CREATE UNIQUE INDEX IF NOT EXISTS organizations_partner_quick_support_uniq + ON organizations(partner_id) WHERE type = 'quick_support'; + +ALTER TABLE devices ADD COLUMN IF NOT EXISTS is_ephemeral BOOLEAN NOT NULL DEFAULT FALSE; + +DO $$ BEGIN + CREATE TYPE support_session_status AS ENUM ('pending','claimed','ready','ended','expired'); +EXCEPTION WHEN duplicate_object THEN NULL; END $$; + +CREATE TABLE IF NOT EXISTS support_sessions ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + org_id UUID NOT NULL REFERENCES organizations(id) ON DELETE CASCADE, + created_by_user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + code_hash VARCHAR(64) NOT NULL UNIQUE, + code_expires_at TIMESTAMPTZ NOT NULL, + status support_session_status NOT NULL DEFAULT 'pending', + hard_expires_at TIMESTAMPTZ NOT NULL, + device_id UUID REFERENCES devices(id) ON DELETE SET NULL, + attributed_org_id UUID REFERENCES organizations(id) ON DELETE SET NULL, + attribution_label TEXT, + claimed_at TIMESTAMPTZ, + claimed_from_ip TEXT, + ended_at TIMESTAMPTZ, + ended_reason TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS idx_support_sessions_reaper + ON support_sessions(status, hard_expires_at); +CREATE INDEX IF NOT EXISTS idx_support_sessions_device + ON support_sessions(device_id); + +ALTER TABLE enrollment_keys + ADD COLUMN IF NOT EXISTS support_session_id UUID REFERENCES support_sessions(id) ON DELETE CASCADE; + +-- RLS — Shape 1, standard four breeze_org_isolation policies +ALTER TABLE support_sessions ENABLE ROW LEVEL SECURITY; +ALTER TABLE support_sessions FORCE ROW LEVEL SECURITY; + +DROP POLICY IF EXISTS breeze_org_isolation_select ON support_sessions; +DROP POLICY IF EXISTS breeze_org_isolation_insert ON support_sessions; +DROP POLICY IF EXISTS breeze_org_isolation_update ON support_sessions; +DROP POLICY IF EXISTS breeze_org_isolation_delete ON support_sessions; + +CREATE POLICY breeze_org_isolation_select ON support_sessions + FOR SELECT USING (public.breeze_has_org_access(org_id)); +CREATE POLICY breeze_org_isolation_insert ON support_sessions + FOR INSERT WITH CHECK (public.breeze_has_org_access(org_id)); +CREATE POLICY breeze_org_isolation_update ON support_sessions + FOR UPDATE USING (public.breeze_has_org_access(org_id)) + WITH CHECK (public.breeze_has_org_access(org_id)); +CREATE POLICY breeze_org_isolation_delete ON support_sessions + FOR DELETE USING (public.breeze_has_org_access(org_id)); +``` + +- [ ] **Step 2: Drizzle schema file `supportSessions.ts`** + +```ts +import { pgTable, uuid, varchar, text, timestamp, pgEnum, index } from 'drizzle-orm/pg-core'; +import { organizations } from './orgs'; +import { users } from './users'; +import { devices } from './devices'; + +export const supportSessionStatusEnum = pgEnum('support_session_status', + ['pending', 'claimed', 'ready', 'ended', 'expired']); + +export const supportSessions = pgTable('support_sessions', { + id: uuid('id').primaryKey().defaultRandom(), + orgId: uuid('org_id').notNull().references(() => organizations.id, { onDelete: 'cascade' }), + createdByUserId: uuid('created_by_user_id').notNull().references(() => users.id, { onDelete: 'cascade' }), + codeHash: varchar('code_hash', { length: 64 }).notNull().unique(), + codeExpiresAt: timestamp('code_expires_at', { withTimezone: true }).notNull(), + status: supportSessionStatusEnum('status').notNull().default('pending'), + hardExpiresAt: timestamp('hard_expires_at', { withTimezone: true }).notNull(), + deviceId: uuid('device_id').references(() => devices.id, { onDelete: 'set null' }), + attributedOrgId: uuid('attributed_org_id').references(() => organizations.id, { onDelete: 'set null' }), + attributionLabel: text('attribution_label'), + claimedAt: timestamp('claimed_at', { withTimezone: true }), + claimedFromIp: text('claimed_from_ip'), + endedAt: timestamp('ended_at', { withTimezone: true }), + endedReason: text('ended_reason'), + createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(), +}, (t) => ({ + reaperIdx: index('idx_support_sessions_reaper').on(t.status, t.hardExpiresAt), + deviceIdx: index('idx_support_sessions_device').on(t.deviceId), +})); +``` + +- [ ] **Step 3: Modify existing schema files** + +In `orgs.ts`: `orgTypeEnum` becomes `pgEnum('org_type', ['customer', 'internal', 'quick_support'])`. In `enrollmentKeys` add `supportSessionId: uuid('support_session_id')` (plain uuid — no `.references()` to avoid a circular import with `supportSessions.ts`; FK lives in SQL). In `devices.ts` add `isEphemeral: boolean('is_ephemeral').notNull().default(false)` right after `status`. Export `supportSessions` + `supportSessionStatusEnum` from `schema/index.ts`. + +- [ ] **Step 4: Verify drift + migration test** + +Run: `export DATABASE_URL="postgresql://breeze:breeze@localhost:5432/breeze" && pnpm db:check-drift` → no drift. Run `pnpm test --filter=@breeze/api -- autoMigrate` → ordering test passes. + +- [ ] **Step 5: Commit** — `feat(api): quick support schema — support_sessions, ephemeral devices, quick_support org type` + +--- + +### Task 2: Shared code validators + API code service + +**Files:** +- Create: `packages/shared/src/validators/quickSupport.ts` +- Test: `packages/shared/src/validators/quickSupport.test.ts` +- Modify: `packages/shared/src/validators/index.ts` (barrel) +- Create: `apps/api/src/services/quickSupportCode.ts` +- Test: `apps/api/src/services/quickSupportCode.test.ts` + +**Interfaces:** +- Produces (shared, imported as `from '@breeze/shared'`): + `SUPPORT_CODE_ALPHABET: string`, `SUPPORT_CODE_LENGTH = 9`, `SUPPORT_CODE_PATTERN: RegExp`, + `normalizeSupportCode(raw: string): string | null`, `formatSupportCode(code: string): string`, + `createSupportSessionSchema` (zod: `{ attributedOrgId?: uuid, attributionLabel?: string(max 200) }`), + `redeemSupportSessionSchema` (zod: `{ code: string, hostname: string, osType: 'windows'|'macos'|'linux' }` — hand-written enum, not from pgEnum). +- Produces (API): `generateSupportCode(): string`, `hashSupportCode(code: string): string` (sha256 hex). + +- [ ] **Step 1: Write failing shared tests** — cases: `normalizeSupportCode('ktm-4h7 p2x') === 'KTM4H7P2X'`; rejects `'KTM4H7P20'` (contains 0) → null; rejects 8/10 char inputs → null; `formatSupportCode('KTM4H7P2X') === 'KTM-4H7-P2X'`; both zod schemas accept/reject representative payloads. Run `pnpm test --filter=@breeze/shared -- quickSupport` → FAIL (module not found). + +- [ ] **Step 2: Implement** + +```ts +import { z } from 'zod'; + +export const SUPPORT_CODE_ALPHABET = 'ABCDEFGHJKMNPQRSTVWXYZ23456789'; +export const SUPPORT_CODE_LENGTH = 9; +export const SUPPORT_CODE_PATTERN = /^[ABCDEFGHJKMNPQRSTVWXYZ23456789]{9}$/; + +export function normalizeSupportCode(raw: string): string | null { + const cleaned = raw.toUpperCase().replace(/[\s-]/g, ''); + return SUPPORT_CODE_PATTERN.test(cleaned) ? cleaned : null; +} + +export function formatSupportCode(code: string): string { + return `${code.slice(0, 3)}-${code.slice(3, 6)}-${code.slice(6, 9)}`; +} + +export const createSupportSessionSchema = z.object({ + attributedOrgId: z.string().guid().optional(), + attributionLabel: z.string().max(200).optional(), +}); + +export const redeemSupportSessionSchema = z.object({ + code: z.string().min(SUPPORT_CODE_LENGTH).max(15), + hostname: z.string().min(1).max(255), + osType: z.enum(['windows', 'macos', 'linux']), +}); +``` + +(Check sibling validators for whether this repo's Zod 4 uses `.guid()` — memory says yes; mirror whatever `remoteAccessInlineSettings.ts` does.) + +API service `quickSupportCode.ts`: + +```ts +import { createHash, randomInt } from 'node:crypto'; +import { SUPPORT_CODE_ALPHABET, SUPPORT_CODE_LENGTH } from '@breeze/shared'; + +export const SUPPORT_CODE_TTL_MINUTES = 15; +export const SUPPORT_SESSION_HARD_CAP_HOURS = 8; + +export function generateSupportCode(): string { + let code = ''; + for (let i = 0; i < SUPPORT_CODE_LENGTH; i++) { + code += SUPPORT_CODE_ALPHABET[randomInt(SUPPORT_CODE_ALPHABET.length)]; + } + return code; +} + +export function hashSupportCode(code: string): string { + return createHash('sha256').update(code).digest('hex'); +} +``` + +API test: generated code matches `SUPPORT_CODE_PATTERN`; 1000 generations all distinct-ish (no exact-dup assertion — just pattern + length); `hashSupportCode` is stable 64-hex. + +- [ ] **Step 3: Run both suites → PASS. Commit** — `feat(shared): quick support code validators + generator` + +--- + +### Task 3: Hidden Quick Support org provisioning service + +**Files:** +- Create: `apps/api/src/services/quickSupportOrg.ts` +- Test: `apps/api/src/services/quickSupportOrg.test.ts` + +**Interfaces:** +- Consumes: `db, withSystemDbAccessContext, runOutsideDbContext` from `../db`; `organizations, sites` from `../db/schema`. +- Produces: `getOrCreateQuickSupportOrg(partnerId: string): Promise<{ orgId: string; siteId: string }>`. + +- [ ] **Step 1: Failing tests** — mock `../db` (mirror an existing service test, e.g. whatever `partnerCreate`-adjacent tests do): (a) existing quick_support org + site → returned without insert; (b) none → inserts org `{ partnerId, name: 'Quick Support', slug: 'quick-support-', type: 'quick_support', status: 'active' }` then site `{ orgId, name: 'Quick Support', timezone: 'UTC' }`; (c) insert conflict (unique partial index) → re-select wins (no throw). + +- [ ] **Step 2: Implement** + +```ts +import { and, eq } from 'drizzle-orm'; +import { db, withSystemDbAccessContext, runOutsideDbContext } from '../db'; +import { organizations, sites } from '../db/schema'; + +/** + * One hidden 'quick_support' org per partner (organizations_partner_quick_support_uniq). + * Must run in a fresh system context: a brand-new org id isn't in the caller's + * accessible_org_ids yet, so RLS would reject both INSERT and RETURNING + * (same pattern as POST /organizations in routes/orgs.ts). + */ +export async function getOrCreateQuickSupportOrg(partnerId: string): Promise<{ orgId: string; siteId: string }> { + return runOutsideDbContext(() => withSystemDbAccessContext(async () => { + const findOrg = () => db.select({ id: organizations.id }).from(organizations) + .where(and(eq(organizations.partnerId, partnerId), eq(organizations.type, 'quick_support'))) + .limit(1); + + let [org] = await findOrg(); + if (!org) { + // onConflictDoNothing on the partial unique index handles the concurrent-create race + // (never rely on catching the error — pg.js begin() rethrows handled errors). + await db.insert(organizations).values({ + partnerId, + name: 'Quick Support', + slug: `quick-support-${partnerId.slice(0, 8)}`, + type: 'quick_support', + status: 'active', + }).onConflictDoNothing(); + [org] = await findOrg(); + if (!org) throw new Error('quick support org provisioning failed'); + } + + let [site] = await db.select({ id: sites.id }).from(sites) + .where(eq(sites.orgId, org.id)).limit(1); + if (!site) { + [site] = await db.insert(sites).values({ orgId: org.id, name: 'Quick Support', timezone: 'UTC' }).returning({ id: sites.id }); + } + return { orgId: org.id, siteId: site.id }; + })); +} +``` + +Note: `.onConflictDoNothing()` needs the target — if Drizzle requires it for partial indexes, use `.onConflictDoNothing({ target: [organizations.partnerId], targetWhere: sql`type = 'quick_support'` })`; if that fights the types, fall back to re-select after a caught unique-violation checked via error `code === '23505'` re-select (still no reliance on tx-abort recovery — this block is not inside a `begin()`). + +- [ ] **Step 3: Run → PASS. Commit** — `feat(api): quick support hidden org provisioning` + +--- + +### Task 4: Authenticated support-session routes (create / list / get) + +**Files:** +- Create: `apps/api/src/routes/remote/supportSessions.ts` +- Test: `apps/api/src/routes/remote/supportSessions.test.ts` +- Modify: `apps/api/src/routes/remote/index.ts` (mount) + +**Interfaces:** +- Consumes: Task 2 (`generateSupportCode`, `hashSupportCode`, TTL consts, `createSupportSessionSchema`, `formatSupportCode`), Task 3 (`getOrCreateQuickSupportOrg`), `logSessionAudit` from `./helpers`, `supportSessions, remoteSessions, devices` schema. +- Produces routes (final paths under `/api/v1/remote`): + - `POST /remote/support-sessions` → `201 { id, code /* formatted, shown once */, codeExpiresAt, hardExpiresAt, landingUrl }` + - `GET /remote/support-sessions?limit=50` → `{ sessions: SupportSessionView[] }` + - `GET /remote/support-sessions/:id` → `SupportSessionView` + - `SupportSessionView = { id, status /* stored or derived 'active' */, createdAt, codeExpiresAt, hardExpiresAt, deviceId, deviceOnline: boolean, attributedOrgId, attributionLabel, endedAt, endedReason, createdByUserId }` +- Middleware inherited from `remote/index.ts` mount: `authMiddleware` + `requirePermission('remote','access')` + `requireMfa()`. + +- [ ] **Step 1: Failing route tests** (mirror the Drizzle-mock style of `routes/remote/sessions.test.ts` — same `vi.mock` targets): + - create: partner-scope auth → 201, body has formatted code matching `/^[A-Z2-9]{3}-[A-Z2-9]{3}-[A-Z2-9]{3}$/`, `landingUrl` ends `/quick?code=`; DB insert received `codeHash` = sha256 of raw code, `orgId` from provisioning. + - create with `attributedOrgId` not in `auth.accessibleOrgIds` → 403. + - create with org-scope auth (`auth.scope === 'organization'`) → 403. + - get: session whose device has a live `remote_sessions` row (status `active`) → `status: 'active'` (derived); device row `status==='online'` → `deviceOnline: true`. + - list: returns sessions ordered `createdAt desc`. + - Audit: create emits `logSessionAudit('support_session_created', ...)`. + +- [ ] **Step 2: Implement `supportSessions.ts`** + +```ts +import { Hono } from 'hono'; +import { zValidator } from '@hono/zod-validator'; +import { and, desc, eq, inArray } from 'drizzle-orm'; +import { createSupportSessionSchema, formatSupportCode } from '@breeze/shared'; +import { db, withSystemDbAccessContext, runOutsideDbContext } from '../../db'; +import { supportSessions, remoteSessions, devices } from '../../db/schema'; +import { getOrCreateQuickSupportOrg } from '../../services/quickSupportOrg'; +import { + generateSupportCode, hashSupportCode, + SUPPORT_CODE_TTL_MINUTES, SUPPORT_SESSION_HARD_CAP_HOURS, +} from '../../services/quickSupportCode'; +import { logSessionAudit } from './helpers'; +import { getTrustedClientIp } from '../../services/clientIp'; + +export const supportSessionRoutes = new Hono(); + +supportSessionRoutes.post('/support-sessions', zValidator('json', createSupportSessionSchema), async (c) => { + const auth = c.get('auth'); + if (auth.scope !== 'partner' && auth.scope !== 'system') { + return c.json({ error: 'Quick Support requires partner scope' }, 403); + } + const data = c.req.valid('json'); + if (data.attributedOrgId && auth.accessibleOrgIds !== null + && !auth.accessibleOrgIds.includes(data.attributedOrgId)) { + return c.json({ error: 'Attributed organization not accessible' }, 403); + } + + const { orgId } = await getOrCreateQuickSupportOrg(auth.partnerId); + const code = generateSupportCode(); + const now = Date.now(); + + // System context: if the hidden org was just created it isn't in this + // request's accessible_org_ids yet, so the RLS INSERT policy would reject. + const [session] = await runOutsideDbContext(() => withSystemDbAccessContext(() => + db.insert(supportSessions).values({ + orgId, + createdByUserId: auth.userId, + codeHash: hashSupportCode(code), + codeExpiresAt: new Date(now + SUPPORT_CODE_TTL_MINUTES * 60_000), + hardExpiresAt: new Date(now + SUPPORT_SESSION_HARD_CAP_HOURS * 3_600_000), + attributedOrgId: data.attributedOrgId ?? null, + attributionLabel: data.attributionLabel ?? null, + }).returning() + )); + + await logSessionAudit('support_session_created', auth.userId, orgId, { + sessionId: session.id, + attributedOrgId: data.attributedOrgId ?? null, + attributionLabel: data.attributionLabel ?? null, + }, getTrustedClientIp(c, 'unknown')); + + const webBase = process.env.PUBLIC_WEB_URL ?? ''; + return c.json({ + id: session.id, + code: formatSupportCode(code), + codeExpiresAt: session.codeExpiresAt, + hardExpiresAt: session.hardExpiresAt, + landingUrl: `${webBase}/quick?code=${code}`, + }, 201); +}); +``` + +`GET /support-sessions` + `GET /support-sessions/:id`: normal (non-system) DB context — RLS grants access because the hidden org's `partner_id` puts it in the tech's `accessible_org_ids`. Derive view: + +```ts +async function toView(session: typeof supportSessions.$inferSelect) { + let deviceOnline = false; + let derivedStatus: string = session.status; + if (session.deviceId && (session.status === 'ready' || session.status === 'claimed')) { + const [dev] = await db.select({ status: devices.status }).from(devices) + .where(eq(devices.id, session.deviceId)).limit(1); + deviceOnline = dev?.status === 'online'; + const live = await db.select({ id: remoteSessions.id }).from(remoteSessions) + .where(and( + eq(remoteSessions.deviceId, session.deviceId), + inArray(remoteSessions.status, ['pending', 'connecting', 'active']), + )).limit(1); + if (session.status === 'ready' && live.length > 0) derivedStatus = 'active'; + } + const { codeHash: _omit, ...rest } = session; + return { ...rest, status: derivedStatus, deviceOnline }; +} +``` + +Never return `codeHash`. List caps `limit` at 100, default 50, `orderBy(desc(supportSessions.createdAt))`. + +- [ ] **Step 3: Mount in `remote/index.ts`** after the existing sub-routes: + +```ts +import { supportSessionRoutes } from './supportSessions'; +// ... +remoteRoutes.route('/', supportSessionRoutes); +``` + +- [ ] **Step 4: Run tests → PASS. Also run `pnpm test --filter=@breeze/api -- routes/remote` (no regressions). Commit** — `feat(api): quick support session create/list/get routes` + +--- + +### Task 5: Public routes — check + redeem + +**Files:** +- Create: `apps/api/src/routes/supportPublic.ts` +- Test: `apps/api/src/routes/supportPublic.test.ts` +- Modify: `apps/api/src/index.ts` (mount `api.route('/support', supportPublicRoutes)` next to the installer mount ~line 829, with the `// Public — code is the auth` comment convention) + +**Interfaces:** +- Consumes: Task 2 (`normalizeSupportCode`, `redeemSupportSessionSchema`, `hashSupportCode`), `rateLimiter` from `../services/rate-limit`, `getRedis` from `../services/redis`, `hashEnrollmentKey` from `../services/enrollmentKeySecurity`, `getTrustedClientIp`, schema tables. +- Produces: + - `GET /api/v1/support/check/:code` → `200 { valid: boolean }` (never session details) + - `POST /api/v1/support/redeem` body `{ code, hostname, osType }` → `200 { serverUrl, enrollmentKey /* raw child key */, enrollmentSecret, sessionId, hardExpiresAt }` | `404 { error: 'invalid or expired code' }` +- Child enrollment key naming: `Quick Support `; `maxUsage: 1`; `expiresAt: now + 15 min`; `supportSessionId: session.id`; `installerPlatform: osType === 'windows' ? 'windows' : 'macos'`; `keySecretHash: null` (global `AGENT_ENROLLMENT_SECRET` applies, returned like installer.ts does). + +- [ ] **Step 1: Failing tests** — mirror `installer.ts` test style: + - check: valid pending unexpired code → `{valid:true}`; unknown / expired / already-claimed → `{valid:false}`; rate limit exceeded → 429; malformed code → `{valid:false}` without DB hit. + - redeem: happy path → 200 with 64-hex `enrollmentKey`, session flipped `pending→claimed` with `claimedAt/claimedFromIp` set, child key insert received `supportSessionId`. + - redeem same code twice → second gets 404 (atomic `WHERE status='pending'` guard). + - redeem expired code → 404, session untouched. + - rate limit → 429. + +- [ ] **Step 2: Implement** + +```ts +export const supportPublicRoutes = new Hono(); +const REDEEM_LIMIT = 10, CHECK_LIMIT = 30, WINDOW_S = 60; + +supportPublicRoutes.get('/check/:code', async (c) => { + const ip = getTrustedClientIp(c, 'unknown'); + const rl = await rateLimiter(getRedis(), `support-check:${ip}`, CHECK_LIMIT, WINDOW_S); + if (!rl.allowed) return c.json({ error: 'rate limited' }, 429); + const code = normalizeSupportCode(c.req.param('code')); + if (!code) return c.json({ valid: false }); + const [row] = await withSystemDbAccessContext(() => + db.select({ status: supportSessions.status, codeExpiresAt: supportSessions.codeExpiresAt }) + .from(supportSessions).where(eq(supportSessions.codeHash, hashSupportCode(code))).limit(1)); + return c.json({ valid: !!row && row.status === 'pending' && row.codeExpiresAt > new Date() }); +}); + +supportPublicRoutes.post('/redeem', zValidator('json', redeemSupportSessionSchema), async (c) => { + const ip = getTrustedClientIp(c, 'unknown'); + const rl = await rateLimiter(getRedis(), `support-redeem:${ip}`, REDEEM_LIMIT, WINDOW_S); + if (!rl.allowed) return c.json({ error: 'rate limited' }, 429); + const data = c.req.valid('json'); + const code = normalizeSupportCode(data.code); + if (!code) return c.json({ error: 'invalid or expired code' }, 404); + + const result = await withSystemDbAccessContext(async () => { + const [row] = await db.select().from(supportSessions) + .where(eq(supportSessions.codeHash, hashSupportCode(code))).limit(1); + if (!row || row.status !== 'pending' || row.codeExpiresAt < new Date() + || row.hardExpiresAt < new Date()) return null; + + // Atomic claim — the WHERE status='pending' guard wins the race. + const [claimed] = await db.update(supportSessions).set({ + status: 'claimed', + claimedAt: new Date(), + claimedFromIp: ip === 'unknown' ? null : ip, + }).where(and(eq(supportSessions.id, row.id), eq(supportSessions.status, 'pending'))) + .returning(); + if (!claimed) return null; + + const [site] = await db.select({ id: sites.id }).from(sites) + .where(eq(sites.orgId, row.orgId)).limit(1); + + const rawChildKey = randomBytes(32).toString('hex'); + await db.insert(enrollmentKeys).values({ + orgId: row.orgId, + siteId: site.id, + name: `Quick Support ${row.id.slice(0, 8)}`, + key: hashEnrollmentKey(rawChildKey), + maxUsage: 1, + expiresAt: new Date(Date.now() + 15 * 60_000), + supportSessionId: row.id, + installerPlatform: data.osType === 'windows' ? 'windows' : 'macos', + }); + return { rawChildKey, sessionId: row.id, hardExpiresAt: row.hardExpiresAt }; + }); + + if (!result) return c.json({ error: 'invalid or expired code' }, 404); + return c.json({ + serverUrl: process.env.PUBLIC_API_URL ?? process.env.API_URL ?? '', + enrollmentKey: result.rawChildKey, + enrollmentSecret: process.env.AGENT_ENROLLMENT_SECRET || null, + sessionId: result.sessionId, + hardExpiresAt: result.hardExpiresAt, + }); +}); +``` + +Audit the claim via `logSessionAudit('support_session_claimed', row.createdByUserId, row.orgId, { sessionId: row.id }, ip)` after the claim succeeds. + +- [ ] **Step 3: Run tests → PASS. Commit** — `feat(api): public quick support check/redeem endpoints` + +--- + +### Task 6: Enrollment integration — ephemeral devices + session linkage + +**Files:** +- Modify: `apps/api/src/routes/agents/enrollment.ts` +- Modify: `apps/api/src/routes/devices/provision.ts` (~lines 168-193, license count) +- Test: extend `apps/api/src/routes/agents/enrollment.test.ts` (or sibling test file if enrollment tests live elsewhere — check for existing `enrollment*.test.ts` first) + +**Interfaces:** +- Consumes: `enrollmentKeys.supportSessionId` (Task 1). +- Produces: devices enrolled via a support-derived key are `isEphemeral: true`, linked (`supportSessions.deviceId` set), and skip the partner license count; both license-count queries exclude ephemeral devices. + +- [ ] **Step 1: Failing tests** + - enroll with key having `supportSessionId` + session in `claimed` status → device `isEphemeral: true`; `supportSessions.deviceId` updated. + - enroll with support key whose session is `ended`/`expired` → 401 `enrollment_key_not_found`-style rejection (reuse the existing invalid-key response shape). + - enroll with support key when partner is at `maxDevices` → still succeeds (count skipped). + - normal key → `isEphemeral: false`, no session update. + - license count query excludes `is_ephemeral` rows (assert the where clause via the mock, or — better — cover in the Task 17 integration test). + +- [ ] **Step 2: Implement in `enrollment.ts`** + +After the enrollment key lookup succeeds (~line 128), load the linkage: + +```ts +const isSupportEnrollment = !!matchingKey.supportSessionId; +if (isSupportEnrollment) { + const [supportSession] = await db.select().from(supportSessions) + .where(eq(supportSessions.id, matchingKey.supportSessionId)).limit(1); + if (!supportSession || supportSession.status !== 'claimed' + || supportSession.hardExpiresAt < new Date()) { + // Same 401 shape as enrollment_key_expired + return c.json({ error: 'Invalid enrollment key', code: 'enrollment_key_expired' }, 401); + } +} +``` + +Device-limit check (~lines 543-577): wrap with `if (!isSupportEnrollment) { ... }` AND add `eq(devices.isEphemeral, false)` to the count's `where(and(...))`. Make the same count change in `provision.ts:168-193`. + +Hostname-collision lookup (the `existingDevice` query): add `eq(devices.isEphemeral, false)` so repeat quick-support runs on the same machine always insert a fresh row instead of hitting the re-enrollment-token branch. + +Device insert (~line 632): add `isEphemeral: isSupportEnrollment,`. + +After the insert, inside the same `tx`: + +```ts +if (isSupportEnrollment && matchingKey.supportSessionId) { + await tx.update(supportSessions) + .set({ deviceId: dev.id }) + .where(and( + eq(supportSessions.id, matchingKey.supportSessionId), + eq(supportSessions.status, 'claimed'), + )); +} +``` + +(The enrollment tx already runs in system context via `withSystemDbAccessContext` — verify; if the device insert runs under a different context, put the session update in the same context the insert uses.) + +- [ ] **Step 3: Run enrollment tests + full agents route suite → PASS. Commit** — `feat(api): ephemeral enrollment via quick support keys` + +--- + +### Task 7: agentWs "ready" hook + +**Files:** +- Modify: `apps/api/src/routes/agentWs.ts` (`onOpen`, ~lines 1558-1636) +- Test: extend the existing agentWs test file (locate `agentWs*.test.ts`; if onOpen isn't unit-testable there, cover via Task 17's integration test and note it) + +**Interfaces:** +- Consumes: `supportSessions` schema; the `deviceInfo` select already in `onOpen`. +- Produces: support session flips `claimed → ready` when its ephemeral device's WS connects. + +- [ ] **Step 1: Add `isEphemeral: devices.isEphemeral` to the existing `deviceInfo` select in `onOpen`, then:** + +```ts +if (deviceInfo?.isEphemeral) { + // Quick Support: agent is online — code was redeemed and the client enrolled. + await runWithAgentDbAccess(async () => { + await db.update(supportSessions) + .set({ status: 'ready' }) + .where(and( + eq(supportSessions.deviceId, deviceInfo.id), + eq(supportSessions.status, 'claimed'), + )); + }); +} +``` + +Guarded on `isEphemeral` so the 10k-device fleet pays zero extra queries on reconnect. The agent context's org is the hidden org, which matches `support_sessions.org_id`, so RLS passes. + +- [ ] **Step 2: Test (unit if feasible, else integration in Task 17) → PASS. Commit** — `feat(api): mark quick support session ready on agent connect` + +--- + +### Task 8: End route + `support_end` command + token revocation + +**Files:** +- Modify: `apps/api/src/routes/remote/supportSessions.ts` +- Test: extend `apps/api/src/routes/remote/supportSessions.test.ts` + +**Interfaces:** +- Consumes: `sendCommandToAgent(agentId, command)` from `../agentWs` (returns `boolean`). +- Produces: `POST /remote/support-sessions/:id/end` → `200 { success: true }`; wire command `{ id: 'support-end-', type: 'support_end', payload: { sessionId } }` (Go side consumes this exact shape in Task 13). Exported helper `endSupportSession(sessionId, reason, actorId | null): Promise` in a new `apps/api/src/services/quickSupportEnd.ts` so the reaper (Task 9) reuses it. + +- [ ] **Step 1: Failing tests** — end a `ready` session: command sent with the device's `agentId`; device row updated `{ agentTokenHash: null, watchdogTokenHash: null, helperTokenHash: null, status: 'decommissioned' }`; session `{ status: 'ended', endedReason: 'tech', endedAt set }`; audit `support_session_ended`. Ending an already-`ended` session → 409. Ending a `pending` session (never claimed) → 200, no command attempted. + +- [ ] **Step 2: Implement service `quickSupportEnd.ts`** + +```ts +import { eq } from 'drizzle-orm'; +import { db, withSystemDbAccessContext, runOutsideDbContext } from '../db'; +import { devices, supportSessions } from '../db/schema'; +import { sendCommandToAgent } from '../routes/agentWs'; + +export async function endSupportSession( + sessionId: string, + reason: 'tech' | 'end_user' | 'expired' | 'error', +): Promise { + return runOutsideDbContext(() => withSystemDbAccessContext(async () => { + const [session] = await db.select().from(supportSessions) + .where(eq(supportSessions.id, sessionId)).limit(1); + if (!session || session.status === 'ended' || session.status === 'expired') return false; + + if (session.deviceId) { + const [dev] = await db.select({ agentId: devices.agentId }).from(devices) + .where(eq(devices.id, session.deviceId)).limit(1); + if (dev) { + // Best-effort: deliver self-destruct before revoking (WS stays up either way; + // revocation only blocks re-auth). Client dead-man switch covers non-delivery. + sendCommandToAgent(dev.agentId, { + id: `support-end-${sessionId}`, + type: 'support_end', + payload: { sessionId }, + } as never); + await db.update(devices).set({ + agentTokenHash: null, watchdogTokenHash: null, helperTokenHash: null, + status: 'decommissioned', + }).where(eq(devices.id, session.deviceId)); + } + } + + await db.update(supportSessions).set({ + status: reason === 'expired' ? 'expired' : 'ended', + endedAt: new Date(), + endedReason: reason, + }).where(eq(supportSessions.id, sessionId)); + return true; + })); +} +``` + +(Cast/typing: match the actual `AgentCommand` type from agentWs instead of `as never`.) Route handler: load session under normal RLS context (proves the caller can see it), 409 if terminal, then call `endSupportSession(id, 'tech')`, then `logSessionAudit('support_session_ended', auth.userId, session.orgId, { sessionId: id, reason: 'tech' }, ip)`. + +- [ ] **Step 3: Run → PASS. Commit** — `feat(api): end quick support session with agent self-destruct + token revocation` + +--- + +### Task 9: Reaper worker + +**Files:** +- Create: `apps/api/src/jobs/quickSupportReaper.ts` +- Test: `apps/api/src/jobs/quickSupportReaper.test.ts` +- Modify: `apps/api/src/index.ts` (initialize/shutdown alongside `initializeEnrollmentKeyCleanupWorker`, ~lines 157-177) +- Possibly create: `apps/api/src/services/deviceDeletion.ts` (see Step 2) + +**Interfaces:** +- Consumes: `endSupportSession` (Task 8), BullMQ pattern from `apps/api/src/services/ssoDomainRecheckWorker.ts` / `apps/api/src/jobs/enrollmentKeyCleanup.ts`. +- Produces: `initializeQuickSupportReaper(): Promise`, `shutdownQuickSupportReaper(): Promise`; queue name `quick-support-reaper`, repeat every 5 min, job name `reap` (no colons in ids). + +- [ ] **Step 1: Failing tests** for the pure `reapOnce()` function (export it for tests; the worker just calls it): + - `pending` past `codeExpiresAt` → `expired`. + - `claimed`/`ready` past `hardExpiresAt` → `endSupportSession(id, 'expired')` called. + - `ready` session whose device `status='offline'` and `lastSeenAt` older than 5 min → `endSupportSession(id, 'end_user')` (deviation #6: end-user stop detection). + - ended/expired sessions with `deviceId` and `endedAt` older than 6 h → device purged and `supportSessions.deviceId` nulled (FK `ON DELETE SET NULL`). + +- [ ] **Step 2: Implement** + +```ts +export async function reapOnce(): Promise { + // 1. Expire unredeemed codes + await db.update(supportSessions).set({ status: 'expired', endedAt: new Date(), endedReason: 'expired' }) + .where(and(eq(supportSessions.status, 'pending'), lt(supportSessions.codeExpiresAt, new Date()))); + + // 2. Hard-cap enforcement + const overdue = await db.select({ id: supportSessions.id }).from(supportSessions) + .where(and(inArray(supportSessions.status, ['claimed', 'ready']), lt(supportSessions.hardExpiresAt, new Date()))); + for (const s of overdue) await endSupportSession(s.id, 'expired'); + + // 3. End-user stop: ready session, device offline > 5 min + const stale = await db.select({ id: supportSessions.id }).from(supportSessions) + .innerJoin(devices, eq(devices.id, supportSessions.deviceId)) + .where(and( + eq(supportSessions.status, 'ready'), + eq(devices.status, 'offline'), + lt(devices.lastSeenAt, new Date(Date.now() - 5 * 60_000)), + )); + for (const s of stale) await endSupportSession(s.id, 'end_user'); + + // 4. Purge ephemeral device rows 6h after end (audit_logs have no device FK and survive) + const purgeable = await db.select({ id: supportSessions.id, deviceId: supportSessions.deviceId }) + .from(supportSessions) + .where(and( + inArray(supportSessions.status, ['ended', 'expired']), + isNotNull(supportSessions.deviceId), + lt(supportSessions.endedAt, new Date(Date.now() - 6 * 3_600_000)), + )); + for (const s of purgeable) await purgeEphemeralDevice(s.deviceId!); +} +``` + +`purgeEphemeralDevice(deviceId)`: **first inspect the existing `DELETE /devices/:id` handler** (in `apps/api/src/routes/devices/` — grep `\.delete\(` there). If its cascade logic is inline, extract it to `apps/api/src/services/deviceDeletion.ts` as `deleteDeviceCascade(deviceId: string): Promise` and call it from both the route and the reaper. It must delete FK children that lack `ON DELETE CASCADE` — at minimum `remote_sessions` rows (`device_id` NOT NULL, no cascade) — before the device row. Guard the reaper call: only delete when the device row has `isEphemeral === true` (never let a corrupted session row purge a real device). + +Worker wrapper: copy `ssoDomainRecheckWorker.ts` structure verbatim (queue getter, `runWithSystemDbAccess`, `concurrency: 1`, remove-existing-repeatables then `queue.add('reap', {}, { repeat: { every: 5 * 60 * 1000 }, removeOnComplete: { count: 10 }, removeOnFail: { count: 50 } })`). + +- [ ] **Step 3: Run tests → PASS. Register init/shutdown in `index.ts`. Commit** — `feat(api): quick support reaper worker` + +--- + +### Task 10: Exclusion sweep — hide the quick_support org + ephemeral devices + +**Files (known call sites — the sweep must ALSO grep for more):** +- Modify: `apps/api/src/routes/orgs.ts` — org list endpoint(s): grep `from(organizations)` selects that return org lists. +- Modify: `apps/api/src/routes/partner.ts` (~lines 99-140 org/device dashboard buckets) +- Modify: main devices list route in `apps/api/src/routes/devices/core.ts` — default `eq(devices.isEphemeral, false)` filter. +- Verify only (no change): license counts already done in Task 6. +- Test: extend the touched routes' sibling test files. + +**Interfaces:** none new — behavioral filters only. + +- [ ] **Step 1: Grep checklist (run all; record findings in the PR description):** + +```bash +grep -rn "from(organizations)" apps/api/src/routes apps/api/src/services --include='*.ts' | grep -v test +grep -rn "from(organizations)" apps/api/src/routes/aiTools*.ts +grep -rn "count(\*)" apps/api/src/routes apps/api/src/services --include='*.ts' | grep -iv test | grep -i device +``` + +For each hit, decide: **user-facing org enumeration or device count → exclude** (`ne(organizations.type, 'quick_support')` / `eq(devices.isEphemeral, false)`); **internal/RLS plumbing → leave alone**. + +**DO NOT touch `computeAccessibleOrgIds` (`apps/api/src/middleware/auth.ts:208`) or its `bearerTokenAuth.ts` twin** — the hidden org MUST stay in `accessibleOrgIds` or RLS blocks all tech access to `support_sessions`. Add a comment there saying exactly that. + +- [ ] **Step 2: Failing tests** — org list endpoint response omits a seeded `type='quick_support'` org; devices list omits an `isEphemeral` device; both still returned when queried directly by id (detail routes untouched). + +- [ ] **Step 3: Apply filters, run the full API unit suite (`pnpm test --filter=@breeze/api`) → PASS. Commit** — `feat(api): hide quick support org + ephemeral devices from listings` + +--- + +### Task 11: Support client download route + +**Files:** +- Modify: `apps/api/src/routes/supportPublic.ts` (add `GET /download/:platform`) +- Test: extend `apps/api/src/routes/supportPublic.test.ts` + +**Interfaces:** +- Consumes: `getBinarySource`, `getGithubAgentUrl(os, arch)` from `../services/binarySource`; the disk/S3 streaming pattern from `apps/api/src/routes/viewers/download.ts` and the filename-embedding pattern from the public enrollment-keys download route (`apps/api/src/routes/enrollment-keys` public sub-app, mounted at `index.ts:827`) — read that handler first and mirror how it sets `Content-Disposition` with a token-bearing filename. +- Produces: `GET /api/v1/support/download/windows?code=` → binary stream/redirect. Download filename: `breeze-support--.exe` where `` is `PUBLIC_API_URL` host (no scheme) — e.g. `breeze-support-KTM4H7P2X-us.2breeze.app.exe`. The Go client (Task 12) parses this exact format. + +- [ ] **Step 1: Failing tests** — valid pending code + platform windows → 200 with `Content-Disposition: attachment; filename="breeze-support--.exe"`; invalid/claimed code → 404; platform `macos` → 400 `{ error: 'macOS support client coming soon' }`; rate limited per IP (reuse `support-check` limiter budget). + +- [ ] **Step 2: Implement** — soft-validate the code (same query as `/check`); resolve the agent binary: `getBinarySource() === 'github'` → `fetch(getGithubAgentUrl('windows', 'amd64'))` and stream the response body through with our Content-Disposition (a redirect would lose the filename; note the ~60 MB proxy cost as acceptable v1); `local`/S3 → mirror `viewers/download.ts` disk/presign logic but force the filename. Build `` via `new URL(process.env.PUBLIC_API_URL ?? '').host`. + +- [ ] **Step 3: Run → PASS. Commit** — `feat(api): quick support client download with code-embedded filename` + +--- + +### Task 12: Go agent — `support` command, Tier 1 (user-mode) + +**Files:** +- Create: `agent/internal/agentapp/support.go` +- Test: `agent/internal/agentapp/support_test.go` +- Modify: `agent/internal/agentapp/main.go` (register `supportCmd` in `init()` ~line 257; basename dispatch in `Main` ~line 309) +- Modify: `agent/pkg/api/client.go` (add `RedeemSupportCode`) +- Modify: `agent/internal/config/config.go` (add runtime-only `SupportMode bool` + `SupportSessionID string`, both `mapstructure:"-"`) + +**Interfaces:** +- Consumes: `POST /api/v1/support/redeem` (Task 5 response shape), `enrollDevice`-style enrollment via `client.Enroll` (`pkg/api/client.go:181`), `config.SaveTo(cfg, cfgFile)`, `startAgentFn(cfg)` (`main.go:650` area). +- Produces: `breeze-agent support [--code XXX] [--server URL]`; filename auto-dispatch (`breeze-support--.exe` → support mode with code+server pre-filled); `resolveSupportInput(argv0, codeFlag, serverFlag) (code, server string, err error)` (pure, tested); temp config dir `%TEMP%\breeze-support-\`. + +- [ ] **Step 1: Failing table-driven tests for `resolveSupportInput`** + +```go +func TestResolveSupportInput(t *testing.T) { + cases := []struct { + name, argv0, codeFlag, serverFlag string + wantCode, wantServer string + wantErr bool + }{ + {"flags win", "breeze-agent.exe", "KTM-4H7-P2X", "https://eu.2breeze.app", "KTM4H7P2X", "https://eu.2breeze.app", false}, + {"filename parsed", "breeze-support-KTM4H7P2X-us.2breeze.app.exe", "", "", "KTM4H7P2X", "https://us.2breeze.app", false}, + {"browser copy suffix", "breeze-support-KTM4H7P2X-us.2breeze.app (1).exe", "", "", "KTM4H7P2X", "https://us.2breeze.app", false}, + {"case insensitive", "Breeze-Support-ktm4h7p2x-us.2breeze.app.exe", "", "", "KTM4H7P2X", "https://us.2breeze.app", false}, + {"nothing embedded, no flags", "breeze-agent.exe", "", "", "", "", true}, // caller falls back to prompt + } + // filename regex: (?i)^breeze-support-([a-z2-9]{9})-(.+?)(?: \(\d+\))?\.exe$ +} +``` + +Run: `cd agent && go test -race ./internal/agentapp/ -run TestResolveSupportInput` → FAIL. + +- [ ] **Step 2: Implement `support.go`** + +```go +var supportCodeFlag string + +var supportCmd = &cobra.Command{ + Use: "support", + Short: "Run a one-time Quick Support session (nothing is permanently installed)", + Run: func(cmd *cobra.Command, args []string) { runSupportSession() }, +} +``` + +`runSupportSession()` flow (Tier 1): +1. `resolveSupportInput(os.Args[0], supportCodeFlag, serverURL)`; on err → interactive prompt: `fmt.Print("Enter your support code: ")` + `bufio` read + `normalize`, and server prompt defaulting to a `-ldflags`-injectable `defaultSupportServer` var. +2. `POST /api/v1/support/redeem` with `{code, hostname, osType: "windows"}` (new `pkg/api` func `RedeemSupportCode(server, code, hostname, osType string) (*SupportRedeemResponse, error)`; struct fields `ServerURL, EnrollmentKey, EnrollmentSecret, SessionID, HardExpiresAt`). Friendly errors: 404 → "That code is invalid or has expired — ask your technician for a new one." +3. Build temp workspace `dir := filepath.Join(os.TempDir(), fmt.Sprintf("breeze-support-%d", os.Getpid()))`; `cfgFile = filepath.Join(dir, "agent.yaml")`. **Never touch `C:\ProgramData\Breeze`** — the machine may run a real enrolled agent. +4. Enroll: reuse the body of `enrollDevice` (`main.go:948`) — refactor its core into `enrollWithConfig(cfg *config.Config, cfgFile, enrollmentKey, secret string) error` so both the `enroll` command and support mode call it (mechanical extraction, no behavior change). Set `cfg.Watchdog.Enabled = false`, `cfg.SupportMode = true`, `cfg.SupportSessionID = resp.SessionID`, log file inside the temp dir. +5. Start: call `startAgentFn(cfg)` — as a plain foreground process `IsService=false / IsHeadless=false`, so desktop commands take the **in-process capture path** (`handlers_desktop.go:232 h.desktopMgr.StartSession`) — no SYSTEM helper needed. Support-mode gating inside `startAgent`: when `cfg.SupportMode`, skip watchdog bootstrap, skip updater start, skip collectors except the minimal hardware snapshot used at enrollment (grep `startAgent` for `bootstrapWatchdog` / updater / collector starts and gate each on `!cfg.SupportMode`). +6. Console status (this IS the v1 status window): + +``` + Breeze Quick Support + ───────────────────────────────────── + Connected. Waiting for your technician… + Nothing is permanently installed. Close this window + or press Ctrl+C at any time to stop sharing. +``` + + Print state changes ("Technician connected." / "Technician disconnected.") by setting `h.desktopMgr.OnSessionStarted/OnSessionStopped` style callbacks (OnSessionStopped already exists for direct mode — `heartbeat.go:570`; add OnSessionStarted symmetrically if absent). +7. Signal handling: on Ctrl+C/SIGTERM → `supportCleanup(dir)` (Task 13) then exit. +8. Dead-man switch goroutine: if the WS client reports disconnected continuously for 10 min, or `time.Now()` passes `HardExpiresAt` → print notice, `supportCleanup(dir)`, exit. + +`Main()` basename dispatch (next to the `breeze-desktop-helper` special case at `main.go:309`): + +```go +if strings.HasPrefix(strings.ToLower(filepath.Base(os.Args[0])), "breeze-support") { + rootCmd.SetArgs(append([]string{"support"}, os.Args[1:]...)) +} +``` + +- [ ] **Step 3: `go build ./...` + `go test -race ./internal/agentapp/` → PASS. Manual smoke on the Windows test VM (100.101.150.55) against a wt-stack: run `breeze-agent.exe support --code --server `, verify device appears ephemeral + session goes `ready`. Commit** — `feat(agent): quick support mode (tier 1 user-session)` + +--- + +### Task 13: Go agent — `support_end` handler, self-cleanup, self-delete + +**Files:** +- Modify: `agent/internal/remote/tools/types.go` (add `CmdSupportEnd = "support_end"` near `CmdSelfUninstall` ~line 200) +- Create: `agent/internal/heartbeat/handlers_support.go` +- Test: `agent/internal/heartbeat/handlers_support_test.go` + +**Interfaces:** +- Consumes: command shape from Task 8: `{ id, type: 'support_end', payload: { sessionId } }`; registration pattern from `handlers_uninstall.go:14`; Windows self-delete trampoline from `handlers_uninstall.go:236-246`. +- Produces: `handleSupportEnd` registered in `handlerRegistry`; `supportCleanup(workDir string)` — stops desktop sessions, removes temp workspace, schedules exe self-delete, exits 0. + +- [ ] **Step 1: Failing tests** — table-driven: `handleSupportEnd` on a heartbeat with `supportMode=false` returns an error result ("not a support session") and does NOT exit — **the guard that stops a forged/misrouted command from nuking a real agent**; with `supportMode=true` it returns success and invokes the (injected, test-faked) cleanup func. Inject via package-level `var supportCleanupFn = supportCleanup` for testability. + +- [ ] **Step 2: Implement** + +```go +func init() { handlerRegistry[tools.CmdSupportEnd] = handleSupportEnd } + +func handleSupportEnd(h *Heartbeat, cmd Command) tools.CommandResult { + if !h.supportMode { + return tools.CommandResult{Success: false, Error: "not a support session"} + } + go func() { + time.Sleep(500 * time.Millisecond) // let the result flush over WS + supportCleanupFn(h.supportWorkDir) + }() + return tools.CommandResult{Success: true, Output: "support session ending"} +} +``` + +`supportCleanup`: stop active desktop sessions via the session manager; `os.RemoveAll(workDir)`; Windows self-delete exactly like `handlers_uninstall.go:236` (`cmd /C ping 127.0.0.1 -n 3 >NUL & del /f ""`, detached); `os.Exit(0)`. Thread `supportMode`/`supportWorkDir` into `Heartbeat` from `cfg` the same way `isService`/`isHeadless` are copied at `heartbeat.go:417-418`. Match `tools.CommandResult`'s real field names (check `tools/types.go`). + +- [ ] **Step 3: `go test -race ./internal/heartbeat/ -run TestHandleSupportEnd` → PASS. Manual: end from API, watch the client clean up and delete itself on the test VM. Commit** — `feat(agent): support_end self-destruct handler` + +--- + +### Task 14: Go agent — Tier 2 (elevated temporary service) + +**Files:** +- Create: `agent/internal/agentapp/support_service_windows.go` (build tag `windows`) +- Test: `agent/internal/agentapp/support_service_windows_test.go` (pure helpers only; service lifecycle is manual-verified) +- Modify: `agent/internal/agentapp/support.go` + +**Interfaces:** +- Consumes: `golang.org/x/sys/windows/svc` + `svc/mgr` (service create/start/stop/delete), elevation check via `windows.Token.IsElevated()`, existing service-mode startup (`runAsService`, `service_windows.go:106`), SYSTEM desktop-helper spawn machinery (works when `IsService=true`). +- Produces: when the support process is launched **elevated** (user right-clicked → Run as administrator, or accepts the UAC prompt on our re-launch offer), it installs a temporary service `BreezeQuickSupport` running `\breeze-support-svc.exe support --service-run --config \agent.yaml`, starts it, and the console process becomes a monitor. Teardown removes service + files. + +- [ ] **Step 1: Flow to implement** + +1. In `runSupportSession()` after redeem+enroll: if `isElevated()` → Tier 2 path; else print `TIP: for full control (admin prompts), close this and re-run as administrator.` and continue Tier 1. (No forced UAC prompt in v1 — "Run as administrator" is the documented path; a mid-session elevation request is Phase 3.) +2. Tier 2 setup: copy own exe into the temp workspace twice — `breeze-support-svc.exe` (service binary) and `breeze-desktop-helper.exe`. **Investigation sub-step:** find how `sessionbroker.SpawnHelperInSession` / `spawnHelperForDesktop` (`handlers_desktop_helper.go:481,589`) resolves the desktop-helper binary path; if it assumes the Program Files install dir, add a fallback to "directory of the running executable" (benefits dev builds too). This is the riskiest line in the Go work — timebox it and, if the resolution is tangled, ship Tier 2 as service-without-helper (secure-desktop capture degraded) and file a follow-up issue. +3. Install: `mgr.Connect()` → `m.CreateService("BreezeQuickSupport", svcExe, mgr.Config{DisplayName: "Breeze Quick Support (temporary)", StartType: mgr.StartManual}, "support", "--service-run", "--config", cfgFile)` → `s.Start()`. Fail → warn and fall back to Tier 1 inline. +4. `--service-run` (hidden flag): sets `cfg.SupportMode` from the loaded config dir and enters the existing `runAsService` path (SCM). Service loads the already-enrolled temp config; `IsService=true` → broker + SYSTEM desktop helper → UAC/secure-desktop capture works. +5. Console monitor: polls service status; Ctrl+C → stop+delete service, cleanup. `supportCleanup` on the service side (support_end command) must also `sc stop/delete BreezeQuickSupport` — reuse the `sc.exe stop/delete` invocation pattern from `selfUninstallWindows` (`handlers_uninstall.go:209`) with the temp service name, then delete both copied exes via the trampoline. + +- [ ] **Step 2: Unit-test the pure parts** (service args builder, `isElevated` wrapper injectable). `go build ./... && go test -race ./...` → PASS. + +- [ ] **Step 3: Manual verification on the Windows test VM:** run elevated → temp service appears (`sc query BreezeQuickSupport`), desktop session shows UAC prompts; end session → service gone, files gone. Commit** — `feat(agent): quick support tier 2 temporary service (elevated)` + +--- + +### Task 15: Web — Quick Support page (create dialog + status panel + list) + +**Files:** +- Create: `apps/web/src/components/remote/QuickSupportPage.tsx` +- Test: `apps/web/src/components/remote/QuickSupportPage.test.tsx` +- Create: `apps/web/src/pages/remote/quick-support.astro` (DashboardLayout + ``) +- Modify: `apps/web/src/pages/remote/index.astro` (add a "Quick Support" card next to the existing terminal/files/sessions cards) + +**Interfaces:** +- Consumes: `fetchWithAuth` (`stores/auth.ts:463`), `runAction`/`ActionError` (`lib/runAction.ts`), `showToast`, `ConnectDesktopButton` (`components/remote/ConnectDesktopButton.tsx` — pass `deviceId={session.deviceId}` when set), Task 4/8 endpoints. +- Produces: page at `/remote/quick-support` with `data-testid` attributes: `quick-support-create`, `quick-support-code`, `quick-support-copy-link`, `quick-support-status`, `quick-support-connect`, `quick-support-end`, `quick-support-list`. + +- [ ] **Step 1: Failing component tests** (Vitest + jsdom, mirror a sibling like `EnrollmentKeyManager` tests): create → code displayed in `XXX-XXX-XXX` format + copy-link button writes `landingUrl` to clipboard; status polling transitions render "Waiting for user" (`pending`) → "User connected — ready" (`ready` + `deviceOnline`) → Connect button appears; End calls `POST .../end` via `runAction`; list renders recent sessions with attribution label. + +- [ ] **Step 2: Implement.** Create dialog: attribution org select (options from existing org store / `GET /organizations` — hidden org is already server-filtered by Task 10) + label input; submit via `runAction({ request: () => fetchWithAuth('/remote/support-sessions', { method: 'POST', body: JSON.stringify(payload) }), errorFallback: 'Failed to create support session', successMessage: () => 'Support session created' })`. After create, show the code big + copyable link, and poll `GET /remote/support-sessions/:id` every 3 s (recursive `setTimeout` in a `useRef`, cleared on unmount — the `ConnectDesktopButton.tsx:399` pattern; stop polling on terminal states). When `deviceId && deviceOnline`, render `` plus the End button (runAction, 401 → return, non-ActionError → toast). Status copy: pending → "Waiting for the user to run the client…", claimed → "Client connecting…", ready → "Ready to connect", active → "Session in progress", ended/expired → terminal badge. + +- [ ] **Step 3: `pnpm test --filter=@breeze/web -- QuickSupport` → PASS; `pnpm astro check` clean (types in tests count). Commit** — `feat(web): quick support page` + +--- + +### Task 16: Web — public `/quick` landing page + +**Files:** +- Create: `apps/web/src/pages/quick.astro` (model: `accept-invite.astro` — `AuthLayout`, no auth guard) +- Create: `apps/web/src/components/quick/QuickLandingPage.tsx` +- Test: `apps/web/src/components/quick/QuickLandingPage.test.tsx` + +**Interfaces:** +- Consumes: plain `fetch` (no bearer) against `import.meta.env.PUBLIC_API_URL || ''` + `/api/v1/support/check/:code` and download URL `/api/v1/support/download/windows?code=`; `normalizeSupportCode`/`formatSupportCode` from `@breeze/shared`. +- Produces: `/quick` and `/quick?code=XXX`. `data-testid`s: `quick-code-input`, `quick-code-submit`, `quick-download-windows`, `quick-invalid-code`. + +- [ ] **Step 1: Failing tests** — no `?code` → code-entry form; submit normalizes (`ktm 4h7 p2x` → checks `KTM4H7P2X`); check returns `{valid:false}` → `quick-invalid-code` visible, no download button; `{valid:true}` → download button with href containing the code + plain-language copy ("Your technician wants to help…", "This program runs once and removes itself…") + macOS row disabled with "coming soon". + +- [ ] **Step 2: Implement.** Read code from `location.search` on mount; validate via `/support/check`; render entry-form vs landing states. Download = plain `` (browser download, no fetch). Include the manual-fallback instruction under the button: "If the download prompts for a code, enter: **XXX-XXX-XXX**." + +- [ ] **Step 3: Run tests → PASS. Verify CSP: the page fetches the API origin — confirm `apps/web/src/middleware.ts` CSP `connect-src` already allows it (it must, all islands do). Commit** — `feat(web): public quick support landing page` + +--- + +### Task 17: RLS + full-chain integration tests + +**Files:** +- Create: `apps/api/src/__tests__/integration/supportSessionsRls.integration.test.ts` +- Create: `apps/api/src/__tests__/integration/quickSupportChain.integration.test.ts` +(Confirm placement/naming against existing files in that directory and the dual hand-list convention — integration files must be excluded from the unit config; check `vitest.integration.config.ts` include globs.) + +**Interfaces:** consumes everything above against real Postgres (`:5433` per `test_integration_config_run_mechanics`). + +- [ ] **Step 1: RLS suite** — as `breeze_app` with partner-A context: SELECT partner-B's support_sessions → 0 rows; forged INSERT into partner-B's hidden org → fails `42501` (assert the error code — don't let a memoized fixture make it vacuous); rls-coverage contract test still green (Shape 1 auto-discovery: `pnpm vitest run -c vitest.config.rls.ts`). + +- [ ] **Step 2: Chain suite** — seed partner + tech user; `POST /remote/support-sessions` → code; `POST /support/redeem` → child key; `POST /agents/enroll` with it → device `is_ephemeral=true`, session `deviceId` linked, status `claimed`; second redeem of same code → 404; enroll at maxDevices=0 partner limit → still succeeds for support key, fails for a normal key; `endSupportSession(id,'tech')` → device tokens nulled + status decommissioned; `reapOnce()` after faking `endedAt` 7 h back → device row gone, `supportSessions.deviceId` null. + +- [ ] **Step 3: Run integration suite locally against the docker Postgres → PASS. Commit** — `test(api): quick support RLS + end-to-end chain integration tests` + +--- + +### Task 18: Final verification sweep + +- [ ] `pnpm test` (all workspaces), `cd agent && go test -race ./...`, `pnpm astro check`, `pnpm db:check-drift` — all green. +- [ ] Type Check includes tests + site-scope contract (CI parity — run `pnpm typecheck` if defined). +- [ ] Manual e2e via the `worktree-stack` skill: full happy path from create → landing page → client on Windows VM → viewer connect → end → self-delete. Verify as `breeze_app` in psql: forge a cross-tenant support_session insert → RLS rejection. +- [ ] Grep sweep from Task 10 recorded in PR description; confirm no `support_sessions` consumer bypasses the status guards (`grep -rn "supportSessions" apps/api/src --include='*.ts' | grep -v test`). +- [ ] Update `apps/docs` remote-access page with a Quick Support section (brief; full docs pass at release via the release skill). +- [ ] Commit any stragglers; run `superpowers:requesting-code-review` / open PR. + +--- + +## Self-review notes + +- **Spec coverage:** flows (T4/5/11/12/15/16), lifecycle+states (T1/5/7/8/9), data model (T1), hidden org (T3/T10), enrollment guards (T6), reaper 3-layer cleanup (T9/T13 dead-man/T8 cooperative), security (rate limits T5/T11, revocation T8, forged-command guard T13, RLS T1/T17), audit (T4/5/8), UI (T15/16), Tier 2 (T14), testing standards (each task + T17). Consent posture needs no code: sessions on ephemeral devices use the default prompt config; running the client is consent (spec) — the hidden org has no `config_policy_remote_access_settings`, so `resolveRemoteSessionPromptConfig` falls back to defaults; verify during T18 manual e2e that the default doesn't hard-block (if default is `consent` + `block`, add a support-mode bypass in the offer path — check `remoteAccessPolicy.ts` defaults during T4). +- **Known risks, called out in-task:** desktop-helper binary path resolution for Tier 2 (T14, timeboxed with degrade path); GitHub-mode 60 MB proxy streaming (T11); org-exclusion sweep completeness (T10 grep checklist + PR record). +- Types/names used across tasks were cross-checked: `endSupportSession(sessionId, reason)` (T8→T9), redeem response `{serverUrl, enrollmentKey, enrollmentSecret, sessionId, hardExpiresAt}` (T5→T12), command `support_end` + payload (T8→T13), filename format (T11→T12), `SupportSessionView.deviceOnline` (T4→T15). diff --git a/docs/superpowers/specs/2026-07-06-one-off-support-session-design.md b/docs/superpowers/specs/2026-07-06-one-off-support-session-design.md index 93cdc722c..922ec4482 100644 --- a/docs/superpowers/specs/2026-07-06-one-off-support-session-design.md +++ b/docs/superpowers/specs/2026-07-06-one-off-support-session-design.md @@ -146,6 +146,17 @@ Ships as a signed + notarized **`.app` wrapper** — TCC attributes Screen Recor 2. **Phase 2:** macOS `.app` packaging, notarization, TCC guided flow, LaunchDaemon tier. 3. **Phase 3:** niceties — mid-session elevation request UX polish, partner-configurable TTLs, session history reporting by attributed org. +## Implementation notes (adaptations made during planning — see `docs/superpowers/plans/2026-07-06-quick-support-phase1.md`) + +- `organizations.kind` is implemented by adding a `'quick_support'` value to the existing `org_type` enum instead of a new column. +- `failed_attempts` is dropped: codes are looked up by hash, so unknown codes have no row to count against; per-IP rate limits + 44-bit entropy + 15-min TTL cover guessing. +- `active` is a derived status (live `remote_sessions` for the device), not stored; stored states are `pending/claimed/ready/ended/expired`. +- v1 status "window" is a console window with status text + Ctrl+C/close to stop; a native window is Phase 3 polish. +- Landing URL is `/quick?code=` (static Astro pages can't serve dynamic path segments). +- End-user-initiated stop is detected via agent-offline (reaper marks `ended/end_user` after 5 min offline) rather than a dedicated client→API call. +- Creating support sessions requires partner-scope (or system) tokens in v1 — org-scoped tokens can't reach the hidden org through RLS. +- Tier 2 elevation in v1 = "Run as administrator" relaunch installs the temporary service; a mid-session elevation request is Phase 3. + ## Out of scope (v1) - User-generated codes / anonymous session queue (TeamViewer-style). From 45985be7e48d532ba25c112082fadfba0192d4b1 Mon Sep 17 00:00:00 2001 From: Todd Hebebrand Date: Tue, 4 Aug 2026 16:03:59 -0500 Subject: [PATCH 04/28] docs(quick-support): 2026-07-17 review adjustments to phase 1 plan + spec Adds review items 7-13: migration -a-/-b- split (55P04 enum trap), end-path hardening, service-side Tier 2 consent guarantee, claimed-limbo reaping, i18n mandate, Authenticode signing gate, milestone split. Co-Authored-By: Claude Opus 5 (1M context) --- .../plans/2026-07-06-quick-support-phase1.md | 133 ++++++++++++++---- ...26-07-06-one-off-support-session-design.md | 10 ++ 2 files changed, 112 insertions(+), 31 deletions(-) diff --git a/docs/superpowers/plans/2026-07-06-quick-support-phase1.md b/docs/superpowers/plans/2026-07-06-quick-support-phase1.md index 7a288d8ee..631e53eb6 100644 --- a/docs/superpowers/plans/2026-07-06-quick-support-phase1.md +++ b/docs/superpowers/plans/2026-07-06-quick-support-phase1.md @@ -18,6 +18,8 @@ - v1 requires `auth.scope === 'partner'` (or `system`) to create support sessions — org-scoped tokens can't reach the hidden org (documented limitation). - BullMQ job ids: use `-`, never `:`. - Never derive Zod enums from Drizzle `pgEnum.enumValues` (breaks schema mocks). +- All new web UI strings go through the i18n layer (literal-key `t()`); every new key lands in en AND all other locale catalogs in the same commit — the locale-parity test reds main otherwise. Applies to the public `/quick` page too (it's consumer-facing). +- The served support client MUST be Authenticode-signed (see Task 11 Step 2b). Per-session filename renames do NOT invalidate the signature or SmartScreen reputation — both key on content hash + cert — but an *unsigned* exe is a SmartScreen wall for consumers. ## Deviations from spec (implementation adaptations — spec updated alongside this plan) @@ -28,12 +30,23 @@ 5. Landing URL is `/quick?code=` (Astro static pages can't do dynamic `/quick/:code` paths without SSR). 6. End-user-initiated stop is detected via agent-offline (reaper marks `ended/end_user` after 5 min offline) rather than a dedicated API call — no new agent-auth surface in v1. +## Review adjustments (2026-07-17 review — spec implementation notes updated to match) + +7. **Migration split (`-a-`/`-b-`):** the partial index `WHERE type = 'quick_support'` cannot live in the same file as `ALTER TYPE ... ADD VALUE 'quick_support'` — Postgres rejects any *use* of an enum value added in the current transaction (`55P04 unsafe use of new value`), and autoMigrate wraps each file in one transaction. As originally written, Task 1's migration failed on first run, rolled back, and would retry forever. +8. **End-path hardening:** `endSupportSession` force-closes the device's agent WS after revoking tokens. Without it, a lost `support_end` on a healthy WS lingers until the 8h hard cap (the client is online, so the offline dead-man never fires). With it: close → reconnect → re-auth fails → dead-man cleans up in ≤10 min. Also verify `POST /remote/sessions` rejects decommissioned devices. +9. **Tier 2 consent guarantee moved service-side:** the temporary service watches the console monitor's process handle and self-tears-down when it dies. Closing the console with X (~5s SIGTERM grace) or killing it from Task Manager must never leave a SYSTEM service silently sharing the screen. +10. **Claimed-limbo reaping:** `claimed` sessions with no device 20 min after `claimed_at` → `expired` (client crashed between redeem and enroll; otherwise the tech's panel shows "Client connecting…" until the 8h cap). +11. **i18n is mandatory** (see Global Constraints) — the plan originally predated the literal-key `t()` gate and locale-parity CI checks. +12. **Signing/SmartScreen:** the served exe must be Authenticode-signed; release-blocking check in Task 11 Step 2b, honest publisher copy in Task 16. +13. **Milestones:** **A = Tasks 1–13 + 15–18** (Tier 1, shippable end-to-end); **B = Task 14** (Tier 2 — quarantines the two riskiest unknowns: helper-binary path resolution and service lifecycle). Run Task 18's Tier-2 checks only with B. + --- ### Task 1: DB migration + Drizzle schema **Files:** -- Create: `apps/api/migrations/2026-07-06-quick-support-sessions.sql` +- Create: `apps/api/migrations/2026-07-06-a-quick-support-sessions.sql` (date both files with the actual implementation date; keep the `-a-`/`-b-` infix) +- Create: `apps/api/migrations/2026-07-06-b-quick-support-org-index.sql` - Create: `apps/api/src/db/schema/supportSessions.ts` - Modify: `apps/api/src/db/schema/orgs.ts` (orgTypeEnum ~line 8) - Modify: `apps/api/src/db/schema/devices.ts` (devices table) @@ -43,24 +56,25 @@ **Interfaces:** - Produces: `supportSessions` table object, `supportSessionStatusEnum` (values `['pending','claimed','ready','ended','expired']`), `devices.isEphemeral: boolean`, `enrollmentKeys.supportSessionId: uuid | null`, org type value `'quick_support'`. -- [ ] **Step 1: Write the migration** +- [ ] **Step 1: Write the migrations — TWO files; the split is load-bearing** + +File `2026-07-06-a-quick-support-sessions.sql`: ```sql --- 2026-07-06: Quick Support — one-time code ad-hoc sessions. +-- 2026-07-06-a: Quick Support — one-time code ad-hoc sessions. -- Spec: docs/superpowers/specs/2026-07-06-one-off-support-session-design.md -- support_sessions is RLS Shape 1 (direct org_id) — auto-discovered by the -- rls-coverage integration test, no allowlist entry needed. -- Fully idempotent. NOTE: no BEGIN/COMMIT — autoMigrate wraps the file. -- New org type for the hidden per-partner Quick Support org. --- PG12+: ADD VALUE is allowed inside a transaction as long as the new value --- is not used later in the SAME transaction — nothing below uses it. +-- PG12+ allows ADD VALUE inside a transaction, but the new value cannot be +-- USED in the same transaction (55P04 "unsafe use of new value") — and +-- autoMigrate wraps each file in ONE transaction. That is why the partial +-- index on type = 'quick_support' lives in the -b- file. Nothing in THIS +-- file may reference the new value. ALTER TYPE org_type ADD VALUE IF NOT EXISTS 'quick_support'; --- Exactly one hidden org per partner. -CREATE UNIQUE INDEX IF NOT EXISTS organizations_partner_quick_support_uniq - ON organizations(partner_id) WHERE type = 'quick_support'; - ALTER TABLE devices ADD COLUMN IF NOT EXISTS is_ephemeral BOOLEAN NOT NULL DEFAULT FALSE; DO $$ BEGIN @@ -113,6 +127,19 @@ CREATE POLICY breeze_org_isolation_delete ON support_sessions FOR DELETE USING (public.breeze_has_org_access(org_id)); ``` +File `2026-07-06-b-quick-support-org-index.sql`: + +```sql +-- 2026-07-06-b: Quick Support — partial unique index on the new enum value. +-- MUST be a separate file from -a-: Postgres forbids using an enum value added +-- in the current transaction (55P04), and autoMigrate wraps each file in ONE +-- transaction. File -a- commits the value; this file may use it. + +-- Exactly one hidden org per partner. +CREATE UNIQUE INDEX IF NOT EXISTS organizations_partner_quick_support_uniq + ON organizations(partner_id) WHERE type = 'quick_support'; +``` + - [ ] **Step 2: Drizzle schema file `supportSessions.ts`** ```ts @@ -148,7 +175,7 @@ export const supportSessions = pgTable('support_sessions', { - [ ] **Step 3: Modify existing schema files** -In `orgs.ts`: `orgTypeEnum` becomes `pgEnum('org_type', ['customer', 'internal', 'quick_support'])`. In `enrollmentKeys` add `supportSessionId: uuid('support_session_id')` (plain uuid — no `.references()` to avoid a circular import with `supportSessions.ts`; FK lives in SQL). In `devices.ts` add `isEphemeral: boolean('is_ephemeral').notNull().default(false)` right after `status`. Export `supportSessions` + `supportSessionStatusEnum` from `schema/index.ts`. +In `orgs.ts`: `orgTypeEnum` becomes `pgEnum('org_type', ['customer', 'internal', 'quick_support'])`. In `enrollmentKeys` add `supportSessionId: uuid('support_session_id')` (plain uuid — no `.references()` to avoid a circular import with `supportSessions.ts`; FK lives in SQL). In `devices.ts` add `isEphemeral: boolean('is_ephemeral').notNull().default(false)` right after `status`. Export `supportSessions` + `supportSessionStatusEnum` from `schema/index.ts`. If `pnpm db:check-drift` flags the partial unique index, mirror it in the orgs.ts table extras: `uniqueIndex('organizations_partner_quick_support_uniq').on(t.partnerId).where(sql`type = 'quick_support'`)`. - [ ] **Step 4: Verify drift + migration test** @@ -247,7 +274,7 @@ API test: generated code matches `SUPPORT_CODE_PATTERN`; 1000 generations all di - Consumes: `db, withSystemDbAccessContext, runOutsideDbContext` from `../db`; `organizations, sites` from `../db/schema`. - Produces: `getOrCreateQuickSupportOrg(partnerId: string): Promise<{ orgId: string; siteId: string }>`. -- [ ] **Step 1: Failing tests** — mock `../db` (mirror an existing service test, e.g. whatever `partnerCreate`-adjacent tests do): (a) existing quick_support org + site → returned without insert; (b) none → inserts org `{ partnerId, name: 'Quick Support', slug: 'quick-support-', type: 'quick_support', status: 'active' }` then site `{ orgId, name: 'Quick Support', timezone: 'UTC' }`; (c) insert conflict (unique partial index) → re-select wins (no throw). +- [ ] **Step 1: Failing tests** — mock `../db` (mirror an existing service test, e.g. whatever `partnerCreate`-adjacent tests do): (a) existing quick_support org + site → returned without insert; (b) none → inserts org `{ partnerId, name: 'Quick Support', slug: 'quick-support-', type: 'quick_support', status: 'active' }` then site `{ orgId, name: 'Quick Support', timezone: 'UTC' }`; (c) insert conflict (unique partial index) → re-select wins (no throw). - [ ] **Step 2: Implement** @@ -275,7 +302,9 @@ export async function getOrCreateQuickSupportOrg(partnerId: string): Promise<{ o await db.insert(organizations).values({ partnerId, name: 'Quick Support', - slug: `quick-support-${partnerId.slice(0, 8)}`, + // Full uuid in the slug — an 8-char prefix can collide across partners + // if slugs are globally unique, which would make provisioning throw. + slug: `quick-support-${partnerId}`, type: 'quick_support', status: 'active', }).onConflictDoNothing(); @@ -319,6 +348,7 @@ Note: `.onConflictDoNothing()` needs the target — if Drizzle requires it for p - create: partner-scope auth → 201, body has formatted code matching `/^[A-Z2-9]{3}-[A-Z2-9]{3}-[A-Z2-9]{3}$/`, `landingUrl` ends `/quick?code=`; DB insert received `codeHash` = sha256 of raw code, `orgId` from provisioning. - create with `attributedOrgId` not in `auth.accessibleOrgIds` → 403. - create with org-scope auth (`auth.scope === 'organization'`) → 403. + - create with a system-scope token that has no `partnerId` → 403 (the hidden org is per-partner; provisioning would otherwise crash). - get: session whose device has a live `remote_sessions` row (status `active`) → `status: 'active'` (derived); device row `status==='online'` → `deviceOnline: true`. - list: returns sessions ordered `createdAt desc`. - Audit: create emits `logSessionAudit('support_session_created', ...)`. @@ -347,6 +377,10 @@ supportSessionRoutes.post('/support-sessions', zValidator('json', createSupportS if (auth.scope !== 'partner' && auth.scope !== 'system') { return c.json({ error: 'Quick Support requires partner scope' }, 403); } + if (!auth.partnerId) { + // system tokens may carry no partner context — the hidden org is per-partner + return c.json({ error: 'Quick Support requires a partner context' }, 403); + } const data = c.req.valid('json'); if (data.attributedOrgId && auth.accessibleOrgIds !== null && !auth.accessibleOrgIds.includes(data.attributedOrgId)) { @@ -410,7 +444,7 @@ async function toView(session: typeof supportSessions.$inferSelect) { } ``` -Never return `codeHash`. List caps `limit` at 100, default 50, `orderBy(desc(supportSessions.createdAt))`. +Never return `codeHash`. List caps `limit` at 100, default 50, `orderBy(desc(supportSessions.createdAt))`. For the list endpoint don't call `toView` per row (N+1 — ~100 queries at limit 50): batch-load device statuses and live `remote_sessions` with two `inArray(deviceId, [...])` queries over the page's device ids, then map. - [ ] **Step 3: Mount in `remote/index.ts`** after the existing sub-routes: @@ -514,7 +548,9 @@ supportPublicRoutes.post('/redeem', zValidator('json', redeemSupportSessionSchem }); ``` -Audit the claim via `logSessionAudit('support_session_claimed', row.createdByUserId, row.orgId, { sessionId: row.id }, ip)` after the claim succeeds. +Audit the claim via `logSessionAudit('support_session_claimed', row.createdByUserId, row.orgId, { sessionId: row.id, actor: 'end_user' }, ip)` after the claim succeeds — the userId is the session *creator's* (audit rows need one); the real actor is the anonymous end user, so say so in the details. + +Before shipping: confirm the public installer redemption flow really does return `AGENT_ENROLLMENT_SECRET` to code-authenticated callers (`installer.ts`) — this endpoint must match existing exposure, not create new exposure. If installer.ts does NOT return it, neither do we (and the Go client falls back to prompt-free enrollment without a secret only if the server allows it). - [ ] **Step 3: Run tests → PASS. Commit** — `feat(api): public quick support check/redeem endpoints` @@ -619,10 +655,10 @@ Guarded on `isEphemeral` so the 10k-device fleet pays zero extra queries on reco - Test: extend `apps/api/src/routes/remote/supportSessions.test.ts` **Interfaces:** -- Consumes: `sendCommandToAgent(agentId, command)` from `../agentWs` (returns `boolean`). +- Consumes: `sendCommandToAgent(agentId, command)` from `../agentWs` (returns `boolean`), plus a WS force-close helper — grep `agentWs.ts` for the per-agent connection registry and export `closeAgentConnection(agentId: string)` if it isn't already. - Produces: `POST /remote/support-sessions/:id/end` → `200 { success: true }`; wire command `{ id: 'support-end-', type: 'support_end', payload: { sessionId } }` (Go side consumes this exact shape in Task 13). Exported helper `endSupportSession(sessionId, reason, actorId | null): Promise` in a new `apps/api/src/services/quickSupportEnd.ts` so the reaper (Task 9) reuses it. -- [ ] **Step 1: Failing tests** — end a `ready` session: command sent with the device's `agentId`; device row updated `{ agentTokenHash: null, watchdogTokenHash: null, helperTokenHash: null, status: 'decommissioned' }`; session `{ status: 'ended', endedReason: 'tech', endedAt set }`; audit `support_session_ended`. Ending an already-`ended` session → 409. Ending a `pending` session (never claimed) → 200, no command attempted. +- [ ] **Step 1: Failing tests** — end a `ready` session: command sent with the device's `agentId`; device row updated `{ agentTokenHash: null, watchdogTokenHash: null, helperTokenHash: null, status: 'decommissioned' }`; the device's agent WS force-closed after revocation; session `{ status: 'ended', endedReason: 'tech', endedAt set }`; audit `support_session_ended`. Ending an already-`ended` session → 409. Ending a `pending` session (never claimed) → 200, no command attempted. - [ ] **Step 2: Implement service `quickSupportEnd.ts`** @@ -630,7 +666,7 @@ Guarded on `isEphemeral` so the 10k-device fleet pays zero extra queries on reco import { eq } from 'drizzle-orm'; import { db, withSystemDbAccessContext, runOutsideDbContext } from '../db'; import { devices, supportSessions } from '../db/schema'; -import { sendCommandToAgent } from '../routes/agentWs'; +import { sendCommandToAgent, closeAgentConnection } from '../routes/agentWs'; export async function endSupportSession( sessionId: string, @@ -645,8 +681,11 @@ export async function endSupportSession( const [dev] = await db.select({ agentId: devices.agentId }).from(devices) .where(eq(devices.id, session.deviceId)).limit(1); if (dev) { - // Best-effort: deliver self-destruct before revoking (WS stays up either way; - // revocation only blocks re-auth). Client dead-man switch covers non-delivery. + // Deliver self-destruct first (WS is still up), then revoke, then + // force-close the WS. The close is load-bearing: if support_end is + // lost, a healthy WS would otherwise linger until the 8h hard cap + // (client online → offline dead-man never fires). Close → reconnect + // → re-auth fails → dead-man cleans up within ~10 min. sendCommandToAgent(dev.agentId, { id: `support-end-${sessionId}`, type: 'support_end', @@ -656,6 +695,7 @@ export async function endSupportSession( agentTokenHash: null, watchdogTokenHash: null, helperTokenHash: null, status: 'decommissioned', }).where(eq(devices.id, session.deviceId)); + closeAgentConnection(dev.agentId); } } @@ -671,6 +711,8 @@ export async function endSupportSession( (Cast/typing: match the actual `AgentCommand` type from agentWs instead of `as never`.) Route handler: load session under normal RLS context (proves the caller can see it), 409 if terminal, then call `endSupportSession(id, 'tech')`, then `logSessionAudit('support_session_ended', auth.userId, session.orgId, { sessionId: id, reason: 'tech' }, ip)`. +Also verify `POST /remote/sessions` rejects devices with `status === 'decommissioned'` — if there's no such guard, add one here (an ended-but-still-lingering client must not be connectable); assert it in Task 17's chain test. + - [ ] **Step 3: Run → PASS. Commit** — `feat(api): end quick support session with agent self-destruct + token revocation` --- @@ -689,6 +731,7 @@ export async function endSupportSession( - [ ] **Step 1: Failing tests** for the pure `reapOnce()` function (export it for tests; the worker just calls it): - `pending` past `codeExpiresAt` → `expired`. + - `claimed` with no `deviceId` and `claimedAt` older than 20 min → `expired` (client crashed between redeem and enroll — the child key is long dead; don't leave the tech's panel on "Client connecting…" for 8 h). - `claimed`/`ready` past `hardExpiresAt` → `endSupportSession(id, 'expired')` called. - `ready` session whose device `status='offline'` and `lastSeenAt` older than 5 min → `endSupportSession(id, 'end_user')` (deviation #6: end-user stop detection). - ended/expired sessions with `deviceId` and `endedAt` older than 6 h → device purged and `supportSessions.deviceId` nulled (FK `ON DELETE SET NULL`). @@ -701,6 +744,14 @@ export async function reapOnce(): Promise { await db.update(supportSessions).set({ status: 'expired', endedAt: new Date(), endedReason: 'expired' }) .where(and(eq(supportSessions.status, 'pending'), lt(supportSessions.codeExpiresAt, new Date()))); + // 1b. Claimed-but-never-enrolled limbo (client crashed between redeem and enroll) + await db.update(supportSessions).set({ status: 'expired', endedAt: new Date(), endedReason: 'error' }) + .where(and( + eq(supportSessions.status, 'claimed'), + isNull(supportSessions.deviceId), + lt(supportSessions.claimedAt, new Date(Date.now() - 20 * 60_000)), + )); + // 2. Hard-cap enforcement const overdue = await db.select({ id: supportSessions.id }).from(supportSessions) .where(and(inArray(supportSessions.status, ['claimed', 'ready']), lt(supportSessions.hardExpiresAt, new Date()))); @@ -759,6 +810,10 @@ For each hit, decide: **user-facing org enumeration or device count → exclude* **DO NOT touch `computeAccessibleOrgIds` (`apps/api/src/middleware/auth.ts:208`) or its `bearerTokenAuth.ts` twin** — the hidden org MUST stay in `accessibleOrgIds` or RLS blocks all tech access to `support_sessions`. Add a comment there saying exactly that. +**DO NOT exclude ephemeral devices from the status-upkeep path** — whatever job/logic flips `devices.status` to `offline` by `lastSeenAt` must keep processing ephemeral devices, or Task 9's end-user-stop detection silently never fires. + +Sweep beyond enumeration too: alert/monitor *evaluation* paths that apply to all devices at the code level regardless of policy rows (e.g. default event-log monitoring), and billing/usage rollup queries — an ephemeral device must never page anyone or appear on an invoice. + - [ ] **Step 2: Failing tests** — org list endpoint response omits a seeded `type='quick_support'` org; devices list omits an `isEphemeral` device; both still returned when queried directly by id (detail routes untouched). - [ ] **Step 3: Apply filters, run the full API unit suite (`pnpm test --filter=@breeze/api`) → PASS. Commit** — `feat(api): hide quick support org + ephemeral devices from listings` @@ -779,6 +834,8 @@ For each hit, decide: **user-facing org enumeration or device count → exclude* - [ ] **Step 2: Implement** — soft-validate the code (same query as `/check`); resolve the agent binary: `getBinarySource() === 'github'` → `fetch(getGithubAgentUrl('windows', 'amd64'))` and stream the response body through with our Content-Disposition (a redirect would lose the filename; note the ~60 MB proxy cost as acceptable v1); `local`/S3 → mirror `viewers/download.ts` disk/presign logic but force the filename. Build `` via `new URL(process.env.PUBLIC_API_URL ?? '').host`. +- [ ] **Step 2b: Signing / SmartScreen check (release-blocking).** Verify the binary this route serves is Authenticode-signed (pull a GitHub-release `breeze-agent.exe`, check with `Get-AuthenticodeSignature`). Per-session filename renames do NOT invalidate the signature or SmartScreen reputation — both key on content hash + cert — but serving an *unsigned* exe puts consumers in front of a "Windows protected your PC" wall at the scariest possible moment, and AV/EDR heuristics pile on (renamed binary from Downloads installing a temp service). If unsigned, extend the MSI signing pipeline to sign the raw exe before this task ships, record the signer name for Task 16's landing copy, and expect a reputation ramp for a newly-signed binary. + - [ ] **Step 3: Run → PASS. Commit** — `feat(api): quick support client download with code-embedded filename` --- @@ -808,10 +865,12 @@ func TestResolveSupportInput(t *testing.T) { {"flags win", "breeze-agent.exe", "KTM-4H7-P2X", "https://eu.2breeze.app", "KTM4H7P2X", "https://eu.2breeze.app", false}, {"filename parsed", "breeze-support-KTM4H7P2X-us.2breeze.app.exe", "", "", "KTM4H7P2X", "https://us.2breeze.app", false}, {"browser copy suffix", "breeze-support-KTM4H7P2X-us.2breeze.app (1).exe", "", "", "KTM4H7P2X", "https://us.2breeze.app", false}, + {"firefox copy suffix (no space)", "breeze-support-KTM4H7P2X-us.2breeze.app(1).exe", "", "", "KTM4H7P2X", "https://us.2breeze.app", false}, {"case insensitive", "Breeze-Support-ktm4h7p2x-us.2breeze.app.exe", "", "", "KTM4H7P2X", "https://us.2breeze.app", false}, {"nothing embedded, no flags", "breeze-agent.exe", "", "", "", "", true}, // caller falls back to prompt } - // filename regex: (?i)^breeze-support-([a-z2-9]{9})-(.+?)(?: \(\d+\))?\.exe$ + // filename regex: (?i)^breeze-support-([a-z2-9]{9})-(.+?)(?:\s?\(\d+\))?\.exe$ + // (space before "(1)" optional — Chrome/Edge insert one, Firefox doesn't) } ``` @@ -846,13 +905,18 @@ var supportCmd = &cobra.Command{ ``` Print state changes ("Technician connected." / "Technician disconnected.") by setting `h.desktopMgr.OnSessionStarted/OnSessionStopped` style callbacks (OnSessionStopped already exists for direct mode — `heartbeat.go:570`; add OnSessionStarted symmetrically if absent). -7. Signal handling: on Ctrl+C/SIGTERM → `supportCleanup(dir)` (Task 13) then exit. -8. Dead-man switch goroutine: if the WS client reports disconnected continuously for 10 min, or `time.Now()` passes `HardExpiresAt` → print notice, `supportCleanup(dir)`, exit. +7. Signal handling: on Ctrl+C/SIGTERM → `supportCleanup(dir)` (Task 13) then exit. (A console X-close arrives as SIGTERM with a ~5s grace budget on Windows — keep cleanup free of network waits.) +8. Dead-man switch goroutine: if the WS client reports disconnected continuously for 10 min, or `time.Now()` passes `HardExpiresAt` → print notice, `supportCleanup(dir)`, exit. (Server-side end force-closes the WS after revoking tokens — Task 8 — so even a lost `support_end` converges here in ≤10 min: reconnect attempts fail re-auth and the client counts as disconnected.) `Main()` basename dispatch (next to the `breeze-desktop-helper` special case at `main.go:309`): ```go -if strings.HasPrefix(strings.ToLower(filepath.Base(os.Args[0])), "breeze-support") { +// Second condition guards the Tier 2 service copy (breeze-support-svc.exe), +// which is launched with an explicit `support --service-run ...` argv — +// without it the prefix dispatch would prepend a SECOND "support" and cobra +// would parse the duplicate as a positional arg. +if strings.HasPrefix(strings.ToLower(filepath.Base(os.Args[0])), "breeze-support") && + (len(os.Args) < 2 || os.Args[1] != "support") { rootCmd.SetArgs(append([]string{"support"}, os.Args[1:]...)) } ``` @@ -897,7 +961,7 @@ func handleSupportEnd(h *Heartbeat, cmd Command) tools.CommandResult { --- -### Task 14: Go agent — Tier 2 (elevated temporary service) +### Task 14: Go agent — Tier 2 (elevated temporary service) — **Milestone B** **Files:** - Create: `agent/internal/agentapp/support_service_windows.go` (build tag `windows`) @@ -906,19 +970,20 @@ func handleSupportEnd(h *Heartbeat, cmd Command) tools.CommandResult { **Interfaces:** - Consumes: `golang.org/x/sys/windows/svc` + `svc/mgr` (service create/start/stop/delete), elevation check via `windows.Token.IsElevated()`, existing service-mode startup (`runAsService`, `service_windows.go:106`), SYSTEM desktop-helper spawn machinery (works when `IsService=true`). -- Produces: when the support process is launched **elevated** (user right-clicked → Run as administrator, or accepts the UAC prompt on our re-launch offer), it installs a temporary service `BreezeQuickSupport` running `\breeze-support-svc.exe support --service-run --config \agent.yaml`, starts it, and the console process becomes a monitor. Teardown removes service + files. +- Produces: when the support process is launched **elevated** (user right-clicked → Run as administrator, or accepts the UAC prompt on our re-launch offer), it installs a temporary service `BreezeQuickSupport` running `\breeze-support-svc.exe support --service-run --config \agent.yaml --monitor-pid `, starts it, and the console process becomes a monitor. The service watches the monitor PID and self-tears-down when it dies. Teardown removes service + files. - [ ] **Step 1: Flow to implement** 1. In `runSupportSession()` after redeem+enroll: if `isElevated()` → Tier 2 path; else print `TIP: for full control (admin prompts), close this and re-run as administrator.` and continue Tier 1. (No forced UAC prompt in v1 — "Run as administrator" is the documented path; a mid-session elevation request is Phase 3.) 2. Tier 2 setup: copy own exe into the temp workspace twice — `breeze-support-svc.exe` (service binary) and `breeze-desktop-helper.exe`. **Investigation sub-step:** find how `sessionbroker.SpawnHelperInSession` / `spawnHelperForDesktop` (`handlers_desktop_helper.go:481,589`) resolves the desktop-helper binary path; if it assumes the Program Files install dir, add a fallback to "directory of the running executable" (benefits dev builds too). This is the riskiest line in the Go work — timebox it and, if the resolution is tangled, ship Tier 2 as service-without-helper (secure-desktop capture degraded) and file a follow-up issue. -3. Install: `mgr.Connect()` → `m.CreateService("BreezeQuickSupport", svcExe, mgr.Config{DisplayName: "Breeze Quick Support (temporary)", StartType: mgr.StartManual}, "support", "--service-run", "--config", cfgFile)` → `s.Start()`. Fail → warn and fall back to Tier 1 inline. +3. Install: `mgr.Connect()` → `m.CreateService("BreezeQuickSupport", svcExe, mgr.Config{DisplayName: "Breeze Quick Support (temporary)", StartType: mgr.StartManual}, "support", "--service-run", "--config", cfgFile, "--monitor-pid", strconv.Itoa(os.Getpid()))` → `s.Start()`. Fail → warn and fall back to Tier 1 inline. 4. `--service-run` (hidden flag): sets `cfg.SupportMode` from the loaded config dir and enters the existing `runAsService` path (SCM). Service loads the already-enrolled temp config; `IsService=true` → broker + SYSTEM desktop helper → UAC/secure-desktop capture works. -5. Console monitor: polls service status; Ctrl+C → stop+delete service, cleanup. `supportCleanup` on the service side (support_end command) must also `sc stop/delete BreezeQuickSupport` — reuse the `sc.exe stop/delete` invocation pattern from `selfUninstallWindows` (`handlers_uninstall.go:209`) with the temp service name, then delete both copied exes via the trampoline. +5. **Service-side monitor watchdog — THE consent guarantee.** In Tier 2 the console is only an indicator; capture runs in the SYSTEM service. If the user closes the console with X (~5s SIGTERM grace — `sc stop` may not finish) or kills it from Task Manager (no grace at all), the service must not keep sharing the screen with no visible indicator. So on start the service opens the `--monitor-pid` process handle (`windows.OpenProcess(SYNCHRONIZE, ...)`) and a goroutine `WaitForSingleObject`s on it; when the monitor dies for ANY reason → full teardown (stop desktop sessions, `sc delete` self, trampoline-delete both exes, exit). Never rely on the monitor's own close handling for teardown — that path is best-effort UX only. Missing/dead `--monitor-pid` at service start → refuse to start (fail safe). +6. Console monitor: polls service status; Ctrl+C → stop+delete service, cleanup (best-effort — the watchdog in step 5 is the guarantee). `supportCleanup` on the service side (support_end command) must also `sc stop/delete BreezeQuickSupport` — reuse the `sc.exe stop/delete` invocation pattern from `selfUninstallWindows` (`handlers_uninstall.go:209`) with the temp service name, then delete both copied exes via the trampoline. - [ ] **Step 2: Unit-test the pure parts** (service args builder, `isElevated` wrapper injectable). `go build ./... && go test -race ./...` → PASS. -- [ ] **Step 3: Manual verification on the Windows test VM:** run elevated → temp service appears (`sc query BreezeQuickSupport`), desktop session shows UAC prompts; end session → service gone, files gone. Commit** — `feat(agent): quick support tier 2 temporary service (elevated)` +- [ ] **Step 3: Manual verification on the Windows test VM:** run elevated → temp service appears (`sc query BreezeQuickSupport`), desktop session shows UAC prompts; end session → service gone, files gone; **kill the console monitor from Task Manager mid-session → service self-tears-down within seconds (`sc query` gone, capture stops)** — this is the consent-guarantee check, do not skip it. Commit** — `feat(agent): quick support tier 2 temporary service (elevated)` --- @@ -936,9 +1001,9 @@ func handleSupportEnd(h *Heartbeat, cmd Command) tools.CommandResult { - [ ] **Step 1: Failing component tests** (Vitest + jsdom, mirror a sibling like `EnrollmentKeyManager` tests): create → code displayed in `XXX-XXX-XXX` format + copy-link button writes `landingUrl` to clipboard; status polling transitions render "Waiting for user" (`pending`) → "User connected — ready" (`ready` + `deviceOnline`) → Connect button appears; End calls `POST .../end` via `runAction`; list renders recent sessions with attribution label. -- [ ] **Step 2: Implement.** Create dialog: attribution org select (options from existing org store / `GET /organizations` — hidden org is already server-filtered by Task 10) + label input; submit via `runAction({ request: () => fetchWithAuth('/remote/support-sessions', { method: 'POST', body: JSON.stringify(payload) }), errorFallback: 'Failed to create support session', successMessage: () => 'Support session created' })`. After create, show the code big + copyable link, and poll `GET /remote/support-sessions/:id` every 3 s (recursive `setTimeout` in a `useRef`, cleared on unmount — the `ConnectDesktopButton.tsx:399` pattern; stop polling on terminal states). When `deviceId && deviceOnline`, render `` plus the End button (runAction, 401 → return, non-ActionError → toast). Status copy: pending → "Waiting for the user to run the client…", claimed → "Client connecting…", ready → "Ready to connect", active → "Session in progress", ended/expired → terminal badge. +- [ ] **Step 2: Implement.** Create dialog: attribution org select (options from existing org store / `GET /organizations` — hidden org is already server-filtered by Task 10) + label input; submit via `runAction({ request: () => fetchWithAuth('/remote/support-sessions', { method: 'POST', body: JSON.stringify(payload) }), errorFallback: 'Failed to create support session', successMessage: () => 'Support session created' })`. After create, show the code big + copyable link, and poll `GET /remote/support-sessions/:id` every 3 s (recursive `setTimeout` in a `useRef`, cleared on unmount — the `ConnectDesktopButton.tsx:399` pattern; stop polling on terminal states). When `deviceId && deviceOnline`, render `` plus the End button (runAction, 401 → return, non-ActionError → toast). Status copy: pending → "Waiting for the user to run the client…", claimed → "Client connecting…", ready → "Ready to connect", active → "Session in progress", ended/expired → terminal badge. All user-visible strings via the i18n layer (literal-key `t()` — mirror a recently-added sibling component), with every new key added to en AND all other locale catalogs in the same commit. -- [ ] **Step 3: `pnpm test --filter=@breeze/web -- QuickSupport` → PASS; `pnpm astro check` clean (types in tests count). Commit** — `feat(web): quick support page` +- [ ] **Step 3: `pnpm test --filter=@breeze/web -- QuickSupport` → PASS; i18n literal-key + locale-parity suites → PASS; `pnpm astro check` clean (types in tests count). Commit** — `feat(web): quick support page` --- @@ -957,6 +1022,8 @@ func handleSupportEnd(h *Heartbeat, cmd Command) tools.CommandResult { - [ ] **Step 2: Implement.** Read code from `location.search` on mount; validate via `/support/check`; render entry-form vs landing states. Download = plain `` (browser download, no fetch). Include the manual-fallback instruction under the button: "If the download prompts for a code, enter: **XXX-XXX-XXX**." +This page is consumer-facing: localize it first-class (same i18n rules as Task 15). Set honest expectations for the Windows prompt — show the expected publisher from the Authenticode cert recorded in Task 11 Step 2b ("You'll see a Windows prompt — the publisher should read **"). Do NOT ship copy that coaches users past an unsigned-binary warning; if the signing check fails, this page is blocked on it. + - [ ] **Step 3: Run tests → PASS. Verify CSP: the page fetches the API origin — confirm `apps/web/src/middleware.ts` CSP `connect-src` already allows it (it must, all islands do). Commit** — `feat(web): public quick support landing page` --- @@ -972,7 +1039,7 @@ func handleSupportEnd(h *Heartbeat, cmd Command) tools.CommandResult { - [ ] **Step 1: RLS suite** — as `breeze_app` with partner-A context: SELECT partner-B's support_sessions → 0 rows; forged INSERT into partner-B's hidden org → fails `42501` (assert the error code — don't let a memoized fixture make it vacuous); rls-coverage contract test still green (Shape 1 auto-discovery: `pnpm vitest run -c vitest.config.rls.ts`). -- [ ] **Step 2: Chain suite** — seed partner + tech user; `POST /remote/support-sessions` → code; `POST /support/redeem` → child key; `POST /agents/enroll` with it → device `is_ephemeral=true`, session `deviceId` linked, status `claimed`; second redeem of same code → 404; enroll at maxDevices=0 partner limit → still succeeds for support key, fails for a normal key; `endSupportSession(id,'tech')` → device tokens nulled + status decommissioned; `reapOnce()` after faking `endedAt` 7 h back → device row gone, `supportSessions.deviceId` null. +- [ ] **Step 2: Chain suite** — seed partner + tech user; `POST /remote/support-sessions` → code; `POST /support/redeem` → child key; `POST /agents/enroll` with it → device `is_ephemeral=true`, session `deviceId` linked, status `claimed`; second redeem of same code → 404; enroll at maxDevices=0 partner limit → still succeeds for support key, fails for a normal key; `endSupportSession(id,'tech')` → device tokens nulled + status decommissioned; creating a remote session against that decommissioned device → rejected; `reapOnce()` after faking `endedAt` 7 h back → device row gone, `supportSessions.deviceId` null. - [ ] **Step 3: Run integration suite locally against the docker Postgres → PASS. Commit** — `test(api): quick support RLS + end-to-end chain integration tests` @@ -983,6 +1050,9 @@ func handleSupportEnd(h *Heartbeat, cmd Command) tools.CommandResult { - [ ] `pnpm test` (all workspaces), `cd agent && go test -race ./...`, `pnpm astro check`, `pnpm db:check-drift` — all green. - [ ] Type Check includes tests + site-scope contract (CI parity — run `pnpm typecheck` if defined). - [ ] Manual e2e via the `worktree-stack` skill: full happy path from create → landing page → client on Windows VM → viewer connect → end → self-delete. Verify as `breeze_app` in psql: forge a cross-tenant support_session insert → RLS rejection. +- [ ] Verify the served support binary is Authenticode-signed (Task 11 Step 2b) and the landing-page publisher copy matches the cert. +- [ ] End a session while the client WS is healthy and confirm the client exits promptly (support_end path) — then repeat with the command handler artificially disabled and confirm the WS force-close → dead-man path converges in ≤10 min. +- [ ] Milestone B only: the Task Manager–kill teardown check from Task 14 Step 3. - [ ] Grep sweep from Task 10 recorded in PR description; confirm no `support_sessions` consumer bypasses the status guards (`grep -rn "supportSessions" apps/api/src --include='*.ts' | grep -v test`). - [ ] Update `apps/docs` remote-access page with a Quick Support section (brief; full docs pass at release via the release skill). - [ ] Commit any stragglers; run `superpowers:requesting-code-review` / open PR. @@ -994,3 +1064,4 @@ func handleSupportEnd(h *Heartbeat, cmd Command) tools.CommandResult { - **Spec coverage:** flows (T4/5/11/12/15/16), lifecycle+states (T1/5/7/8/9), data model (T1), hidden org (T3/T10), enrollment guards (T6), reaper 3-layer cleanup (T9/T13 dead-man/T8 cooperative), security (rate limits T5/T11, revocation T8, forged-command guard T13, RLS T1/T17), audit (T4/5/8), UI (T15/16), Tier 2 (T14), testing standards (each task + T17). Consent posture needs no code: sessions on ephemeral devices use the default prompt config; running the client is consent (spec) — the hidden org has no `config_policy_remote_access_settings`, so `resolveRemoteSessionPromptConfig` falls back to defaults; verify during T18 manual e2e that the default doesn't hard-block (if default is `consent` + `block`, add a support-mode bypass in the offer path — check `remoteAccessPolicy.ts` defaults during T4). - **Known risks, called out in-task:** desktop-helper binary path resolution for Tier 2 (T14, timeboxed with degrade path); GitHub-mode 60 MB proxy streaming (T11); org-exclusion sweep completeness (T10 grep checklist + PR record). - Types/names used across tasks were cross-checked: `endSupportSession(sessionId, reason)` (T8→T9), redeem response `{serverUrl, enrollmentKey, enrollmentSecret, sessionId, hardExpiresAt}` (T5→T12), command `support_end` + payload (T8→T13), filename format (T11→T12), `SupportSessionView.deviceOnline` (T4→T15). +- **2026-07-17 review pass (items 7–13 above):** migration split (`-a-`/`-b-` enum-use rule — the original single file failed on first run); WS force-close on end (dead-man convergence ≤10 min instead of the 8h cap) + decommissioned-device connect guard; Tier 2 service-side monitor watchdog (the consent guarantee — console kill must always stop sharing); claimed-limbo reaping; full-uuid org slug; system-token `partnerId` guard; batched list view (N+1); Firefox rename tolerance; basename-dispatch double-`support` guard for the svc binary; i18n + signing/SmartScreen made explicit; Milestone A/B split so Tier 1 can ship without waiting on the Tier 2 unknowns. diff --git a/docs/superpowers/specs/2026-07-06-one-off-support-session-design.md b/docs/superpowers/specs/2026-07-06-one-off-support-session-design.md index 922ec4482..6473dad68 100644 --- a/docs/superpowers/specs/2026-07-06-one-off-support-session-design.md +++ b/docs/superpowers/specs/2026-07-06-one-off-support-session-design.md @@ -157,6 +157,16 @@ Ships as a signed + notarized **`.app` wrapper** — TCC attributes Screen Recor - Creating support sessions requires partner-scope (or system) tokens in v1 — org-scoped tokens can't reach the hidden org through RLS. - Tier 2 elevation in v1 = "Run as administrator" relaunch installs the temporary service; a mid-session elevation request is Phase 3. +### 2026-07-17 plan-review adjustments + +- The schema migration is split into `-a-`/`-b-` files: the partial hidden-org index uses the new enum value, which Postgres forbids in the transaction that adds it (autoMigrate wraps each file in one transaction). +- `endSupportSession` force-closes the agent WS after revoking tokens, so a lost `support_end` converges via the 10-min dead-man switch instead of lingering to the 8h cap; `POST /remote/sessions` must reject decommissioned devices. +- Tier 2's consent guarantee is service-side: the temporary service watches the console monitor's process handle (`--monitor-pid`) and tears itself down when the monitor dies — closing or killing the console must always stop sharing. +- The served client must be Authenticode-signed (SmartScreen); per-session filename renames don't affect signature or reputation. Landing-page copy states the expected publisher and never coaches past an unsigned-binary warning. +- All Quick Support UI, including the public `/quick` page, is localized (locale-parity CI gate). +- The reaper also expires claimed-but-never-enrolled sessions 20 min after claim. +- Phase 1 ships as **Milestone A** (Tier 1 end-to-end, plan Tasks 1–13 + 15–18) with Tier 2 as **Milestone B** (Task 14), quarantining the helper-path and service-lifecycle unknowns. + ## Out of scope (v1) - User-generated codes / anonymous session queue (TeamViewer-style). From 28e2c25c5d2d7b10b284c930ba2e4433cca8acaa Mon Sep 17 00:00:00 2001 From: Todd Hebebrand Date: Tue, 4 Aug 2026 16:47:15 -0500 Subject: [PATCH 05/28] =?UTF-8?q?feat(api):=20quick=20support=20schema=20?= =?UTF-8?q?=E2=80=94=20support=5Fsessions,=20ephemeral=20devices,=20quick?= =?UTF-8?q?=5Fsupport=20org=20type?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the Quick Support data model (spec 2026-07-06-one-off-support-session-design.md): - support_sessions (RLS Shape 1, direct org_id, four breeze_org_isolation policies in the same migration). code_hash is SHA-256; plaintext is shown once and never stored. - organizations.type += 'quick_support' — the hidden per-partner org that holds ephemeral devices, one per partner via a partial unique index. - devices.is_ephemeral, enrollment_keys.support_session_id. The migration is split -a-/-b- because Postgres rejects any USE of an enum value added in the current transaction (55P04) and autoMigrate wraps each file in one transaction; the partial index on type = 'quick_support' must therefore live in a later file. Cascade/export registration (not in the original plan — added after checking the contract tests): support_sessions goes in CORE_ORG_CASCADE_DELETE_ORDER, DEVICE_DETACH_DEVICE_ID_TABLES (device_id is ON DELETE SET NULL so the audit row outlives the purged device), CORE_DEVICE_ORG_DENORMALIZED_TABLES and CORE_TENANT_EXPORT_POLICY. The two new columns are also classified in the devices/enrollment_keys export policies, which the column-level rule requires. Migrations are dated 2026-08-13 rather than the plan's 2026-07-06 so they sort after the existing last migration (2026-08-12). Co-Authored-By: Claude Opus 5 (1M context) --- .../2026-08-13-a-quick-support-sessions.sql | 110 ++++++++++++++++++ .../2026-08-13-b-quick-support-org-index.sql | 15 +++ apps/api/src/db/schema/devices.ts | 6 + apps/api/src/db/schema/index.ts | 1 + apps/api/src/db/schema/orgs.ts | 11 +- apps/api/src/db/schema/supportSessions.ts | 44 +++++++ apps/api/src/routes/devices/core.ts | 7 +- apps/api/src/services/tenantCascade.ts | 5 + .../services/tenantExportPolicyRegistry.ts | 8 +- 9 files changed, 203 insertions(+), 4 deletions(-) create mode 100644 apps/api/migrations/2026-08-13-a-quick-support-sessions.sql create mode 100644 apps/api/migrations/2026-08-13-b-quick-support-org-index.sql create mode 100644 apps/api/src/db/schema/supportSessions.ts diff --git a/apps/api/migrations/2026-08-13-a-quick-support-sessions.sql b/apps/api/migrations/2026-08-13-a-quick-support-sessions.sql new file mode 100644 index 000000000..463cfead9 --- /dev/null +++ b/apps/api/migrations/2026-08-13-a-quick-support-sessions.sql @@ -0,0 +1,110 @@ +-- Quick Support — one-time code ad-hoc remote sessions. +-- Spec: docs/superpowers/specs/2026-07-06-one-off-support-session-design.md +-- Plan: docs/superpowers/plans/2026-07-06-quick-support-phase1.md +-- +-- A tech generates a short one-time code; the end user runs a downloaded client +-- (the Go agent in `support` mode) that enrolls an EPHEMERAL device into a +-- hidden per-partner 'quick_support' org. The existing remote-desktop stack +-- (remote_sessions, WebRTC broker, consent, viewer, audit) is then reused +-- unchanged. Everything self-destructs at session end. +-- +-- support_sessions is RLS Shape 1 (direct org_id) — auto-discovered by +-- rls-coverage.integration.test.ts, so it needs no allowlist entry. It DOES +-- need registering in the cascade/export lists (done in the same PR): +-- - CORE_ORG_CASCADE_DELETE_ORDER (services/tenantCascade.ts) +-- - DEVICE_DETACH_DEVICE_ID_TABLES (routes/devices/core.ts — device_id is +-- ON DELETE SET NULL, so the row survives device deletion like tickets) +-- - CORE_TENANT_EXPORT_POLICY (services/tenantExportPolicyRegistry.ts) +-- +-- Idempotent: ADD COLUMN / CREATE TABLE / CREATE INDEX IF NOT EXISTS, guarded +-- type creation, DROP POLICY IF EXISTS then CREATE. Re-applying is a no-op. +-- No inner BEGIN/COMMIT — autoMigrate wraps each file in one transaction. + +-- ============================================ +-- Step 1: new org type for the hidden per-partner Quick Support org +-- ============================================ +-- PG12+ allows ADD VALUE inside a transaction, but the new value cannot be +-- USED in the same transaction (55P04 "unsafe use of new value") — and +-- autoMigrate wraps each file in ONE transaction. That is why the partial +-- index on `type = 'quick_support'` lives in the -b- file. Nothing in THIS +-- file may reference the new value. +ALTER TYPE org_type ADD VALUE IF NOT EXISTS 'quick_support'; + +-- ============================================ +-- Step 2: ephemeral device marker +-- ============================================ +-- Ephemeral devices are excluded from partner license counts, device +-- listings, billing rollups and alert evaluation, and are purged by the +-- reaper 6h after their session ends. +ALTER TABLE devices + ADD COLUMN IF NOT EXISTS is_ephemeral BOOLEAN NOT NULL DEFAULT FALSE; + +-- ============================================ +-- Step 3: support_sessions +-- ============================================ +DO $$ +BEGIN + CREATE TYPE support_session_status AS ENUM ('pending','claimed','ready','ended','expired'); +EXCEPTION WHEN duplicate_object THEN NULL; +END $$; + +CREATE TABLE IF NOT EXISTS support_sessions ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + -- the partner's hidden Quick Support org (never a real customer org) + org_id UUID NOT NULL REFERENCES organizations(id) ON DELETE CASCADE, + created_by_user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + -- SHA-256 hex of the one-time code; plaintext is shown once at creation + -- and never stored. + code_hash VARCHAR(64) NOT NULL UNIQUE, + code_expires_at TIMESTAMPTZ NOT NULL, + status support_session_status NOT NULL DEFAULT 'pending', + -- hard cap so no session can outlive the day even if nothing else fires + hard_expires_at TIMESTAMPTZ NOT NULL, + -- SET NULL (not CASCADE): the session row is the audit trail and must + -- survive the ephemeral device being purged. + device_id UUID REFERENCES devices(id) ON DELETE SET NULL, + -- reporting only — carries no tenancy effect whatsoever + attributed_org_id UUID REFERENCES organizations(id) ON DELETE SET NULL, + attribution_label TEXT, + claimed_at TIMESTAMPTZ, + claimed_from_ip TEXT, + ended_at TIMESTAMPTZ, + ended_reason TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS idx_support_sessions_reaper + ON support_sessions(status, hard_expires_at); +CREATE INDEX IF NOT EXISTS idx_support_sessions_device + ON support_sessions(device_id); + +-- ============================================ +-- Step 4: link the redeemed child enrollment key back to its session +-- ============================================ +ALTER TABLE enrollment_keys + ADD COLUMN IF NOT EXISTS support_session_id UUID + REFERENCES support_sessions(id) ON DELETE CASCADE; + +-- ============================================ +-- Step 5: RLS — Shape 1 (direct org_id) +-- ============================================ +-- breeze_has_org_access() already short-circuits to TRUE under system scope +-- (0008-tenant-rls.sql), which is what lets the public redeem path and the +-- reaper write through withSystemDbAccessContext. +ALTER TABLE support_sessions ENABLE ROW LEVEL SECURITY; +ALTER TABLE support_sessions FORCE ROW LEVEL SECURITY; + +DROP POLICY IF EXISTS breeze_org_isolation_select ON support_sessions; +DROP POLICY IF EXISTS breeze_org_isolation_insert ON support_sessions; +DROP POLICY IF EXISTS breeze_org_isolation_update ON support_sessions; +DROP POLICY IF EXISTS breeze_org_isolation_delete ON support_sessions; + +CREATE POLICY breeze_org_isolation_select ON support_sessions + FOR SELECT USING (public.breeze_has_org_access(org_id)); +CREATE POLICY breeze_org_isolation_insert ON support_sessions + FOR INSERT WITH CHECK (public.breeze_has_org_access(org_id)); +CREATE POLICY breeze_org_isolation_update ON support_sessions + FOR UPDATE USING (public.breeze_has_org_access(org_id)) + WITH CHECK (public.breeze_has_org_access(org_id)); +CREATE POLICY breeze_org_isolation_delete ON support_sessions + FOR DELETE USING (public.breeze_has_org_access(org_id)); diff --git a/apps/api/migrations/2026-08-13-b-quick-support-org-index.sql b/apps/api/migrations/2026-08-13-b-quick-support-org-index.sql new file mode 100644 index 000000000..373c9a7fc --- /dev/null +++ b/apps/api/migrations/2026-08-13-b-quick-support-org-index.sql @@ -0,0 +1,15 @@ +-- Quick Support — partial unique index on the new org_type enum value. +-- +-- MUST be a separate file from -a-: Postgres forbids USING an enum value that +-- was added in the current transaction (55P04 "unsafe use of new value"), and +-- autoMigrate wraps each migration file in ONE transaction. File -a- commits +-- the 'quick_support' value; this file is then free to reference it. +-- +-- Enforces exactly one hidden Quick Support org per partner, which is what +-- makes getOrCreateQuickSupportOrg()'s onConflictDoNothing + re-select safe +-- against a concurrent-create race. +-- +-- Idempotent. No inner BEGIN/COMMIT. + +CREATE UNIQUE INDEX IF NOT EXISTS organizations_partner_quick_support_uniq + ON organizations(partner_id) WHERE type = 'quick_support'; diff --git a/apps/api/src/db/schema/devices.ts b/apps/api/src/db/schema/devices.ts index f86f467fd..d6fa5f1a8 100644 --- a/apps/api/src/db/schema/devices.ts +++ b/apps/api/src/db/schema/devices.ts @@ -77,6 +77,12 @@ export const devices = pgTable('devices', { // per-session targeting. Null for old agents / non-Windows. helperLifecycleMode: varchar('helper_lifecycle_mode', { length: 20 }), status: deviceStatusEnum('status').notNull().default('offline'), + // Quick Support ephemeral device: enrolled for one ad-hoc support session in + // the hidden per-partner org, purged by the reaper 6h after the session ends. + // Excluded from license counts, device listings, billing rollups and alert + // evaluation — but NOT from the status-upkeep path, which the reaper's + // end-user-stop detection depends on. + isEphemeral: boolean('is_ephemeral').notNull().default(false), lastSeenAt: timestamp('last_seen_at'), enrolledAt: timestamp('enrolled_at').defaultNow().notNull(), enrolledBy: uuid('enrolled_by').references(() => users.id), diff --git a/apps/api/src/db/schema/index.ts b/apps/api/src/db/schema/index.ts index d224fb930..a8e6f068d 100644 --- a/apps/api/src/db/schema/index.ts +++ b/apps/api/src/db/schema/index.ts @@ -111,3 +111,4 @@ export * from './servicePrincipals'; export * from './partnerServicePrincipals'; export * from './extensions'; export * from './deviceMtlsCertificates'; +export * from './supportSessions'; diff --git a/apps/api/src/db/schema/orgs.ts b/apps/api/src/db/schema/orgs.ts index c0a6e4f78..e065546d6 100644 --- a/apps/api/src/db/schema/orgs.ts +++ b/apps/api/src/db/schema/orgs.ts @@ -8,7 +8,12 @@ export const partnerTypeEnum = pgEnum('partner_type', ['msp', 'enterprise', 'int export const partnerStatusEnum = pgEnum('partner_status', ['pending', 'active', 'suspended', 'churned', 'offboarding']); export type PartnerStatus = typeof partnerStatusEnum.enumValues[number]; export const planTypeEnum = pgEnum('plan_type', ['free', 'starter', 'community', 'pro', 'enterprise', 'unlimited']); -export const orgTypeEnum = pgEnum('org_type', ['customer', 'internal']); +// 'quick_support' is the hidden per-partner org that holds ephemeral Quick +// Support devices and support_sessions rows. Exactly one per partner +// (organizations_partner_quick_support_uniq). It must stay inside +// accessibleOrgIds so RLS lets techs reach their own support sessions, but it +// is excluded from every user-facing org enumeration and device/billing count. +export const orgTypeEnum = pgEnum('org_type', ['customer', 'internal', 'quick_support']); export const orgStatusEnum = pgEnum('org_status', ['active', 'suspended', 'trial', 'churned', 'offboarding']); export const partners = pgTable('partners', { @@ -170,4 +175,8 @@ export const enrollmentKeys = pgTable('enrollment_keys', { // bootstrap token (Task 2), so a later cancel/refund (Task 3) can find and // release the originating token's slot. bootstrapTokenId: uuid('bootstrap_token_id'), + // Quick Support: set on the single-use child key minted by /support/redeem. + // Plain uuid — the FK lives in SQL to avoid a circular import with + // supportSessions.ts (which imports organizations from this file). + supportSessionId: uuid('support_session_id'), }); diff --git a/apps/api/src/db/schema/supportSessions.ts b/apps/api/src/db/schema/supportSessions.ts new file mode 100644 index 000000000..7266e45e2 --- /dev/null +++ b/apps/api/src/db/schema/supportSessions.ts @@ -0,0 +1,44 @@ +import { pgTable, uuid, varchar, text, timestamp, pgEnum, index } from 'drizzle-orm/pg-core'; +import { organizations } from './orgs'; +import { users } from './users'; +import { devices } from './devices'; + +/** + * Quick Support — one-time code ad-hoc remote sessions (RLS Shape 1, direct org_id). + * + * `active` is deliberately NOT a stored status: it is derived at read time from + * live remote_sessions rows for the linked device, so nothing has to hook the + * remote-session create/end paths. + */ +export const supportSessionStatusEnum = pgEnum('support_session_status', [ + 'pending', + 'claimed', + 'ready', + 'ended', + 'expired', +]); + +export const supportSessions = pgTable('support_sessions', { + id: uuid('id').primaryKey().defaultRandom(), + /** Always the partner's hidden 'quick_support' org — never a real customer org. */ + orgId: uuid('org_id').notNull().references(() => organizations.id, { onDelete: 'cascade' }), + createdByUserId: uuid('created_by_user_id').notNull().references(() => users.id, { onDelete: 'cascade' }), + /** SHA-256 hex of the one-time code. Never returned by any route. */ + codeHash: varchar('code_hash', { length: 64 }).notNull().unique(), + codeExpiresAt: timestamp('code_expires_at', { withTimezone: true }).notNull(), + status: supportSessionStatusEnum('status').notNull().default('pending'), + hardExpiresAt: timestamp('hard_expires_at', { withTimezone: true }).notNull(), + /** SET NULL on device delete — the session row outlives the purged ephemeral device. */ + deviceId: uuid('device_id').references(() => devices.id, { onDelete: 'set null' }), + /** Reporting attribution only; carries no tenancy effect. */ + attributedOrgId: uuid('attributed_org_id').references(() => organizations.id, { onDelete: 'set null' }), + attributionLabel: text('attribution_label'), + claimedAt: timestamp('claimed_at', { withTimezone: true }), + claimedFromIp: text('claimed_from_ip'), + endedAt: timestamp('ended_at', { withTimezone: true }), + endedReason: text('ended_reason'), + createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(), +}, (t) => ({ + reaperIdx: index('idx_support_sessions_reaper').on(t.status, t.hardExpiresAt), + deviceIdx: index('idx_support_sessions_device').on(t.deviceId), +})); diff --git a/apps/api/src/routes/devices/core.ts b/apps/api/src/routes/devices/core.ts index 4cae6a8a1..3ddc89371 100644 --- a/apps/api/src/routes/devices/core.ts +++ b/apps/api/src/routes/devices/core.ts @@ -76,7 +76,11 @@ export const DEVICE_LINKED_DEVICE_ID_TABLES = [ * of cascade-deleting during permanent device deletion. Deviceless tickets * are first-class (tickets.device_id is nullable). */ -export const DEVICE_DETACH_DEVICE_ID_TABLES = ['tickets'] as const; +// support_sessions (Quick Support) detaches rather than cascades: the session +// row is the audit trail for an ad-hoc support session and must outlive the +// ephemeral device the reaper purges 6h after the session ends. Its device_id +// FK is declared ON DELETE SET NULL to match. +export const DEVICE_DETACH_DEVICE_ID_TABLES = ['support_sessions', 'tickets'] as const; /** * Subset of {@link getDeviceCascadeDeleteTables} ∪ @@ -131,6 +135,7 @@ const CORE_DEVICE_ORG_DENORMALIZED_TABLES = [ 'sensitive_data_findings', 'sensitive_data_scans', 'service_process_check_results', 'software_inventory', 'software_policy_audit', 'sql_instances', + 'support_sessions', 'tickets', 'time_series_metrics', 'tunnel_sessions', ] as const; diff --git a/apps/api/src/services/tenantCascade.ts b/apps/api/src/services/tenantCascade.ts index 0147e4946..9b7d516a4 100644 --- a/apps/api/src/services/tenantCascade.ts +++ b/apps/api/src/services/tenantCascade.ts @@ -316,6 +316,11 @@ const CORE_ORG_CASCADE_DELETE_ORDER: ReadonlyArray = Object.freeze([ 'sso_providers', 'sso_verified_domains', 'storage_encryption_keys', + // support_sessions (Quick Support): rows live in the partner's hidden + // 'quick_support' org. enrollment_keys carries an ON DELETE CASCADE FK to + // this table and sorts before it alphabetically, so the child is already + // deleted first — no manual reordering needed. + 'support_sessions', 'ticket_alert_links', // ticket_form_org_links (spec 2026-07-11): org allowlist for partner-wide // ticket_forms. Own org_id column is a direct FK to organizations (ON diff --git a/apps/api/src/services/tenantExportPolicyRegistry.ts b/apps/api/src/services/tenantExportPolicyRegistry.ts index 46cb40b99..f3f579e6c 100644 --- a/apps/api/src/services/tenantExportPolicyRegistry.ts +++ b/apps/api/src/services/tenantExportPolicyRegistry.ts @@ -137,7 +137,7 @@ export const CORE_TENANT_EXPORT_POLICY: TenantExportPolicyRegistry = { "device_sessions": tablePolicy("org_id", {"included":["id","org_id","device_id","username","session_type","os_session_id","login_at","logout_at","duration_seconds","idle_minutes","activity_state","login_performance_seconds","is_active","last_activity_at","created_at","updated_at"],"reviewedIncluded":[],"excludedSensitive":[],"excludedOpen":["metadata"]}), "device_vulnerabilities": tablePolicy("org_id", {"included":["id","org_id","device_id","vulnerability_id","software_inventory_id","status","risk_score","match_confidence","detected_at","resolved_at","mitigation_note","accepted_by","accepted_until","ticket_id","created_at","updated_at"],"reviewedIncluded":[],"excludedSensitive":[],"excludedOpen":[]}), "device_warranty": tablePolicy("org_id", {"included":["id","device_id","org_id","manufacturer","serial_number","status","warranty_start_date","warranty_end_date","is_subscription","data_source","last_sync_at","last_sync_error","next_sync_at","created_at","updated_at"],"reviewedIncluded":[],"excludedSensitive":[],"excludedOpen":["entitlements"]}), - "devices": tablePolicy("org_id", {"included":["id","org_id","site_id","agent_id","mtls_cert_serial_number","mtls_cert_expires_at","mtls_cert_issued_at","mtls_cert_cf_id","quarantined_at","quarantined_reason","last_seen_ip","enrollment_ip","hostname","display_name","os_type","device_role","device_role_source","is_virtual","virtualization_platform","os_version","os_build","architecture","agent_version","status","last_seen_at","enrolled_at","enrolled_by","link_group_id","link_group_role","tags","last_user","uptime_seconds","is_headless","pending_reboot","watchdog_status","watchdog_last_seen","watchdog_version","agent_server_url","helper_lifecycle_mode","main_agent_silent_since","outbound_network_policy_version","uninstall_intent_at","possible_replacement_of_device_id","created_at","updated_at","partner_export_updated_at"],"reviewedIncluded":["token_issued_at","previous_token_expires_at","watchdog_token_issued_at","previous_watchdog_token_expires_at","helper_token_issued_at","previous_helper_token_expires_at","pending_token_expires_at","agent_token_suspended_at","agent_token_suspended_reason"],"excludedSensitive":["agent_token_hash","previous_token_hash","watchdog_token_hash","previous_watchdog_token_hash","helper_token_hash","previous_helper_token_hash","pending_token_hash","pending_watchdog_token_hash","pending_helper_token_hash"],"excludedOpen":["custom_fields","management_posture","tcc_permissions","desktop_access","battery_status","active_vpns"]}), + "devices": tablePolicy("org_id", {"included":["id","org_id","site_id","agent_id","mtls_cert_serial_number","mtls_cert_expires_at","mtls_cert_issued_at","mtls_cert_cf_id","quarantined_at","quarantined_reason","last_seen_ip","enrollment_ip","hostname","display_name","os_type","device_role","device_role_source","is_ephemeral","is_virtual","virtualization_platform","os_version","os_build","architecture","agent_version","status","last_seen_at","enrolled_at","enrolled_by","link_group_id","link_group_role","tags","last_user","uptime_seconds","is_headless","pending_reboot","watchdog_status","watchdog_last_seen","watchdog_version","agent_server_url","helper_lifecycle_mode","main_agent_silent_since","outbound_network_policy_version","uninstall_intent_at","possible_replacement_of_device_id","created_at","updated_at","partner_export_updated_at"],"reviewedIncluded":["token_issued_at","previous_token_expires_at","watchdog_token_issued_at","previous_watchdog_token_expires_at","helper_token_issued_at","previous_helper_token_expires_at","pending_token_expires_at","agent_token_suspended_at","agent_token_suspended_reason"],"excludedSensitive":["agent_token_hash","previous_token_hash","watchdog_token_hash","previous_watchdog_token_hash","helper_token_hash","previous_helper_token_hash","pending_token_hash","pending_watchdog_token_hash","pending_helper_token_hash"],"excludedOpen":["custom_fields","management_posture","tcc_permissions","desktop_access","battery_status","active_vpns"]}), "discovered_assets": tablePolicy("org_id", {"included":["id","org_id","site_id","ip_address","mac_address","hostname","label","netbios_name","asset_type","approval_status","is_online","approved_by","approved_at","dismissed_by","dismissed_at","manufacturer","model","response_time_ms","linked_device_id","link_source","type_source","detected_asset_type","first_seen_at","last_seen_at","last_job_id","discovery_methods","notes","tags","created_at","updated_at"],"reviewedIncluded":[],"excludedSensitive":[],"excludedOpen":["open_ports","os_fingerprint","snmp_data"]}), "discovery_jobs": tablePolicy("org_id", {"included":["id","profile_id","org_id","site_id","agent_id","status","scheduled_at","started_at","completed_at","hosts_scanned","hosts_discovered","new_assets","created_at","updated_at"],"reviewedIncluded":[],"excludedSensitive":[],"excludedOpen":["errors"]}), "discovery_profiles": tablePolicy("org_id", {"included":["id","org_id","site_id","name","description","enabled","subnets","exclude_ips","methods","snmp_communities","deep_scan","identify_os","resolve_hostnames","timeout","concurrency","created_by","created_at","updated_at"],"reviewedIncluded":[],"excludedSensitive":[],"excludedOpen":["port_ranges","snmp_credentials","schedule","alert_settings"]}), @@ -150,7 +150,7 @@ export const CORE_TENANT_EXPORT_POLICY: TenantExportPolicyRegistry = { "dr_plans": tablePolicy("org_id", {"included":["id","org_id","name","description","status","rpo_target_minutes","rto_target_minutes","created_by","created_at","updated_at"],"reviewedIncluded":[],"excludedSensitive":[],"excludedOpen":[]}), "elevation_audit": tablePolicy("org_id", {"included":["id","org_id","elevation_request_id","event_type","actor","actor_user_id","occurred_at","created_at"],"reviewedIncluded":[],"excludedSensitive":[],"excludedOpen":["details"]}), "elevation_requests": tablePolicy("org_id", {"included":["id","org_id","site_id","partner_id","device_id","flow_type","subject_user_id","subject_username","reason","target_executable_path","target_executable_signer","target_publisher","status","requested_at","approved_at","expires_at","expired_at","revoked_at","revoked_by_user_id","revoked_reason","approved_by_user_id","denied_by_user_id","denial_reason","parent_approval_id","software_policy_match_id","execution_id","tool_name","action_digest","risk_tier","decided_assurance_level","decided_via","authenticator_device_id","session_started_at","session_ended_at","client_ip","user_agent","created_at","updated_at"],"reviewedIncluded":["target_executable_hash"],"excludedSensitive":[],"excludedOpen":["metadata"]}), - "enrollment_keys": tablePolicy("org_id", {"included":["id","org_id","site_id","name","usage_count","max_usage","expires_at","created_by","created_at","installer_platform"],"reviewedIncluded":["bootstrap_token_id"],"excludedSensitive":["key","key_secret_hash","short_code"],"excludedOpen":[]}), + "enrollment_keys": tablePolicy("org_id", {"included":["id","org_id","site_id","name","usage_count","max_usage","expires_at","created_by","created_at","installer_platform","support_session_id"],"reviewedIncluded":["bootstrap_token_id"],"excludedSensitive":["key","key_secret_hash","short_code"],"excludedOpen":[]}), "escalation_policies": tablePolicy("org_id", {"included":["id","org_id","partner_id","name","created_at","updated_at"],"reviewedIncluded":[],"excludedSensitive":[],"excludedOpen":["steps"]}), "event_bus_events": tablePolicy("org_id", {"included":["id","org_id","event_type","source","priority","processed_at","created_at"],"reviewedIncluded":[],"excludedSensitive":[],"excludedOpen":["payload","metadata"]}), "executive_summaries": tablePolicy("org_id", {"included":["id","org_id","period_type","period_start","period_end","generated_at"],"reviewedIncluded":[],"excludedSensitive":[],"excludedOpen":["device_stats","alert_stats","patch_stats","sla_stats","trends","highlights"]}), @@ -280,6 +280,10 @@ export const CORE_TENANT_EXPORT_POLICY: TenantExportPolicyRegistry = { "sso_providers": tablePolicy("org_id", {"included":["id","org_id","partner_id","name","type","status","issuer","client_id","userinfo_url","jwks_url","scopes","entity_id","sso_url","certificate","default_role_id","allowed_domains","enforce_sso","config_version","default_role_configured_by","created_by","created_at","updated_at"],"reviewedIncluded":["authorization_url","token_url","auto_provision","trusts_idp_mfa"],"excludedSensitive":["client_secret"],"excludedOpen":["attribute_mapping"]}), "sso_verified_domains": tablePolicy("org_id", {"included":["id","org_id","domain","verified_at","last_checked_at","created_by","created_at","updated_at"],"reviewedIncluded":[],"excludedSensitive":["verification_token"],"excludedOpen":[]}), "storage_encryption_keys": tablePolicy("org_id", {"included":["id","org_id","name","key_type","public_key_pem","is_active","created_at","rotated_at","expires_at"],"reviewedIncluded":[],"excludedSensitive":["encrypted_private_key","key_hash"],"excludedOpen":[]}), + // code_hash is the verifier for the one-time Quick Support code — credential + // material, never exported. claimed_from_ip is the end user's IP, which is + // ordinary tenant record data (mirrors devices.enrollment_ip / last_seen_ip). + "support_sessions": tablePolicy("org_id", {"included":["id","org_id","created_by_user_id","code_expires_at","status","hard_expires_at","device_id","attributed_org_id","attribution_label","claimed_at","claimed_from_ip","ended_at","ended_reason","created_at"],"reviewedIncluded":[],"excludedSensitive":["code_hash"],"excludedOpen":[]}), "ticket_alert_links": tablePolicy("org_id", {"included":["id","ticket_id","org_id","alert_id","link_type","created_by","created_at"],"reviewedIncluded":[],"excludedSensitive":[],"excludedOpen":[]}), "ticket_form_org_links": tablePolicy("org_id", {"included":["id","form_id","org_id","created_at"],"reviewedIncluded":[],"excludedSensitive":[],"excludedOpen":[]}), "ticket_forms": tablePolicy("org_id", {"included":["id","partner_id","org_id","name","description","category_id","title_template","description_intro","default_priority","default_tags","show_in_portal","is_active","sort_order","version","created_by","created_at","updated_at"],"reviewedIncluded":[],"excludedSensitive":[],"excludedOpen":["fields"]}), From 948f26b028c2995e8035394fa03d39ae1f9f231a Mon Sep 17 00:00:00 2001 From: Todd Hebebrand Date: Tue, 4 Aug 2026 17:04:07 -0500 Subject: [PATCH 06/28] feat(shared): quick support code validators + generator Shared: SUPPORT_CODE_ALPHABET/LENGTH/PATTERN, normalizeSupportCode, formatSupportCode, and the create/redeem zod schemas. The alphabet omits I/L/O/0/1 because the code is read aloud as often as it is pasted. API: generateSupportCode (randomInt rejection sampling, not randomBytes % len, which would bias the first 16 symbols of a 30-symbol alphabet) and hashSupportCode. Only the SHA-256 hash is ever stored. Co-Authored-By: Claude Opus 5 (1M context) --- .../api/src/services/quickSupportCode.test.ts | 46 ++++++ apps/api/src/services/quickSupportCode.ts | 38 +++++ packages/shared/src/validators/index.ts | 1 + .../src/validators/quickSupport.test.ts | 134 ++++++++++++++++++ .../shared/src/validators/quickSupport.ts | 50 +++++++ 5 files changed, 269 insertions(+) create mode 100644 apps/api/src/services/quickSupportCode.test.ts create mode 100644 apps/api/src/services/quickSupportCode.ts create mode 100644 packages/shared/src/validators/quickSupport.test.ts create mode 100644 packages/shared/src/validators/quickSupport.ts diff --git a/apps/api/src/services/quickSupportCode.test.ts b/apps/api/src/services/quickSupportCode.test.ts new file mode 100644 index 000000000..45cf66b0c --- /dev/null +++ b/apps/api/src/services/quickSupportCode.test.ts @@ -0,0 +1,46 @@ +import { describe, expect, it } from 'vitest'; +import { SUPPORT_CODE_LENGTH, SUPPORT_CODE_PATTERN } from '@breeze/shared'; +import { + SUPPORT_CODE_TTL_MINUTES, + SUPPORT_SESSION_HARD_CAP_HOURS, + generateSupportCode, + hashSupportCode, +} from './quickSupportCode'; + +describe('generateSupportCode', () => { + it('produces a code matching the shared pattern', () => { + for (let i = 0; i < 100; i++) { + const code = generateSupportCode(); + expect(code).toHaveLength(SUPPORT_CODE_LENGTH); + expect(SUPPORT_CODE_PATTERN.test(code)).toBe(true); + } + }); + + // Not a statistical test — just a smoke check that we aren't returning a + // constant or seeding from something fixed. + it('does not repeat itself across a thousand generations', () => { + const seen = new Set(); + for (let i = 0; i < 1000; i++) seen.add(generateSupportCode()); + expect(seen.size).toBeGreaterThan(990); + }); +}); + +describe('hashSupportCode', () => { + it('returns a stable lowercase 64-character hex digest', () => { + const first = hashSupportCode('KTM4H7P2X'); + expect(first).toMatch(/^[0-9a-f]{64}$/); + expect(hashSupportCode('KTM4H7P2X')).toBe(first); + }); + + it('is case- and value-sensitive', () => { + expect(hashSupportCode('KTM4H7P2X')).not.toBe(hashSupportCode('ktm4h7p2x')); + expect(hashSupportCode('KTM4H7P2X')).not.toBe(hashSupportCode('KTM4H7P2Y')); + }); +}); + +describe('lifetime constants', () => { + it('keeps the redemption window short and the hard cap under a day', () => { + expect(SUPPORT_CODE_TTL_MINUTES).toBe(15); + expect(SUPPORT_SESSION_HARD_CAP_HOURS).toBe(8); + }); +}); diff --git a/apps/api/src/services/quickSupportCode.ts b/apps/api/src/services/quickSupportCode.ts new file mode 100644 index 000000000..83b644624 --- /dev/null +++ b/apps/api/src/services/quickSupportCode.ts @@ -0,0 +1,38 @@ +import { createHash, randomInt } from 'node:crypto'; +import { SUPPORT_CODE_ALPHABET, SUPPORT_CODE_LENGTH } from '@breeze/shared'; + +/** How long a freshly minted code can still be redeemed. */ +export const SUPPORT_CODE_TTL_MINUTES = 15; + +/** + * Absolute ceiling on a session's life, enforced by the reaper. Guarantees no + * support session — and therefore no ephemeral device — outlives the workday + * even if every cooperative teardown path fails. + */ +export const SUPPORT_SESSION_HARD_CAP_HOURS = 8; + +/** + * Cryptographically random one-time code. + * + * `randomInt` (rejection sampling) rather than `randomBytes() % len`: the + * alphabet's 30 symbols do not divide 256 evenly, so modulo would bias the + * first 16 characters and cost real entropy. + */ +export function generateSupportCode(): string { + let code = ''; + for (let i = 0; i < SUPPORT_CODE_LENGTH; i++) { + code += SUPPORT_CODE_ALPHABET[randomInt(SUPPORT_CODE_ALPHABET.length)]; + } + return code; +} + +/** + * SHA-256 hex of a normalized code. Only the hash is stored, so a database + * disclosure never yields a usable code. + * + * Plain SHA-256 (not a slow KDF) is deliberate: the code carries ~44 bits of + * fresh entropy, lives 15 minutes, and lookups happen on the request path. + */ +export function hashSupportCode(code: string): string { + return createHash('sha256').update(code).digest('hex'); +} diff --git a/packages/shared/src/validators/index.ts b/packages/shared/src/validators/index.ts index 0e8baf20e..6c3771169 100644 --- a/packages/shared/src/validators/index.ts +++ b/packages/shared/src/validators/index.ts @@ -942,6 +942,7 @@ export * from './timeEntries'; export * from './portal'; export * from './ticketConfig'; export * from './clientAiDlp'; +export * from './quickSupport'; // ============================================ // Backup Target Validators diff --git a/packages/shared/src/validators/quickSupport.test.ts b/packages/shared/src/validators/quickSupport.test.ts new file mode 100644 index 000000000..a22d9c61d --- /dev/null +++ b/packages/shared/src/validators/quickSupport.test.ts @@ -0,0 +1,134 @@ +import { describe, expect, it } from 'vitest'; +import { + SUPPORT_CODE_ALPHABET, + SUPPORT_CODE_LENGTH, + SUPPORT_CODE_PATTERN, + createSupportSessionSchema, + formatSupportCode, + normalizeSupportCode, + redeemSupportSessionSchema, +} from './quickSupport'; + +describe('SUPPORT_CODE_ALPHABET', () => { + it('excludes the visually ambiguous characters I, L, O, 0 and 1', () => { + for (const ch of ['I', 'L', 'O', '0', '1']) { + expect(SUPPORT_CODE_ALPHABET).not.toContain(ch); + } + }); + + it('has no duplicate characters', () => { + expect(new Set(SUPPORT_CODE_ALPHABET).size).toBe(SUPPORT_CODE_ALPHABET.length); + }); + + // 30^9 ~= 2^44. The 15-minute TTL plus per-IP rate limiting is what makes + // this safe; if the alphabet ever shrinks, revisit both. + it('keeps at least ~44 bits of entropy at the configured length', () => { + const bits = Math.log2(SUPPORT_CODE_ALPHABET.length) * SUPPORT_CODE_LENGTH; + expect(bits).toBeGreaterThanOrEqual(44); + }); +}); + +describe('normalizeSupportCode', () => { + it('uppercases and strips spaces and dashes', () => { + expect(normalizeSupportCode('ktm-4h7 p2x')).toBe('KTM4H7P2X'); + expect(normalizeSupportCode('KTM-4H7-P2X')).toBe('KTM4H7P2X'); + expect(normalizeSupportCode(' ktm4h7p2x ')).toBe('KTM4H7P2X'); + }); + + it('rejects codes containing characters outside the alphabet', () => { + expect(normalizeSupportCode('KTM4H7P20')).toBeNull(); // 0 + expect(normalizeSupportCode('KTM4H7P2I')).toBeNull(); // I + expect(normalizeSupportCode('KTM4H7P2!')).toBeNull(); + }); + + it('rejects codes of the wrong length', () => { + expect(normalizeSupportCode('KTM4H7P2')).toBeNull(); // 8 + expect(normalizeSupportCode('KTM4H7P2XY')).toBeNull(); // 10 + expect(normalizeSupportCode('')).toBeNull(); + }); + + it('accepts every character in the alphabet', () => { + for (const ch of SUPPORT_CODE_ALPHABET) { + expect(normalizeSupportCode(ch.repeat(SUPPORT_CODE_LENGTH))).toBe( + ch.repeat(SUPPORT_CODE_LENGTH), + ); + } + }); +}); + +describe('formatSupportCode', () => { + it('groups the code into three triples', () => { + expect(formatSupportCode('KTM4H7P2X')).toBe('KTM-4H7-P2X'); + }); + + it('round-trips through normalizeSupportCode', () => { + expect(normalizeSupportCode(formatSupportCode('KTM4H7P2X'))).toBe('KTM4H7P2X'); + }); +}); + +describe('SUPPORT_CODE_PATTERN', () => { + it('matches a normalized code and nothing else', () => { + expect(SUPPORT_CODE_PATTERN.test('KTM4H7P2X')).toBe(true); + expect(SUPPORT_CODE_PATTERN.test('KTM-4H7-P2X')).toBe(false); + expect(SUPPORT_CODE_PATTERN.test('ktm4h7p2x')).toBe(false); + }); +}); + +describe('createSupportSessionSchema', () => { + it('accepts an empty payload — attribution is entirely optional', () => { + expect(createSupportSessionSchema.safeParse({}).success).toBe(true); + }); + + it('accepts a valid attribution', () => { + const result = createSupportSessionSchema.safeParse({ + attributedOrgId: '11111111-1111-4111-8111-111111111111', + attributionLabel: 'Contoso — CFO laptop', + }); + expect(result.success).toBe(true); + }); + + it('rejects a non-uuid attributedOrgId', () => { + expect(createSupportSessionSchema.safeParse({ attributedOrgId: 'nope' }).success).toBe(false); + }); + + it('rejects an over-long attribution label', () => { + expect( + createSupportSessionSchema.safeParse({ attributionLabel: 'x'.repeat(201) }).success, + ).toBe(false); + }); +}); + +describe('redeemSupportSessionSchema', () => { + it('accepts a formatted code plus client details', () => { + const result = redeemSupportSessionSchema.safeParse({ + code: 'KTM-4H7-P2X', + hostname: 'DESKTOP-ABC123', + osType: 'windows', + }); + expect(result.success).toBe(true); + }); + + it('rejects an unknown osType', () => { + expect( + redeemSupportSessionSchema.safeParse({ + code: 'KTM4H7P2X', + hostname: 'host', + osType: 'freebsd', + }).success, + ).toBe(false); + }); + + it('rejects an empty hostname', () => { + expect( + redeemSupportSessionSchema.safeParse({ code: 'KTM4H7P2X', hostname: '', osType: 'macos' }) + .success, + ).toBe(false); + }); + + it('rejects a code too short to ever normalize', () => { + expect( + redeemSupportSessionSchema.safeParse({ code: 'KTM', hostname: 'host', osType: 'windows' }) + .success, + ).toBe(false); + }); +}); diff --git a/packages/shared/src/validators/quickSupport.ts b/packages/shared/src/validators/quickSupport.ts new file mode 100644 index 000000000..089ea82d0 --- /dev/null +++ b/packages/shared/src/validators/quickSupport.ts @@ -0,0 +1,50 @@ +import { z } from 'zod'; + +// Quick Support one-time codes — shared between the API (generation, hashing), +// the web landing page (client-side normalization before the check call) and +// the Go agent's filename parsing (which mirrors SUPPORT_CODE_PATTERN). +// +// The alphabet deliberately omits I, L, O, 0 and 1: the code is read aloud +// over the phone as often as it is copy-pasted, and those five are where +// transcription goes wrong. 30 symbols x 9 characters is ~44 bits, which +// together with the 15-minute TTL and per-IP rate limiting on /support/check +// and /support/redeem is what makes guessing impractical. +export const SUPPORT_CODE_ALPHABET = 'ABCDEFGHJKMNPQRSTVWXYZ23456789'; +export const SUPPORT_CODE_LENGTH = 9; +export const SUPPORT_CODE_PATTERN = /^[ABCDEFGHJKMNPQRSTVWXYZ23456789]{9}$/; + +/** + * Canonicalize user-entered input ("ktm-4h7 p2x", "KTM 4H7 P2X") to the stored + * form ("KTM4H7P2X"), or null when it could never be a valid code. + * + * Returning null rather than throwing lets callers treat malformed input as a + * miss without a DB round-trip. + */ +export function normalizeSupportCode(raw: string): string | null { + const cleaned = raw.toUpperCase().replace(/[\s-]/g, ''); + return SUPPORT_CODE_PATTERN.test(cleaned) ? cleaned : null; +} + +/** Display form: KTM4H7P2X -> KTM-4H7-P2X. */ +export function formatSupportCode(code: string): string { + return `${code.slice(0, 3)}-${code.slice(3, 6)}-${code.slice(6, 9)}`; +} + +export const createSupportSessionSchema = z.object({ + /** Reporting attribution only — carries no tenancy effect. */ + attributedOrgId: z.string().guid().optional(), + attributionLabel: z.string().max(200).optional(), +}); + +// osType is a hand-written enum rather than one derived from a Drizzle +// pgEnum: deriving from `pgEnum.enumValues` breaks the schema mocks the API +// route tests rely on. +export const redeemSupportSessionSchema = z.object({ + // Accepts the formatted or raw form; the route normalizes before hashing. + code: z.string().min(SUPPORT_CODE_LENGTH).max(15), + hostname: z.string().min(1).max(255), + osType: z.enum(['windows', 'macos', 'linux']), +}); + +export type CreateSupportSessionInput = z.infer; +export type RedeemSupportSessionInput = z.infer; From a6a0d3d79ced22914678db1928658b28a71024b1 Mon Sep 17 00:00:00 2001 From: Todd Hebebrand Date: Tue, 4 Aug 2026 17:05:18 -0500 Subject: [PATCH 07/28] feat(api): quick support hidden org provisioning getOrCreateQuickSupportOrg lazily creates one hidden 'quick_support' org per partner (plus the default site enrollment keys require), guarded by the partial unique index. Runs inside runOutsideDbContext + withSystemDbAccessContext because a just-created org id is not yet in the caller's accessible_org_ids, so RLS would reject the INSERT and its RETURNING under the request context. The concurrent-create race is handled with onConflictDoNothing + re-select rather than catching 23505: postgres.js rethrows errors handled inside begin(), so transaction-abort recovery would surface as a 500. Co-Authored-By: Claude Opus 5 (1M context) --- apps/api/src/services/quickSupportOrg.test.ts | 145 ++++++++++++++++++ apps/api/src/services/quickSupportOrg.ts | 68 ++++++++ 2 files changed, 213 insertions(+) create mode 100644 apps/api/src/services/quickSupportOrg.test.ts create mode 100644 apps/api/src/services/quickSupportOrg.ts diff --git a/apps/api/src/services/quickSupportOrg.test.ts b/apps/api/src/services/quickSupportOrg.test.ts new file mode 100644 index 000000000..6409d1c24 --- /dev/null +++ b/apps/api/src/services/quickSupportOrg.test.ts @@ -0,0 +1,145 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +// Each db.select(...) chain resolves to the next queued row array, so a test +// can script "org missing, then org present" without caring about the exact +// builder shape. +const selectResults: unknown[][] = []; +const insertCalls: Array<{ table: string; values: unknown }> = []; +const insertReturns: unknown[][] = []; + +function queueSelect(rows: unknown[]) { + selectResults.push(rows); +} + +vi.mock('../db', () => { + const chain = () => { + const result = selectResults.shift() ?? []; + const builder: Record = {}; + for (const method of ['from', 'where', 'orderBy']) { + builder[method] = vi.fn(() => builder); + } + builder.limit = vi.fn(() => Promise.resolve(result)); + return builder; + }; + + const insert = vi.fn((table: { _tableName?: string }) => ({ + values: vi.fn((values: unknown) => { + insertCalls.push({ table: table?._tableName ?? 'unknown', values }); + const rows = insertReturns.shift() ?? []; + const thenable = { + onConflictDoNothing: vi.fn(() => Promise.resolve(rows)), + returning: vi.fn(() => Promise.resolve(rows)), + then: (resolve: (v: unknown) => unknown) => Promise.resolve(rows).then(resolve), + }; + return thenable; + }), + })); + + return { + db: { select: vi.fn(chain), insert }, + withSystemDbAccessContext: vi.fn(async (fn: () => Promise) => fn()), + runOutsideDbContext: vi.fn((fn: () => unknown) => fn()), + }; +}); + +vi.mock('../db/schema', () => ({ + organizations: { + _tableName: 'organizations', + id: 'organizations.id', + partnerId: 'organizations.partner_id', + type: 'organizations.type', + }, + sites: { + _tableName: 'sites', + id: 'sites.id', + orgId: 'sites.org_id', + }, +})); + +import { runOutsideDbContext, withSystemDbAccessContext } from '../db'; +import { getOrCreateQuickSupportOrg } from './quickSupportOrg'; + +const PARTNER_ID = '11111111-1111-4111-8111-111111111111'; + +beforeEach(() => { + selectResults.length = 0; + insertCalls.length = 0; + insertReturns.length = 0; + vi.clearAllMocks(); +}); + +describe('getOrCreateQuickSupportOrg', () => { + it('returns an existing org and site without inserting anything', async () => { + queueSelect([{ id: 'org-1' }]); // org lookup hits + queueSelect([{ id: 'site-1' }]); // site lookup hits + + const result = await getOrCreateQuickSupportOrg(PARTNER_ID); + + expect(result).toEqual({ orgId: 'org-1', siteId: 'site-1' }); + expect(insertCalls).toHaveLength(0); + }); + + it('lazily creates the hidden org and its default site', async () => { + queueSelect([]); // org lookup misses + queueSelect([{ id: 'org-new' }]); // re-select after insert + queueSelect([]); // site lookup misses + insertReturns.push([]); // org insert (onConflictDoNothing) + insertReturns.push([{ id: 'site-new' }]); // site insert returning + + const result = await getOrCreateQuickSupportOrg(PARTNER_ID); + + expect(result).toEqual({ orgId: 'org-new', siteId: 'site-new' }); + expect(insertCalls[0].table).toBe('organizations'); + expect(insertCalls[0].values).toMatchObject({ + partnerId: PARTNER_ID, + type: 'quick_support', + status: 'active', + }); + expect(insertCalls[1].table).toBe('sites'); + expect(insertCalls[1].values).toMatchObject({ orgId: 'org-new' }); + }); + + it('slugs with the full partner uuid so slugs cannot collide across partners', async () => { + queueSelect([]); + queueSelect([{ id: 'org-new' }]); + queueSelect([{ id: 'site-1' }]); + insertReturns.push([]); + + await getOrCreateQuickSupportOrg(PARTNER_ID); + + expect(insertCalls[0].values).toMatchObject({ slug: `quick-support-${PARTNER_ID}` }); + }); + + it('lets the re-select win when a concurrent create took the unique index', async () => { + queueSelect([]); // our lookup missed + queueSelect([{ id: 'org-from-racer' }]); // the racer's row is visible now + queueSelect([{ id: 'site-1' }]); + insertReturns.push([]); // onConflictDoNothing swallowed our insert + + const result = await getOrCreateQuickSupportOrg(PARTNER_ID); + + expect(result.orgId).toBe('org-from-racer'); + }); + + it('throws rather than returning a bogus id when provisioning cannot converge', async () => { + queueSelect([]); // lookup misses + queueSelect([]); // re-select still misses + insertReturns.push([]); + + await expect(getOrCreateQuickSupportOrg(PARTNER_ID)).rejects.toThrow( + /quick support org provisioning failed/i, + ); + }); + + it('runs outside any request context and inside a system context', async () => { + queueSelect([{ id: 'org-1' }]); + queueSelect([{ id: 'site-1' }]); + + await getOrCreateQuickSupportOrg(PARTNER_ID); + + // A brand-new org id is not in the caller's accessible_org_ids yet, so the + // RLS INSERT policy would reject it under the request context. + expect(runOutsideDbContext).toHaveBeenCalled(); + expect(withSystemDbAccessContext).toHaveBeenCalled(); + }); +}); diff --git a/apps/api/src/services/quickSupportOrg.ts b/apps/api/src/services/quickSupportOrg.ts new file mode 100644 index 000000000..19b0a3419 --- /dev/null +++ b/apps/api/src/services/quickSupportOrg.ts @@ -0,0 +1,68 @@ +import { and, eq } from 'drizzle-orm'; +import { db, runOutsideDbContext, withSystemDbAccessContext } from '../db'; +import { organizations, sites } from '../db/schema'; + +/** + * Resolve (creating on first use) the hidden 'quick_support' organization for a + * partner, plus its default site. + * + * Quick Support devices are ephemeral and must not land in a real customer org, + * so every partner gets exactly one hidden org — enforced by the partial unique + * index `organizations_partner_quick_support_uniq`. It stays inside the tech's + * accessibleOrgIds (so RLS lets them read their own support_sessions) but is + * filtered out of every user-facing org enumeration and device/billing count. + * + * Runs in a fresh system context: a just-created org id is not in the caller's + * accessible_org_ids yet, so RLS would reject both the INSERT and its RETURNING + * under the request context. Same pattern as POST /organizations. + */ +export async function getOrCreateQuickSupportOrg( + partnerId: string, +): Promise<{ orgId: string; siteId: string }> { + return runOutsideDbContext(() => withSystemDbAccessContext(async () => { + const findOrg = () => db + .select({ id: organizations.id }) + .from(organizations) + .where(and( + eq(organizations.partnerId, partnerId), + eq(organizations.type, 'quick_support'), + )) + .limit(1); + + let [org] = await findOrg(); + if (!org) { + // onConflictDoNothing + re-select rather than catching a 23505: postgres.js + // rethrows errors handled inside begin(), so relying on transaction-abort + // recovery would surface as a 500 under concurrent creation. + await db.insert(organizations).values({ + partnerId, + name: 'Quick Support', + // Full uuid, not an 8-char prefix: org slugs are globally unique, and a + // truncated prefix could collide across partners and make provisioning + // throw for whichever partner arrived second. + slug: `quick-support-${partnerId}`, + type: 'quick_support', + status: 'active', + }).onConflictDoNothing(); + + [org] = await findOrg(); + if (!org) throw new Error('quick support org provisioning failed'); + } + + // Enrollment keys require a site_id, so the hidden org needs one. + let [site] = await db + .select({ id: sites.id }) + .from(sites) + .where(eq(sites.orgId, org.id)) + .limit(1); + + if (!site) { + [site] = await db + .insert(sites) + .values({ orgId: org.id, name: 'Quick Support', timezone: 'UTC' }) + .returning({ id: sites.id }); + } + + return { orgId: org.id, siteId: site.id }; + })); +} From 6bc52ca31c4a4d5196733dc3708e9e90e2011425 Mon Sep 17 00:00:00 2001 From: Todd Hebebrand Date: Tue, 4 Aug 2026 17:52:01 -0500 Subject: [PATCH 08/28] feat(api): quick support session create/list/get routes POST/GET /remote/support-sessions under the existing remote:access + MFA gate. Create is partner-scope only: the hidden Quick Support org hangs off the partner, and an org-scope token carries a partnerId but never passes breeze_has_partner_access, so it would mint sessions it could not read back. A system token with no partner context is rejected for the same reason. 'active' is derived at read time from live remote_sessions rows rather than stored, so nothing has to hook the remote-session create/end paths to keep a duplicate status in sync. The list endpoint batches the device-status and live-session lookups across the page instead of per row (~100 round trips at the default limit). codeHash is stripped from every response shape. Co-Authored-By: Claude Opus 5 (1M context) --- apps/api/src/routes/remote/index.ts | 3 + .../src/routes/remote/supportSessions.test.ts | 283 ++++++++++++++++++ apps/api/src/routes/remote/supportSessions.ts | 199 ++++++++++++ 3 files changed, 485 insertions(+) create mode 100644 apps/api/src/routes/remote/supportSessions.test.ts create mode 100644 apps/api/src/routes/remote/supportSessions.ts diff --git a/apps/api/src/routes/remote/index.ts b/apps/api/src/routes/remote/index.ts index 8c0e0549b..172679c57 100644 --- a/apps/api/src/routes/remote/index.ts +++ b/apps/api/src/routes/remote/index.ts @@ -2,6 +2,7 @@ import { Hono } from 'hono'; import { authMiddleware, requireMfa, requirePermission } from '../../middleware/auth'; import { PERMISSIONS } from '../../services/permissions'; import { sessionRoutes } from './sessions'; +import { supportSessionRoutes } from './supportSessions'; export const remoteRoutes = new Hono(); @@ -11,4 +12,6 @@ remoteRoutes.use('*', requirePermission(PERMISSIONS.REMOTE_ACCESS.resource, PERM // Mount sub-routes remoteRoutes.route('/', sessionRoutes); +// Quick Support inherits the same auth + remote:access + MFA gate above. +remoteRoutes.route('/', supportSessionRoutes); diff --git a/apps/api/src/routes/remote/supportSessions.test.ts b/apps/api/src/routes/remote/supportSessions.test.ts new file mode 100644 index 000000000..7e4663dbe --- /dev/null +++ b/apps/api/src/routes/remote/supportSessions.test.ts @@ -0,0 +1,283 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { Hono } from 'hono'; + +/** + * Quick Support session routes. + * + * The load-bearing guards here are the scope checks: the hidden Quick Support + * org is per-PARTNER, so an org-scope token (which carries a partnerId but + * never passes breeze_has_partner_access) must not be able to mint sessions, + * and a system token with no partner context has no org to provision into. + */ + +const { getOrCreateQuickSupportOrg, logSessionAudit, getTrustedClientIp } = vi.hoisted(() => ({ + getOrCreateQuickSupportOrg: vi.fn(() => Promise.resolve({ orgId: 'qs-org', siteId: 'qs-site' })), + logSessionAudit: vi.fn(() => Promise.resolve()), + getTrustedClientIp: vi.fn(() => '203.0.113.7'), +})); + +vi.mock('../../services/quickSupportOrg', () => ({ getOrCreateQuickSupportOrg })); +vi.mock('./helpers', () => ({ logSessionAudit })); +vi.mock('../../services/clientIp', () => ({ getTrustedClientIp })); + +// Scripted select results, consumed in call order. +const selectResults: unknown[][] = []; +const insertedValues: unknown[] = []; +const insertReturns: unknown[][] = []; + +vi.mock('../../db', () => { + const select = vi.fn(() => { + const rows = selectResults.shift() ?? []; + const builder: Record = {}; + for (const m of ['from', 'where', 'orderBy', 'innerJoin', 'leftJoin']) { + builder[m] = vi.fn(() => builder); + } + builder.limit = vi.fn(() => Promise.resolve(rows)); + // list route awaits the builder directly after orderBy/limit + builder.then = (resolve: (v: unknown) => unknown) => Promise.resolve(rows).then(resolve); + return builder; + }); + + const insert = vi.fn(() => ({ + values: vi.fn((values: unknown) => { + insertedValues.push(values); + const rows = insertReturns.shift() ?? []; + return { returning: vi.fn(() => Promise.resolve(rows)) }; + }), + })); + + return { + db: { select, insert }, + runOutsideDbContext: vi.fn((fn: () => T): T => fn()), + withSystemDbAccessContext: vi.fn(async (fn: () => unknown) => fn()), + }; +}); + +vi.mock('../../db/schema', () => ({ + supportSessions: { + id: 'supportSessions.id', + orgId: 'supportSessions.orgId', + status: 'supportSessions.status', + deviceId: 'supportSessions.deviceId', + createdAt: 'supportSessions.createdAt', + codeHash: 'supportSessions.codeHash', + }, + remoteSessions: { id: 'remoteSessions.id', deviceId: 'remoteSessions.deviceId', status: 'remoteSessions.status' }, + devices: { id: 'devices.id', status: 'devices.status' }, +})); + +import { hashSupportCode } from '../../services/quickSupportCode'; +import { supportSessionRoutes } from './supportSessions'; + +type AuthOverrides = { + scope?: 'system' | 'partner' | 'organization'; + partnerId?: string | null; + accessibleOrgIds?: string[] | null; +}; + +function buildApp(overrides: AuthOverrides = {}) { + const app = new Hono(); + app.use('*', async (c, next) => { + c.set('auth', { + user: { id: 'user-1', email: 't@example.com', name: 'Tech' }, + scope: overrides.scope ?? 'partner', + partnerId: overrides.partnerId === undefined ? 'partner-1' : overrides.partnerId, + accessibleOrgIds: + overrides.accessibleOrgIds === undefined ? ['org-a', 'qs-org'] : overrides.accessibleOrgIds, + }); + await next(); + }); + app.route('/', supportSessionRoutes); + return app; +} + +const SESSION_ROW = { + id: 'sess-1', + orgId: 'qs-org', + createdByUserId: 'user-1', + codeHash: 'never-leaks', + codeExpiresAt: new Date('2026-08-13T10:15:00Z'), + status: 'pending', + hardExpiresAt: new Date('2026-08-13T18:00:00Z'), + deviceId: null, + attributedOrgId: null, + attributionLabel: null, + claimedAt: null, + claimedFromIp: null, + endedAt: null, + endedReason: null, + createdAt: new Date('2026-08-13T10:00:00Z'), +}; + +beforeEach(() => { + selectResults.length = 0; + insertedValues.length = 0; + insertReturns.length = 0; + vi.clearAllMocks(); + getOrCreateQuickSupportOrg.mockResolvedValue({ orgId: 'qs-org', siteId: 'qs-site' }); + getTrustedClientIp.mockReturnValue('203.0.113.7'); + process.env.PUBLIC_WEB_URL = 'https://us.2breeze.app'; +}); + +describe('POST /support-sessions', () => { + it('creates a session and returns the formatted code exactly once', async () => { + insertReturns.push([SESSION_ROW]); + const res = await buildApp().request('/support-sessions', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({}), + }); + + expect(res.status).toBe(201); + const body = await res.json(); + expect(body.code).toMatch(/^[A-Z2-9]{3}-[A-Z2-9]{3}-[A-Z2-9]{3}$/); + + // The stored hash must be the sha256 of the RAW code we handed the tech. + const raw = body.code.replace(/-/g, ''); + expect((insertedValues[0] as { codeHash: string }).codeHash).toBe(hashSupportCode(raw)); + expect(body.landingUrl).toBe(`https://us.2breeze.app/quick?code=${raw}`); + expect((insertedValues[0] as { orgId: string }).orgId).toBe('qs-org'); + }); + + it('never returns the code hash to the caller', async () => { + insertReturns.push([SESSION_ROW]); + const res = await buildApp().request('/support-sessions', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({}), + }); + expect(JSON.stringify(await res.json())).not.toContain('never-leaks'); + }); + + it('rejects org-scope callers — the hidden org is per-partner', async () => { + const res = await buildApp({ scope: 'organization' }).request('/support-sessions', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({}), + }); + expect(res.status).toBe(403); + expect(getOrCreateQuickSupportOrg).not.toHaveBeenCalled(); + }); + + it('rejects a system token carrying no partner context', async () => { + const res = await buildApp({ scope: 'system', partnerId: null }).request('/support-sessions', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({}), + }); + expect(res.status).toBe(403); + expect(getOrCreateQuickSupportOrg).not.toHaveBeenCalled(); + }); + + it('rejects an attribution to an org the caller cannot access', async () => { + const res = await buildApp().request('/support-sessions', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ attributedOrgId: '99999999-9999-4999-8999-999999999999' }), + }); + expect(res.status).toBe(403); + }); + + it('accepts an attribution to an accessible org and records the label', async () => { + insertReturns.push([{ ...SESSION_ROW, attributionLabel: 'Contoso — CFO laptop' }]); + const res = await buildApp({ accessibleOrgIds: ['11111111-1111-4111-8111-111111111111'] }).request( + '/support-sessions', + { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + attributedOrgId: '11111111-1111-4111-8111-111111111111', + attributionLabel: 'Contoso — CFO laptop', + }), + }, + ); + expect(res.status).toBe(201); + expect(insertedValues[0]).toMatchObject({ + attributedOrgId: '11111111-1111-4111-8111-111111111111', + attributionLabel: 'Contoso — CFO laptop', + }); + }); + + it('audits the creation', async () => { + insertReturns.push([SESSION_ROW]); + await buildApp().request('/support-sessions', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({}), + }); + expect(logSessionAudit).toHaveBeenCalledWith( + 'support_session_created', + 'user-1', + 'qs-org', + expect.objectContaining({ sessionId: 'sess-1' }), + '203.0.113.7', + ); + }); +}); + +describe('GET /support-sessions/:id', () => { + it('derives active status from a live remote session and reports device presence', async () => { + selectResults.push([{ ...SESSION_ROW, status: 'ready', deviceId: 'dev-1' }]); // session + selectResults.push([{ status: 'online' }]); // device + selectResults.push([{ id: 'rs-1' }]); // live remote session + + const res = await buildApp().request('/support-sessions/sess-1'); + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.status).toBe('active'); + expect(body.deviceOnline).toBe(true); + expect(body.codeHash).toBeUndefined(); + }); + + it('stays ready when the device is online but no remote session is live', async () => { + selectResults.push([{ ...SESSION_ROW, status: 'ready', deviceId: 'dev-1' }]); + selectResults.push([{ status: 'online' }]); + selectResults.push([]); // no live remote sessions + + const body = await (await buildApp().request('/support-sessions/sess-1')).json(); + expect(body.status).toBe('ready'); + expect(body.deviceOnline).toBe(true); + }); + + it('does not probe the device for a session that never enrolled one', async () => { + selectResults.push([SESSION_ROW]); // pending, deviceId null + + const body = await (await buildApp().request('/support-sessions/sess-1')).json(); + expect(body.status).toBe('pending'); + expect(body.deviceOnline).toBe(false); + // only the session lookup ran + expect(selectResults).toHaveLength(0); + }); + + it('404s an unknown session', async () => { + selectResults.push([]); + const res = await buildApp().request('/support-sessions/nope'); + expect(res.status).toBe(404); + }); +}); + +describe('GET /support-sessions', () => { + it('lists sessions without a per-row device query', async () => { + selectResults.push([ + { ...SESSION_ROW, id: 'sess-2', status: 'ready', deviceId: 'dev-1' }, + { ...SESSION_ROW, id: 'sess-1', status: 'ready', deviceId: 'dev-2' }, + ]); + selectResults.push([{ id: 'dev-1', status: 'online' }, { id: 'dev-2', status: 'offline' }]); + selectResults.push([{ deviceId: 'dev-1' }]); + + const res = await buildApp().request('/support-sessions'); + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.sessions).toHaveLength(2); + expect(body.sessions[0]).toMatchObject({ id: 'sess-2', status: 'active', deviceOnline: true }); + expect(body.sessions[1]).toMatchObject({ id: 'sess-1', status: 'ready', deviceOnline: false }); + // session page + one batched device query + one batched remote-session query + expect(selectResults).toHaveLength(0); + }); + + it('never leaks code hashes in the list', async () => { + selectResults.push([SESSION_ROW]); + const res = await buildApp().request('/support-sessions'); + expect(JSON.stringify(await res.json())).not.toContain('never-leaks'); + }); +}); diff --git a/apps/api/src/routes/remote/supportSessions.ts b/apps/api/src/routes/remote/supportSessions.ts new file mode 100644 index 000000000..e50f84d7b --- /dev/null +++ b/apps/api/src/routes/remote/supportSessions.ts @@ -0,0 +1,199 @@ +import { Hono } from 'hono'; +import { zValidator } from '@hono/zod-validator'; +import { and, desc, eq, inArray } from 'drizzle-orm'; +import { createSupportSessionSchema, formatSupportCode } from '@breeze/shared'; +import { db, runOutsideDbContext, withSystemDbAccessContext } from '../../db'; +import { devices, remoteSessions, supportSessions } from '../../db/schema'; +import { getOrCreateQuickSupportOrg } from '../../services/quickSupportOrg'; +import { + SUPPORT_CODE_TTL_MINUTES, + SUPPORT_SESSION_HARD_CAP_HOURS, + generateSupportCode, + hashSupportCode, +} from '../../services/quickSupportCode'; +import { getTrustedClientIp } from '../../services/clientIp'; +import { logSessionAudit } from './helpers'; + +export const supportSessionRoutes = new Hono(); + +/** Remote-session statuses that mean "a tech is connected right now". */ +const LIVE_REMOTE_STATUSES = ['pending', 'connecting', 'active'] as const; + +const LIST_DEFAULT_LIMIT = 50; +const LIST_MAX_LIMIT = 100; + +type SupportSessionRow = typeof supportSessions.$inferSelect; + +/** + * Strip the code hash and fold in the two derived fields. + * + * `active` is derived rather than stored so nothing has to hook the + * remote-session create/end paths just to keep a duplicate status in sync. + */ +function toView( + session: SupportSessionRow, + deviceOnline: boolean, + hasLiveRemoteSession: boolean, +) { + const { codeHash: _codeHash, ...rest } = session; + return { + ...rest, + status: session.status === 'ready' && hasLiveRemoteSession ? 'active' : session.status, + deviceOnline, + }; +} + +/** Only 'claimed'/'ready' sessions can have a live device worth probing. */ +function isProbeable(session: SupportSessionRow): boolean { + return !!session.deviceId && (session.status === 'ready' || session.status === 'claimed'); +} + +supportSessionRoutes.post( + '/support-sessions', + zValidator('json', createSupportSessionSchema), + async (c) => { + const auth = c.get('auth'); + + // The hidden Quick Support org hangs off the PARTNER. An org-scope token + // carries a partnerId but never passes breeze_has_partner_access, so it + // must not be able to mint sessions it could not then read back. + if (auth.scope !== 'partner' && auth.scope !== 'system') { + return c.json({ error: 'Quick Support requires partner scope' }, 403); + } + if (!auth.partnerId) { + // System tokens may carry no partner context — there is no org to + // provision into, and provisioning would otherwise throw. + return c.json({ error: 'Quick Support requires a partner context' }, 403); + } + + const data = c.req.valid('json'); + + // Attribution is reporting-only, but it still names a real customer org, + // so it must be one the caller can actually see. + if ( + data.attributedOrgId + && auth.accessibleOrgIds !== null + && !auth.accessibleOrgIds.includes(data.attributedOrgId) + ) { + return c.json({ error: 'Attributed organization not accessible' }, 403); + } + + const { orgId } = await getOrCreateQuickSupportOrg(auth.partnerId); + const code = generateSupportCode(); + const now = Date.now(); + + // System context: when the hidden org was just created it is not in this + // request's accessible_org_ids yet, so the RLS INSERT policy would reject. + const [session] = await runOutsideDbContext(() => withSystemDbAccessContext(() => + db.insert(supportSessions).values({ + orgId, + createdByUserId: auth.user.id, + codeHash: hashSupportCode(code), + codeExpiresAt: new Date(now + SUPPORT_CODE_TTL_MINUTES * 60_000), + hardExpiresAt: new Date(now + SUPPORT_SESSION_HARD_CAP_HOURS * 3_600_000), + attributedOrgId: data.attributedOrgId ?? null, + attributionLabel: data.attributionLabel ?? null, + }).returning() + )); + + await logSessionAudit( + 'support_session_created', + auth.user.id, + orgId, + { + sessionId: session.id, + attributedOrgId: data.attributedOrgId ?? null, + attributionLabel: data.attributionLabel ?? null, + }, + getTrustedClientIp(c, 'unknown'), + ); + + const webBase = process.env.PUBLIC_WEB_URL ?? ''; + return c.json({ + id: session.id, + // The one and only time the plaintext code leaves the server. + code: formatSupportCode(code), + codeExpiresAt: session.codeExpiresAt, + hardExpiresAt: session.hardExpiresAt, + landingUrl: `${webBase}/quick?code=${code}`, + }, 201); + }, +); + +supportSessionRoutes.get('/support-sessions', async (c) => { + const rawLimit = Number.parseInt(c.req.query('limit') ?? '', 10); + const limit = Number.isFinite(rawLimit) + ? Math.min(Math.max(rawLimit, 1), LIST_MAX_LIMIT) + : LIST_DEFAULT_LIMIT; + + // Normal (non-system) context: the hidden org's partner_id puts it inside + // the tech's accessible_org_ids, so RLS grants exactly their own sessions. + const sessions = await db + .select() + .from(supportSessions) + .orderBy(desc(supportSessions.createdAt)) + .limit(limit) as SupportSessionRow[]; + + // Batch the two derived lookups across the whole page — per-row queries here + // would be ~100 round trips at the default limit. + const deviceIds = [...new Set(sessions.filter(isProbeable).map((s) => s.deviceId!))]; + + const onlineDeviceIds = new Set(); + const liveSessionDeviceIds = new Set(); + + if (deviceIds.length > 0) { + const deviceRows = await db + .select({ id: devices.id, status: devices.status }) + .from(devices) + .where(inArray(devices.id, deviceIds)) as Array<{ id: string; status: string }>; + for (const row of deviceRows) { + if (row.status === 'online') onlineDeviceIds.add(row.id); + } + + const liveRows = await db + .select({ deviceId: remoteSessions.deviceId }) + .from(remoteSessions) + .where(and( + inArray(remoteSessions.deviceId, deviceIds), + inArray(remoteSessions.status, [...LIVE_REMOTE_STATUSES]), + )) as Array<{ deviceId: string }>; + for (const row of liveRows) liveSessionDeviceIds.add(row.deviceId); + } + + return c.json({ + sessions: sessions.map((s) => toView( + s, + !!s.deviceId && onlineDeviceIds.has(s.deviceId), + !!s.deviceId && liveSessionDeviceIds.has(s.deviceId), + )), + }); +}); + +supportSessionRoutes.get('/support-sessions/:id', async (c) => { + const [session] = await db + .select() + .from(supportSessions) + .where(eq(supportSessions.id, c.req.param('id'))) + .limit(1) as SupportSessionRow[]; + + if (!session) return c.json({ error: 'Support session not found' }, 404); + + if (!isProbeable(session)) return c.json(toView(session, false, false)); + + const [device] = await db + .select({ status: devices.status }) + .from(devices) + .where(eq(devices.id, session.deviceId!)) + .limit(1) as Array<{ status: string }>; + + const live = await db + .select({ id: remoteSessions.id }) + .from(remoteSessions) + .where(and( + eq(remoteSessions.deviceId, session.deviceId!), + inArray(remoteSessions.status, [...LIVE_REMOTE_STATUSES]), + )) + .limit(1) as Array<{ id: string }>; + + return c.json(toView(session, device?.status === 'online', live.length > 0)); +}); From b2b56057d2f9b2f3b4eaae3a27adf261d908d0e1 Mon Sep 17 00:00:00 2001 From: Todd Hebebrand Date: Tue, 4 Aug 2026 17:56:33 -0500 Subject: [PATCH 09/28] feat(api): public quick support check/redeem endpoints MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GET /support/check/:code returns nothing but a boolean, so it cannot be used to enumerate tenants. POST /support/redeem performs one atomic pending->claimed transition — the WHERE status='pending' guard is what makes a code strictly single-use under concurrent redemption — and mints a single-use child enrollment key. Deviation from the plan, resolving its own open question: the plan proposed returning AGENT_ENROLLMENT_SECRET alongside the child key, conditional on the installer flow already doing so. It does not — the global secret appears nowhere outside config validation. Instead the child key carries its OWN per-key secret, which takes precedence over the global secret in /agents/enroll. It is single-use, expires in 15 minutes, and cannot enroll anything else, so no new exposure is created. hashEnrollmentSecret moves to enrollmentKeySecurity.ts so the minting and verifying sides cannot drift apart. Unknown, expired, claimed and malformed codes all return one identical 404 body. Co-Authored-By: Claude Opus 5 (1M context) --- apps/api/src/index.ts | 5 + apps/api/src/routes/agents/enrollment.test.ts | 16 +- apps/api/src/routes/agents/enrollment.ts | 6 +- apps/api/src/routes/supportPublic.test.ts | 275 ++++++++++++++++++ apps/api/src/routes/supportPublic.ts | 165 +++++++++++ .../api/src/services/enrollmentKeySecurity.ts | 13 + 6 files changed, 471 insertions(+), 9 deletions(-) create mode 100644 apps/api/src/routes/supportPublic.test.ts create mode 100644 apps/api/src/routes/supportPublic.ts diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts index 1e443022a..0e27453f5 100644 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -67,6 +67,7 @@ import { partnerServicePrincipalRoutes } from './routes/partnerServicePrincipals import { partnerApiRoutes } from './routes/partnerApi'; import { enrollmentKeyRoutes, publicEnrollmentRoutes, publicShortLinkRoutes } from './routes/enrollmentKeys'; import { installerRoutes } from './routes/installer'; +import { supportPublicRoutes } from './routes/supportPublic'; import { ssoRoutes } from './routes/sso'; import { partnerLoginBrandingRoutes } from './routes/partnerLoginBranding'; import { docsRoutes } from './routes/docs'; @@ -915,6 +916,10 @@ api.route('/partner-api', partnerApiRoutes); api.route('/enrollment-keys', publicEnrollmentRoutes); // Public download (no auth) — must precede auth-protected routes api.route('/enrollment-keys', enrollmentKeyRoutes); api.route('/installer', installerRoutes); +// Public Quick Support — the one-time code is the auth (no bearer token). +// Guarded by ~44 bits of code entropy, a 15-minute TTL, per-IP rate limits +// and a single atomic pending->claimed transition. +api.route('/support', supportPublicRoutes); api.route('/sso', ssoRoutes); // Mounted directly at /partners (not nested under /orgs' /partners/me or the // legacy singular /partner router) — final URL /api/v1/partners/me/login-branding diff --git a/apps/api/src/routes/agents/enrollment.test.ts b/apps/api/src/routes/agents/enrollment.test.ts index 96aeb765a..6024abd38 100644 --- a/apps/api/src/routes/agents/enrollment.test.ts +++ b/apps/api/src/routes/agents/enrollment.test.ts @@ -68,10 +68,18 @@ vi.mock('../../services/auditEvents', () => ({ writeAuditEvent: vi.fn(), })); -vi.mock('../../services/enrollmentKeySecurity', () => ({ - hashEnrollmentKey: vi.fn((k: string) => `hashed:${k}`), - hashEnrollmentKeyCandidates: vi.fn((k: string) => [`hashed:${k}`]), -})); +vi.mock('../../services/enrollmentKeySecurity', async () => { + // Dynamic import: vi.mock factories are hoisted above the file's imports. + const { createHash: sha } = await import('node:crypto'); + return { + hashEnrollmentKey: vi.fn((k: string) => `hashed:${k}`), + hashEnrollmentKeyCandidates: vi.fn((k: string) => [`hashed:${k}`]), + // Real implementation, not a stub: the secret-comparison branches assert + // on actual digests, and stubbing would make the invalid-secret denials + // vacuous. + hashEnrollmentSecret: vi.fn((s: string) => sha('sha256').update(s).digest('hex')), + }; +}); vi.mock('../../services/clientIp', () => ({ getTrustedClientIp: vi.fn(() => '127.0.0.1'), diff --git a/apps/api/src/routes/agents/enrollment.ts b/apps/api/src/routes/agents/enrollment.ts index e3eaabf0d..7971e68f4 100644 --- a/apps/api/src/routes/agents/enrollment.ts +++ b/apps/api/src/routes/agents/enrollment.ts @@ -14,7 +14,7 @@ import { } from '../../db/schema'; import { getActiveOrgTenant } from '../../services/tenantStatus'; import { writeAuditEvent } from '../../services/auditEvents'; -import { hashEnrollmentKeyCandidates } from '../../services/enrollmentKeySecurity'; +import { hashEnrollmentKeyCandidates, hashEnrollmentSecret } from '../../services/enrollmentKeySecurity'; import { getTrustedClientIp } from '../../services/clientIp'; import { getRedis } from '../../services/redis'; import { rateLimiter } from '../../services/rate-limit'; @@ -61,10 +61,6 @@ function timingSafeStringEqual(left: string, right: string): boolean { return leftBuf.length === rightBuf.length && timingSafeEqual(leftBuf, rightBuf); } -function hashEnrollmentSecret(secret: string): string { - return createHash('sha256').update(secret).digest('hex'); -} - export function getGlobalEnrollmentSecret(): string | null { const configuredSecret = process.env.AGENT_ENROLLMENT_SECRET?.trim() ?? ''; return configuredSecret.length > 0 ? configuredSecret : null; diff --git a/apps/api/src/routes/supportPublic.test.ts b/apps/api/src/routes/supportPublic.test.ts new file mode 100644 index 000000000..c8ea9a05a --- /dev/null +++ b/apps/api/src/routes/supportPublic.test.ts @@ -0,0 +1,275 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +/** + * Public Quick Support endpoints. The code IS the auth, so the tests that + * matter are the ones proving a code cannot be reused, cannot outlive its + * TTL, and that failures are indistinguishable from one another. + */ + +const { rateLimiter, getRedis, logSessionAudit, getTrustedClientIp } = vi.hoisted(() => ({ + rateLimiter: vi.fn(() => Promise.resolve({ allowed: true, currentCount: 1 })), + getRedis: vi.fn(() => ({}) as unknown), + logSessionAudit: vi.fn(() => Promise.resolve()), + getTrustedClientIp: vi.fn(() => '203.0.113.9'), +})); + +vi.mock('../services/rate-limit', () => ({ rateLimiter })); +vi.mock('../services/redis', () => ({ getRedis })); +vi.mock('./remote/helpers', () => ({ logSessionAudit })); +vi.mock('../services/clientIp', () => ({ getTrustedClientIp })); + +vi.mock('../services/enrollmentKeySecurity', async () => { + const { createHash } = await import('node:crypto'); + return { + hashEnrollmentKey: vi.fn((k: string) => `keyhash:${k}`), + hashEnrollmentSecret: vi.fn((s: string) => createHash('sha256').update(s).digest('hex')), + }; +}); + +const selectResults: unknown[][] = []; +const updateResults: unknown[][] = []; +const insertedValues: unknown[] = []; +let updateWhereCalled = 0; + +vi.mock('../db', () => { + const select = vi.fn(() => { + const rows = selectResults.shift() ?? []; + const builder: Record = {}; + for (const m of ['from', 'where']) builder[m] = vi.fn(() => builder); + builder.limit = vi.fn(() => Promise.resolve(rows)); + return builder; + }); + + const update = vi.fn(() => ({ + set: vi.fn(() => ({ + where: vi.fn(() => { + updateWhereCalled++; + return { returning: vi.fn(() => Promise.resolve(updateResults.shift() ?? [])) }; + }), + })), + })); + + const insert = vi.fn(() => ({ + values: vi.fn((v: unknown) => { + insertedValues.push(v); + return Promise.resolve([]); + }), + })); + + return { + db: { select, update, insert }, + withSystemDbAccessContext: vi.fn(async (fn: () => unknown) => fn()), + runOutsideDbContext: vi.fn((fn: () => T): T => fn()), + }; +}); + +vi.mock('../db/schema', () => ({ + supportSessions: { + id: 'supportSessions.id', + codeHash: 'supportSessions.codeHash', + status: 'supportSessions.status', + codeExpiresAt: 'supportSessions.codeExpiresAt', + }, + enrollmentKeys: {}, + sites: { id: 'sites.id', orgId: 'sites.orgId' }, +})); + +import { hashSupportCode } from '../services/quickSupportCode'; +import { supportPublicRoutes } from './supportPublic'; + +const CODE = 'KTM4H7P2X'; +const FUTURE = new Date(Date.now() + 10 * 60_000); +const PAST = new Date(Date.now() - 60_000); + +function pendingSession(overrides: Record = {}) { + return { + id: 'aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee', + orgId: 'qs-org', + createdByUserId: 'creator-1', + status: 'pending', + codeExpiresAt: FUTURE, + hardExpiresAt: new Date(Date.now() + 8 * 3_600_000), + ...overrides, + }; +} + +function redeem(body: Record = {}) { + return supportPublicRoutes.request('/redeem', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ code: CODE, hostname: 'DESKTOP-1', osType: 'windows', ...body }), + }); +} + +beforeEach(() => { + selectResults.length = 0; + updateResults.length = 0; + insertedValues.length = 0; + updateWhereCalled = 0; + vi.clearAllMocks(); + rateLimiter.mockResolvedValue({ allowed: true, currentCount: 1 }); + getTrustedClientIp.mockReturnValue('203.0.113.9'); + process.env.PUBLIC_API_URL = 'https://us.2breeze.app'; +}); + +describe('GET /check/:code', () => { + it('reports a pending unexpired code as valid', async () => { + selectResults.push([{ status: 'pending', codeExpiresAt: FUTURE }]); + const body = await (await supportPublicRoutes.request(`/check/${CODE}`)).json(); + expect(body).toEqual({ valid: true }); + }); + + it('reports an expired code as invalid', async () => { + selectResults.push([{ status: 'pending', codeExpiresAt: PAST }]); + expect(await (await supportPublicRoutes.request(`/check/${CODE}`)).json()).toEqual({ valid: false }); + }); + + it('reports an already-claimed code as invalid', async () => { + selectResults.push([{ status: 'claimed', codeExpiresAt: FUTURE }]); + expect(await (await supportPublicRoutes.request(`/check/${CODE}`)).json()).toEqual({ valid: false }); + }); + + it('reports an unknown code as invalid', async () => { + selectResults.push([]); + expect(await (await supportPublicRoutes.request(`/check/${CODE}`)).json()).toEqual({ valid: false }); + }); + + it('rejects a malformed code without touching the database', async () => { + const body = await (await supportPublicRoutes.request('/check/not-a-code')).json(); + expect(body).toEqual({ valid: false }); + expect(selectResults).toHaveLength(0); // nothing was consumed + }); + + it('accepts the human-formatted code', async () => { + selectResults.push([{ status: 'pending', codeExpiresAt: FUTURE }]); + expect(await (await supportPublicRoutes.request('/check/KTM-4H7-P2X')).json()) + .toEqual({ valid: true }); + }); + + it('429s when rate limited', async () => { + rateLimiter.mockResolvedValue({ allowed: false, currentCount: 99 }); + const res = await supportPublicRoutes.request(`/check/${CODE}`); + expect(res.status).toBe(429); + }); +}); + +describe('POST /redeem', () => { + it('claims the session and mints a single-use key with its own secret', async () => { + const session = pendingSession(); + selectResults.push([session]); // code lookup + updateResults.push([{ ...session, status: 'claimed' }]); // atomic claim wins + selectResults.push([{ id: 'site-1' }]); // site lookup + + const res = await redeem(); + expect(res.status).toBe(200); + const body = await res.json(); + + expect(body.enrollmentKey).toMatch(/^[0-9a-f]{64}$/); + expect(body.enrollmentSecret).toMatch(/^[0-9a-f]{64}$/); + expect(body.serverUrl).toBe('https://us.2breeze.app'); + expect(body.sessionId).toBe(session.id); + + const key = insertedValues[0] as Record; + expect(key.maxUsage).toBe(1); + expect(key.supportSessionId).toBe(session.id); + expect(key.orgId).toBe('qs-org'); + expect(key.installerPlatform).toBe('windows'); + // The raw key/secret are never stored. + expect(key.key).toBe(`keyhash:${body.enrollmentKey}`); + expect(key.key).not.toBe(body.enrollmentKey); + expect(key.keySecretHash).not.toBe(body.enrollmentSecret); + }); + + it('never hands out the global AGENT_ENROLLMENT_SECRET', async () => { + process.env.AGENT_ENROLLMENT_SECRET = 'super-secret-global-value'; + const session = pendingSession(); + selectResults.push([session]); + updateResults.push([{ ...session, status: 'claimed' }]); + selectResults.push([{ id: 'site-1' }]); + + const body = await (await redeem()).json(); + expect(JSON.stringify(body)).not.toContain('super-secret-global-value'); + delete process.env.AGENT_ENROLLMENT_SECRET; + }); + + it('guards the claim on status=pending so a concurrent redeem loses', async () => { + const session = pendingSession(); + selectResults.push([session]); + updateResults.push([]); // the racer already flipped it — 0 rows updated + + const res = await redeem(); + expect(res.status).toBe(404); + expect(updateWhereCalled).toBe(1); + expect(insertedValues).toHaveLength(0); // no key minted + }); + + it('404s an already-claimed code', async () => { + selectResults.push([pendingSession({ status: 'claimed' })]); + const res = await redeem(); + expect(res.status).toBe(404); + expect(updateWhereCalled).toBe(0); + }); + + it('404s an expired code without touching the session', async () => { + selectResults.push([pendingSession({ codeExpiresAt: PAST })]); + const res = await redeem(); + expect(res.status).toBe(404); + expect(updateWhereCalled).toBe(0); + expect(insertedValues).toHaveLength(0); + }); + + it('404s a session already past its hard cap', async () => { + selectResults.push([pendingSession({ hardExpiresAt: PAST })]); + expect((await redeem()).status).toBe(404); + expect(updateWhereCalled).toBe(0); + }); + + it('404s an unknown code', async () => { + selectResults.push([]); + expect((await redeem()).status).toBe(404); + }); + + it('returns the same error shape for unknown, expired and claimed codes', async () => { + const bodies: unknown[] = []; + selectResults.push([]); + bodies.push(await (await redeem()).json()); + selectResults.push([pendingSession({ codeExpiresAt: PAST })]); + bodies.push(await (await redeem()).json()); + selectResults.push([pendingSession({ status: 'claimed' })]); + bodies.push(await (await redeem()).json()); + expect(new Set(bodies.map((b) => JSON.stringify(b))).size).toBe(1); + }); + + it('records the claiming IP and audits the anonymous actor', async () => { + const session = pendingSession(); + selectResults.push([session]); + updateResults.push([{ ...session, status: 'claimed' }]); + selectResults.push([{ id: 'site-1' }]); + + await redeem(); + expect(logSessionAudit).toHaveBeenCalledWith( + 'support_session_claimed', + 'creator-1', + 'qs-org', + expect.objectContaining({ actor: 'end_user', sessionId: session.id }), + '203.0.113.9', + ); + }); + + it('429s when rate limited before any DB work', async () => { + rateLimiter.mockResolvedValue({ allowed: false, currentCount: 99 }); + expect((await redeem()).status).toBe(429); + expect(selectResults).toHaveLength(0); + }); + + it('rejects a payload with an unknown osType', async () => { + const res = await redeem({ osType: 'freebsd' }); + expect(res.status).toBe(400); + }); + + it('hashes the code before lookup — the plaintext is never queried', async () => { + selectResults.push([]); + await redeem(); + expect(hashSupportCode(CODE)).toMatch(/^[0-9a-f]{64}$/); + }); +}); diff --git a/apps/api/src/routes/supportPublic.ts b/apps/api/src/routes/supportPublic.ts new file mode 100644 index 000000000..0b4ab0ee5 --- /dev/null +++ b/apps/api/src/routes/supportPublic.ts @@ -0,0 +1,165 @@ +import { Hono } from 'hono'; +import { zValidator } from '@hono/zod-validator'; +import { randomBytes } from 'node:crypto'; +import { and, eq } from 'drizzle-orm'; +import { normalizeSupportCode, redeemSupportSessionSchema } from '@breeze/shared'; +import { db, withSystemDbAccessContext } from '../db'; +import { enrollmentKeys, sites, supportSessions } from '../db/schema'; +import { hashSupportCode } from '../services/quickSupportCode'; +import { hashEnrollmentKey, hashEnrollmentSecret } from '../services/enrollmentKeySecurity'; +import { rateLimiter } from '../services/rate-limit'; +import { getRedis } from '../services/redis'; +import { getTrustedClientIp } from '../services/clientIp'; +import { logSessionAudit } from './remote/helpers'; + +/** + * Public Quick Support endpoints — the one-time code IS the authentication. + * + * Everything here is unauthenticated by design (the end user is a stranger + * holding a code their technician read out), so the guards are: ~44 bits of + * code entropy, a 15-minute redemption TTL, per-IP rate limits, and a single + * atomic pending->claimed transition that makes a code strictly single-use. + * + * These handlers run under withSystemDbAccessContext because an anonymous + * caller has no org context at all — the code lookup is the authorization. + */ +export const supportPublicRoutes = new Hono(); + +const CHECK_LIMIT = 30; +const REDEEM_LIMIT = 10; +const RATE_WINDOW_SECONDS = 60; + +/** Child enrollment keys are minted with the same lifetime as the code. */ +const CHILD_KEY_TTL_MS = 15 * 60_000; + +/** + * Is this code redeemable right now? Deliberately returns nothing but a + * boolean — never session details, org names, or timings — so the endpoint + * cannot be used to enumerate or fingerprint tenants. + */ +supportPublicRoutes.get('/check/:code', async (c) => { + const ip = getTrustedClientIp(c, 'unknown'); + const limit = await rateLimiter(getRedis(), `support-check:${ip}`, CHECK_LIMIT, RATE_WINDOW_SECONDS); + if (!limit.allowed) return c.json({ error: 'rate limited' }, 429); + + const code = normalizeSupportCode(c.req.param('code')); + // Malformed input can never match a stored hash — skip the DB entirely. + if (!code) return c.json({ valid: false }); + + const [row] = await withSystemDbAccessContext(() => db + .select({ + status: supportSessions.status, + codeExpiresAt: supportSessions.codeExpiresAt, + }) + .from(supportSessions) + .where(eq(supportSessions.codeHash, hashSupportCode(code))) + .limit(1)) as Array<{ status: string; codeExpiresAt: Date }>; + + return c.json({ + valid: !!row && row.status === 'pending' && row.codeExpiresAt > new Date(), + }); +}); + +/** + * Redeem a code for a single-use enrollment key. + * + * The child key carries its OWN secret (key_secret_hash), which takes + * precedence over the global AGENT_ENROLLMENT_SECRET in + * /agents/enroll. That is deliberate: no existing route hands the global + * enrollment secret to a code-authenticated caller, and this endpoint must + * not become the first. A per-key secret is single-use, expires in 15 + * minutes, and is worthless for enrolling anything else. + */ +supportPublicRoutes.post('/redeem', zValidator('json', redeemSupportSessionSchema), async (c) => { + const ip = getTrustedClientIp(c, 'unknown'); + const limit = await rateLimiter(getRedis(), `support-redeem:${ip}`, REDEEM_LIMIT, RATE_WINDOW_SECONDS); + if (!limit.allowed) return c.json({ error: 'rate limited' }, 429); + + const data = c.req.valid('json'); + const code = normalizeSupportCode(data.code); + // One indistinguishable failure shape for malformed, unknown, expired and + // already-claimed codes — nothing here should confirm a code ever existed. + if (!code) return c.json({ error: 'invalid or expired code' }, 404); + + const result = await withSystemDbAccessContext(async () => { + const now = new Date(); + const [row] = await db + .select() + .from(supportSessions) + .where(eq(supportSessions.codeHash, hashSupportCode(code))) + .limit(1); + + if (!row + || row.status !== 'pending' + || row.codeExpiresAt < now + || row.hardExpiresAt < now) { + return null; + } + + // Atomic claim: the WHERE status='pending' guard is what makes a + // simultaneous second redemption lose rather than mint a second key. + const [claimed] = await db + .update(supportSessions) + .set({ + status: 'claimed', + claimedAt: now, + claimedFromIp: ip === 'unknown' ? null : ip, + }) + .where(and( + eq(supportSessions.id, row.id), + eq(supportSessions.status, 'pending'), + )) + .returning(); + if (!claimed) return null; + + const [site] = await db + .select({ id: sites.id }) + .from(sites) + .where(eq(sites.orgId, row.orgId)) + .limit(1); + + const rawChildKey = randomBytes(32).toString('hex'); + const rawChildSecret = randomBytes(32).toString('hex'); + + await db.insert(enrollmentKeys).values({ + orgId: row.orgId, + siteId: site?.id ?? null, + name: `Quick Support ${row.id.slice(0, 8)}`, + key: hashEnrollmentKey(rawChildKey), + keySecretHash: hashEnrollmentSecret(rawChildSecret), + maxUsage: 1, + expiresAt: new Date(Date.now() + CHILD_KEY_TTL_MS), + supportSessionId: row.id, + installerPlatform: data.osType === 'windows' ? 'windows' : 'macos', + }); + + return { + rawChildKey, + rawChildSecret, + sessionId: row.id, + hardExpiresAt: row.hardExpiresAt, + orgId: row.orgId, + createdByUserId: row.createdByUserId, + }; + }); + + if (!result) return c.json({ error: 'invalid or expired code' }, 404); + + // The audit row needs a user id, so it carries the session CREATOR's — the + // real actor is an anonymous end user, which the details say explicitly. + await logSessionAudit( + 'support_session_claimed', + result.createdByUserId, + result.orgId, + { sessionId: result.sessionId, actor: 'end_user', hostname: data.hostname }, + ip, + ); + + return c.json({ + serverUrl: process.env.PUBLIC_API_URL ?? process.env.API_URL ?? '', + enrollmentKey: result.rawChildKey, + enrollmentSecret: result.rawChildSecret, + sessionId: result.sessionId, + hardExpiresAt: result.hardExpiresAt, + }); +}); diff --git a/apps/api/src/services/enrollmentKeySecurity.ts b/apps/api/src/services/enrollmentKeySecurity.ts index 8de2f7ffc..500e0d645 100644 --- a/apps/api/src/services/enrollmentKeySecurity.ts +++ b/apps/api/src/services/enrollmentKeySecurity.ts @@ -38,6 +38,19 @@ export function hashEnrollmentKey(rawKey: string): string { return hashWithPepper(getPrimaryPepper(), rawKey); } +/** + * Hash for `enrollment_keys.key_secret_hash` and for the secret an enrolling + * agent presents. + * + * Deliberately unpeppered plain SHA-256, unlike {@link hashEnrollmentKey}: the + * comparison also has to work against the global AGENT_ENROLLMENT_SECRET, + * which is hashed on the fly at verification time. Changing this breaks every + * already-issued per-key secret. + */ +export function hashEnrollmentSecret(secret: string): string { + return createHash('sha256').update(secret).digest('hex'); +} + // Returns every hash a stored enrollment-key row could match — primary first, // then any legacy peppers. Use with `inArray(enrollmentKeys.key, candidates)` // on lookup paths. Order is significant: callers that do per-row comparison From 94289210d989fa5196c17ca60f09dce4cb552e5b Mon Sep 17 00:00:00 2001 From: Todd Hebebrand Date: Tue, 4 Aug 2026 18:27:11 -0500 Subject: [PATCH 10/28] feat(api): mark quick support session ready on agent connect MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The ephemeral agent's WebSocket coming up is the only signal that the client actually installed and reached us, so onOpen is what moves a claimed session to 'ready' for the waiting technician. Guarded on devices.is_ephemeral, which is already on the row onOpen just loaded: this handler runs on every agent reconnect across a 10k-device fleet, and normal devices must pay zero extra queries. The agent's context org is the hidden Quick Support org that owns the row, so org RLS passes. Gets its own try/catch rather than sharing the enclosing one, which would file a failure under "failed to query device for online event" and misdirect anyone debugging a session stuck at 'claimed'. A failure is non-fatal — the reaper expires claimed-limbo sessions after 20 minutes. Both branches are pinned by tests, including the non-ephemeral case asserting zero support_sessions updates, so the perf guard is behaviour rather than a review convention. Co-Authored-By: Claude Opus 5 (1M context) --- apps/api/src/routes/agentWs.test.ts | 65 ++++++++++++++++++++++++++++- apps/api/src/routes/agentWs.ts | 35 +++++++++++++++- 2 files changed, 96 insertions(+), 4 deletions(-) diff --git a/apps/api/src/routes/agentWs.test.ts b/apps/api/src/routes/agentWs.test.ts index 96a4c30ea..a1cd21eb6 100644 --- a/apps/api/src/routes/agentWs.test.ts +++ b/apps/api/src/routes/agentWs.test.ts @@ -27,7 +27,15 @@ vi.mock('../db/schema', () => ({ orgId: 'devices.orgId', status: 'devices.status', lastSeenAt: 'devices.lastSeenAt', - updatedAt: 'devices.updatedAt' + updatedAt: 'devices.updatedAt', + siteId: 'devices.siteId', + hostname: 'devices.hostname', + agentVersion: 'devices.agentVersion', + isEphemeral: 'devices.isEphemeral' + }, + supportSessions: { + deviceId: 'supportSessions.deviceId', + status: 'supportSessions.status', }, deviceCommands: { id: 'deviceCommands.id', @@ -274,7 +282,7 @@ vi.mock('../services/sentry', async (importOriginal) => { }); import { db, runOutsideDbContext, withSystemDbAccessContext, withDbAccessContext } from '../db'; -import { devices, deviceCommands, scriptExecutions } from '../db/schema'; +import { devices, deviceCommands, scriptExecutions, supportSessions } from '../db/schema'; import { captureMessage } from '../services/sentry'; import { createAgentWsHandlers, @@ -2305,6 +2313,59 @@ describe('WS frames never claim pending commands (#2407)', () => { }); }); +// Quick Support: the ephemeral agent's socket is the readiness signal, so onOpen +// owns the claimed -> ready transition. The isEphemeral guard is the reason this +// is affordable on a 10k-device fleet — the "no extra query" case is pinned here +// as behaviour, not left to review. +describe('Quick Support — support_sessions claimed -> ready on agent connect', () => { + beforeEach(() => { + vi.resetAllMocks(); + }); + + /** Capture every db.update(table).set(...).where(...) the handshake performs. */ + function rigOnOpen(deviceRow: Record) { + const updates: Array<{ table: unknown; values: unknown; where: unknown }> = []; + vi.mocked(db.update).mockImplementation(((table: unknown) => ({ + set: (values: unknown) => ({ + where: (where: unknown) => { + updates.push({ table, values, where }); + return Promise.resolve([]); + }, + }), + })) as any); + vi.mocked(db.select).mockReturnValue(selectAgentDevice([deviceRow]) as any); + vi.mocked(publishEvent).mockResolvedValue('event-id'); + return updates; + } + + it('flips the claimed session to ready when an ephemeral agent connects', async () => { + const updates = rigOnOpen({ + id: 'device-eph', siteId: null, hostname: 'quick-support-pc', agentVersion: '1.0.0', isEphemeral: true, + }); + + const handlers = createAgentWsHandlers('agent-eph', { deviceId: 'device-eph', orgId: 'org-qs' }); + await handlers.onOpen({}, wsMock() as any); + + const sessionUpdate = updates.find(u => u.table === supportSessions); + expect(sessionUpdate).toBeDefined(); + expect(sessionUpdate!.values).toEqual({ status: 'ready' }); + expect(sessionUpdate!.where).toEqual( + and(eq(supportSessions.deviceId, 'device-eph'), eq(supportSessions.status, 'claimed')) + ); + }); + + it('touches support_sessions not at all for a normal (non-ephemeral) device', async () => { + const updates = rigOnOpen({ + id: 'device-normal', siteId: 'site-1', hostname: 'workstation-7', agentVersion: '1.0.0', isEphemeral: false, + }); + + const handlers = createAgentWsHandlers('agent-normal', { deviceId: 'device-normal', orgId: 'org-1' }); + await handlers.onOpen({}, wsMock() as any); + + expect(updates.some(u => u.table === supportSessions)).toBe(false); + }); +}); + // #2434 — agent-supplied error/output strings persisted OUTSIDE device_commands // must be redacted too (script_executions, tunnel_sessions, remote_sessions). describe('#2434 — secret redaction on non-device_commands persistence surfaces', () => { diff --git a/apps/api/src/routes/agentWs.ts b/apps/api/src/routes/agentWs.ts index e1e120a20..f6a40a8aa 100644 --- a/apps/api/src/routes/agentWs.ts +++ b/apps/api/src/routes/agentWs.ts @@ -6,7 +6,7 @@ import { createHash } from 'crypto'; import { db, withDbAccessContext, withSystemDbAccessContext, runOutsideDbContext } from '../db'; import { dbWriteExpectingRows } from '../db/dbWriteExpectingRows'; import { commandCasPriorStatusTags } from '../services/commandCasDiagnostics'; -import { devices, deviceCommands, discoveryJobs, scriptExecutions, scriptExecutionBatches, remoteSessions, backupJobs, restoreJobs, tunnelSessions } from '../db/schema'; +import { devices, deviceCommands, discoveryJobs, scriptExecutions, scriptExecutionBatches, remoteSessions, backupJobs, restoreJobs, tunnelSessions, supportSessions } from '../db/schema'; import { handleTerminalOutput, getActiveTerminalSession, @@ -2157,7 +2157,7 @@ export function createAgentWsHandlers(agentId: string, preValidatedAgent: AgentD if (agentDb) { try { const [deviceInfo] = await runWithAgentDbAccess('agentWs.onOpen.loadDevice', async () => - db.select({ id: devices.id, siteId: devices.siteId, hostname: devices.hostname, agentVersion: devices.agentVersion }) + db.select({ id: devices.id, siteId: devices.siteId, hostname: devices.hostname, agentVersion: devices.agentVersion, isEphemeral: devices.isEphemeral }) .from(devices) .where(eq(devices.agentId, agentId)) .limit(1) @@ -2173,6 +2173,37 @@ export function createAgentWsHandlers(agentId: string, preValidatedAgent: AgentD captureException(err); }); } + + // Quick Support: this socket coming up is the only signal that the + // ephemeral agent actually installed and reached us, so it is what + // moves the claimed session to 'ready' for the tech waiting on it. + // + // The isEphemeral guard is load-bearing for throughput, not just + // tidiness: onOpen runs on EVERY agent reconnect across a 10k-device + // fleet, and a normal device must pay zero extra queries here. The + // flag is already on the row we just loaded, so the guard costs + // nothing. The agent's context org IS the hidden Quick Support org + // that owns the support_sessions row, so org RLS passes. + if (deviceInfo?.isEphemeral) { + // Own try/catch: sharing the enclosing one would file this under + // "failed to query device for online event" and misdirect whoever + // debugs a session stuck at 'claimed'. Failure is not fatal — the + // reaper expires claimed-limbo sessions after 20 minutes — so log + // and let the connection proceed. + try { + await runWithAgentDbAccess('agentWs.onOpen.supportSessionReady', async () => + db.update(supportSessions) + .set({ status: 'ready' }) + .where(and( + eq(supportSessions.deviceId, deviceInfo.id), + eq(supportSessions.status, 'claimed'), + )) + ); + } catch (err) { + console.error('[AgentWs] Failed to mark quick support session ready:', err); + captureException(err instanceof Error ? err : new Error(String(err))); + } + } } catch (err) { console.error('[AgentWs] Failed to query device for online event:', err); captureException(err instanceof Error ? err : new Error(String(err))); From 186c91b54362941659b66cb0a7d592c166da5027 Mon Sep 17 00:00:00 2001 From: Todd Hebebrand Date: Tue, 4 Aug 2026 18:34:02 -0500 Subject: [PATCH 11/28] feat(api): ephemeral enrollment via quick support keys A key carrying supportSessionId enrolls an ephemeral device: the session must still be 'claimed' and within its hard cap, the partner licence count is skipped, and the session is bound to the new device id inside the same transaction as the device insert. Both licence counts (enrollment + provision) now exclude ephemeral devices, so an ad-hoc support session can never consume a customer's seat. The hostname-collision lookup excludes them too, so a repeat support run on the same machine inserts a fresh row instead of taking the re-enrollment-token branch. The rejection reuses the expired-key response byte-for-byte, so a caller holding only a key cannot probe session state; the real reason (support_session_not_claimable) goes to the audit row only. Co-Authored-By: Claude Opus 5 (1M context) --- apps/api/src/routes/agents/enrollment.test.ts | 240 +++++++++++++++++- apps/api/src/routes/agents/enrollment.ts | 98 ++++++- apps/api/src/routes/devices/provision.ts | 6 + 3 files changed, 340 insertions(+), 4 deletions(-) diff --git a/apps/api/src/routes/agents/enrollment.test.ts b/apps/api/src/routes/agents/enrollment.test.ts index 6024abd38..75f503d40 100644 --- a/apps/api/src/routes/agents/enrollment.test.ts +++ b/apps/api/src/routes/agents/enrollment.test.ts @@ -43,6 +43,7 @@ vi.mock('../../db/schema', () => ({ expiresAt: 'expiresAt', maxUsage: 'maxUsage', usageCount: 'usageCount', + supportSessionId: 'supportSessionId', }, devices: { id: 'id', @@ -57,11 +58,18 @@ vi.mock('../../db/schema', () => ({ possibleReplacementOfDeviceId: 'possibleReplacementOfDeviceId', enrollmentIp: 'enrollment_ip', createdAt: 'createdAt', + isEphemeral: 'devices.isEphemeral', }, deviceHardware: { deviceId: 'deviceId', serialNumber: 'serialNumber' }, deviceNetwork: { deviceId: 'deviceId', macAddress: 'macAddress' }, organizations: { id: 'id', partnerId: 'partnerId' }, partners: { id: 'id', maxDevices: 'maxDevices' }, + supportSessions: { + id: 'supportSessions.id', + status: 'supportSessions.status', + hardExpiresAt: 'supportSessions.hardExpiresAt', + deviceId: 'supportSessions.deviceId', + }, })); vi.mock('../../services/auditEvents', () => ({ @@ -125,7 +133,7 @@ import * as manifestSigning from '../../services/manifestSigning'; import { getTrustedClientIp } from '../../services/clientIp'; import { queueWarrantySyncForDevice } from '../../services/warrantyWorker'; import { raiseDeviceIdentityCollisionAlert } from '../../services/deviceIdentityCollisionAlert'; -import { devices as devicesTable } from '../../db/schema'; +import { devices as devicesTable, supportSessions as supportSessionsTable } from '../../db/schema'; import { enrollmentRoutes } from './enrollment'; function buildApp(): Hono { @@ -2769,3 +2777,233 @@ describe('POST /agents/enroll — enrollment IP persistence', () => { ); }); }); + +describe('POST /agents/enroll — Quick Support ephemeral enrollment', () => { + beforeEach(() => { + vi.clearAllMocks(); + delete process.env.AGENT_ENROLLMENT_SECRET; + process.env.NODE_ENV = 'test'; + }); + + interface SupportTxSpy { + /** values() handed to tx.insert(devices). */ + deviceInsertValues: Record[]; + /** Every tx.update(...) as [table, setPayload]. */ + updates: Array<{ table: unknown; values: Record }>; + /** where() conditions handed to the in-transaction licence count. */ + countWhere: unknown[]; + } + + function mockSupportTransaction(countAtCap = 0): SupportTxSpy { + const spy: SupportTxSpy = { deviceInsertValues: [], updates: [], countWhere: [] }; + + vi.mocked(db.transaction).mockImplementation(async (fn: any) => { + const fakeTx = { + select: vi.fn().mockReturnValue({ + from: vi.fn().mockReturnValue({ + where: vi.fn((cond: unknown) => { + spy.countWhere.push(cond); + return Promise.resolve([{ count: countAtCap }]); + }), + }), + }), + insert: vi.fn((table: unknown) => ({ + values: vi.fn((values: Record) => { + if (table === devicesTable) spy.deviceInsertValues.push(values); + return { + returning: vi.fn().mockResolvedValue([ + { id: 'device-support', orgId: 'org-support', siteId: 'site-support', hostname: 'host-1' }, + ]), + onConflictDoUpdate: vi.fn().mockResolvedValue(undefined), + }; + }), + })), + update: vi.fn((table: unknown) => ({ + set: vi.fn((values: Record) => { + spy.updates.push({ table, values }); + return { + where: vi.fn(() => Object.assign( + Promise.resolve(undefined) as any, + { returning: vi.fn().mockResolvedValue([{ id: 'key-support' }]) }, + )), + }; + }), + })), + delete: vi.fn().mockReturnValue({ where: vi.fn().mockResolvedValue(undefined) }), + }; + return fn(fakeTx); + }); + + return spy; + } + + /** + * The two db.select calls a REJECTED support enrollment consumes: the key + * lookup and the support-session lookup. Queueing only what the handler + * actually reads matters — `mockReturnValueOnce` entries survive + * `clearAllMocks`, so an over-queued rejection test corrupts the next one. + * `session: null` stands in for a purged session row. + */ + function arrangeSupportKey(session: { status: string; hardExpiresAt: Date } | null) { + mockKeyLookup({ + id: 'key-support', + orgId: 'org-support', + siteId: 'site-support', + keySecretHash: null, + expiresAt: new Date(Date.now() + 3600_000), + maxUsage: 1, + usageCount: 0, + supportSessionId: 'session-1', + }); + mockSelectRows(session ? [session] : []); // support-session lookup + } + + /** Full select sequence for an accepted support enrollment. */ + function arrangeSupportEnroll( + session: { status: string; hardExpiresAt: Date }, + maxDevices: number | null = null, + ) { + arrangeSupportKey(session); + mockSelectRows([{ partnerId: 'partner-support' }]); // org lookup + mockSelectRows([{ maxDevices }]); // partner.maxDevices + mockSelectRows([]); // no colliding device + } + + function enroll() { + return buildApp().request('/agents/enroll', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(baseEnrollBody), + }); + } + + it('marks the device ephemeral and binds it to the claimed session in the same transaction', async () => { + arrangeSupportEnroll({ status: 'claimed', hardExpiresAt: new Date(Date.now() + 3600_000) }); + const spy = mockSupportTransaction(); + + const resp = await enroll(); + + expect(resp.status).toBe(201); + expect(spy.deviceInsertValues[0]).toEqual( + expect.objectContaining({ isEphemeral: true }), + ); + // The session must learn its device id, or the technician's session never + // becomes connectable. + expect(spy.updates).toContainEqual({ + table: supportSessionsTable, + values: { deviceId: 'device-support' }, + }); + }); + + it.each(['ended', 'expired', 'pending'])( + 'rejects a support key whose session is %s, with the generic expired-key shape', + async (status) => { + arrangeSupportKey({ status, hardExpiresAt: new Date(Date.now() + 3600_000) }); + const spy = mockSupportTransaction(); + + const resp = await enroll(); + + expect(resp.status).toBe(401); + const body = (await resp.json()) as Record; + expect(body.reason).toBe('enrollment_key_expired'); + expect(spy.deviceInsertValues).toHaveLength(0); + expect(writeAuditEvent).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + result: 'denied', + details: expect.objectContaining({ + reason: 'support_session_not_claimable', + supportSessionId: 'session-1', + sessionStatus: status, + }), + }), + ); + }, + ); + + it('rejects a claimed session that has passed its hard expiry', async () => { + arrangeSupportKey({ status: 'claimed', hardExpiresAt: new Date(Date.now() - 1_000) }); + const spy = mockSupportTransaction(); + + const resp = await enroll(); + + expect(resp.status).toBe(401); + expect(spy.deviceInsertValues).toHaveLength(0); + }); + + it('rejects a support key whose session row no longer exists', async () => { + arrangeSupportKey(null); + const spy = mockSupportTransaction(); + + const resp = await enroll(); + + expect(resp.status).toBe(401); + expect(spy.deviceInsertValues).toHaveLength(0); + }); + + it('enrolls even when the partner is at its device limit — a support session is not a licensed endpoint', async () => { + arrangeSupportEnroll({ status: 'claimed', hardExpiresAt: new Date(Date.now() + 3600_000) }, 2); + const spy = mockSupportTransaction(2); // fleet already at the cap + + const resp = await enroll(); + + expect(resp.status).toBe(201); + // The cap block is skipped wholesale, so the count query never runs. + expect(spy.countWhere).toHaveLength(0); + expect(spy.deviceInsertValues[0]).toEqual( + expect.objectContaining({ isEphemeral: true }), + ); + }); + + it('leaves an ordinary enrollment non-ephemeral and touches no support session', async () => { + mockKeyLookup({ + id: 'key-normal', + orgId: 'org-normal', + siteId: 'site-normal', + keySecretHash: null, + expiresAt: new Date(Date.now() + 3600_000), + maxUsage: 10, + usageCount: 0, + supportSessionId: null, + }); + mockSelectRows([{ partnerId: 'partner-normal' }]); + mockSelectRows([{ maxDevices: null }]); + mockSelectRows([]); + const spy = mockSupportTransaction(); + + const resp = await enroll(); + + expect(resp.status).toBe(201); + expect(spy.deviceInsertValues[0]).toEqual( + expect.objectContaining({ isEphemeral: false }), + ); + expect(spy.updates.some((u) => u.table === supportSessionsTable)).toBe(false); + }); + + it('excludes ephemeral rows from the partner licence count', async () => { + mockKeyLookup({ + id: 'key-count', + orgId: 'org-count', + siteId: 'site-count', + keySecretHash: null, + expiresAt: new Date(Date.now() + 3600_000), + maxUsage: 10, + usageCount: 0, + supportSessionId: null, + }); + mockSelectRows([{ partnerId: 'partner-count' }]); + mockSelectRows([{ maxDevices: 5 }]); // cap set, so the count actually runs + mockSelectRows([]); + const spy = mockSupportTransaction(1); + + const resp = await enroll(); + + expect(resp.status).toBe(201); + // [0] is the partnerOrgIds subquery's where, [1] the fleet count itself. + expect(spy.countWhere).toHaveLength(2); + // The mocked schema names the column 'devices.isEphemeral', so its presence + // in the serialized condition proves the exclusion reached the SQL — a + // regression here silently re-bills every Quick Support session. + expect(JSON.stringify(spy.countWhere[1])).toContain('devices.isEphemeral'); + }); +}); diff --git a/apps/api/src/routes/agents/enrollment.ts b/apps/api/src/routes/agents/enrollment.ts index 7971e68f4..61d7221b2 100644 --- a/apps/api/src/routes/agents/enrollment.ts +++ b/apps/api/src/routes/agents/enrollment.ts @@ -11,6 +11,7 @@ import { enrollmentKeys, organizations, partners, + supportSessions, } from '../../db/schema'; import { getActiveOrgTenant } from '../../services/tenantStatus'; import { writeAuditEvent } from '../../services/auditEvents'; @@ -142,6 +143,9 @@ enrollmentRoutes.post('/enroll', zValidator('json', enrollSchema), async (c) => expiresAt: enrollmentKeys.expiresAt, maxUsage: enrollmentKeys.maxUsage, usageCount: enrollmentKeys.usageCount, + // Set only on the single-use child keys minted by POST /support/redeem; + // NULL on every ordinary key. Drives the whole Quick Support branch below. + supportSessionId: enrollmentKeys.supportSessionId, }) .from(enrollmentKeys) .where(inArray(enrollmentKeys.key, enrollmentKeyCandidates)) @@ -164,6 +168,11 @@ enrollmentRoutes.post('/enroll', zValidator('json', enrollSchema), async (c) => }, 401); } + // Quick Support enrollments ride the same key path as everything else, but + // mint an EPHEMERAL device: excluded from the partner licence count, never + // adopted by a hostname collision, and linked back to its session below. + const isSupportEnrollment = !!matchingKey.supportSessionId; + // Step 2: the row exists — now tell the admin precisely which invariant // it's violating. Both branches stay on 401 for backwards compatibility // with older agents that don't parse `reason`. @@ -310,6 +319,50 @@ enrollmentRoutes.post('/enroll', zValidator('json', enrollSchema), async (c) => } } + // Step 3 (Quick Support only): the child key is single-use and short-lived, + // but the SESSION is the real authority — a technician who ends a session, + // or a hard-expiry reaper run, must invalidate a key that was redeemed + // moments earlier and never used. Checked AFTER the secret verification so + // a caller holding only the key cannot probe session state. + // + // The response is byte-for-byte the ordinary expired-key rejection: the end + // user is an anonymous stranger and nothing here should confirm that a + // support session ever existed. The audit row carries the real reason. + if (isSupportEnrollment) { + const [session] = await db + .select({ + status: supportSessions.status, + hardExpiresAt: supportSessions.hardExpiresAt, + }) + .from(supportSessions) + .where(eq(supportSessions.id, matchingKey.supportSessionId!)) + .limit(1); + + const supportNow = new Date(); + if (!session || session.status !== 'claimed' || new Date(session.hardExpiresAt) < supportNow) { + writeAuditEvent(c, { + orgId: matchingKey.orgId, + actorType: 'system', + action: 'agent.enroll', + resourceType: 'device', + resourceName: data.hostname, + details: { + reason: 'support_session_not_claimable', + keyId: matchingKey.id, + supportSessionId: matchingKey.supportSessionId, + sessionStatus: session?.status ?? null, + }, + result: 'denied', + errorMessage: 'Quick Support session is not claimable', + }); + recordAgentEnrollment('error'); + return c.json({ + error: 'Enrollment key has expired — regenerate the key or installer link and retry', + reason: 'enrollment_key_expired', + }, 401); + } + } + if (!matchingKey.siteId) { throw new HTTPException(400, { message: 'Enrollment key must be associated with a site' }); } @@ -407,7 +460,15 @@ enrollmentRoutes.post('/enroll', zValidator('json', enrollSchema), async (c) => and( eq(devices.hostname, data.hostname), eq(devices.orgId, key.orgId), - eq(devices.siteId, siteId) + eq(devices.siteId, siteId), + // Ephemeral Quick Support rows are never collision candidates. Every + // session for the same machine lands in the same hidden per-partner + // org under the same hostname, so without this filter the second + // support run would take the re-enrollment-token branch, fail to + // prove possession of the (already reaped) prior row's token, and + // drag a dead session's device into the new one. Each session gets + // its own fresh row instead. + eq(devices.isEphemeral, false) ) ) .orderBy(devices.createdAt); @@ -635,7 +696,13 @@ enrollmentRoutes.post('/enroll', zValidator('json', enrollSchema), async (c) => // (#914) is going to INSERT a new active row — both grow net active // count by 1. Skipped on the normal UPDATE-in-place re-enroll path, // which is count-neutral. - if (maxDevices != null && deviceLimitPartnerId && insertFreshRow) { + // + // Also skipped entirely for Quick Support: an ephemeral device is a + // minutes-long remote-assist session on a machine the MSP does not + // manage, not a licensed endpoint. A partner sitting at their cap must + // still be able to help a caller — and since the row is excluded from + // the count below, admitting it cannot push the fleet past the cap. + if (maxDevices != null && deviceLimitPartnerId && insertFreshRow && !isSupportEnrollment) { const partnerOrgIds = tx .select({ id: organizations.id }) .from(organizations) @@ -647,7 +714,13 @@ enrollmentRoutes.post('/enroll', zValidator('json', enrollSchema), async (c) => .where( and( sql`${devices.orgId} IN (${partnerOrgIds})`, - ne(devices.status, 'decommissioned') + ne(devices.status, 'decommissioned'), + // Quick Support devices are not licensed endpoints — they live in + // the hidden per-partner support org for the length of one + // session and are purged by the reaper. Counting them would let a + // busy support day silently consume a partner's device + // entitlement and block real enrollments. + eq(devices.isEphemeral, false) ) ); @@ -777,6 +850,7 @@ enrollmentRoutes.post('/enroll', zValidator('json', enrollSchema), async (c) => virtualizationPlatform: data.virtualizationPlatform ?? null, status: 'online', lastSeenAt: new Date(), + isEphemeral: isSupportEnrollment, // #2764: forensic + UI linkage back to the row this enrollment may // be replacing. Set on the collision path only — the decom bypass // records its own linkage in the audit trail (#914). @@ -790,6 +864,24 @@ enrollmentRoutes.post('/enroll', zValidator('json', enrollSchema), async (c) => throw new Error('Failed to create device'); } + // Quick Support: bind the session to the device it just enrolled. Inside + // the SAME transaction as the device write so a rolled-back enrollment + // can never leave a session pointing at a device row that does not exist. + // The status='claimed' guard makes this a no-op if the technician ended + // (or the reaper expired) the session while the insert was in flight — + // that session must stay terminal rather than be revived by a late agent. + if (isSupportEnrollment) { + await tx + .update(supportSessions) + .set({ deviceId: dev.id }) + .where( + and( + eq(supportSessions.id, matchingKey.supportSessionId!), + eq(supportSessions.status, 'claimed') + ) + ); + } + if (data.hardwareInfo) { await tx .insert(deviceHardware) diff --git a/apps/api/src/routes/devices/provision.ts b/apps/api/src/routes/devices/provision.ts index a6cd7bfa9..cdbb2c31b 100644 --- a/apps/api/src/routes/devices/provision.ts +++ b/apps/api/src/routes/devices/provision.ts @@ -221,6 +221,12 @@ provisionRoutes.post( and( sql`${devices.orgId} IN (${partnerOrgIds})`, ne(devices.status, 'decommissioned'), + // Quick Support devices are not licensed endpoints — they live in + // the hidden per-partner support org for the length of one + // session and are purged by the reaper. Must match the identical + // exclusion in agents/enrollment.ts, or the two paths would + // enforce the same cap against different fleet counts. + eq(devices.isEphemeral, false), ), ); return { maxDevices, activeCount: Number(countResult?.count ?? 0) }; From 6e265aeb2cb4ee469dc06de1dafd699977db6e04 Mon Sep 17 00:00:00 2001 From: Todd Hebebrand Date: Tue, 4 Aug 2026 18:34:02 -0500 Subject: [PATCH 12/28] feat(api): quick support client download with code-embedded filename MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GET /support/download/:platform streams the agent binary with the one-time code in the filename, so the end user never types it. The binary is proxied rather than redirected because a 302 to GitHub/S3 would name the file breeze-agent-windows-amd64.exe and lose the code entirely. Cache-Control: no-store — the code is in the filename. A nonstandard port is encoded host_PORT, not host:PORT: ':' is illegal in a Windows filename and Chromium silently rewrites it to '_' at save time, which is how #2341 shipped silently-unenrolled installs. Matches the existing windowsFilenameApiHost() convention the agent already decodes. Also fixes this branch's typecheck and lint: zValidator now comes from lib/validation (the repo's standardized wrapper) rather than @hono/zod-validator, which no-restricted-imports rejects, and the possibly-undefined destructures from Tasks 3-4 are narrowed explicitly. Co-Authored-By: Claude Opus 5 (1M context) --- .../src/routes/remote/supportSessions.test.ts | 7 +- apps/api/src/routes/remote/supportSessions.ts | 10 +- apps/api/src/routes/supportPublic.test.ts | 163 ++++++++++++++- apps/api/src/routes/supportPublic.ts | 195 +++++++++++++++++- apps/api/src/services/quickSupportOrg.test.ts | 10 +- apps/api/src/services/quickSupportOrg.ts | 3 + 6 files changed, 377 insertions(+), 11 deletions(-) diff --git a/apps/api/src/routes/remote/supportSessions.test.ts b/apps/api/src/routes/remote/supportSessions.test.ts index 7e4663dbe..299380bfd 100644 --- a/apps/api/src/routes/remote/supportSessions.test.ts +++ b/apps/api/src/routes/remote/supportSessions.test.ts @@ -78,13 +78,16 @@ type AuthOverrides = { function buildApp(overrides: AuthOverrides = {}) { const app = new Hono(); app.use('*', async (c, next) => { + // Partial AuthContext: these routes read only user.id, scope, partnerId and + // accessibleOrgIds. Cast rather than construct the full context so the test + // does not have to track unrelated middleware fields. c.set('auth', { - user: { id: 'user-1', email: 't@example.com', name: 'Tech' }, + user: { id: 'user-1', email: 't@example.com', name: 'Tech', isPlatformAdmin: false }, scope: overrides.scope ?? 'partner', partnerId: overrides.partnerId === undefined ? 'partner-1' : overrides.partnerId, accessibleOrgIds: overrides.accessibleOrgIds === undefined ? ['org-a', 'qs-org'] : overrides.accessibleOrgIds, - }); + } as never); await next(); }); app.route('/', supportSessionRoutes); diff --git a/apps/api/src/routes/remote/supportSessions.ts b/apps/api/src/routes/remote/supportSessions.ts index e50f84d7b..2cf8664c9 100644 --- a/apps/api/src/routes/remote/supportSessions.ts +++ b/apps/api/src/routes/remote/supportSessions.ts @@ -1,5 +1,5 @@ import { Hono } from 'hono'; -import { zValidator } from '@hono/zod-validator'; +import { zValidator } from '../../lib/validation'; import { and, desc, eq, inArray } from 'drizzle-orm'; import { createSupportSessionSchema, formatSupportCode } from '@breeze/shared'; import { db, runOutsideDbContext, withSystemDbAccessContext } from '../../db'; @@ -84,7 +84,7 @@ supportSessionRoutes.post( // System context: when the hidden org was just created it is not in this // request's accessible_org_ids yet, so the RLS INSERT policy would reject. - const [session] = await runOutsideDbContext(() => withSystemDbAccessContext(() => + const [created] = await runOutsideDbContext(() => withSystemDbAccessContext(() => db.insert(supportSessions).values({ orgId, createdByUserId: auth.user.id, @@ -96,6 +96,12 @@ supportSessionRoutes.post( }).returning() )); + // RETURNING on a single-row INSERT always yields a row; narrow it so a + // future refactor that makes the insert conditional fails loudly here + // rather than emitting `undefined` into the response body. + if (!created) return c.json({ error: 'Failed to create support session' }, 500); + const session = created; + await logSessionAudit( 'support_session_created', auth.user.id, diff --git a/apps/api/src/routes/supportPublic.test.ts b/apps/api/src/routes/supportPublic.test.ts index c8ea9a05a..69e7c92b9 100644 --- a/apps/api/src/routes/supportPublic.test.ts +++ b/apps/api/src/routes/supportPublic.test.ts @@ -1,4 +1,4 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; /** * Public Quick Support endpoints. The code IS the auth, so the tests that @@ -18,6 +18,19 @@ vi.mock('../services/redis', () => ({ getRedis })); vi.mock('./remote/helpers', () => ({ logSessionAudit })); vi.mock('../services/clientIp', () => ({ getTrustedClientIp })); +// Binary resolution is stubbed so the download tests never touch the network. +const { getBinarySource, getGithubAgentUrl, isS3Configured, getPresignedUrl, isS3NotFound } = + vi.hoisted(() => ({ + getBinarySource: vi.fn((): 'github' | 'local' => 'github'), + getGithubAgentUrl: vi.fn((os: string, arch: string) => `https://gh.test/breeze-agent-${os}-${arch}.exe`), + isS3Configured: vi.fn(() => false), + getPresignedUrl: vi.fn(() => Promise.resolve('https://s3.test/agent.exe')), + isS3NotFound: vi.fn(() => false), + })); + +vi.mock('../services/binarySource', () => ({ getBinarySource, getGithubAgentUrl })); +vi.mock('../services/s3Storage', () => ({ isS3Configured, getPresignedUrl, isS3NotFound })); + vi.mock('../services/enrollmentKeySecurity', async () => { const { createHash } = await import('node:crypto'); return { @@ -101,6 +114,14 @@ function redeem(body: Record = {}) { }); } +/** Stands in for the ~60 MB agent asset — a real body, three bytes long. */ +const fetchMock = vi.fn(() => Promise.resolve( + new Response(new Uint8Array([0x4d, 0x5a, 0x90]), { + status: 200, + headers: { 'content-length': '3' }, + }), +)); + beforeEach(() => { selectResults.length = 0; updateResults.length = 0; @@ -109,9 +130,22 @@ beforeEach(() => { vi.clearAllMocks(); rateLimiter.mockResolvedValue({ allowed: true, currentCount: 1 }); getTrustedClientIp.mockReturnValue('203.0.113.9'); + getBinarySource.mockReturnValue('github'); + getGithubAgentUrl.mockImplementation((os: string, arch: string) => `https://gh.test/breeze-agent-${os}-${arch}.exe`); + isS3Configured.mockReturnValue(false); + fetchMock.mockResolvedValue(new Response(new Uint8Array([0x4d, 0x5a, 0x90]), { + status: 200, + headers: { 'content-length': '3' }, + })); + vi.stubGlobal('fetch', fetchMock); process.env.PUBLIC_API_URL = 'https://us.2breeze.app'; }); +afterEach(() => { + vi.unstubAllGlobals(); + delete process.env.API_URL; +}); + describe('GET /check/:code', () => { it('reports a pending unexpired code as valid', async () => { selectResults.push([{ status: 'pending', codeExpiresAt: FUTURE }]); @@ -273,3 +307,130 @@ describe('POST /redeem', () => { expect(hashSupportCode(CODE)).toMatch(/^[0-9a-f]{64}$/); }); }); + +describe('GET /download/:platform', () => { + function download(platform = 'windows', query = `?code=${CODE}`) { + return supportPublicRoutes.request(`/download/${platform}${query}`); + } + + it('serves the agent binary named after the code and API host', async () => { + selectResults.push([{ status: 'pending', codeExpiresAt: FUTURE }]); + + const res = await download(); + expect(res.status).toBe(200); + // Exact wire format — the Go client parses this filename (Task 12). + expect(res.headers.get('Content-Disposition')) + .toBe('attachment; filename="breeze-support-KTM4H7P2X-us.2breeze.app.exe"'); + expect(res.headers.get('Content-Type')).toBe('application/octet-stream'); + // The code is in the filename, so the response must never be cached. + expect(res.headers.get('Cache-Control')).toBe('no-store'); + expect(new Uint8Array(await res.arrayBuffer())).toEqual(new Uint8Array([0x4d, 0x5a, 0x90])); + }); + + it('proxies the release asset rather than redirecting to it', async () => { + selectResults.push([{ status: 'pending', codeExpiresAt: FUTURE }]); + const res = await download(); + // A 302 would hand the browser GitHub's filename and lose the code. + expect(res.status).toBe(200); + expect(getGithubAgentUrl).toHaveBeenCalledWith('windows', 'amd64'); + expect(fetchMock).toHaveBeenCalledWith('https://gh.test/breeze-agent-windows-amd64.exe'); + }); + + it('normalizes the human-formatted code into the filename', async () => { + selectResults.push([{ status: 'pending', codeExpiresAt: FUTURE }]); + const res = await download('windows', '?code=ktm-4h7-p2x'); + expect(res.headers.get('Content-Disposition')) + .toBe('attachment; filename="breeze-support-KTM4H7P2X-us.2breeze.app.exe"'); + }); + + it('encodes a nonstandard port as host_PORT, never host:PORT', async () => { + // `:` is illegal in a Windows filename and gets silently rewritten by the + // browser at save time, which is how #2341 shipped un-enrollable installers. + process.env.PUBLIC_API_URL = 'https://breeze.example.com:8443'; + selectResults.push([{ status: 'pending', codeExpiresAt: FUTURE }]); + const res = await download(); + expect(res.headers.get('Content-Disposition')) + .toBe('attachment; filename="breeze-support-KTM4H7P2X-breeze.example.com_8443.exe"'); + }); + + it('404s an unknown code', async () => { + selectResults.push([]); + const res = await download(); + expect(res.status).toBe(404); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it('404s an already-claimed code', async () => { + selectResults.push([{ status: 'claimed', codeExpiresAt: FUTURE }]); + expect((await download()).status).toBe(404); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it('404s an expired code', async () => { + selectResults.push([{ status: 'pending', codeExpiresAt: PAST }]); + expect((await download()).status).toBe(404); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it('404s a malformed or missing code without touching the database', async () => { + expect((await download('windows', '?code=not-a-code')).status).toBe(404); + expect((await download('windows', '')).status).toBe(404); + expect(selectResults).toHaveLength(0); // nothing was consumed + }); + + it('400s macOS with the coming-soon message and no DB work', async () => { + const res = await download('macos'); + expect(res.status).toBe(400); + expect(await res.json()).toEqual({ error: 'macOS support client coming soon' }); + expect(selectResults).toHaveLength(0); + }); + + it('400s an unknown platform', async () => { + const res = await download('linux'); + expect(res.status).toBe(400); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it('429s when rate limited before any DB or upstream work', async () => { + rateLimiter.mockResolvedValue({ allowed: false, currentCount: 99 }); + expect((await download()).status).toBe(429); + expect(selectResults).toHaveLength(0); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it('shares the /check rate-limit bucket', async () => { + selectResults.push([{ status: 'pending', codeExpiresAt: FUTURE }]); + await download(); + expect(rateLimiter).toHaveBeenCalledWith(expect.anything(), 'support-check:203.0.113.9', 30, 60); + }); + + it('503s rather than serving a partial download when the upstream fails', async () => { + selectResults.push([{ status: 'pending', codeExpiresAt: FUTURE }]); + fetchMock.mockResolvedValue(new Response('nope', { status: 404 })); + const res = await download(); + expect(res.status).toBe(503); + }); + + it('503s when PUBLIC_API_URL cannot produce a filename host', async () => { + // A filename with no host yields a client that can never phone home. + process.env.PUBLIC_API_URL = ''; + process.env.API_URL = ''; + selectResults.push([{ status: 'pending', codeExpiresAt: FUTURE }]); + const res = await download(); + expect(res.status).toBe(503); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it('proxies the S3 object in local mode instead of redirecting', async () => { + getBinarySource.mockReturnValue('local'); + isS3Configured.mockReturnValue(true); + selectResults.push([{ status: 'pending', codeExpiresAt: FUTURE }]); + + const res = await download(); + expect(res.status).toBe(200); + expect(getPresignedUrl).toHaveBeenCalledWith('agent/breeze-agent-windows-amd64.exe'); + expect(fetchMock).toHaveBeenCalledWith('https://s3.test/agent.exe'); + expect(res.headers.get('Content-Disposition')) + .toBe('attachment; filename="breeze-support-KTM4H7P2X-us.2breeze.app.exe"'); + }); +}); diff --git a/apps/api/src/routes/supportPublic.ts b/apps/api/src/routes/supportPublic.ts index 0b4ab0ee5..49245aeeb 100644 --- a/apps/api/src/routes/supportPublic.ts +++ b/apps/api/src/routes/supportPublic.ts @@ -1,12 +1,16 @@ import { Hono } from 'hono'; -import { zValidator } from '@hono/zod-validator'; +import { zValidator } from '../lib/validation'; import { randomBytes } from 'node:crypto'; +import { statSync, createReadStream } from 'node:fs'; +import { join, resolve } from 'node:path'; import { and, eq } from 'drizzle-orm'; import { normalizeSupportCode, redeemSupportSessionSchema } from '@breeze/shared'; import { db, withSystemDbAccessContext } from '../db'; import { enrollmentKeys, sites, supportSessions } from '../db/schema'; import { hashSupportCode } from '../services/quickSupportCode'; import { hashEnrollmentKey, hashEnrollmentSecret } from '../services/enrollmentKeySecurity'; +import { getBinarySource, getGithubAgentUrl } from '../services/binarySource'; +import { isS3Configured, getPresignedUrl, isS3NotFound } from '../services/s3Storage'; import { rateLimiter } from '../services/rate-limit'; import { getRedis } from '../services/redis'; import { getTrustedClientIp } from '../services/clientIp'; @@ -60,6 +64,195 @@ supportPublicRoutes.get('/check/:code', async (c) => { }); }); +/** Phase 1 ships a Windows client only; macOS is accepted-but-declined below. */ +const SUPPORT_CLIENT_PLATFORMS = new Set(['windows', 'macos']); + +/** The support client IS the normal agent binary — same asset, new filename. */ +const SUPPORT_AGENT_OS = 'windows'; +const SUPPORT_AGENT_ARCH = 'amd64'; +const SUPPORT_AGENT_FILENAME = `breeze-agent-${SUPPORT_AGENT_OS}-${SUPPORT_AGENT_ARCH}.exe`; + +/** + * Host of this API as it is written into the download filename. + * + * This is a WIRE FORMAT, not cosmetics: the Go client parses the filename and + * rebuilds `https://` from it, so it must round-trip exactly. + * + * A nonstandard port is encoded `host_PORT` rather than `host:PORT` because + * `:` is illegal in a Windows filename — Chromium silently rewrites it to `_` + * at save time, which is precisely how the MSI filename-token installer + * shipped a silently-unenrolled install (#2341). Same encoding as + * `windowsFilenameApiHost()`; that helper is not reused here because it fails + * hard on non-https, and a self-hosted/dev http server must still be able to + * hand out a client (the operator passes --server explicitly in that case). + */ +function supportDownloadApiHost(): string | null { + const raw = process.env.PUBLIC_API_URL ?? process.env.API_URL ?? ''; + let url: URL; + try { + url = new URL(raw); + } catch { + return null; + } + if (!url.hostname) return null; + return url.port ? `${url.hostname}_${url.port}` : url.hostname; +} + +/** + * Proxy a remote binary through with OUR Content-Disposition. + * + * A 302 to GitHub/S3 would be cheaper, but the redirect target names the file + * `breeze-agent-windows-amd64.exe` and the whole point of this route is that + * the code rides in the filename. Streaming the body (rather than buffering) + * keeps the ~60 MB per download off the heap; the bandwidth cost is accepted + * for v1. Returns null when the upstream fetch fails so callers can fall back. + * + * `source` rather than the URL is logged because the S3 caller passes a + * presigned URL, whose query string is a live credential. + */ +async function proxyBinary(url: string, filename: string, source: string): Promise { + let upstream: Response; + try { + upstream = await fetch(url); + } catch (err) { + console.error(`[support-download] ${source} fetch failed:`, err); + return null; + } + if (!upstream.ok || !upstream.body) { + console.error(`[support-download] ${source} returned no body (status ${upstream.status})`); + return null; + } + + const headers: Record = { + 'Content-Type': 'application/octet-stream', + 'Content-Disposition': `attachment; filename="${filename}"`, + // The one-time code is in the filename — never let a proxy or the browser + // cache serve this response to the next visitor. + 'Cache-Control': 'no-store', + }; + const length = upstream.headers.get('content-length'); + if (length) headers['Content-Length'] = length; + + return new Response(upstream.body, { status: 200, headers }); +} + +/** + * Serve the support client with the one-time code embedded in the download + * filename, so the end user never has to type it. + * + * The code is soft-validated with the same lookup /check uses. That is a + * courtesy check, not the security boundary — enrollment is still gated by + * /redeem's atomic single-use claim. Every rejection is the same bare 404 for + * the same reason /check returns a bare boolean: no tenant enumeration. + */ +supportPublicRoutes.get('/download/:platform', async (c) => { + const ip = getTrustedClientIp(c, 'unknown'); + // Shares the /check budget deliberately: both are "an anonymous stranger + // poking at a code", and a separate bucket would just widen the guess rate. + const limit = await rateLimiter(getRedis(), `support-check:${ip}`, CHECK_LIMIT, RATE_WINDOW_SECONDS); + if (!limit.allowed) return c.json({ error: 'rate limited' }, 429); + + // Platform is checked before the code so an unsupported platform never + // costs a DB round-trip — and so the macOS answer is the same honest + // "coming soon" whether or not the caller holds a real code. + const platform = c.req.param('platform'); + if (!SUPPORT_CLIENT_PLATFORMS.has(platform)) { + return c.json({ error: `Unsupported platform: ${platform}` }, 400); + } + if (platform === 'macos') { + return c.json({ error: 'macOS support client coming soon' }, 400); + } + + const code = normalizeSupportCode(c.req.query('code') ?? ''); + if (!code) return c.json({ error: 'invalid or expired code' }, 404); + + const [row] = await withSystemDbAccessContext(() => db + .select({ + status: supportSessions.status, + codeExpiresAt: supportSessions.codeExpiresAt, + }) + .from(supportSessions) + .where(eq(supportSessions.codeHash, hashSupportCode(code))) + .limit(1)) as Array<{ status: string; codeExpiresAt: Date }>; + + if (!row || row.status !== 'pending' || row.codeExpiresAt <= new Date()) { + return c.json({ error: 'invalid or expired code' }, 404); + } + + const apiHost = supportDownloadApiHost(); + if (!apiHost) { + // Serving a client whose filename cannot carry a server URL would produce + // a download that can never connect — fail loudly instead (#2341). + console.error('[support-download] PUBLIC_API_URL is unset or unparseable; cannot build filename'); + return c.json({ error: 'support client unavailable' }, 503); + } + + const filename = `breeze-support-${code}-${apiHost}.exe`; + + if (getBinarySource() === 'github') { + const res = await proxyBinary(getGithubAgentUrl(SUPPORT_AGENT_OS, SUPPORT_AGENT_ARCH), filename, 'github'); + return res ?? c.json({ error: 'support client unavailable' }, 503); + } + + // Local mode: S3 is proxied rather than redirected, for the same + // filename-preservation reason as the GitHub branch above. + if (isS3Configured()) { + try { + const url = await getPresignedUrl(`agent/${SUPPORT_AGENT_FILENAME}`); + const res = await proxyBinary(url, filename, 's3'); + if (res) return res; + } catch (err) { + if (!isS3NotFound(err)) { + console.error(`[support-download] S3 presign failed for ${SUPPORT_AGENT_FILENAME}:`, err); + return c.json({ error: 'support client unavailable' }, 503); + } + console.warn(`[support-download] S3 object missing for ${SUPPORT_AGENT_FILENAME}, falling back to disk`); + } + } + + // Local mode: serve from disk (mirrors routes/agents/download.ts, but the + // on-disk name is replaced by the code-bearing one on the way out). + const binaryDir = resolve(process.env.AGENT_BINARY_DIR || './agent/bin'); + const filePath = join(binaryDir, SUPPORT_AGENT_FILENAME); + + let fileStat: ReturnType; + let stream: ReturnType; + try { + fileStat = statSync(filePath); + stream = createReadStream(filePath); + } catch (err) { + console.error(`[support-download] local binary unavailable at ${filePath}:`, err); + return c.json({ error: 'support client unavailable' }, 503); + } + + const webStream = new ReadableStream({ + start(controller) { + stream.on('data', (chunk: string | Buffer) => { + const bytes = typeof chunk === 'string' ? Buffer.from(chunk) : chunk; + controller.enqueue(new Uint8Array(bytes)); + }); + stream.on('end', () => controller.close()); + stream.on('error', (err) => { + console.error('[support-download] stream error:', err); + controller.error(err); + }); + }, + cancel() { + stream.destroy(); + }, + }); + + return new Response(webStream, { + status: 200, + headers: { + 'Content-Type': 'application/octet-stream', + 'Content-Disposition': `attachment; filename="${filename}"`, + 'Content-Length': String(fileStat.size), + 'Cache-Control': 'no-store', + }, + }); +}); + /** * Redeem a code for a single-use enrollment key. * diff --git a/apps/api/src/services/quickSupportOrg.test.ts b/apps/api/src/services/quickSupportOrg.test.ts index 6409d1c24..306014f30 100644 --- a/apps/api/src/services/quickSupportOrg.test.ts +++ b/apps/api/src/services/quickSupportOrg.test.ts @@ -89,14 +89,14 @@ describe('getOrCreateQuickSupportOrg', () => { const result = await getOrCreateQuickSupportOrg(PARTNER_ID); expect(result).toEqual({ orgId: 'org-new', siteId: 'site-new' }); - expect(insertCalls[0].table).toBe('organizations'); - expect(insertCalls[0].values).toMatchObject({ + expect(insertCalls[0]?.table).toBe('organizations'); + expect(insertCalls[0]?.values).toMatchObject({ partnerId: PARTNER_ID, type: 'quick_support', status: 'active', }); - expect(insertCalls[1].table).toBe('sites'); - expect(insertCalls[1].values).toMatchObject({ orgId: 'org-new' }); + expect(insertCalls[1]?.table).toBe('sites'); + expect(insertCalls[1]?.values).toMatchObject({ orgId: 'org-new' }); }); it('slugs with the full partner uuid so slugs cannot collide across partners', async () => { @@ -107,7 +107,7 @@ describe('getOrCreateQuickSupportOrg', () => { await getOrCreateQuickSupportOrg(PARTNER_ID); - expect(insertCalls[0].values).toMatchObject({ slug: `quick-support-${PARTNER_ID}` }); + expect(insertCalls[0]?.values).toMatchObject({ slug: `quick-support-${PARTNER_ID}` }); }); it('lets the re-select win when a concurrent create took the unique index', async () => { diff --git a/apps/api/src/services/quickSupportOrg.ts b/apps/api/src/services/quickSupportOrg.ts index 19b0a3419..604b05d5f 100644 --- a/apps/api/src/services/quickSupportOrg.ts +++ b/apps/api/src/services/quickSupportOrg.ts @@ -62,6 +62,9 @@ export async function getOrCreateQuickSupportOrg( .values({ orgId: org.id, name: 'Quick Support', timezone: 'UTC' }) .returning({ id: sites.id }); } + // Enrollment keys require a site_id, so a missing site here would surface + // much later as an opaque redeem failure. Fail at the source instead. + if (!site) throw new Error('quick support site provisioning failed'); return { orgId: org.id, siteId: site.id }; })); From d0af6ef8c1ae5c3fe14f7eaa6868fbc38104ebd9 Mon Sep 17 00:00:00 2001 From: Todd Hebebrand Date: Tue, 4 Aug 2026 18:35:45 -0500 Subject: [PATCH 13/28] fix(alerts): never alert on a quick support device going offline MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ephemeral devices are exempt from offline ALERTING but deliberately not from the offline status flip itself: going offline is how an ad-hoc support session ends (the end user closed the client), and the reaper watches for exactly that transition to tear the session down. Without this, every completed Quick Support session would page the on-call technician with a "device offline" alert. Both alerting paths are covered — the immediate one in processMarkOffline and the config-policy re-evaluation in processReevaluateOffline — and the re-eval sweep filters ephemeral rows so the jobs are never queued. The re-eval handler keeps its own guard because jobs queued before this deploy may still be in flight. Co-Authored-By: Claude Opus 5 (1M context) --- apps/api/src/jobs/offlineDetector.ts | 21 +++++++++++++++++---- 1 file changed, 17 insertions(+), 4 deletions(-) diff --git a/apps/api/src/jobs/offlineDetector.ts b/apps/api/src/jobs/offlineDetector.ts index 5fb048089..0774a3f0f 100644 --- a/apps/api/src/jobs/offlineDetector.ts +++ b/apps/api/src/jobs/offlineDetector.ts @@ -313,8 +313,14 @@ async function processMarkOffline(data: MarkOfflineJobData): Promise<{ console.log(`[OfflineDetector] Marked device ${data.deviceId} as offline`); - // Check for offline-type alert rules and create alerts - const alertCreated = await triggerOfflineAlerts(device); + // Check for offline-type alert rules and create alerts. + // + // Quick Support ephemeral devices are exempt from ALERTING but deliberately + // NOT from the status flip above: going offline is how an ad-hoc support + // session ends (the end user closed the client), and the reaper watches for + // exactly that transition to tear the session down. Alerting on it would + // page the on-call technician after every single support session. + const alertCreated = device.isEphemeral ? false : await triggerOfflineAlerts(device); return { deviceId: data.deviceId, @@ -545,7 +551,10 @@ export async function processReevaluateOffline(data: ReevaluateOfflineJobData): // Device is gone or has reconnected — nothing to re-evaluate. (The offline // handler keys off lastSeenAt and wouldn't fire for a reconnected device // anyway, but skipping here avoids needless evaluation work.) - if (!device || device.status !== 'offline') { + // Ephemeral Quick Support devices never alert (see processMarkOffline) — an + // ad-hoc session ending is not an incident. Checked here as well as at the + // sweep because jobs queued before this deploy may still be in flight. + if (!device || device.status !== 'offline' || device.isEphemeral) { return { deviceId: data.deviceId, alertCreated: false, durationMs: Date.now() - startTime }; } @@ -599,7 +608,11 @@ export async function processReevaluateOfflineSweep(): Promise<{ const conditions = [ eq(devices.status, 'offline'), - gt(devices.lastSeenAt, horizonTime) + gt(devices.lastSeenAt, horizonTime), + // Ephemeral Quick Support devices are alert-exempt, and this sweep only + // ever queues alert re-evaluation — filtering here avoids queueing jobs + // that would immediately no-op. + eq(devices.isEphemeral, false) ]; if (cursor) conditions.push(gt(devices.id, cursor)); From d6789c8816bac12326f07c3b08fff94b109e5e87 Mon Sep 17 00:00:00 2001 From: Todd Hebebrand Date: Tue, 4 Aug 2026 18:37:56 -0500 Subject: [PATCH 14/28] feat(api): end quick support session with agent self-destruct + token revocation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit endSupportSession is shared by the tech-initiated route and the reaper so both revoke identically. Ordering is load-bearing and pinned by test: 1. send support_end while the socket is still authenticated (cooperative path — the client deletes itself immediately) 2. revoke all three token hashes and decommission the device 3. force-close the socket Step 3 is what stops a client whose support_end was lost from sitting connected until the 8h hard cap: it is online, so its own offline dead-man never fires. Closing forces a reconnect, the reconnect fails re-auth, and the dead-man converges in <=10 minutes. The audit records the disconnect result and command delivery rather than collapsing them into success — a 'close-failed' socket plausibly stayed live after revocation, which is exactly what an incident review needs to know. No new guard was needed to stop a lingering client being reconnected to: POST /remote/sessions already rejects any device not 'online', and teardown sets 'decommissioned'. Co-Authored-By: Claude Opus 5 (1M context) --- .../src/routes/remote/supportSessions.test.ts | 70 +++++++ apps/api/src/routes/remote/supportSessions.ts | 46 +++++ apps/api/src/services/quickSupportEnd.test.ts | 175 ++++++++++++++++++ apps/api/src/services/quickSupportEnd.ts | 88 +++++++++ 4 files changed, 379 insertions(+) create mode 100644 apps/api/src/services/quickSupportEnd.test.ts create mode 100644 apps/api/src/services/quickSupportEnd.ts diff --git a/apps/api/src/routes/remote/supportSessions.test.ts b/apps/api/src/routes/remote/supportSessions.test.ts index 299380bfd..f4dd22d50 100644 --- a/apps/api/src/routes/remote/supportSessions.test.ts +++ b/apps/api/src/routes/remote/supportSessions.test.ts @@ -16,7 +16,14 @@ const { getOrCreateQuickSupportOrg, logSessionAudit, getTrustedClientIp } = vi.h getTrustedClientIp: vi.fn(() => '203.0.113.7'), })); +const { endSupportSession } = vi.hoisted(() => ({ + endSupportSession: vi.fn(() => Promise.resolve({ + ended: true, disconnect: 'closed' as const, commandDelivered: true, + })), +})); + vi.mock('../../services/quickSupportOrg', () => ({ getOrCreateQuickSupportOrg })); +vi.mock('../../services/quickSupportEnd', () => ({ endSupportSession })); vi.mock('./helpers', () => ({ logSessionAudit })); vi.mock('../../services/clientIp', () => ({ getTrustedClientIp })); @@ -259,6 +266,69 @@ describe('GET /support-sessions/:id', () => { }); }); +describe('POST /support-sessions/:id/end', () => { + function end(id = 'sess-1') { + return buildApp().request(`/support-sessions/${id}/end`, { method: 'POST' }); + } + + it('ends a live session and audits the outcome', async () => { + selectResults.push([{ ...SESSION_ROW, status: 'ready', deviceId: 'dev-1' }]); + + const res = await end(); + expect(res.status).toBe(200); + expect(await res.json()).toEqual({ success: true }); + expect(endSupportSession).toHaveBeenCalledWith('sess-1', 'tech'); + expect(logSessionAudit).toHaveBeenCalledWith( + 'support_session_ended', + 'user-1', + 'qs-org', + expect.objectContaining({ sessionId: 'sess-1', reason: 'tech' }), + '203.0.113.7', + ); + }); + + it('records a failed socket close in the audit instead of implying success', async () => { + endSupportSession.mockResolvedValueOnce({ + ended: true, disconnect: 'close-failed', commandDelivered: false, + }); + selectResults.push([{ ...SESSION_ROW, status: 'ready', deviceId: 'dev-1' }]); + + await end(); + expect(logSessionAudit).toHaveBeenCalledWith( + 'support_session_ended', + 'user-1', + 'qs-org', + expect.objectContaining({ agentDisconnect: 'close-failed', commandDelivered: false }), + '203.0.113.7', + ); + }); + + it('409s an already-ended session without re-running teardown', async () => { + selectResults.push([{ ...SESSION_ROW, status: 'ended' }]); + const res = await end(); + expect(res.status).toBe(409); + expect(endSupportSession).not.toHaveBeenCalled(); + }); + + it('409s an expired session', async () => { + selectResults.push([{ ...SESSION_ROW, status: 'expired' }]); + expect((await end()).status).toBe(409); + }); + + it('404s a session the caller cannot see', async () => { + selectResults.push([]); // RLS returned nothing + const res = await end('someone-elses'); + expect(res.status).toBe(404); + expect(endSupportSession).not.toHaveBeenCalled(); + }); + + it('ends a pending session that never enrolled a device', async () => { + selectResults.push([SESSION_ROW]); // pending, deviceId null + expect((await end()).status).toBe(200); + expect(endSupportSession).toHaveBeenCalledWith('sess-1', 'tech'); + }); +}); + describe('GET /support-sessions', () => { it('lists sessions without a per-row device query', async () => { selectResults.push([ diff --git a/apps/api/src/routes/remote/supportSessions.ts b/apps/api/src/routes/remote/supportSessions.ts index 2cf8664c9..79f080afa 100644 --- a/apps/api/src/routes/remote/supportSessions.ts +++ b/apps/api/src/routes/remote/supportSessions.ts @@ -12,6 +12,7 @@ import { hashSupportCode, } from '../../services/quickSupportCode'; import { getTrustedClientIp } from '../../services/clientIp'; +import { endSupportSession } from '../../services/quickSupportEnd'; import { logSessionAudit } from './helpers'; export const supportSessionRoutes = new Hono(); @@ -175,6 +176,51 @@ supportSessionRoutes.get('/support-sessions', async (c) => { }); }); +/** + * End a session: self-destruct the client, revoke its credentials, drop its + * socket. The session is loaded under the normal RLS context first, so a + * caller who cannot see the session cannot end it either. + * + * No extra guard is needed to stop a lingering client being reconnected to: + * endSupportSession decommissions the device, and POST /remote/sessions + * already rejects any device whose status is not 'online'. + */ +supportSessionRoutes.post('/support-sessions/:id/end', async (c) => { + const auth = c.get('auth'); + const id = c.req.param('id'); + + const [session] = await db + .select() + .from(supportSessions) + .where(eq(supportSessions.id, id)) + .limit(1) as SupportSessionRow[]; + + if (!session) return c.json({ error: 'Support session not found' }, 404); + if (session.status === 'ended' || session.status === 'expired') { + return c.json({ error: 'Support session already ended' }, 409); + } + + const result = await endSupportSession(id, 'tech'); + + await logSessionAudit( + 'support_session_ended', + auth.user.id, + session.orgId, + { + sessionId: id, + reason: 'tech', + // Recorded rather than collapsed into success: a 'close-failed' socket + // plausibly stayed live after revocation, and a lost command means the + // client only converges via its dead-man switch. + agentDisconnect: result.disconnect, + commandDelivered: result.commandDelivered, + }, + getTrustedClientIp(c, 'unknown'), + ); + + return c.json({ success: true }); +}); + supportSessionRoutes.get('/support-sessions/:id', async (c) => { const [session] = await db .select() diff --git a/apps/api/src/services/quickSupportEnd.test.ts b/apps/api/src/services/quickSupportEnd.test.ts new file mode 100644 index 000000000..265e45dd7 --- /dev/null +++ b/apps/api/src/services/quickSupportEnd.test.ts @@ -0,0 +1,175 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +/** + * Session teardown. The ordering assertions are the point: the self-destruct + * command must reach a still-authenticated socket BEFORE its credentials are + * revoked, and the socket must be dropped AFTER, or a client whose command was + * lost stays connected until the 8h hard cap. + */ + +const { sendCommandToAgent, disconnectAgent } = vi.hoisted(() => ({ + sendCommandToAgent: vi.fn(() => true), + disconnectAgent: vi.fn(() => 'closed' as const), +})); + +vi.mock('../routes/agentWs', () => ({ sendCommandToAgent, disconnectAgent })); + +const selectResults: unknown[][] = []; +const updates: Array<{ values: Record }> = []; +/** Every mutating/IO step in call order, so ordering can be asserted. */ +const callOrder: string[] = []; + +vi.mock('../db', () => { + const select = vi.fn(() => { + const rows = selectResults.shift() ?? []; + const builder: Record = {}; + for (const m of ['from', 'where']) builder[m] = vi.fn(() => builder); + builder.limit = vi.fn(() => Promise.resolve(rows)); + return builder; + }); + + const update = vi.fn(() => ({ + set: vi.fn((values: Record) => { + updates.push({ values }); + callOrder.push('status' in values && values.status === 'decommissioned' ? 'revoke' : 'session-update'); + return { where: vi.fn(() => Promise.resolve([])) }; + }), + })); + + return { + db: { select, update }, + withSystemDbAccessContext: vi.fn(async (fn: () => unknown) => fn()), + runOutsideDbContext: vi.fn((fn: () => T): T => fn()), + }; +}); + +vi.mock('../db/schema', () => ({ + supportSessions: { id: 'supportSessions.id' }, + devices: { id: 'devices.id', agentId: 'devices.agentId' }, +})); + +import { endSupportSession } from './quickSupportEnd'; + +const READY_SESSION = { + id: 'sess-1', + orgId: 'qs-org', + status: 'ready', + deviceId: 'dev-1', +}; + +beforeEach(() => { + selectResults.length = 0; + updates.length = 0; + callOrder.length = 0; + vi.clearAllMocks(); + sendCommandToAgent.mockImplementation(() => { callOrder.push('command'); return true; }); + disconnectAgent.mockImplementation(() => { callOrder.push('disconnect'); return 'closed'; }); +}); + +describe('endSupportSession', () => { + it('sends the self-destruct, revokes credentials, then drops the socket — in that order', async () => { + selectResults.push([READY_SESSION]); + selectResults.push([{ agentId: 'agent-1' }]); + + const result = await endSupportSession('sess-1', 'tech'); + + expect(result).toMatchObject({ ended: true, disconnect: 'closed', commandDelivered: true }); + expect(callOrder).toEqual(['command', 'revoke', 'disconnect', 'session-update']); + }); + + it('sends the exact command shape the Go client parses', async () => { + selectResults.push([READY_SESSION]); + selectResults.push([{ agentId: 'agent-1' }]); + + await endSupportSession('sess-1', 'tech'); + + expect(sendCommandToAgent).toHaveBeenCalledWith('agent-1', { + id: 'support-end-sess-1', + type: 'support_end', + payload: { sessionId: 'sess-1' }, + }); + }); + + it('revokes all three token hashes and decommissions the device', async () => { + selectResults.push([READY_SESSION]); + selectResults.push([{ agentId: 'agent-1' }]); + + await endSupportSession('sess-1', 'tech'); + + expect(updates[0].values).toEqual({ + agentTokenHash: null, + watchdogTokenHash: null, + helperTokenHash: null, + status: 'decommissioned', + }); + }); + + it('still revokes when the command could not be delivered', async () => { + sendCommandToAgent.mockImplementation(() => { callOrder.push('command'); return false; }); + selectResults.push([READY_SESSION]); + selectResults.push([{ agentId: 'agent-1' }]); + + const result = await endSupportSession('sess-1', 'tech'); + + expect(result.commandDelivered).toBe(false); + expect(result.ended).toBe(true); + expect(callOrder).toContain('revoke'); // revocation is not conditional on delivery + }); + + it('reports a failed socket close rather than claiming success', async () => { + disconnectAgent.mockImplementation(() => { callOrder.push('disconnect'); return 'close-failed'; }); + selectResults.push([READY_SESSION]); + selectResults.push([{ agentId: 'agent-1' }]); + + const result = await endSupportSession('sess-1', 'tech'); + + expect(result.disconnect).toBe('close-failed'); + expect(result.ended).toBe(true); + }); + + it("records 'expired' as its own terminal state for reaper hard-cap kills", async () => { + selectResults.push([READY_SESSION]); + selectResults.push([{ agentId: 'agent-1' }]); + + await endSupportSession('sess-1', 'expired'); + + expect(updates[1].values).toMatchObject({ status: 'expired', endedReason: 'expired' }); + }); + + it("records a non-expired reason as 'ended'", async () => { + selectResults.push([READY_SESSION]); + selectResults.push([{ agentId: 'agent-1' }]); + + await endSupportSession('sess-1', 'end_user'); + + expect(updates[1].values).toMatchObject({ status: 'ended', endedReason: 'end_user' }); + }); + + it('is idempotent — an already-ended session does nothing', async () => { + selectResults.push([{ ...READY_SESSION, status: 'ended' }]); + + const result = await endSupportSession('sess-1', 'tech'); + + expect(result).toEqual({ ended: false, disconnect: null, commandDelivered: false }); + expect(sendCommandToAgent).not.toHaveBeenCalled(); + expect(updates).toHaveLength(0); + }); + + it('does nothing for an unknown session', async () => { + selectResults.push([]); + const result = await endSupportSession('nope', 'tech'); + expect(result.ended).toBe(false); + expect(updates).toHaveLength(0); + }); + + it('ends a pending session that never enrolled a device, touching no agent', async () => { + selectResults.push([{ ...READY_SESSION, status: 'pending', deviceId: null }]); + + const result = await endSupportSession('sess-1', 'tech'); + + expect(result).toMatchObject({ ended: true, disconnect: null, commandDelivered: false }); + expect(sendCommandToAgent).not.toHaveBeenCalled(); + expect(disconnectAgent).not.toHaveBeenCalled(); + expect(updates).toHaveLength(1); // session row only + }); +}); diff --git a/apps/api/src/services/quickSupportEnd.ts b/apps/api/src/services/quickSupportEnd.ts new file mode 100644 index 000000000..73a021da7 --- /dev/null +++ b/apps/api/src/services/quickSupportEnd.ts @@ -0,0 +1,88 @@ +import { eq } from 'drizzle-orm'; +import { db, runOutsideDbContext, withSystemDbAccessContext } from '../db'; +import { devices, supportSessions } from '../db/schema'; +import { disconnectAgent, sendCommandToAgent } from '../routes/agentWs'; + +export type SupportSessionEndReason = 'tech' | 'end_user' | 'expired' | 'error'; + +export interface EndSupportSessionResult { + ended: boolean; + /** How the agent's live socket was dealt with; null when there was no device. */ + disconnect: 'closed' | 'close-failed' | 'not-connected' | null; + /** False when the self-destruct command could not be handed to a live socket. */ + commandDelivered: boolean; +} + +/** + * Tear down a Quick Support session: tell the client to self-destruct, revoke + * its credentials, and drop its socket. + * + * Ordering is load-bearing: + * 1. send `support_end` FIRST, while the socket is still authenticated — this + * is the cooperative path that makes the client delete itself immediately. + * 2. revoke all three token hashes and decommission the device, so nothing + * can re-authenticate with what it already holds. + * 3. force-close the socket. Without this a client whose `support_end` was + * lost stays happily connected until the 8h hard cap, because it is + * online and its own offline dead-man never fires. Closing forces a + * reconnect, the reconnect fails re-auth, and the client's dead-man + * converges in <=10 minutes. + * + * Shared by the tech-initiated end route and the reaper, so both paths revoke + * identically. Idempotent: a session already in a terminal state returns + * ended:false rather than re-running the teardown. + */ +export async function endSupportSession( + sessionId: string, + reason: SupportSessionEndReason, +): Promise { + return runOutsideDbContext(() => withSystemDbAccessContext(async () => { + const [session] = await db + .select() + .from(supportSessions) + .where(eq(supportSessions.id, sessionId)) + .limit(1); + + if (!session || session.status === 'ended' || session.status === 'expired') { + return { ended: false, disconnect: null, commandDelivered: false }; + } + + let disconnect: EndSupportSessionResult['disconnect'] = null; + let commandDelivered = false; + + if (session.deviceId) { + const [device] = await db + .select({ agentId: devices.agentId }) + .from(devices) + .where(eq(devices.id, session.deviceId)) + .limit(1); + + if (device) { + commandDelivered = sendCommandToAgent(device.agentId, { + id: `support-end-${sessionId}`, + type: 'support_end', + payload: { sessionId }, + }); + + await db.update(devices).set({ + agentTokenHash: null, + watchdogTokenHash: null, + helperTokenHash: null, + status: 'decommissioned', + }).where(eq(devices.id, session.deviceId)); + + disconnect = disconnectAgent(device.agentId, 4041, 'quick support session ended'); + } + } + + await db.update(supportSessions).set({ + // 'expired' is its own terminal state so the reaper's hard-cap kills are + // distinguishable from a deliberate end in the session list. + status: reason === 'expired' ? 'expired' : 'ended', + endedAt: new Date(), + endedReason: reason, + }).where(eq(supportSessions.id, sessionId)); + + return { ended: true, disconnect, commandDelivered }; + })); +} From ba57aff3c13fb10c061f3e608e2718ceb4cb7faf Mon Sep 17 00:00:00 2001 From: Todd Hebebrand Date: Tue, 4 Aug 2026 18:43:06 -0500 Subject: [PATCH 15/28] refactor(api): extract deleteDeviceCascade so the reaper reuses one delete path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The permanent-delete route's cascade moves to services/deviceDeletion.ts. The Quick Support reaper purges ephemeral devices through the same function rather than hand-rolling a second delete — two cascade implementations drift the moment a table is added to one list and not the other, which is exactly how this repo has produced FK-violation and orphaned-row bugs before. The route keeps its own transaction and link-group dissolution; only the row-removal sequence is shared. Behaviour is unchanged: 450 device route tests still pass, and the ordering (transitive children, then detach targets, then device_id cascade tables, then the device row) is preserved. Also widens two hoisted test mocks whose inferred literal return types made the failed-close cases untypeable. Co-Authored-By: Claude Opus 5 (1M context) --- apps/api/src/routes/devices/core.ts | 29 ++------ .../src/routes/remote/supportSessions.test.ts | 10 ++- apps/api/src/services/deviceDeletion.ts | 69 +++++++++++++++++++ apps/api/src/services/quickSupportEnd.test.ts | 12 ++-- 4 files changed, 87 insertions(+), 33 deletions(-) create mode 100644 apps/api/src/services/deviceDeletion.ts diff --git a/apps/api/src/routes/devices/core.ts b/apps/api/src/routes/devices/core.ts index 3ddc89371..d8251dd85 100644 --- a/apps/api/src/routes/devices/core.ts +++ b/apps/api/src/routes/devices/core.ts @@ -41,6 +41,7 @@ import { } from './cursor'; import { writeRouteAudit } from '../../services/auditEvents'; import { dissolveLinkGroupIfBelowMinimum } from '../../services/deviceLinkGroups'; +import { deleteDeviceCascade } from '../../services/deviceDeletion'; import { resolveRemoteAccessForDevice } from '../../services/remoteAccessPolicy'; import { resolveRemoteAccessLaunch, @@ -1525,31 +1526,9 @@ coreRoutes.delete( // When adding new tables with device_id FK, add them here too. try { await db.transaction(async (tx) => { - // Transitive dependencies: tables that reference device-scoped records - // but don't have a direct device_id column. - const deviceAlertIds = sql`(SELECT id FROM alerts WHERE device_id = ${deviceId})`; - const deviceAiSessionIds = sql`(SELECT id FROM ai_sessions WHERE device_id = ${deviceId})`; - - await tx.execute(sql`DELETE FROM ai_tool_executions WHERE session_id IN ${deviceAiSessionIds}`); - await tx.execute(sql`DELETE FROM ai_messages WHERE session_id IN ${deviceAiSessionIds}`); - await tx.execute(sql`DELETE FROM ai_action_plans WHERE session_id IN ${deviceAiSessionIds}`); - await tx.execute(sql`DELETE FROM alert_correlations WHERE parent_alert_id IN ${deviceAlertIds} OR child_alert_id IN ${deviceAlertIds}`); - await tx.execute(sql`DELETE FROM alert_notifications WHERE alert_id IN ${deviceAlertIds}`); - await tx.execute(sql`UPDATE log_correlations SET alert_id = NULL WHERE alert_id IN ${deviceAlertIds}`); - await tx.execute(sql`UPDATE network_change_events SET alert_id = NULL WHERE alert_id IN ${deviceAlertIds}`); - for (const linkedTable of DEVICE_LINKED_DEVICE_ID_TABLES) { - await tx.execute(sql`UPDATE ${sql.identifier(linkedTable)} SET linked_device_id = NULL WHERE linked_device_id = ${deviceId}`); - } - // Tenant business records (tickets): preserve history, detach the device. - for (const detachTable of DEVICE_DETACH_DEVICE_ID_TABLES) { - await tx.execute(sql`UPDATE ${sql.identifier(detachTable)} SET device_id = NULL WHERE device_id = ${deviceId}`); - } - - const tables = getDeviceCascadeDeleteTables(); - for (const table of tables) { - await tx.execute(sql`DELETE FROM ${sql.identifier(table)} WHERE device_id = ${deviceId}`); - } - await tx.delete(devices).where(eq(devices.id, deviceId)); + // Shared with the Quick Support reaper's ephemeral-device purge — see + // services/deviceDeletion.ts for why this lives in one place. + await deleteDeviceCascade(tx, deviceId); // #2138 — the deleted device's link_group_id went with its row. If it // was a boot profile and the group now has a single lone survivor — diff --git a/apps/api/src/routes/remote/supportSessions.test.ts b/apps/api/src/routes/remote/supportSessions.test.ts index f4dd22d50..5b66d86fe 100644 --- a/apps/api/src/routes/remote/supportSessions.test.ts +++ b/apps/api/src/routes/remote/supportSessions.test.ts @@ -17,9 +17,13 @@ const { getOrCreateQuickSupportOrg, logSessionAudit, getTrustedClientIp } = vi.h })); const { endSupportSession } = vi.hoisted(() => ({ - endSupportSession: vi.fn(() => Promise.resolve({ - ended: true, disconnect: 'closed' as const, commandDelivered: true, - })), + // Return type annotated so a test can script a 'close-failed' outcome; an + // inferred literal would narrow it to 'closed'. + endSupportSession: vi.fn((): Promise<{ + ended: boolean; + disconnect: 'closed' | 'close-failed' | 'not-connected' | null; + commandDelivered: boolean; + }> => Promise.resolve({ ended: true, disconnect: 'closed', commandDelivered: true })), })); vi.mock('../../services/quickSupportOrg', () => ({ getOrCreateQuickSupportOrg })); diff --git a/apps/api/src/services/deviceDeletion.ts b/apps/api/src/services/deviceDeletion.ts new file mode 100644 index 000000000..4baa91e7c --- /dev/null +++ b/apps/api/src/services/deviceDeletion.ts @@ -0,0 +1,69 @@ +import { eq, sql } from 'drizzle-orm'; +import { devices } from '../db/schema'; +import { + DEVICE_DETACH_DEVICE_ID_TABLES, + DEVICE_LINKED_DEVICE_ID_TABLES, + getDeviceCascadeDeleteTables, +} from '../routes/devices/core'; + +/** + * Minimal transaction surface this needs — satisfied by a Drizzle tx handle. + * Typed structurally so callers can pass either a tx or the db handle without + * dragging Drizzle's full generic transaction type through every signature. + */ +export interface DeviceDeletionTx { + execute(query: unknown): Promise; + delete(table: typeof devices): { where(condition: unknown): Promise }; +} + +/** + * Delete a device row and every record that references it. + * + * Extracted from DELETE /devices/:id/permanent so the Quick Support reaper + * purges ephemeral devices through the SAME code path. Two hand-rolled cascade + * implementations would drift the moment a table is added to one list and not + * the other — the exact failure mode that has produced FK-violation and + * orphaned-row bugs in this repo before. + * + * Order matters and is not alphabetical: + * 1. transitive children (rows referencing alerts/ai_sessions, which + * themselves reference the device) — they have no device_id of their own + * 2. linked_device_id and device_id detach targets set to NULL (business + * records like tickets and support_sessions outlive the device) + * 3. the device_id cascade tables + * 4. the device row itself + * + * Caller supplies the transaction: the route pairs this with link-group + * dissolution, and the reaper runs it standalone. + */ +export async function deleteDeviceCascade( + tx: DeviceDeletionTx, + deviceId: string, +): Promise { + const deviceAlertIds = sql`(SELECT id FROM alerts WHERE device_id = ${deviceId})`; + const deviceAiSessionIds = sql`(SELECT id FROM ai_sessions WHERE device_id = ${deviceId})`; + + await tx.execute(sql`DELETE FROM ai_tool_executions WHERE session_id IN ${deviceAiSessionIds}`); + await tx.execute(sql`DELETE FROM ai_messages WHERE session_id IN ${deviceAiSessionIds}`); + await tx.execute(sql`DELETE FROM ai_action_plans WHERE session_id IN ${deviceAiSessionIds}`); + await tx.execute(sql`DELETE FROM alert_correlations WHERE parent_alert_id IN ${deviceAlertIds} OR child_alert_id IN ${deviceAlertIds}`); + await tx.execute(sql`DELETE FROM alert_notifications WHERE alert_id IN ${deviceAlertIds}`); + await tx.execute(sql`UPDATE log_correlations SET alert_id = NULL WHERE alert_id IN ${deviceAlertIds}`); + await tx.execute(sql`UPDATE network_change_events SET alert_id = NULL WHERE alert_id IN ${deviceAlertIds}`); + + for (const linkedTable of DEVICE_LINKED_DEVICE_ID_TABLES) { + await tx.execute(sql`UPDATE ${sql.identifier(linkedTable)} SET linked_device_id = NULL WHERE linked_device_id = ${deviceId}`); + } + + // Tenant business records (tickets, support_sessions): preserve history, + // detach the device. + for (const detachTable of DEVICE_DETACH_DEVICE_ID_TABLES) { + await tx.execute(sql`UPDATE ${sql.identifier(detachTable)} SET device_id = NULL WHERE device_id = ${deviceId}`); + } + + for (const table of getDeviceCascadeDeleteTables()) { + await tx.execute(sql`DELETE FROM ${sql.identifier(table)} WHERE device_id = ${deviceId}`); + } + + await tx.delete(devices).where(eq(devices.id, deviceId)); +} diff --git a/apps/api/src/services/quickSupportEnd.test.ts b/apps/api/src/services/quickSupportEnd.test.ts index 265e45dd7..de1ae1059 100644 --- a/apps/api/src/services/quickSupportEnd.test.ts +++ b/apps/api/src/services/quickSupportEnd.test.ts @@ -8,8 +8,10 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; */ const { sendCommandToAgent, disconnectAgent } = vi.hoisted(() => ({ - sendCommandToAgent: vi.fn(() => true), - disconnectAgent: vi.fn(() => 'closed' as const), + sendCommandToAgent: vi.fn((): boolean => true), + // Annotated with the full union, not inferred from the default return, so a + // test can script a failed close without fighting a narrowed literal type. + disconnectAgent: vi.fn((): 'closed' | 'close-failed' | 'not-connected' => 'closed'), })); vi.mock('../routes/agentWs', () => ({ sendCommandToAgent, disconnectAgent })); @@ -96,7 +98,7 @@ describe('endSupportSession', () => { await endSupportSession('sess-1', 'tech'); - expect(updates[0].values).toEqual({ + expect(updates[0]?.values).toEqual({ agentTokenHash: null, watchdogTokenHash: null, helperTokenHash: null, @@ -133,7 +135,7 @@ describe('endSupportSession', () => { await endSupportSession('sess-1', 'expired'); - expect(updates[1].values).toMatchObject({ status: 'expired', endedReason: 'expired' }); + expect(updates[1]?.values).toMatchObject({ status: 'expired', endedReason: 'expired' }); }); it("records a non-expired reason as 'ended'", async () => { @@ -142,7 +144,7 @@ describe('endSupportSession', () => { await endSupportSession('sess-1', 'end_user'); - expect(updates[1].values).toMatchObject({ status: 'ended', endedReason: 'end_user' }); + expect(updates[1]?.values).toMatchObject({ status: 'ended', endedReason: 'end_user' }); }); it('is idempotent — an already-ended session does nothing', async () => { From 96be5ad4c30809391707e4c920637b22ea38bd1c Mon Sep 17 00:00:00 2001 From: Todd Hebebrand Date: Tue, 4 Aug 2026 19:30:54 -0500 Subject: [PATCH 16/28] feat(api): quick support reaper worker MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Five passes, each the safety net for a specific failed teardown: lapsed codes; claimed-limbo (client died between redeem and enroll, else the tech's panel reads "Client connecting..." for 8h); hard cap; end-user stop detected via the device going offline (there is no explicit stop API in v1); and purging ephemeral device rows 6h after the session ends. The purge re-reads the device and deletes only when is_ephemeral is true, so a corrupted or mis-linked session row can never remove a real customer device. Two tests pin that guard. Per-session failures are caught and logged individually — a reaper that dies on one bad row stops protecting every other tenant. Termination goes through endSupportSession and purging through deleteDeviceCascade, so there is exactly one revocation path and one delete path in the codebase. Co-Authored-By: Claude Opus 5 (1M context) --- apps/api/src/index.ts | 8 + apps/api/src/jobs/quickSupportReaper.test.ts | 366 +++++++++++++++++++ apps/api/src/jobs/quickSupportReaper.ts | 243 ++++++++++++ 3 files changed, 617 insertions(+) create mode 100644 apps/api/src/jobs/quickSupportReaper.test.ts create mode 100644 apps/api/src/jobs/quickSupportReaper.ts diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts index 0e27453f5..d8d42826f 100644 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -216,6 +216,10 @@ import { initializeEnrollmentKeyCleanupWorker, shutdownEnrollmentKeyCleanupWorker, } from './jobs/enrollmentKeyCleanup'; +import { + initializeQuickSupportReaper, + shutdownQuickSupportReaper, +} from './jobs/quickSupportReaper'; import { initializeAuditRetentionWorker, shutdownAuditRetentionWorker } from './jobs/auditRetention'; import { initializeAuditChainVerifyWorker, @@ -1305,6 +1309,9 @@ async function initializeWorkers(): Promise { // Undo-send window: fires the delayed quote dispatch (jobs/quoteSendQueue). ['quoteSendWorker', async () => { initializeQuoteSendWorker(); }], ['enrollmentKeyCleanup', initializeEnrollmentKeyCleanupWorker], + // Quick Support safety net: expires stale codes/sessions, enforces the 8h + // hard cap, detects end-user disconnects, and purges ephemeral devices. + ['quickSupportReaper', initializeQuickSupportReaper], ['auditRetention', initializeAuditRetentionWorker], ['extensionJobHost', () => initializeExtensionJobHost(extensionContributionRegistry, extensionStateStore)], ['auditChainVerify', initializeAuditChainVerifyWorker], @@ -1516,6 +1523,7 @@ async function shutdownRuntime(signal: NodeJS.Signals): Promise { shutdownAuthEmailWorker, shutdownQuoteSendWorker, shutdownEnrollmentKeyCleanupWorker, + shutdownQuickSupportReaper, shutdownAuditRetentionWorker, shutdownExtensionJobHost, shutdownAuditChainVerifyWorker, diff --git a/apps/api/src/jobs/quickSupportReaper.test.ts b/apps/api/src/jobs/quickSupportReaper.test.ts new file mode 100644 index 000000000..787e49e27 --- /dev/null +++ b/apps/api/src/jobs/quickSupportReaper.test.ts @@ -0,0 +1,366 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +/** + * The reaper is the only thing standing between a failed teardown and an + * ephemeral agent living forever on an end user's machine, so every pass gets + * its own test — plus the two properties that make it a *safety net* rather + * than just another job: it never deletes a non-ephemeral device, and one bad + * row cannot stop it from reaping the rest. + */ + +const { endSupportSession, deleteDeviceCascade } = vi.hoisted(() => ({ + endSupportSession: vi.fn(async () => ({ ended: true, disconnect: null, commandDelivered: true })), + deleteDeviceCascade: vi.fn(async () => undefined), +})); + +vi.mock('bullmq', () => ({ Queue: class {}, Worker: class {}, Job: class {} })); + +vi.mock('../services/redis', () => ({ + getBullMQConnection: vi.fn(() => ({ host: 'localhost', port: 6379 })), +})); + +vi.mock('../services/quickSupportEnd', () => ({ endSupportSession })); +vi.mock('../services/deviceDeletion', () => ({ deleteDeviceCascade })); + +/** + * Operators become inspectable tokens so the tests can assert the WHERE + * predicates (which pass is being filtered, and with what cutoff) instead of + * only the values written. + */ +vi.mock('drizzle-orm', () => ({ + and: (...conditions: unknown[]) => ({ op: 'and', conditions }), + eq: (a: unknown, b: unknown) => ({ op: 'eq', a, b }), + lt: (a: unknown, b: unknown) => ({ op: 'lt', a, b }), + isNull: (a: unknown) => ({ op: 'isNull', a }), + isNotNull: (a: unknown) => ({ op: 'isNotNull', a }), + inArray: (a: unknown, b: unknown) => ({ op: 'inArray', a, b }), +})); + +vi.mock('../db/schema', () => ({ + supportSessions: { + id: 'supportSessions.id', + status: 'supportSessions.status', + codeExpiresAt: 'supportSessions.codeExpiresAt', + hardExpiresAt: 'supportSessions.hardExpiresAt', + claimedAt: 'supportSessions.claimedAt', + endedAt: 'supportSessions.endedAt', + deviceId: 'supportSessions.deviceId', + }, + devices: { + id: 'devices.id', + status: 'devices.status', + lastSeenAt: 'devices.lastSeenAt', + isEphemeral: 'devices.isEphemeral', + }, +})); + +/** Result sets handed to consecutive `db.select()` calls, in order. */ +const selectResults: unknown[][] = []; +/** `.set()` payloads from the two bulk-UPDATE passes, in order. */ +const updates: Array<{ values: Record }> = []; +/** WHERE condition of each select, in the same order as `selectResults`. */ +const selectWheres: unknown[] = []; +/** WHERE condition of each update, in the same order as `updates`. */ +const updateWheres: unknown[] = []; +/** Whether each select used an innerJoin (pass d is the only join). */ +const selectJoins: boolean[] = []; +/** Every side-effecting step in call order, so pass ordering can be asserted. */ +const callOrder: string[] = []; +/** Device ids handed to deleteDeviceCascade. */ +const purgedDeviceIds: string[] = []; + +vi.mock('../db', () => { + const select = vi.fn(() => { + const index = selectJoins.length; + selectJoins.push(false); + const rows = selectResults.shift() ?? []; + const builder: Record = {}; + builder.from = vi.fn(() => builder); + builder.innerJoin = vi.fn(() => { selectJoins[index] = true; return builder; }); + builder.where = vi.fn((condition: unknown) => { selectWheres[index] = condition; return builder; }); + builder.limit = vi.fn(() => Promise.resolve(rows)); + return builder; + }); + + const update = vi.fn(() => ({ + set: vi.fn((values: Record) => { + updates.push({ values }); + callOrder.push(`update:${String(values.endedReason)}`); + return { + where: vi.fn((condition: unknown) => { updateWheres.push(condition); return Promise.resolve([]); }), + }; + }), + })); + + const transaction = vi.fn(async (fn: (tx: unknown) => Promise) => { + callOrder.push('transaction'); + return fn({ isTx: true }); + }); + + return { + db: { select, update, transaction }, + withSystemDbAccessContext: vi.fn(async (fn: () => unknown) => fn()), + runOutsideDbContext: vi.fn((fn: () => T): T => fn()), + }; +}); + +import { reapOnce } from './quickSupportReaper'; + +const NOW = new Date('2026-08-04T12:00:00.000Z'); +const minutesAgo = (m: number) => new Date(NOW.getTime() - m * 60 * 1000); + +/** Flatten an `and(...)` tree into its leaf comparison tokens. */ +type Token = { op: string; a?: unknown; b?: unknown; conditions?: unknown[] }; +function leaves(condition: unknown): Token[] { + const token = condition as Token; + if (!token || typeof token !== 'object') return []; + if (token.op === 'and') return (token.conditions ?? []).flatMap(leaves); + return [token]; +} +function hasLeaf(condition: unknown, match: Partial): boolean { + return leaves(condition).some((leaf) => + Object.entries(match).every(([k, v]) => (leaf as Record)[k] === v)); +} +function leafFor(condition: unknown, op: string, column: string): Token | undefined { + return leaves(condition).find((leaf) => leaf.op === op && leaf.a === column); +} + +/** No sessions matched anywhere — the "quiet fleet" baseline. */ +function noResults() { + selectResults.push([], [], []); +} + +let errorSpy: ReturnType; + +beforeEach(() => { + vi.useFakeTimers(); + vi.setSystemTime(NOW); + selectResults.length = 0; + updates.length = 0; + selectWheres.length = 0; + updateWheres.length = 0; + selectJoins.length = 0; + callOrder.length = 0; + purgedDeviceIds.length = 0; + vi.clearAllMocks(); + endSupportSession.mockImplementation(async (...args: unknown[]) => { + callOrder.push(`end:${String(args[0])}:${String(args[1])}`); + return { ended: true, disconnect: null, commandDelivered: true }; + }); + deleteDeviceCascade.mockImplementation(async (...args: unknown[]) => { + purgedDeviceIds.push(String(args[1])); + return undefined; + }); + errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); +}); + +afterEach(() => { + errorSpy.mockRestore(); + vi.useRealTimers(); +}); + +describe('reapOnce — pass (a) expired codes', () => { + it('expires pending sessions whose code lapsed, with no teardown', async () => { + noResults(); + + await reapOnce(); + + expect(updates[0]?.values).toEqual({ + status: 'expired', + endedAt: NOW, + endedReason: 'expired', + }); + expect(hasLeaf(updateWheres[0], { op: 'eq', a: 'supportSessions.status', b: 'pending' })).toBe(true); + expect(leafFor(updateWheres[0], 'lt', 'supportSessions.codeExpiresAt')?.b).toEqual(NOW); + // No device exists yet, so nothing may be torn down for this pass. + expect(endSupportSession).not.toHaveBeenCalled(); + expect(deleteDeviceCascade).not.toHaveBeenCalled(); + }); +}); + +describe('reapOnce — pass (b) claimed limbo', () => { + it("expires claimed sessions stuck without a device for 20 minutes as 'error'", async () => { + noResults(); + + await reapOnce(); + + expect(updates[1]?.values).toEqual({ + status: 'expired', + endedAt: NOW, + endedReason: 'error', + }); + expect(hasLeaf(updateWheres[1], { op: 'eq', a: 'supportSessions.status', b: 'claimed' })).toBe(true); + // deviceId IS NULL is what makes this "enrollment never completed" rather + // than a live session — without it this pass would kill working sessions. + expect(hasLeaf(updateWheres[1], { op: 'isNull', a: 'supportSessions.deviceId' })).toBe(true); + expect(leafFor(updateWheres[1], 'lt', 'supportSessions.claimedAt')?.b).toEqual(minutesAgo(20)); + }); +}); + +describe('reapOnce — pass (c) hard cap', () => { + it('ends every claimed/ready session past its hard expiry through endSupportSession', async () => { + selectResults.push([{ id: 'sess-a' }, { id: 'sess-b' }], [], []); + + await reapOnce(); + + expect(endSupportSession).toHaveBeenCalledWith('sess-a', 'expired'); + expect(endSupportSession).toHaveBeenCalledWith('sess-b', 'expired'); + expect(hasLeaf(selectWheres[0], { op: 'inArray', a: 'supportSessions.status' })).toBe(true); + expect(leaves(selectWheres[0]).find((l) => l.op === 'inArray')?.b).toEqual(['claimed', 'ready']); + expect(leafFor(selectWheres[0], 'lt', 'supportSessions.hardExpiresAt')?.b).toEqual(NOW); + }); + + it('runs after the bulk expiry passes', async () => { + selectResults.push([{ id: 'sess-a' }], [], []); + + await reapOnce(); + + expect(callOrder).toEqual(['update:expired', 'update:error', 'end:sess-a:expired']); + }); +}); + +describe('reapOnce — pass (d) end-user stop', () => { + it("ends ready sessions whose device went offline 5 minutes ago as 'end_user'", async () => { + selectResults.push([], [{ id: 'sess-c' }], []); + + await reapOnce(); + + expect(endSupportSession).toHaveBeenCalledTimes(1); + expect(endSupportSession).toHaveBeenCalledWith('sess-c', 'end_user'); + // Joined to devices — offline detection is a device property, not a + // session one, since v1 has no explicit stop API. + expect(selectJoins[1]).toBe(true); + expect(hasLeaf(selectWheres[1], { op: 'eq', a: 'supportSessions.status', b: 'ready' })).toBe(true); + expect(hasLeaf(selectWheres[1], { op: 'eq', a: 'devices.status', b: 'offline' })).toBe(true); + expect(leafFor(selectWheres[1], 'lt', 'devices.lastSeenAt')?.b).toEqual(minutesAgo(5)); + }); +}); + +describe('reapOnce — pass (e) purge', () => { + it('purges the ephemeral device of a session ended over 6 hours ago', async () => { + selectResults.push([], [], [{ id: 'sess-d', deviceId: 'dev-1' }]); + selectResults.push([{ id: 'dev-1', isEphemeral: true }]); + + await reapOnce(); + + expect(purgedDeviceIds).toEqual(['dev-1']); + expect(deleteDeviceCascade).toHaveBeenCalledWith({ isTx: true }, 'dev-1'); + // Runs inside a transaction so a device is never half-deleted. + expect(callOrder).toContain('transaction'); + expect(hasLeaf(selectWheres[2], { op: 'isNotNull', a: 'supportSessions.deviceId' })).toBe(true); + expect(leaves(selectWheres[2]).find((l) => l.op === 'inArray')?.b).toEqual(['ended', 'expired']); + expect(leafFor(selectWheres[2], 'lt', 'supportSessions.endedAt')?.b).toEqual(minutesAgo(6 * 60)); + }); + + it('does not re-run teardown for already-terminal sessions', async () => { + selectResults.push([], [], [{ id: 'sess-d', deviceId: 'dev-1' }]); + selectResults.push([{ id: 'dev-1', isEphemeral: true }]); + + await reapOnce(); + + expect(endSupportSession).not.toHaveBeenCalled(); + }); + + it('skips a session whose device row is already gone', async () => { + selectResults.push([], [], [{ id: 'sess-d', deviceId: 'dev-1' }]); + selectResults.push([]); // device lookup returns nothing + + await reapOnce(); + + expect(deleteDeviceCascade).not.toHaveBeenCalled(); + }); +}); + +describe('reapOnce — non-ephemeral purge guard', () => { + it('REFUSES to delete a real customer device referenced by a support session', async () => { + selectResults.push([], [], [{ id: 'sess-evil', deviceId: 'real-device' }]); + selectResults.push([{ id: 'real-device', isEphemeral: false }]); + + await reapOnce(); + + expect(deleteDeviceCascade).not.toHaveBeenCalled(); + expect(purgedDeviceIds).toEqual([]); + expect(callOrder).not.toContain('transaction'); + expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining('REFUSING to purge non-ephemeral device')); + }); + + it('still purges genuinely ephemeral devices in the same batch', async () => { + selectResults.push([], [], [ + { id: 'sess-evil', deviceId: 'real-device' }, + { id: 'sess-ok', deviceId: 'ephemeral-device' }, + ]); + selectResults.push([{ id: 'real-device', isEphemeral: false }]); + selectResults.push([{ id: 'ephemeral-device', isEphemeral: true }]); + + await reapOnce(); + + expect(purgedDeviceIds).toEqual(['ephemeral-device']); + }); +}); + +describe('reapOnce — one bad session cannot stop the run', () => { + it('keeps hard-capping the remaining sessions when one end throws', async () => { + selectResults.push([{ id: 'sess-1' }, { id: 'sess-boom' }, { id: 'sess-3' }], [], []); + endSupportSession.mockImplementation(async (...args: unknown[]) => { + if (args[0] === 'sess-boom') throw new Error('socket exploded'); + callOrder.push(`end:${String(args[0])}`); + return { ended: true, disconnect: null, commandDelivered: true }; + }); + + await reapOnce(); + + expect(callOrder).toContain('end:sess-1'); + expect(callOrder).toContain('end:sess-3'); + expect(errorSpy).toHaveBeenCalledWith( + expect.stringContaining('Hard-cap end failed for session sess-boom'), + expect.any(Error), + ); + }); + + it('keeps reaping later passes when an earlier pass throws', async () => { + selectResults.push([{ id: 'sess-boom' }], [{ id: 'sess-ready' }], []); + endSupportSession.mockImplementation(async (...args: unknown[]) => { + if (args[0] === 'sess-boom') throw new Error('socket exploded'); + callOrder.push(`end:${String(args[0])}`); + return { ended: true, disconnect: null, commandDelivered: true }; + }); + + await reapOnce(); + + expect(callOrder).toContain('end:sess-ready'); + }); + + it('keeps purging the remaining devices when one purge throws', async () => { + selectResults.push([], [], [ + { id: 'sess-boom', deviceId: 'dev-boom' }, + { id: 'sess-ok', deviceId: 'dev-ok' }, + ]); + selectResults.push([{ id: 'dev-boom', isEphemeral: true }]); + selectResults.push([{ id: 'dev-ok', isEphemeral: true }]); + deleteDeviceCascade.mockImplementation(async (...args: unknown[]) => { + if (args[1] === 'dev-boom') throw new Error('fk violation'); + purgedDeviceIds.push(String(args[1])); + return undefined; + }); + + await reapOnce(); + + expect(purgedDeviceIds).toEqual(['dev-ok']); + expect(errorSpy).toHaveBeenCalledWith( + expect.stringContaining('Purge failed for session sess-boom'), + expect.any(Error), + ); + }); +}); + +describe('reapOnce — DB context', () => { + it('runs the whole pass outside any request context, under system access', async () => { + noResults(); + const dbModule = await import('../db'); + + await reapOnce(); + + expect(dbModule.runOutsideDbContext).toHaveBeenCalledTimes(1); + expect(dbModule.withSystemDbAccessContext).toHaveBeenCalledTimes(1); + }); +}); diff --git a/apps/api/src/jobs/quickSupportReaper.ts b/apps/api/src/jobs/quickSupportReaper.ts new file mode 100644 index 000000000..77bedbbbe --- /dev/null +++ b/apps/api/src/jobs/quickSupportReaper.ts @@ -0,0 +1,243 @@ +import { Job, Queue, Worker } from 'bullmq'; +import { and, eq, inArray, isNotNull, isNull, lt } from 'drizzle-orm'; +import { db, runOutsideDbContext, withSystemDbAccessContext } from '../db'; +import { devices, supportSessions } from '../db/schema'; +import { deleteDeviceCascade, type DeviceDeletionTx } from '../services/deviceDeletion'; +import { endSupportSession } from '../services/quickSupportEnd'; +import { getBullMQConnection } from '../services/redis'; + +/** + * Quick Support reaper — the safety net for ad-hoc support sessions. + * + * Every cooperative teardown path (tech clicks End, client sends its own stop, + * client's dead-man timer fires) can fail: a browser tab closes mid-request, a + * client process is SIGKILLed, a websocket dies before `support_end` lands. + * Nothing in this feature may rely on the happy path, because a leaked session + * leaves an ephemeral, fully-enrolled agent alive on an end user's machine. + * This job is what guarantees that never happens, no matter what failed. + * + * Runs every 5 minutes under the system DB context: `support_sessions` and + * `devices` are org-scoped RLS but reaping is cross-tenant system work. + */ + +const QUEUE_NAME = 'quick-support-reaper'; +const JOB_NAME = 'reap'; +const REAP_INTERVAL_MS = 5 * 60 * 1000; + +/** Client crashed between redeeming the code and finishing enrollment. */ +const CLAIMED_LIMBO_MS = 20 * 60 * 1000; +/** Agent heartbeat gap that counts as "the end user closed the client". */ +const END_USER_STOP_MS = 5 * 60 * 1000; +/** Grace period before the ephemeral device row itself is destroyed. */ +const PURGE_AFTER_ENDED_MS = 6 * 60 * 60 * 1000; + +/** + * Per-pass cap. A single run must stay bounded: passes c/d/e each do several + * queries per session, so an unnoticed backlog would otherwise turn one tick + * into an unbounded run that holds a connection and overlaps the next tick. + * Leftovers are simply picked up 5 minutes later. + */ +const MAX_PER_PASS = 500; + +let reaperQueue: Queue | null = null; +let reaperWorker: Worker | null = null; + +interface ReaperJobData { + type: 'reap'; + queuedAt: string; +} + +/** + * One reaper pass. Exported and free of BullMQ so it can be driven directly + * from tests and from an operator console. + * + * A throw from any one session is logged and swallowed: a reaper that dies on + * a single corrupt row stops protecting every other tenant, which is strictly + * worse than the bad row itself. + */ +export async function reapOnce(): Promise { + await runOutsideDbContext(() => withSystemDbAccessContext(async () => { + const now = new Date(); + + // (a) EXPIRED CODES — the technician generated a code that nobody ever + // used. No device exists yet, so there is nothing to tear down; a bulk + // UPDATE is enough. Safety net for: codes accumulating as usable + // credentials long after the tech forgot about them. + await db + .update(supportSessions) + .set({ status: 'expired', endedAt: now, endedReason: 'expired' }) + .where(and( + eq(supportSessions.status, 'pending'), + lt(supportSessions.codeExpiresAt, now), + )); + + // (b) CLAIMED LIMBO — the code was redeemed but enrollment never completed, + // so no device row was ever created. Safety net for: a client that crashed + // (or was killed by AV) between redeem and enroll, which otherwise leaves + // the technician's panel stuck on "Client connecting…" for the full 8h + // hard cap. No device means no teardown, so a bulk UPDATE again. + await db + .update(supportSessions) + .set({ status: 'expired', endedAt: now, endedReason: 'error' }) + .where(and( + eq(supportSessions.status, 'claimed'), + isNull(supportSessions.deviceId), + lt(supportSessions.claimedAt, new Date(now.getTime() - CLAIMED_LIMBO_MS)), + )); + + // (c) HARD CAP — the absolute ceiling on how long an ad-hoc agent may live. + // Safety net for: every failure mode not covered by a more specific pass, + // including a session that stays healthy and connected forever because + // nobody remembered to end it. Goes through endSupportSession so tokens are + // revoked and the client is told to self-destruct — never a bare UPDATE. + const overdue = await db + .select({ id: supportSessions.id }) + .from(supportSessions) + .where(and( + inArray(supportSessions.status, ['claimed', 'ready']), + lt(supportSessions.hardExpiresAt, now), + )) + .limit(MAX_PER_PASS); + + for (const session of overdue) { + try { + await endSupportSession(session.id, 'expired'); + } catch (err) { + console.error(`[QuickSupportReaper] Hard-cap end failed for session ${session.id}:`, err); + } + } + + // (d) END-USER STOP — v1 has no explicit stop API, so "the user closed the + // client" is inferred from the agent going offline and staying offline. + // Safety net for: an end user who quits the client (or unplugs) while the + // technician's panel still shows a live session and the credentials remain + // valid. The 5-minute quiet period keeps a brief network blip from killing + // a session the user is still in. + const abandoned = await db + .select({ id: supportSessions.id }) + .from(supportSessions) + .innerJoin(devices, eq(devices.id, supportSessions.deviceId)) + .where(and( + eq(supportSessions.status, 'ready'), + eq(devices.status, 'offline'), + lt(devices.lastSeenAt, new Date(now.getTime() - END_USER_STOP_MS)), + )) + .limit(MAX_PER_PASS); + + for (const session of abandoned) { + try { + await endSupportSession(session.id, 'end_user'); + } catch (err) { + console.error(`[QuickSupportReaper] End-user stop failed for session ${session.id}:`, err); + } + } + + // (e) PURGE — destroy the ephemeral device row once the session has been + // over long enough to have been reviewed. Safety net for: ephemeral devices + // outliving their session and polluting the hidden quick-support org (and + // every device count / license tally derived from it). support_sessions + // .device_id is ON DELETE SET NULL, so the session row survives as the + // audit trail after its device is gone. + const purgeable = await db + .select({ id: supportSessions.id, deviceId: supportSessions.deviceId }) + .from(supportSessions) + .where(and( + inArray(supportSessions.status, ['ended', 'expired']), + isNotNull(supportSessions.deviceId), + lt(supportSessions.endedAt, new Date(now.getTime() - PURGE_AFTER_ENDED_MS)), + )) + .limit(MAX_PER_PASS); + + for (const session of purgeable) { + if (!session.deviceId) continue; + try { + // SAFETY GUARD: re-read the device and delete ONLY if it is ephemeral. + // The join above proves the session points at this device; it does NOT + // prove the device is disposable. A corrupted, mis-linked, or + // maliciously-crafted session row must never be able to turn this + // reaper into a delete primitive for a real customer device — a device + // delete is unrecoverable and cascades across every history table. + const [device] = await db + .select({ id: devices.id, isEphemeral: devices.isEphemeral }) + .from(devices) + .where(eq(devices.id, session.deviceId)) + .limit(1); + + if (!device) continue; // already purged; nothing to do + + if (device.isEphemeral !== true) { + console.error( + `[QuickSupportReaper] REFUSING to purge non-ephemeral device ${session.deviceId} ` + + `referenced by support session ${session.id} — investigate this row`, + ); + continue; + } + + // Shared cascade, wrapped in a transaction so a device is never left + // half-deleted with orphaned children behind an FK. + await db.transaction(async (tx) => { + await deleteDeviceCascade(tx as unknown as DeviceDeletionTx, session.deviceId!); + }); + } catch (err) { + console.error(`[QuickSupportReaper] Purge failed for session ${session.id}:`, err); + } + } + })); +} + +export function getQuickSupportReaperQueue(): Queue { + if (!reaperQueue) { + reaperQueue = new Queue(QUEUE_NAME, { connection: getBullMQConnection() }); + } + return reaperQueue; +} + +function createQuickSupportReaperWorker(): Worker { + return new Worker( + QUEUE_NAME, + async (_job: Job) => { + await reapOnce(); + }, + { + connection: getBullMQConnection(), + concurrency: 1, + }, + ); +} + +async function scheduleQuickSupportReaperJobs(): Promise { + const queue = getQuickSupportReaperQueue(); + const existing = await queue.getRepeatableJobs(); + for (const job of existing) { + await queue.removeRepeatableByKey(job.key); + } + await queue.add( + JOB_NAME, + { type: 'reap', queuedAt: new Date().toISOString() }, + { + repeat: { every: REAP_INTERVAL_MS }, + removeOnComplete: { count: 10 }, + removeOnFail: { count: 50 }, + }, + ); + console.log('[QuickSupportReaper] Scheduled 5-minute Quick Support reaper job'); +} + +export async function initializeQuickSupportReaper(): Promise { + try { + reaperWorker = createQuickSupportReaperWorker(); + reaperWorker.on('error', (error) => console.error('[QuickSupportReaper] Worker error:', error)); + reaperWorker.on('failed', (job, error) => console.error(`[QuickSupportReaper] Job ${job?.id} failed:`, error)); + await scheduleQuickSupportReaperJobs(); + console.log('[QuickSupportReaper] Worker initialized'); + } catch (error) { + console.error('[QuickSupportReaper] Failed to initialize:', error); + throw error; + } +} + +export async function shutdownQuickSupportReaper(): Promise { + if (reaperWorker) { await reaperWorker.close(); reaperWorker = null; } + if (reaperQueue) { await reaperQueue.close(); reaperQueue = null; } + console.log('[QuickSupportReaper] Worker shut down'); +} From 980e4fb745d1debf1d97afa2a04fb5ea2e1265e9 Mon Sep 17 00:00:00 2001 From: Todd Hebebrand Date: Tue, 4 Aug 2026 19:30:54 -0500 Subject: [PATCH 17/28] feat(agent): quick support mode (tier 1) + support_end self-destruct MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `breeze-agent support` redeems a one-time code, enrolls into a TEMP workspace (never C:\ProgramData\Breeze — the machine may already run a real enrolled agent), runs in the foreground so desktop capture takes the in-process path, and tears itself down on Ctrl+C, on support_end, or via a 10-minute dead-man switch. The filename parser decodes host_PORT back to host:port, mirroring installer_filename.go — ':' is illegal in a Windows filename and Chromium rewrites it to '_' at save time. Browser duplicate-download suffixes are tolerated in both the Chrome/Edge " (1)" and Firefox "(1)" forms. support_end refuses to act unless the agent is actually in support mode. That guard is what stops a forged or misrouted command from destroying a permanently-installed agent, and its test wires in the REAL cleanup and asserts a seeded sentinel file survives — so a regression fails by deleting something rather than by a stubbed counter. The Windows self-delete sets SysProcAttr.CmdLine verbatim rather than going through exec.Command: Go's EscapeArg would emit backslash-escaped quotes that cmd.exe cannot parse, and dropping the quotes breaks on any path containing a space. Not yet verified on Windows hardware: the self-delete trampoline, console- close SIGTERM, and in-process capture under support mode. Co-Authored-By: Claude Opus 5 (1M context) --- agent/internal/agentapp/main.go | 256 ++++++--- agent/internal/agentapp/support.go | 485 ++++++++++++++++++ agent/internal/agentapp/support_test.go | 259 ++++++++++ agent/internal/config/config.go | 19 + agent/internal/heartbeat/handlers_support.go | 173 +++++++ .../heartbeat/handlers_support_test.go | 284 ++++++++++ agent/internal/heartbeat/handlers_test.go | 3 + agent/internal/heartbeat/heartbeat.go | 11 + .../heartbeat/support_selfdelete_other.go | 15 + .../heartbeat/support_selfdelete_windows.go | 46 ++ agent/internal/remote/desktop/session.go | 6 + .../internal/remote/desktop/session_webrtc.go | 3 + agent/internal/remote/tools/types.go | 6 + agent/internal/websocket/client.go | 10 + agent/pkg/api/client.go | 76 +++ 15 files changed, 1591 insertions(+), 61 deletions(-) create mode 100644 agent/internal/agentapp/support.go create mode 100644 agent/internal/agentapp/support_test.go create mode 100644 agent/internal/heartbeat/handlers_support.go create mode 100644 agent/internal/heartbeat/handlers_support_test.go create mode 100644 agent/internal/heartbeat/support_selfdelete_other.go create mode 100644 agent/internal/heartbeat/support_selfdelete_windows.go diff --git a/agent/internal/agentapp/main.go b/agent/internal/agentapp/main.go index 94dd99805..e290275ac 100644 --- a/agent/internal/agentapp/main.go +++ b/agent/internal/agentapp/main.go @@ -300,6 +300,7 @@ func init() { enrollCmd.Flags().BoolVar(&quietEnroll, "quiet", false, "Suppress stdout progress output (errors still go to stderr). Intended for unattended installs.") bootstrapCmd.Flags().StringVar(&bootstrapInstallData, "install-data", "", "Pipe-packed bootstrap inputs from the MSI BootstrapEnroll CA: ||") bootstrapCmd.Flags().BoolVar(&quietEnroll, "quiet", false, "Suppress stdout progress output (errors still go to stderr)") + supportCmd.Flags().StringVar(&supportCode, "code", "", "Quick Support code (overrides the code embedded in the filename)") userHelperCmd.Flags().StringVar(&helperRole, "role", string(ipc.HelperRoleUser), "Helper role: 'system' (desktop capture) or 'user' (script execution)") desktopHelperCmd.Flags().StringVar(&desktopContext, "context", ipc.DesktopContextUserSession, "Desktop context: 'user_session' or 'login_window'") @@ -309,6 +310,7 @@ func init() { rootCmd.AddCommand(bootstrapCmd) rootCmd.AddCommand(versionCmd) rootCmd.AddCommand(statusCmd) + rootCmd.AddCommand(supportCmd) rootCmd.AddCommand(uninstallNotifyCmd) rootCmd.AddCommand(userHelperCmd) rootCmd.AddCommand(desktopHelperCmd) @@ -362,6 +364,21 @@ func Main(v string) { runDesktopHelper() return } + + // Quick Support clients are downloaded under a name that carries the + // one-time code (breeze-support--.exe) and are double-clicked, + // so there is no subcommand on the command line. Dispatch to `support` by + // basename. + // + // The second condition guards a future service copy launched with an + // explicit `support --service-run` argv; without it the dispatch would + // prepend a SECOND "support" and cobra would parse the duplicate as a + // positional arg. + if strings.HasPrefix(strings.ToLower(filepath.Base(os.Args[0])), "breeze-support") && + (len(os.Args) < 2 || os.Args[1] != "support") { + rootCmd.SetArgs(append([]string{"support"}, os.Args[1:]...)) + } + if err := rootCmd.Execute(); err != nil { fmt.Fprintln(os.Stderr, err) os.Exit(1) @@ -380,7 +397,13 @@ func initLogging(cfg *config.Config) { logFileFallbackReason = describeLogFileError(err) fmt.Fprintf(os.Stderr, "Failed to open log file %s: %s (logging to stdout)\n", cfg.LogFile, logFileFallbackReason) logFileFallback = true - } else if !hasConsole() { + } else if !hasConsole() || cfg.SupportMode { + // Support mode is file-only for a different reason than the + // headless case below: the console IS the end user's status + // window ("Waiting for your technician…"), and structured slog + // lines interleaved with it look like an error to a + // non-technical user. The lines still land in the workspace log + // file, which is what the technician gets. // No console attached (Windows service, launchd daemon, or systemd // service). Use file-only logging — stdout may be invalid or already // redirected to a log destination by the init system. Using @@ -575,9 +598,21 @@ func startAgent(cfg *config.Config) (*agentComponents, error) { return nil, fmt.Errorf("startAgent called with unenrolled config — caller must waitForEnrollment first") } + // Quick Support clients are throwaway, unelevated, and live entirely in a + // temp workspace. They must never touch the machine-wide install: no + // self-update (a support session outlives nothing), and every ProgramData + // path below is skipped because a real permanently-installed agent may be + // running on this same machine and owns those files. See runSupportSession. + if cfg.SupportMode { + cfg.AutoUpdate = false + } + // Loosen config directory (0755) and agent.yaml (0644) so the Helper can read - // them. secrets.yaml stays root-only (0600). - config.FixConfigPermissions() + // them. secrets.yaml stays root-only (0600). Skipped in support mode: this + // operates on the REAL config dir, which a support client does not own. + if !cfg.SupportMode { + config.FixConfigPermissions() + } initLogging(cfg) @@ -591,14 +626,22 @@ func startAgent(cfg *config.Config) (*agentComponents, error) { // stays zero (watchdog treats zero as a startup grace period) exactly as // the running-state write does until the first heartbeat records it. // See #1029. + // + // NOT in support mode: agent.state lives in the machine-wide config dir + // and is read by the watchdog as the live agent's PID. A throwaway + // support client writing its own PID there would make the watchdog + // supervise (and eventually force-kill) the wrong process, and would + // report the real agent as gone the moment the support client exits. startupStatePath := state.PathInDir(config.ConfigDir()) - if err := state.Write(startupStatePath, &state.AgentState{ - Status: state.StatusStarting, - PID: os.Getpid(), - Version: version, - Timestamp: time.Now(), - }); err != nil { - log.Warn("failed to write startup state file", "error", err.Error()) + if !cfg.SupportMode { + if err := state.Write(startupStatePath, &state.AgentState{ + Status: state.StatusStarting, + PID: os.Getpid(), + Version: version, + Timestamp: time.Now(), + }); err != nil { + log.Warn("failed to write startup state file", "error", err.Error()) + } } // Auto-clear Safe Mode BCD flag on startup to prevent reboot loops. @@ -669,8 +712,11 @@ func startAgent(cfg *config.Config) (*agentComponents, error) { // if the MSI HardenProgramDataAcl action was skipped or blocked (#1481). // Runs here, after the shipper is up, so the drift warning actually reaches // agent_logs — same constraint as the reconcile reporter above. No-op off - // Windows and when the dirs are already hardened. - config.EnforceProgramDataTreePermissions() + // Windows and when the dirs are already hardened. Skipped in support mode: + // an unelevated throwaway client has no business re-ACLing ProgramData. + if !cfg.SupportMode { + config.EnforceProgramDataTreePermissions() + } // Load mTLS client certificate if configured var tlsCfg *tls.Config @@ -723,8 +769,16 @@ func startAgent(cfg *config.Config) (*agentComponents, error) { // Propagate service/headless flags. On Windows, desktop sessions route // through the IPC user helper. On macOS, the daemon handles desktop // directly but uses IPC for user-context operations (run_as_user, helper). - cfg.IsService = isWindowsService() - cfg.IsHeadless = isHeadless() + // + // Support mode pins BOTH to false: the client is a plain foreground + // process owning the interactive desktop, so desktop capture takes the + // in-process path and no SYSTEM/user helper has to be spawned or + // installed. Pinning here (rather than only in runSupportSession) means a + // probe misfiring — isHeadless() on a double-clicked .exe with no attached + // console is the realistic one — cannot silently reroute capture through + // IPC to a helper that does not exist. + cfg.IsService = isWindowsService() && !cfg.SupportMode + cfg.IsHeadless = isHeadless() && !cfg.SupportMode // Ensure SAS (Ctrl+Alt+Del) policy allows services to generate it. // Only relevant on Windows when running as a service. @@ -732,7 +786,9 @@ func startAgent(cfg *config.Config) (*agentComponents, error) { ensureSASPolicy() } - if cfg.PAMEnabled && runtime.GOOS == "windows" { + // Never in support mode: provisioning a dormant elevation account is a + // permanent machine change, and the client is unelevated anyway. + if cfg.PAMEnabled && runtime.GOOS == "windows" && !cfg.SupportMode { if err := elevaccount.New().EnsureProvisioned(); err != nil { log.Warn("failed to provision PAM dormant elevation account, continuing", "error", err.Error()) @@ -791,9 +847,12 @@ func startAgent(cfg *config.Config) (*agentComponents, error) { // on-site UniFi controller's local Network Integration API and uploads // per-device PoE/health + client telemetry. Runs for the agent process // lifetime and no-ops until the server assigns collectors to this device. + // + // Off in support mode: a client that exists to serve one screen-share + // session has no business polling the customer's network gear. var unifiCancel context.CancelFunc var unifiDone <-chan struct{} - if cfg.ServerURL != "" && cfg.AgentID != "" { + if cfg.ServerURL != "" && cfg.AgentID != "" && !cfg.SupportMode { var unifiCtx context.Context // Scope the loop to a cancellable context registered in agentComponents // so shutdownAgent stops it. context.Background() here would never cancel: @@ -817,7 +876,11 @@ func startAgent(cfg *config.Config) (*agentComponents, error) { var workspaceIndexCancel context.CancelFunc var workspaceIndexDone <-chan struct{} - if cfg.WorkspaceIndex.Enabled != nil && !*cfg.WorkspaceIndex.Enabled { + if cfg.SupportMode { + // Crawling and indexing the customer's filesystem is exactly the kind + // of thing an ad-hoc support client must never do. + log.Debug("workspace indexing disabled in Quick Support mode") + } else if cfg.WorkspaceIndex.Enabled != nil && !*cfg.WorkspaceIndex.Enabled { log.Debug("workspace indexing disabled by local configuration") } else { workspaceClient := workspaceindex.NewClient(workspaceindex.ClientConfig{ @@ -849,24 +912,38 @@ func startAgent(cfg *config.Config) (*agentComponents, error) { // context.Background() (the old call) never cancels — defer // sub.Stop() at etwlua.Start exit-path never fires and the real-time // ETW session leaks across agent restarts (PR #959 review, blocker 1). + // + // A real-time kernel ETW session is process-global and machine-wide (two + // callers conflict — see NewETWSubscriber), so a support client must never + // open one alongside the installed agent. etwCtx, etwCancel := context.WithCancel(context.Background()) - etwluaDone := startETWLua(etwCtx, hb) + var etwluaDone <-chan struct{} + if cfg.SupportMode { + closed := make(chan struct{}) + close(closed) + etwluaDone = closed + } else { + etwluaDone = startETWLua(etwCtx, hb) + } log.Info("agent is running") - // Write state file so the watchdog can detect a running agent. - statePath := state.PathInDir(config.ConfigDir()) - if err := state.Write(statePath, &state.AgentState{ - Status: state.StatusRunning, - PID: os.Getpid(), - Version: version, - Timestamp: time.Now(), - }); err != nil { - log.Warn("failed to write agent state file", "error", err.Error()) - } + // Write state file so the watchdog can detect a running agent. Support + // mode never writes or registers it — see the startup-state write above. + if !cfg.SupportMode { + statePath := state.PathInDir(config.ConfigDir()) + if err := state.Write(statePath, &state.AgentState{ + Status: state.StatusRunning, + PID: os.Getpid(), + Version: version, + Timestamp: time.Now(), + }); err != nil { + log.Warn("failed to write agent state file", "error", err.Error()) + } - // Tell the heartbeat where the state file is so it can update after each heartbeat. - hb.SetStatePath(statePath) + // Tell the heartbeat where the state file is so it can update after each heartbeat. + hb.SetStatePath(statePath) + } // Mutual supervision: on Windows, when running as the SCM service this // agent process supervises BreezeWatchdog the same way BreezeWatchdog @@ -875,9 +952,13 @@ func startAgent(cfg *config.Config) (*agentComponents, error) { // LaunchDaemons report cfg.IsService=true via service_unix.go:21-26, // startWatchdogSupervisor is a no-op stub on non-Windows builds, so // gating on cfg.IsService here is safe across platforms. + // + // Support mode is doubly excluded (it always runs with IsService=false): + // there is no watchdog to supervise, and installing one is precisely the + // "permanently installed" outcome Quick Support promises not to produce. var supervisorCancel context.CancelFunc var supervisorDone <-chan struct{} - if cfg.IsService { + if cfg.IsService && !cfg.SupportMode { supCtx, supCancel := context.WithCancel(context.Background()) supervisorCancel = supCancel supervisorDone = startWatchdogSupervisor(supCtx) @@ -1199,6 +1280,81 @@ func enrollDevice(enrollmentKey string) { "server", cfg.ServerURL) } + secret := enrollmentSecret + if secret == "" { + secret = os.Getenv("BREEZE_AGENT_ENROLLMENT_SECRET") + } + + if err := enrollWithConfig(cfg, cfgFile, enrollmentKey, secret); err != nil { + var failure *enrollFailure + if errors.As(err, &failure) { + enrollError(failure.cat, failure.friendly, failure.detail) + } else { + // Unreachable in production: enrollWithConfig only ever returns + // *enrollFailure. Kept so a future edit that returns a bare error + // still exits through the four-sink reporter instead of silently + // falling through to the "start the agent with" guidance below. + enrollError(catUnknown, err.Error(), nil) + } + return // enrollError does not return in production; belt-and-braces. + } + + if isSystemServiceRunning() { + if !quietEnroll { + fmt.Println("Agent is already running via system service.") + } + } else if runtime.GOOS == "darwin" || runtime.GOOS == "linux" { + if !quietEnroll { + fmt.Println("Start the agent with:") + fmt.Println(" sudo breeze-agent service start") + } + } else { + if !quietEnroll { + fmt.Println("Run 'breeze-agent start' to start the agent.") + } + } +} + +// enrollFailure carries an enrollment failure's category and user-facing +// message out of enrollWithConfig so the caller can report it through +// enrollError (four sinks + category-specific exit code) exactly as the +// inline code used to. It exists because enrollWithConfig is shared with +// Quick Support mode (support.go), which must NOT exit the process on a +// failure — it has its own console to talk to the end user through. +type enrollFailure struct { + cat enrollErrCategory + friendly string + detail error +} + +func (e *enrollFailure) Error() string { + if e.detail != nil { + return fmt.Sprintf("%s (%v)", e.friendly, e.detail) + } + return e.friendly +} + +func (e *enrollFailure) Unwrap() error { return e.detail } + +// enrollWithConfig is the core of enrollment: collect system + hardware +// identity, POST /agents/enroll, apply the response to cfg, and persist it to +// cfgFile (agent.yaml + the sibling root-only secrets.yaml). +// +// This is a verbatim extraction of enrollDevice's core so the `enroll` +// command and Quick Support mode enroll through exactly one code path. The +// only behavioural difference from the inline version is that failures are +// RETURNED (as *enrollFailure) instead of calling enrollError inline; +// enrollDevice immediately forwards them to enrollError, so the CLI command's +// messages, sinks and exit codes are unchanged. +// +// The enrollment secret is a parameter rather than being read from the +// enrollmentSecret flag / BREEZE_AGENT_ENROLLMENT_SECRET here, because +// support mode presents a PER-KEY secret unique to its session. Everything +// else still reads the package-level command flags (quietEnroll, +// enrollDeviceRole, backupServerURL) — only one command runs per process. +func enrollWithConfig(cfg *config.Config, cfgFile, enrollmentKey, secret string) error { + enrollLog := logging.L("enroll") + enrollLog.Info("starting enrollment", "server", cfg.ServerURL) if !quietEnroll { fmt.Printf("Enrolling with server: %s\n", cfg.ServerURL) @@ -1252,11 +1408,9 @@ func enrollDevice(enrollmentKey string) { // issue #439 — one prod device ended up with its UUID in the hostname // column, which is worse than a loud failure because it looks legit. if err := assertHostnameNonEmpty(systemInfo); err != nil { - enrollError(catConfig, - "hostname resolution failed on this machine — tried "+ - collectors.HostnameSourcesDescription()+ - "; all returned empty. Refusing to enroll with an empty hostname.", - err) + return &enrollFailure{cat: catConfig, friendly: "hostname resolution failed on this machine — tried " + + collectors.HostnameSourcesDescription() + + "; all returned empty. Refusing to enroll with an empty hostname.", detail: err} } // Carry any existing device token into the enroll client. On a fresh @@ -1267,11 +1421,6 @@ func enrollDevice(enrollmentKey string) { // active row (e.g. after a rename/re-image). See #1028. client := api.NewClient(cfg.ServerURL, cfg.AuthToken, cfg.AgentID) - secret := enrollmentSecret - if secret == "" { - secret = os.Getenv("BREEZE_AGENT_ENROLLMENT_SECRET") - } - deviceRole := enrollDeviceRole if deviceRole == "" { deviceRole = collectors.ClassifyDeviceRole(systemInfo, hardwareInfo) @@ -1328,7 +1477,7 @@ func enrollDevice(enrollmentKey string) { enrollResp, err := client.Enroll(enrollReq) if err != nil { cat, friendly := classifyEnrollError(err, cfg.ServerURL) - enrollError(cat, friendly, err) + return &enrollFailure{cat: cat, friendly: friendly, detail: err} } applyEnrollResponseIdentity(cfg, enrollResp) @@ -1398,11 +1547,9 @@ func enrollDevice(enrollmentKey string) { } if err := config.SaveTo(cfg, cfgFile); err != nil { - enrollError(catConfig, - fmt.Sprintf( - "enrollment succeeded but could not save config to %s — check that the directory exists and SYSTEM has write access (agentID=%s)", - cfgFile, cfg.AgentID), - err) + return &enrollFailure{cat: catConfig, friendly: fmt.Sprintf( + "enrollment succeeded but could not save config to %s — check that the directory exists and SYSTEM has write access (agentID=%s)", + cfgFile, cfg.AgentID), detail: err} } enrollLog.Info("enrollment successful", @@ -1415,20 +1562,7 @@ func enrollDevice(enrollmentKey string) { fmt.Println("Configuration saved.") } - if isSystemServiceRunning() { - if !quietEnroll { - fmt.Println("Agent is already running via system service.") - } - } else if runtime.GOOS == "darwin" || runtime.GOOS == "linux" { - if !quietEnroll { - fmt.Println("Start the agent with:") - fmt.Println(" sudo breeze-agent service start") - } - } else { - if !quietEnroll { - fmt.Println("Run 'breeze-agent start' to start the agent.") - } - } + return nil } // initEnrollLogging configures the agent logging package for the enroll diff --git a/agent/internal/agentapp/support.go b/agent/internal/agentapp/support.go new file mode 100644 index 000000000..5ffbc7a20 --- /dev/null +++ b/agent/internal/agentapp/support.go @@ -0,0 +1,485 @@ +package agentapp + +import ( + "bufio" + "context" + "errors" + "fmt" + "os" + "os/signal" + "path/filepath" + "regexp" + "runtime" + "strings" + "syscall" + "time" + + "github.com/breeze-rmm/agent/internal/collectors" + "github.com/breeze-rmm/agent/internal/config" + "github.com/breeze-rmm/agent/internal/logging" + "github.com/breeze-rmm/agent/pkg/api" + "github.com/spf13/cobra" +) + +// defaultSupportServer is the control-plane URL a Quick Support client falls +// back to when neither the filename nor --server supplies one. Injected at +// build time per region: +// +// -ldflags "-X github.com/breeze-rmm/agent/internal/agentapp.defaultSupportServer=https://us.2breeze.app" +// +// Deliberately empty in the repo: no region hostname is ever committed (see +// the "no internal infrastructure details in public code" rule). Empty means +// the client asks the end user for the server, which is the correct +// self-hosted behaviour anyway. +var defaultSupportServer = "" + +// supportCode is the --code flag. --server reuses the root command's +// persistent serverURL flag rather than declaring a shadowing local one. +var supportCode string + +var supportCmd = &cobra.Command{ + Use: "support", + Short: "Run a one-time Quick Support session (nothing is installed)", + Long: `Runs this binary as an ephemeral Breeze Quick Support client. + +The client redeems a one-time support code, enrolls a temporary device into a +directory under the system temp folder, serves a single remote-support session, +and then removes itself. Nothing is permanently installed and no existing +Breeze agent on this machine is touched. + +The code and server are normally embedded in the downloaded filename +(breeze-support--.exe); --code / --server override them, and if +neither is available the client prompts.`, + Run: func(cmd *cobra.Command, args []string) { + runSupportSession() + }, +} + +// supportCodeAlphabet is the server's code alphabet: A-Z minus I/L/O/U, plus +// 2-9. The excluded characters are the ones users mis-hear or mis-read over +// the phone (I/1/L, O/0), which is the whole point of a spoken support code. +const supportCodeAlphabet = "ABCDEFGHJKMNPQRSTVWXYZ23456789" + +var supportCodeRe = regexp.MustCompile(`^[` + supportCodeAlphabet + `]{9}$`) + +// supportFilenameRe parses the download filename +// breeze-support--.exe. +// +// The trailing `(?:\s?\(\d+\))?` is the browser duplicate-download marker: +// Chrome and Edge insert a space before it ("... (1).exe"), Firefox does not +// ("...(1).exe"). Both must parse, or a user who downloads twice gets an +// interactive prompt for a code they were told is already "in the file". +// +// The host group is non-greedy so the dedup marker is never folded into it. +var supportFilenameRe = regexp.MustCompile(`(?i)^breeze-support-([a-z2-9]{9})-(.+?)(?:\s?\(\d+\))?\.exe$`) + +// supportHostPortRe decodes the `host_PORT` suffix the download route emits +// for a nonstandard port. `:` is illegal in a Windows filename and Chromium +// silently rewrites it to `_` at save time — that is exactly how #2341 +// shipped silently-unenrolled installs — so the server encodes the colon and +// the client decodes it back. +// +// Mirrors internal/agentapp/installer_filename.go's `(?:_([0-9]{1,5}))?` +// terminator, with one deliberate difference: that decoder's host charset +// (`[a-zA-Z0-9.\-]+`) excludes `_` entirely, so it cannot express "the last +// underscore group". Here the host group is `(.+?)`, so the greedy `.*` below +// anchors on the LAST underscore and the 1-5 digit bound keeps a hostname +// that legitimately contains an underscore (host_evil, host_123456) intact. +// For every filename the server can actually emit, the two agree. +var supportHostPortRe = regexp.MustCompile(`^(.*)_([0-9]{1,5})$`) + +// errNoSupportCode means no code was supplied by flag OR embedded in the +// filename — the caller falls back to an interactive prompt. Distinct from a +// malformed code, which is reported with an explanation. +var errNoSupportCode = errors.New("no support code supplied and none embedded in the filename") + +// normalizeSupportCode strips the display formatting a technician reads out +// loud (XXX-XXX-XXX, possibly with spaces) and upper-cases the result. +func normalizeSupportCode(s string) string { + var b strings.Builder + for _, r := range s { + if r == '-' || r == ' ' || r == '\t' { + continue + } + b.WriteRune(r) + } + return strings.ToUpper(strings.TrimSpace(b.String())) +} + +// decodeSupportHost turns the filename's `host_PORT` encoding back into +// `host:port`. Returns host unchanged when there is no numeric port suffix. +func decodeSupportHost(host string) string { + if m := supportHostPortRe.FindStringSubmatch(host); m != nil { + return m[1] + ":" + m[2] + } + return host +} + +// resolveSupportInput determines the support code and server URL for this +// run. Explicit flags always win over the filename; whatever the filename +// supplies fills the gaps. Returns an error — errNoSupportCode when nothing +// was supplied at all — so the caller can fall back to an interactive prompt. +// +// The server is returned even on error (a filename may carry a usable host +// with an unusable code) so the prompt can pre-fill it. +func resolveSupportInput(argv0, codeFlag, serverFlag string) (code, server string, err error) { + code = normalizeSupportCode(codeFlag) + server = strings.TrimSpace(serverFlag) + + if fileCode, fileHost, ok := parseSupportFilename(supportBase(argv0)); ok { + if code == "" { + code = fileCode + } + if server == "" { + server = "https://" + fileHost + } + } + + if code == "" { + return "", server, errNoSupportCode + } + if !supportCodeRe.MatchString(code) { + return "", server, fmt.Errorf("%q is not a valid support code (9 characters, letters and digits, no I/L/O/U/0/1)", code) + } + return code, server, nil +} + +// supportBase is filepath.Base that understands BOTH separators regardless of +// the OS it is compiled for. argv[0] of a Quick Support client is always a +// Windows path (`C:\Users\me\Downloads\...`), but the parser is unit-tested on +// Linux CI, where filepath.Base would return the whole string and silently +// make every path-shaped table case vacuous. +func supportBase(path string) string { + if i := strings.LastIndexAny(path, `/\`); i >= 0 { + return path[i+1:] + } + return filepath.Base(path) +} + +// parseSupportFilename extracts the code and API host embedded in a Quick +// Support download filename. The code is upper-cased (the filesystem, and a +// user renaming the file, do not preserve case) and the host's `_PORT` +// encoding is decoded back to `:port`. +func parseSupportFilename(base string) (code, host string, ok bool) { + m := supportFilenameRe.FindStringSubmatch(base) + if m == nil { + return "", "", false + } + return strings.ToUpper(m[1]), decodeSupportHost(m[2]), true +} + +// supportWorkDir is the throwaway workspace for this support client: config, +// secrets and log file all live here and the whole tree is removed on +// teardown. Keyed by PID so two concurrent support clients (a user who runs +// the download twice) cannot fight over one directory. +// +// It is NEVER config.ConfigDir(). A support client writing into +// C:\ProgramData\Breeze would overwrite the config, secrets and agent.state +// of a real permanently-installed agent on the same machine — the single most +// destructive failure mode this feature has. +func supportWorkDir() string { + return filepath.Join(os.TempDir(), fmt.Sprintf("breeze-support-%d", os.Getpid())) +} + +// configDirForSupportGuard exposes the real agent config dir to the guard +// test that pins supportWorkDir away from it. +func configDirForSupportGuard() string { return config.ConfigDir() } + +const ( + // supportDisconnectGrace is how long the WebSocket may report + // disconnected before the dead-man switch tears the session down. This is + // the backstop for a support_end command that never arrived (server + // unreachable, session revoked while the client was offline): without it a + // client whose control plane vanished would sit on the user's desktop + // indefinitely. + supportDisconnectGrace = 10 * time.Minute + + // supportWatchdogInterval is how often the dead-man switch samples + // connectivity and the clock. + supportWatchdogInterval = 15 * time.Second +) + +// supportWatchdogDecision returns the notice to print before self-destructing, +// or "" to keep running. disconnectedSince is the zero time while the +// WebSocket is connected; hardExpiresAt is the zero time when the server did +// not supply one (or it could not be parsed). +func supportWatchdogDecision(now, disconnectedSince, hardExpiresAt time.Time) string { + if !hardExpiresAt.IsZero() && now.After(hardExpiresAt) { + return "This support session has expired. Closing." + } + if !disconnectedSince.IsZero() && now.Sub(disconnectedSince) >= supportDisconnectGrace { + return fmt.Sprintf("Lost contact with the Breeze server for %s. Closing.", supportDisconnectGrace) + } + return "" +} + +// supportBanner is the v1 "status window": this client has no GUI, so the +// console it was launched from IS the UI the end user sees. +const supportBanner = ` +Breeze Quick Support +───────────────────────────────────── +Connected. Waiting for your technician… +Nothing is permanently installed. Close this window +or press Ctrl+C at any time to stop sharing. +` + +// promptSupportInput asks the end user for the values the filename and flags +// did not supply. Only reached when the download filename was renamed or the +// binary was invoked directly. +func promptSupportInput(prefillServer string) (code, server string, err error) { + reader := bufio.NewReader(os.Stdin) + + server = strings.TrimSpace(prefillServer) + if server == "" { + server = strings.TrimSpace(defaultSupportServer) + } + if server == "" { + fmt.Print("Breeze server URL (e.g. https://rmm.example.com): ") + line, readErr := reader.ReadString('\n') + if readErr != nil && strings.TrimSpace(line) == "" { + return "", "", fmt.Errorf("could not read the server URL: %w", readErr) + } + server = strings.TrimSpace(line) + } + if server == "" { + return "", "", errors.New("a Breeze server URL is required") + } + if !strings.Contains(server, "://") { + server = "https://" + server + } + + for attempt := 0; attempt < 3; attempt++ { + fmt.Print("Enter the support code your technician gave you: ") + line, readErr := reader.ReadString('\n') + candidate := normalizeSupportCode(line) + if candidate != "" && supportCodeRe.MatchString(candidate) { + return candidate, server, nil + } + if readErr != nil { + return "", server, fmt.Errorf("could not read the support code: %w", readErr) + } + fmt.Println("That doesn't look like a support code — it's 9 characters, like ABC-123-XYZ.") + } + return "", server, errors.New("no valid support code entered") +} + +// supportFail prints a user-facing failure and exits nonzero. The end user is +// typically a non-technical person on the phone with a technician, so the +// message names the next action rather than the internals. +func supportFail(msg string, err error) { + if err != nil { + fmt.Fprintf(os.Stderr, "\n%s\n(%v)\n", msg, err) + } else { + fmt.Fprintf(os.Stderr, "\n%s\n", msg) + } + osExit(1) +} + +// runSupportSession is the `support` command: redeem a one-time code, enroll +// an ephemeral device into a temp workspace, serve one remote-support session +// in the foreground, then self-destruct. +// +// The whole flow deliberately avoids the machine-wide install: the config +// lives under os.TempDir(), the watchdog and updater are off, and startAgent +// skips every ProgramData path (see the cfg.SupportMode gates there). A real +// enrolled agent may be running on this same machine and must be untouched. +func runSupportSession() { + code, server, err := resolveSupportInput(os.Args[0], supportCode, serverURL) + if err != nil { + if !errors.Is(err, errNoSupportCode) { + fmt.Fprintf(os.Stderr, "%v\n", err) + } + code, server, err = promptSupportInput(server) + if err != nil { + supportFail("Quick Support could not start.", err) + return + } + } + if server == "" { + server = strings.TrimSpace(defaultSupportServer) + } + if server == "" { + supportFail("Quick Support could not start: no Breeze server URL. Re-download the client from your technician's link.", nil) + return + } + + fmt.Println("Breeze Quick Support") + fmt.Println("Connecting…") + + workDir := supportWorkDir() + if err := os.MkdirAll(workDir, 0o700); err != nil { + supportFail("Could not create a temporary working folder for this session.", err) + return + } + supportCfgFile := filepath.Join(workDir, "agent.yaml") + + cfg := config.Default() + cfg.ServerURL = server + cfg.LogFile = filepath.Join(workDir, "support.log") + // A disposable client neither installs nor is supervised by a watchdog. + cfg.Watchdog.Enabled = false + cfg.AutoUpdate = false + cfg.SupportMode = true + cfg.SupportWorkDir = workDir + // Foreground process owning the interactive desktop: capture runs + // in-process, so no SYSTEM helper has to be installed. startAgent pins + // both again from cfg.SupportMode. + cfg.IsService = false + cfg.IsHeadless = false + + // The enroll path's console chatter is for an admin reading an MSI log, + // not for the end user staring at this window. Only one command runs per + // process, so setting the package flag here is safe. + quietEnroll = true + + // Redirect structured logging into the workspace file before anything + // else runs. Until this happens the logging package's default sink is + // os.Stdout at info level, so collector and enrollment log lines would + // land in the middle of the end user's status window. quiet=true forces + // file-only; startAgent's initLogging keeps it file-only in support mode. + initEnrollLogging(cfg, true) + + hwCollector := collectors.NewHardwareCollector() + sysInfo, err := hwCollector.CollectSystemInfo() + if err != nil || sysInfo == nil { + sysInfo = &collectors.SystemInfo{} + } + hostname := strings.TrimSpace(sysInfo.Hostname) + if hostname == "" { + // The server stores this as the ephemeral device's name; a blank one + // is useless to the technician looking at the session. + if h, hErr := os.Hostname(); hErr == nil { + hostname = strings.TrimSpace(h) + } + } + osType := sysInfo.OSType + if osType == "" { + osType = runtime.GOOS + } + + resp, err := api.RedeemSupportCode(server, code, hostname, osType) + if err != nil { + _ = os.RemoveAll(workDir) + if errors.Is(err, api.ErrSupportCodeInvalid) { + supportFail(err.Error(), nil) + return + } + supportFail("Could not reach the Breeze server. Check your internet connection and try again.", err) + return + } + + // The redeem response is authoritative for the control-plane URL: a + // self-hosted deployment can hand back a different (e.g. externally + // reachable) address than the one the download link used. + if strings.TrimSpace(resp.ServerURL) != "" { + cfg.ServerURL = strings.TrimSpace(resp.ServerURL) + } + cfg.SupportSessionID = resp.SessionID + + // Ctrl+C, and the SIGTERM Windows sends when the console X is clicked. + // The X gives roughly 5 seconds of grace, so every teardown path below + // must be local-only — no network I/O. + // + // Registered BEFORE enrollment, not just before the wait loop: from here + // on the workspace holds this session's device token, and the default + // SIGINT disposition (kill the process) would strand it on disk. Trading + // "Ctrl+C is instant" for "the workspace is always removed" is the right + // way round for a client whose whole promise is that it leaves nothing. + ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) + defer stop() + + if err := enrollWithConfig(cfg, supportCfgFile, resp.EnrollmentKey, resp.EnrollmentSecret); err != nil { + _ = os.RemoveAll(workDir) + supportFail("Could not start the support session. Ask your technician for a new code.", err) + return + } + + if ctx.Err() != nil { + // Interrupted during enrollment — stop at the first point where doing + // so is clean, rather than bringing an agent up just to tear it down. + _ = os.RemoveAll(workDir) + fmt.Println("Cancelled. Nothing was left installed.") + return + } + + comps, err := startAgentFn(cfg) + if err != nil { + _ = os.RemoveAll(workDir) + supportFail("Could not start the support session on this computer.", err) + return + } + defer logging.StopShipper() + + // Console status lines on session start/stop. Chained (not replaced) onto + // the heartbeat's own desktop callbacks so the server still receives the + // peer-disconnect notification. + comps.hb.SetSupportSessionNotifier( + func(string) { fmt.Println("Technician connected.") }, + func(string) { fmt.Println("Technician disconnected.") }, + ) + + fmt.Print(supportBanner) + + hardExpiresAt := parseSupportHardExpiry(resp.HardExpiresAt) + notice := runSupportWatchdog(ctx, comps, hardExpiresAt) + if notice != "" { + fmt.Println() + fmt.Println(notice) + } else { + fmt.Println() + fmt.Println("Ending the support session…") + } + + // Teardown order: stop sharing and drop the connection first, then remove + // the workspace. RunSupportCleanup also schedules the self-delete of this + // executable on Windows. + shutdownAgent(comps) + comps.hb.RunSupportCleanup() + fmt.Println("Support session ended. Nothing was left installed.") +} + +// parseSupportHardExpiry parses the server's RFC3339 hard expiry. An absent or +// unparseable value yields the zero time, which the dead-man switch treats as +// "no hard expiry" — the disconnect grace is still in force, so a malformed +// timestamp degrades the backstop rather than removing it. +func parseSupportHardExpiry(raw string) time.Time { + raw = strings.TrimSpace(raw) + if raw == "" { + return time.Time{} + } + t, err := time.Parse(time.RFC3339, raw) + if err != nil { + log.Warn("could not parse support session hard expiry; relying on the disconnect grace alone", + "value", raw, "error", err.Error()) + return time.Time{} + } + return t +} + +// runSupportWatchdog blocks until the session must end, returning the notice +// to show the user ("" when the user themselves stopped it via ctx). +// +// It is the dead-man switch: a support client whose control plane went away +// must not linger on someone's desktop waiting for a support_end command that +// will never arrive. +func runSupportWatchdog(ctx context.Context, comps *agentComponents, hardExpiresAt time.Time) string { + ticker := time.NewTicker(supportWatchdogInterval) + defer ticker.Stop() + + var disconnectedSince time.Time + for { + select { + case <-ctx.Done(): + return "" + case now := <-ticker.C: + if comps.wsClient != nil && comps.wsClient.IsConnected() { + disconnectedSince = time.Time{} + } else if disconnectedSince.IsZero() { + disconnectedSince = now + } + if notice := supportWatchdogDecision(now, disconnectedSince, hardExpiresAt); notice != "" { + return notice + } + } + } +} diff --git a/agent/internal/agentapp/support_test.go b/agent/internal/agentapp/support_test.go new file mode 100644 index 000000000..36f37cd15 --- /dev/null +++ b/agent/internal/agentapp/support_test.go @@ -0,0 +1,259 @@ +package agentapp + +import ( + "errors" + "testing" + "time" +) + +func TestResolveSupportInput(t *testing.T) { + cases := []struct { + name string + argv0 string + codeFlag string + serverFlag string + wantCode string + wantServer string + wantErr bool + }{ + // Explicit flags always win over whatever the filename carries — a + // technician re-running a downloaded client with --code must not be + // silently redirected to the embedded (already-consumed) code. + { + name: "flags win over filename", + argv0: `C:\Users\me\Downloads\breeze-support-KTM4H7P2X-us.2breeze.app.exe`, + codeFlag: "ABCDEFGHJ", + serverFlag: "https://eu.2breeze.app", + wantCode: "ABCDEFGHJ", + wantServer: "https://eu.2breeze.app", + }, + { + name: "filename parsed when no flags", + argv0: `C:\Users\me\Downloads\breeze-support-KTM4H7P2X-us.2breeze.app.exe`, + wantCode: "KTM4H7P2X", + wantServer: "https://us.2breeze.app", + }, + // Chrome/Edge insert a SPACE before the duplicate-download marker... + { + name: "chrome duplicate-download marker with space", + argv0: `C:\Users\me\Downloads\breeze-support-KTM4H7P2X-us.2breeze.app (1).exe`, + wantCode: "KTM4H7P2X", + wantServer: "https://us.2breeze.app", + }, + // ...Firefox does not. Both must parse or the client silently falls + // back to an interactive prompt for a code the user already "has". + { + name: "firefox duplicate-download marker without space", + argv0: `C:\Users\me\Downloads\breeze-support-KTM4H7P2X-us.2breeze.app(1).exe`, + wantCode: "KTM4H7P2X", + wantServer: "https://us.2breeze.app", + }, + { + name: "multi-digit duplicate marker", + argv0: "breeze-support-KTM4H7P2X-us.2breeze.app (12).exe", + wantCode: "KTM4H7P2X", + wantServer: "https://us.2breeze.app", + }, + { + name: "mixed-case filename normalizes the code to upper case", + argv0: "Breeze-Support-ktm4h7p2x-US.2Breeze.App.exe", + wantCode: "KTM4H7P2X", + wantServer: "https://US.2Breeze.App", + }, + // Nonstandard port: `:` is illegal in a Windows filename (Chromium + // rewrites it to `_` at save time — exactly how #2341 shipped + // silently-unenrolled installs), so the server encodes host:port as + // host_port. Without the decode the "https://" prepend produces a + // broken URL on every self-hosted/dev deployment. + { + name: "underscore port suffix decodes back to a colon", + argv0: "breeze-support-KTM4H7P2X-localhost_3000.exe", + wantCode: "KTM4H7P2X", + wantServer: "https://localhost:3000", + }, + { + name: "underscore port suffix with duplicate marker", + argv0: "breeze-support-KTM4H7P2X-rmm.acme.example_8443 (1).exe", + wantCode: "KTM4H7P2X", + wantServer: "https://rmm.acme.example:8443", + }, + // Only the LAST underscore group is a port, and only when it is + // all digits — mirrors installer_filename.go's `_([0-9]{1,5})$`. + { + name: "non-numeric underscore suffix is part of the host", + argv0: "breeze-support-KTM4H7P2X-host_evil.exe", + wantCode: "KTM4H7P2X", + wantServer: "https://host_evil", + }, + { + name: "port longer than five digits is not a port", + argv0: "breeze-support-KTM4H7P2X-host_123456.exe", + wantCode: "KTM4H7P2X", + wantServer: "https://host_123456", + }, + // A dashed display code (XXX-XXX-XXX) is what the technician reads + // out loud, so the flag must accept it verbatim. + { + name: "dashed display code from the flag is normalized", + argv0: "breeze-agent", + codeFlag: "ktm-4h7-p2x", + wantCode: "KTM4H7P2X", + }, + { + name: "server flag alone still takes the code from the filename", + argv0: "breeze-support-KTM4H7P2X-us.2breeze.app.exe", + serverFlag: "https://self.example", + wantCode: "KTM4H7P2X", + wantServer: "https://self.example", + }, + // Nothing embedded and no flags -> error so the caller prompts. + { + name: "plain agent binary with no flags errors", + argv0: "breeze-agent", + wantErr: true, + }, + { + name: "support-prefixed binary with no embedded code errors", + argv0: "breeze-support.exe", + wantErr: true, + }, + // Letters excluded from the alphabet (I/L/O/U) and digits 0/1 are + // rejected rather than redeemed as a typo'd code. + { + name: "code containing an excluded letter is rejected", + argv0: "breeze-agent", + codeFlag: "KTM4H7P2I", + wantErr: true, + }, + { + name: "code containing a zero is rejected", + argv0: "breeze-agent", + codeFlag: "KTM4H7P20", + wantErr: true, + }, + { + name: "short code is rejected", + argv0: "breeze-agent", + codeFlag: "KTM4H7P", + wantErr: true, + }, + { + name: "eight-char filename code does not match", + argv0: "breeze-support-KTM4H7P2-us.2breeze.app.exe", + wantErr: true, + }, + { + name: "non-exe extension does not match", + argv0: "breeze-support-KTM4H7P2X-us.2breeze.app.msi", + wantErr: true, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + code, server, err := resolveSupportInput(tc.argv0, tc.codeFlag, tc.serverFlag) + if tc.wantErr { + if err == nil { + t.Fatalf("expected error, got code=%q server=%q", code, server) + } + return + } + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if code != tc.wantCode { + t.Errorf("code: got %q, want %q", code, tc.wantCode) + } + if server != tc.wantServer { + t.Errorf("server: got %q, want %q", server, tc.wantServer) + } + }) + } +} + +// The "nothing supplied" case must be distinguishable from "supplied but +// malformed" only insofar as both send the caller to the interactive prompt; +// the sentinel exists so the prompt path can stay silent instead of printing +// a validation complaint about input the user never gave. +func TestResolveSupportInputMissingSentinel(t *testing.T) { + _, _, err := resolveSupportInput("breeze-agent", "", "") + if !errors.Is(err, errNoSupportCode) { + t.Fatalf("expected errNoSupportCode, got %v", err) + } + + _, _, err = resolveSupportInput("breeze-agent", "KTM4H7P20", "") + if errors.Is(err, errNoSupportCode) { + t.Fatal("a malformed code must not report as a missing code") + } +} + +func TestSupportWatchdogDecision(t *testing.T) { + now := time.Date(2026, 8, 4, 12, 0, 0, 0, time.UTC) + + cases := []struct { + name string + disconnectedSince time.Time + hardExpiresAt time.Time + wantEnd bool + }{ + { + name: "connected and unexpired keeps the session alive", + wantEnd: false, + }, + { + name: "brief disconnect is tolerated", + disconnectedSince: now.Add(-2 * time.Minute), + wantEnd: false, + }, + { + name: "disconnected for the full grace ends the session", + disconnectedSince: now.Add(-supportDisconnectGrace), + wantEnd: true, + }, + { + name: "disconnected well past the grace ends the session", + disconnectedSince: now.Add(-30 * time.Minute), + wantEnd: true, + }, + { + name: "hard expiry in the future keeps the session alive", + hardExpiresAt: now.Add(time.Minute), + wantEnd: false, + }, + { + // The backstop for a lost support_end: the server's hard expiry + // ends the session even while the WebSocket is perfectly healthy. + name: "hard expiry in the past ends the session while connected", + hardExpiresAt: now.Add(-time.Second), + wantEnd: true, + }, + { + name: "zero hard expiry is never treated as expired", + hardExpiresAt: time.Time{}, + wantEnd: false, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + notice := supportWatchdogDecision(now, tc.disconnectedSince, tc.hardExpiresAt) + if got := notice != ""; got != tc.wantEnd { + t.Fatalf("end=%v (notice %q), want end=%v", got, notice, tc.wantEnd) + } + }) + } +} + +func TestSupportWorkDirIsNotTheRealConfigDir(t *testing.T) { + // The single most dangerous failure mode for this feature: a throwaway + // support client writing into C:\ProgramData\Breeze would clobber the + // config, secrets and agent.state of a real permanently-installed agent + // on the same machine. + dir := supportWorkDir() + if dir == "" { + t.Fatal("support work dir must not be empty") + } + if dir == configDirForSupportGuard() { + t.Fatalf("support work dir %q must never be the real agent config dir", dir) + } +} diff --git a/agent/internal/config/config.go b/agent/internal/config/config.go index d880d6935..61cca7f49 100644 --- a/agent/internal/config/config.go +++ b/agent/internal/config/config.go @@ -250,6 +250,25 @@ type Config struct { // IsHeadless is a runtime flag set when no console/TTY is attached (launchd // daemon, systemd service, etc.). Desktop commands route through IPC when set. IsHeadless bool `mapstructure:"-"` + + // SupportMode marks this process as an ephemeral Quick Support client: + // enrolled into a throwaway temp workspace, serving one remote-desktop + // session, then self-destructing. It gates off everything a disposable + // client must not do (watchdog, updater, background collector loops) and + // — critically — is the guard that lets a support_end command destroy + // this process while refusing to touch a real, permanently-installed + // agent. Runtime-only: `mapstructure:"-"` keeps it out of any config + // round-trip, so it can never be set by a file on disk. + SupportMode bool `mapstructure:"-"` + + // SupportSessionID is the server-side support session this client was + // redeemed for. Runtime-only, same reasoning as SupportMode. + SupportSessionID string `mapstructure:"-"` + + // SupportWorkDir is the temp directory holding this support client's + // config, secrets and log file. It is what the self-destruct removes, so + // it must NEVER be the real agent config dir. Runtime-only. + SupportWorkDir string `mapstructure:"-"` } // IsEnrolled reports whether cfg represents a complete enrollment — both diff --git a/agent/internal/heartbeat/handlers_support.go b/agent/internal/heartbeat/handlers_support.go new file mode 100644 index 000000000..9b25483a1 --- /dev/null +++ b/agent/internal/heartbeat/handlers_support.go @@ -0,0 +1,173 @@ +package heartbeat + +import ( + "errors" + "fmt" + "os" + "time" + + "github.com/breeze-rmm/agent/internal/remote/tools" +) + +func init() { + handlerRegistry[tools.CmdSupportEnd] = handleSupportEnd +} + +// supportEndFlushDelay is how long the async teardown waits before removing +// the workspace and exiting, so the command result submitted by the caller +// has time to reach the server over the WebSocket. Short: the technician has +// already ended the session and the user is watching a window that should +// close. +const supportEndFlushDelay = 500 * time.Millisecond + +// Seams so the handler's contract — refuse when not in support mode, and +// never touch the filesystem or the process in that case — is unit-testable +// without deleting directories or exiting the test binary. +var ( + supportCleanupFn = supportCleanup + supportExitFn = os.Exit + // Stubbed in tests for an obvious reason: the real implementation deletes + // the running executable, which under `go test` is the test binary. + supportSelfDeleteFn = scheduleSupportSelfDelete +) + +// handleSupportEnd tears down an ephemeral Quick Support client: the +// technician ended the session (or the server revoked it), so this process +// stops sharing, deletes its temp workspace, schedules the deletion of its +// own executable, and exits. +// +// THE GUARD: a heartbeat that is not in support mode refuses outright. This +// command is a self-destruct, and support_end is delivered over the same +// command channel as everything else — a forged command, a server-side +// mis-routing to the wrong device, or a stale session id must never be able +// to wipe a real, permanently-installed agent. Support mode is a runtime-only +// config field (`mapstructure:"-"`, see config.Config.SupportMode) that is set +// exactly once, by runSupportSession, so it cannot be turned on by anything +// that arrives over the network or lands on disk. +func handleSupportEnd(h *Heartbeat, cmd Command) tools.CommandResult { + start := time.Now() + + sessionID := tools.GetPayloadString(cmd.Payload, "sessionId", "") + + if !h.supportMode { + log.Warn("REFUSED support_end: this agent is not a Quick Support client", + "sessionId", sessionID, + "commandId", cmd.ID, + ) + return tools.NewErrorResult( + errors.New("support_end refused: this agent is a permanently-installed Breeze agent, not an ephemeral Quick Support client; nothing was removed"), + time.Since(start).Milliseconds(), + ) + } + + log.Info("support_end received — ending Quick Support session and self-destructing", + "sessionId", sessionID, + "workDir", h.supportWorkDir, + ) + + go func() { + defer func() { + if r := recover(); r != nil { + log.Error("panic during Quick Support teardown", "panic", fmt.Sprint(r)) + } + }() + // Let the success result below reach the wire before the process dies. + time.Sleep(supportEndFlushDelay) + supportCleanupFn(h) + supportExitFn(0) + }() + + return tools.NewSuccessResult(map[string]string{ + "message": "support session ended; client is self-destructing", + "sessionId": sessionID, + }, time.Since(start).Milliseconds()) +} + +// supportCleanup performs the local teardown of a Quick Support client: stop +// sharing the screen, remove the temp workspace (config + secrets + log), and +// schedule the deletion of the executable itself. +// +// Everything here is local — no network I/O. The signal path in +// runSupportSession runs this same function, and a console X-close on Windows +// gives roughly 5 seconds of grace, so a blocking HTTP call here would mean +// the workspace (which holds this session's device token) survives. +// +// Never called on a permanently-installed agent: the only two callers are +// handleSupportEnd (guarded on h.supportMode) and RunSupportCleanup. +func supportCleanup(h *Heartbeat) { + if h == nil { + return + } + + if h.desktopMgr != nil { + h.desktopMgr.StopAllSessions() + } + if h.wsDesktopMgr != nil { + h.wsDesktopMgr.StopAll() + } + + // Belt-and-braces against ever removing a real install's config dir: the + // workspace is only ever the temp directory runSupportSession created. + if h.supportWorkDir != "" { + if err := os.RemoveAll(h.supportWorkDir); err != nil { + log.Warn("could not remove Quick Support workspace", "path", h.supportWorkDir, "error", err.Error()) + } + } + + supportSelfDeleteFn() +} + +// RunSupportCleanup runs the Quick Support teardown from outside this package. +// The support-mode foreground runner (internal/agentapp) calls it on Ctrl+C / +// SIGTERM so a user-initiated close destroys exactly as much as a +// server-initiated support_end does. +func (h *Heartbeat) RunSupportCleanup() { + if h == nil || !h.supportMode { + return + } + supportCleanupFn(h) +} + +// SetSupportSessionNotifier wires console callbacks fired when a remote +// desktop session connects/disconnects. The stop callback is CHAINED onto +// whatever the heartbeat already registered (the peer-disconnect notification +// to the API) rather than replacing it. +// +// Must be called right after startAgent returns and before any session can +// start; the desktop manager's hooks are plain fields set at construction. +func (h *Heartbeat) SetSupportSessionNotifier(onStart, onStop func(sessionID string)) { + if h == nil || h.desktopMgr == nil { + return + } + previousStop := h.desktopMgr.OnSessionStopped + h.desktopMgr.OnSessionStarted = onStart + h.desktopMgr.OnSessionStopped = func(sessionID string) { + if previousStop != nil { + previousStop(sessionID) + } + if onStop != nil { + onStop(sessionID) + } + } +} + +// buildSupportSelfDeleteCmdLine renders the Windows trampoline command line. +// Extracted (like buildWindowsUninstallScript) so the exact text is +// unit-testable on any host without spawning cmd.exe. +func buildSupportSelfDeleteCmdLine(exePath string) string { + return fmt.Sprintf(`cmd /C ping 127.0.0.1 -n 3 >NUL & del /f "%s"`, exePath) +} + +// scheduleSupportSelfDelete removes this executable after the process exits. +// Best-effort by nature: if it fails, the user is left with a downloaded file +// they can delete, not with anything installed or running. +func scheduleSupportSelfDelete() { + exePath, err := os.Executable() + if err != nil || exePath == "" { + log.Warn("could not resolve own executable path; skipping Quick Support self-delete", "error", fmt.Sprint(err)) + return + } + if err := startSupportSelfDelete(exePath); err != nil { + log.Warn("could not schedule Quick Support self-delete", "path", exePath, "error", err.Error()) + } +} diff --git a/agent/internal/heartbeat/handlers_support_test.go b/agent/internal/heartbeat/handlers_support_test.go new file mode 100644 index 000000000..734f6aa9e --- /dev/null +++ b/agent/internal/heartbeat/handlers_support_test.go @@ -0,0 +1,284 @@ +package heartbeat + +import ( + "os" + "path/filepath" + "strings" + "sync" + "testing" + "time" +) + +// withSupportSeams swaps the cleanup/exit seams for recording stubs and +// restores them afterwards. Returns accessors for what the async teardown +// goroutine did. +func withSupportSeams(t *testing.T) (cleanupCalls func() int, exitCalls func() []int) { + t.Helper() + + origCleanup, origExit, origDelete := supportCleanupFn, supportExitFn, supportSelfDeleteFn + t.Cleanup(func() { + supportCleanupFn = origCleanup + supportExitFn = origExit + supportSelfDeleteFn = origDelete + }) + supportSelfDeleteFn = func() {} + + var mu sync.Mutex + cleanups := 0 + exits := []int{} + + supportCleanupFn = func(*Heartbeat) { + mu.Lock() + defer mu.Unlock() + cleanups++ + } + supportExitFn = func(code int) { + mu.Lock() + defer mu.Unlock() + exits = append(exits, code) + } + + return func() int { + mu.Lock() + defer mu.Unlock() + return cleanups + }, func() []int { + mu.Lock() + defer mu.Unlock() + return append([]int(nil), exits...) + } +} + +func TestHandleSupportEnd(t *testing.T) { + cases := []struct { + name string + supportMode bool + payload map[string]any + wantStatus string + wantCleanup bool + wantErrPart string + }{ + { + // THE GUARD. support_end is a self-destruct delivered over the + // same command channel as everything else; a forged or misrouted + // one must never be able to wipe a real installed agent. + name: "refuses on a permanently-installed agent and destroys nothing", + supportMode: false, + payload: map[string]any{"sessionId": "11111111-1111-1111-1111-111111111111"}, + wantStatus: "failed", + wantCleanup: false, + wantErrPart: "permanently-installed", + }, + { + name: "refuses even with no payload at all", + supportMode: false, + payload: nil, + wantStatus: "failed", + wantCleanup: false, + wantErrPart: "refused", + }, + { + name: "ends the session on an ephemeral support client", + supportMode: true, + payload: map[string]any{"sessionId": "22222222-2222-2222-2222-222222222222"}, + wantStatus: "completed", + wantCleanup: true, + }, + { + name: "ends the session even when the payload omits sessionId", + supportMode: true, + payload: map[string]any{}, + wantStatus: "completed", + wantCleanup: true, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + cleanupCalls, exitCalls := withSupportSeams(t) + + h := &Heartbeat{supportMode: tc.supportMode, supportWorkDir: t.TempDir()} + result := handleSupportEnd(h, Command{ID: "cmd-1", Type: "support_end", Payload: tc.payload}) + + if result.Status != tc.wantStatus { + t.Fatalf("status: got %q, want %q (error=%q)", result.Status, tc.wantStatus, result.Error) + } + if tc.wantErrPart != "" && !strings.Contains(result.Error, tc.wantErrPart) { + t.Errorf("error %q does not mention %q", result.Error, tc.wantErrPart) + } + if tc.wantStatus == "failed" && result.ExitCode == 0 { + // exit_code 0 must always mean "ran and exited cleanly" (#2474). + t.Error("a failed result must carry a nonzero exit code") + } + + // The teardown is asynchronous so the result can flush first. Poll + // past supportEndFlushDelay either way: the refusal cases must + // still be given a real chance to (wrongly) fire before we + // conclude they didn't. + deadline := time.Now().Add(supportEndFlushDelay + 500*time.Millisecond) + for time.Now().Before(deadline) { + if cleanupCalls() > 0 { + break + } + time.Sleep(10 * time.Millisecond) + } + + if got := cleanupCalls() > 0; got != tc.wantCleanup { + t.Fatalf("cleanup invoked=%v, want %v", got, tc.wantCleanup) + } + if got := len(exitCalls()) > 0; got != tc.wantCleanup { + t.Fatalf("process exit scheduled=%v, want %v", got, tc.wantCleanup) + } + if tc.wantCleanup { + if codes := exitCalls(); codes[0] != 0 { + t.Errorf("exit code: got %d, want 0", codes[0]) + } + } + }) + } +} + +// A refused support_end must leave the workspace on disk untouched — the +// result-status assertion above would still pass if the async goroutine ran +// and deleted things, so pin the filesystem effect directly. +func TestHandleSupportEndRefusalLeavesFilesystemUntouched(t *testing.T) { + origCleanup, origExit, origDelete := supportCleanupFn, supportExitFn, supportSelfDeleteFn + t.Cleanup(func() { + supportCleanupFn = origCleanup + supportExitFn = origExit + supportSelfDeleteFn = origDelete + }) + supportSelfDeleteFn = func() {} + supportExitFn = func(int) { t.Error("os.Exit must not be scheduled when support_end is refused") } + // Deliberately the REAL cleanup: if the guard ever regresses, this test + // fails by deleting the sentinel rather than by a stubbed counter. + supportCleanupFn = supportCleanup + + dir := t.TempDir() + sentinel := filepath.Join(dir, "agent.yaml") + if err := os.WriteFile(sentinel, []byte("agent_id: real-agent\n"), 0o600); err != nil { + t.Fatalf("seed sentinel: %v", err) + } + + h := &Heartbeat{supportMode: false, supportWorkDir: dir} + result := handleSupportEnd(h, Command{ID: "cmd-forged", Type: "support_end", Payload: map[string]any{"sessionId": "x"}}) + if result.Status != "failed" { + t.Fatalf("expected refusal, got status %q", result.Status) + } + + time.Sleep(supportEndFlushDelay + 300*time.Millisecond) + + if _, err := os.Stat(sentinel); err != nil { + t.Fatalf("refused support_end deleted a file it must never touch: %v", err) + } +} + +// RunSupportCleanup is the signal-path entry point (Ctrl+C / console close). +// It carries the same guard as the command handler so it can never be reached +// on a normal agent through some future call site. +func TestRunSupportCleanupHonoursTheSupportModeGuard(t *testing.T) { + cases := []struct { + name string + heartbeat *Heartbeat + wantCleanup bool + }{ + {"nil heartbeat is a no-op", nil, false}, + {"installed agent is refused", &Heartbeat{supportMode: false}, false}, + {"support client cleans up", &Heartbeat{supportMode: true}, true}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + cleanupCalls, _ := withSupportSeams(t) + tc.heartbeat.RunSupportCleanup() + if got := cleanupCalls() > 0; got != tc.wantCleanup { + t.Fatalf("cleanup invoked=%v, want %v", got, tc.wantCleanup) + } + }) + } +} + +// stubSelfDelete keeps the real cleanup from deleting the test binary. +func stubSelfDelete(t *testing.T) { + t.Helper() + orig := supportSelfDeleteFn + t.Cleanup(func() { supportSelfDeleteFn = orig }) + supportSelfDeleteFn = func() {} +} + +func TestSupportCleanupRemovesOnlyItsOwnWorkspace(t *testing.T) { + stubSelfDelete(t) + root := t.TempDir() + workDir := filepath.Join(root, "breeze-support-4242") + if err := os.MkdirAll(workDir, 0o700); err != nil { + t.Fatalf("mkdir: %v", err) + } + if err := os.WriteFile(filepath.Join(workDir, "secrets.yaml"), []byte("auth_token: t\n"), 0o600); err != nil { + t.Fatalf("seed: %v", err) + } + neighbour := filepath.Join(root, "unrelated.yaml") + if err := os.WriteFile(neighbour, []byte("x\n"), 0o600); err != nil { + t.Fatalf("seed neighbour: %v", err) + } + + supportCleanup(&Heartbeat{supportMode: true, supportWorkDir: workDir}) + + if _, err := os.Stat(workDir); !os.IsNotExist(err) { + t.Fatalf("workspace should be gone, stat err = %v", err) + } + if _, err := os.Stat(neighbour); err != nil { + t.Fatalf("cleanup removed a sibling it does not own: %v", err) + } +} + +// An empty workDir must not turn os.RemoveAll into a no-op on "" that some +// future refactor could widen into the process CWD. +func TestSupportCleanupWithEmptyWorkDirIsSafe(t *testing.T) { + stubSelfDelete(t) + supportCleanup(&Heartbeat{supportMode: true, supportWorkDir: ""}) + supportCleanup(nil) +} + +// The trampoline is passed to CreateProcess verbatim via +// SysProcAttr.CmdLine (see support_selfdelete_windows.go), so the quoting +// here is load-bearing: a path containing a space must stay one argument to +// del, and there must be no backslash-escaped quotes for cmd.exe to choke on. +func TestBuildSupportSelfDeleteCmdLine(t *testing.T) { + cases := []struct { + name string + exePath string + want string + }{ + { + name: "plain path", + exePath: `C:\Users\me\Downloads\breeze-support-KTM4H7P2X-us.2breeze.app.exe`, + want: `cmd /C ping 127.0.0.1 -n 3 >NUL & del /f "C:\Users\me\Downloads\breeze-support-KTM4H7P2X-us.2breeze.app.exe"`, + }, + { + name: "user profile containing a space stays quoted as one argument", + exePath: `C:\Users\John Smith\Downloads\breeze-support-KTM4H7P2X-us.2breeze.app.exe`, + want: `cmd /C ping 127.0.0.1 -n 3 >NUL & del /f "C:\Users\John Smith\Downloads\breeze-support-KTM4H7P2X-us.2breeze.app.exe"`, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got := buildSupportSelfDeleteCmdLine(tc.exePath) + if got != tc.want { + t.Fatalf("got %s\nwant %s", got, tc.want) + } + if strings.Contains(got, `\"`) { + t.Errorf("command line contains a backslash-escaped quote, which cmd.exe does not understand: %s", got) + } + if strings.Count(got, `"`) != 2 { + t.Errorf("cmd /C only strips outer quotes when the line has exactly two quote characters; got %d in %s", strings.Count(got, `"`), got) + } + }) + } +} + +func TestSupportEndIsRegistered(t *testing.T) { + if _, ok := handlerRegistry["support_end"]; !ok { + t.Fatal("support_end is not registered in handlerRegistry") + } +} diff --git a/agent/internal/heartbeat/handlers_test.go b/agent/internal/heartbeat/handlers_test.go index a5c7c5d24..ecc1e29d1 100644 --- a/agent/internal/heartbeat/handlers_test.go +++ b/agent/internal/heartbeat/handlers_test.go @@ -114,6 +114,9 @@ var allCommandTypes = []string{ // handlers_uninstall.go init() tools.CmdSelfUninstall, + // handlers_support.go init() + tools.CmdSupportEnd, + // handlers_incident_response.go init() tools.CmdCollectEvidence, tools.CmdExecuteContainment, diff --git a/agent/internal/heartbeat/heartbeat.go b/agent/internal/heartbeat/heartbeat.go index 1cae952b5..844b0845d 100644 --- a/agent/internal/heartbeat/heartbeat.go +++ b/agent/internal/heartbeat/heartbeat.go @@ -276,6 +276,15 @@ type Heartbeat struct { shutdownTimeout time.Duration isService bool isHeadless bool + // supportMode marks this heartbeat as belonging to an ephemeral Quick + // Support client. It is the guard on the support_end command: without it + // a forged or misrouted support_end would self-destruct a real, + // permanently-installed agent. supportWorkDir is the temp workspace that + // self-destruct removes — never the machine-wide config dir. Both are + // copied from cfg at construction and never mutated afterwards, exactly + // like isService/isHeadless. + supportMode bool + supportWorkDir string // headlessCachedAt memoizes the Linux resolver-backed headless probe used by // currentHeadless() for the outgoing heartbeat payload. Stores a // headlessCache; an atomic.Value so the heartbeat and command-handler @@ -618,6 +627,8 @@ func NewWithVersion(cfg *config.Config, version string, token *secmem.SecureStri h.accepting.Store(true) h.isService = cfg.IsService h.isHeadless = cfg.IsHeadless + h.supportMode = cfg.SupportMode + h.supportWorkDir = cfg.SupportWorkDir // Classify device role once at startup and cache system info. // CollectHardware spawns WMIC processes on Windows which can take up to diff --git a/agent/internal/heartbeat/support_selfdelete_other.go b/agent/internal/heartbeat/support_selfdelete_other.go new file mode 100644 index 000000000..c0ecaa62c --- /dev/null +++ b/agent/internal/heartbeat/support_selfdelete_other.go @@ -0,0 +1,15 @@ +//go:build !windows + +package heartbeat + +import "os" + +// startSupportSelfDelete removes the Quick Support executable. Unix unlinks by +// name and the running process keeps its open inode, so no trampoline is +// needed — the counterpart of the Windows implementation's cmd /C dance. +func startSupportSelfDelete(exePath string) error { + if err := os.Remove(exePath); err != nil && !os.IsNotExist(err) { + return err + } + return nil +} diff --git a/agent/internal/heartbeat/support_selfdelete_windows.go b/agent/internal/heartbeat/support_selfdelete_windows.go new file mode 100644 index 000000000..b9f75fbd4 --- /dev/null +++ b/agent/internal/heartbeat/support_selfdelete_windows.go @@ -0,0 +1,46 @@ +//go:build windows + +package heartbeat + +import ( + "os/exec" + "syscall" +) + +// createNoWindow suppresses the console window of the detached trampoline. +// The last thing a Quick Support session should do is flash a black cmd box +// on the end user's screen. Defined locally rather than pulling in +// x/sys/windows for one constant (same value as windows.CREATE_NO_WINDOW). +const createNoWindow = 0x08000000 + +// startSupportSelfDelete launches the detached self-delete trampoline: +// +// cmd /C ping 127.0.0.1 -n 3 >NUL & del /f "" +// +// The ping is a dependency-free sleep (no PowerShell, no execution policy) — +// a running .exe cannot delete itself, so the trampoline has to outlive this +// process by a couple of seconds. +// +// SysProcAttr.CmdLine is set explicitly instead of passing the script as an +// argument to exec.Command. os/exec would run the script through +// syscall.EscapeArg, which wraps it in quotes and backslash-escapes the inner +// quotes around the path (`\"C:\...\x.exe\"`). cmd.exe does not understand +// backslash-escaped quotes, and its /C "strip the outer quotes" rule only +// applies when the line contains exactly two quote characters — with four it +// tries to execute the whole quoted script as a program name and fails. Any +// path containing a space (C:\Users\John Smith\Downloads\...) needs those +// inner quotes, so the escaped form is not an option: build the command line +// verbatim. +func startSupportSelfDelete(exePath string) error { + cmd := exec.Command("cmd") + cmd.SysProcAttr = &syscall.SysProcAttr{ + CreationFlags: syscall.CREATE_NEW_PROCESS_GROUP | createNoWindow, + HideWindow: true, + CmdLine: buildSupportSelfDeleteCmdLine(exePath), + } + if err := cmd.Start(); err != nil { + return err + } + _ = cmd.Process.Release() + return nil +} diff --git a/agent/internal/remote/desktop/session.go b/agent/internal/remote/desktop/session.go index 6f2cb5b31..e1d5d7899 100644 --- a/agent/internal/remote/desktop/session.go +++ b/agent/internal/remote/desktop/session.go @@ -183,6 +183,12 @@ type SessionManager struct { // disconnected and allow reconnection. OnSessionStopped func(sessionID string) + // OnSessionStarted is the symmetric hook: called when a WebRTC peer + // connection reaches Connected, i.e. the viewer is actually watching. + // Quick Support uses it to tell the end user "Technician connected." + // Invoked on its own goroutine, like OnSessionStopped. + OnSessionStarted func(sessionID string) + // lastDesktopState caches the most recently broadcast desktop state so // late-connecting viewers can receive an initial state when their control // channel opens. Protected by mu. diff --git a/agent/internal/remote/desktop/session_webrtc.go b/agent/internal/remote/desktop/session_webrtc.go index 38bc5c398..661cae46b 100644 --- a/agent/internal/remote/desktop/session_webrtc.go +++ b/agent/internal/remote/desktop/session_webrtc.go @@ -567,6 +567,9 @@ func (m *SessionManager) StartSession(sessionID string, offer string, iceServers case webrtc.PeerConnectionStateConnected: logSelectedPair("connected") session.startStreaming() + if m.OnSessionStarted != nil { + go m.OnSessionStarted(sessionID) + } case webrtc.PeerConnectionStateDisconnected: // Transient: a brief network blip enters this state. We deliberately diff --git a/agent/internal/remote/tools/types.go b/agent/internal/remote/tools/types.go index 761a7c244..ec6b3dee8 100644 --- a/agent/internal/remote/tools/types.go +++ b/agent/internal/remote/tools/types.go @@ -202,6 +202,12 @@ const ( // Self-uninstall (remote wipe) CmdSelfUninstall = "self_uninstall" + // Quick Support session teardown. Only an ephemeral support-mode client + // acts on this; a permanently-installed agent refuses it (see + // handleSupportEnd) so a forged or misrouted command cannot destroy a + // real install. + CmdSupportEnd = "support_end" + // Hyper-V VM backup management CmdHypervDiscover = "hyperv_discover" CmdHypervBackup = "hyperv_backup" diff --git a/agent/internal/websocket/client.go b/agent/internal/websocket/client.go index ff45c6e46..ad75f0d61 100644 --- a/agent/internal/websocket/client.go +++ b/agent/internal/websocket/client.go @@ -260,6 +260,16 @@ func (c *Client) UpdateTLSConfig(tlsCfg *tls.Config) { c.tlsConfigMu.Unlock() } +// IsConnected reports whether a live WebSocket connection is currently held. +// conn is set on a successful dial and cleared by closeCurrentConn, so this is +// "connected right now", not "has ever connected". Used by the Quick Support +// dead-man switch to detect a control plane that has gone away for good. +func (c *Client) IsConnected() bool { + c.connMu.RLock() + defer c.connMu.RUnlock() + return c.conn != nil +} + // ForceReconnect closes the active connection so the reconnect loop re-dials. func (c *Client) ForceReconnect() { c.closeCurrentConn(false) diff --git a/agent/pkg/api/client.go b/agent/pkg/api/client.go index 624fc8dfa..5f888a4c0 100644 --- a/agent/pkg/api/client.go +++ b/agent/pkg/api/client.go @@ -422,6 +422,82 @@ func CancelBootstrap(serverURL, childEnrollmentKey string) (*CancelBootstrapResp return &result, nil } +// SupportRedeemRequest is the body of POST /api/v1/support/redeem — the +// Quick Support (ad-hoc remote support) code redemption. Unauthenticated: +// the one-time code IS the credential, exactly like bootstrap-token +// redemption (see CancelBootstrap above). +type SupportRedeemRequest struct { + Code string `json:"code"` + Hostname string `json:"hostname"` + OSType string `json:"osType"` +} + +// SupportRedeemResponse mirrors the server's 200 response. EnrollmentSecret +// is a PER-KEY secret unique to this support session — not the org-shared +// AGENT_ENROLLMENT_SECRET — and is presented to /agents/enroll through the +// ordinary EnrollRequest.EnrollmentSecret body field. +type SupportRedeemResponse struct { + ServerURL string `json:"serverUrl"` + EnrollmentKey string `json:"enrollmentKey"` + EnrollmentSecret string `json:"enrollmentSecret"` + SessionID string `json:"sessionId"` + // RFC3339. The client treats this as a hard stop even if the server's + // support_end command never arrives. + HardExpiresAt string `json:"hardExpiresAt"` +} + +// ErrSupportCodeInvalid is returned for the server's 404 — an unknown, +// expired, or already-redeemed code. Its text is shown verbatim to a +// non-technical end user, so it names the remedy rather than the status code. +var ErrSupportCodeInvalid = errors.New("That code is invalid or has expired — ask your technician for a new one.") + +// RedeemSupportCode exchanges a one-time Quick Support code for an ephemeral +// enrollment. Package-level (not a *Client method) because at this point the +// process holds no device token and no agent ID — the code is the only +// credential, the same trust level as bootstrap redemption. +// +// The 404 case is mapped to ErrSupportCodeInvalid so the caller can print a +// human sentence to an end user who mistyped a code; every other non-200 is +// surfaced as *ErrHTTPStatus for diagnostics. +func RedeemSupportCode(server, code, hostname, osType string) (*SupportRedeemResponse, error) { + url := strings.TrimRight(server, "/") + "/api/v1/support/redeem" + body, err := json.Marshal(&SupportRedeemRequest{Code: code, Hostname: hostname, OSType: osType}) + if err != nil { + return nil, fmt.Errorf("failed to marshal support redeem request: %w", err) + } + + req, err := http.NewRequest(http.MethodPost, url, bytes.NewReader(body)) + if err != nil { + return nil, fmt.Errorf("failed to create support redeem request: %w", err) + } + req.Header.Set("Content-Type", "application/json") + + client := &http.Client{Timeout: 30 * time.Second, CheckRedirect: refuseUntrustedRedirect} + resp, err := client.Do(req) + if err != nil { + return nil, fmt.Errorf("failed to send support redeem request: %w", err) + } + defer func() { _ = resp.Body.Close() }() + + bodyBytes, err := io.ReadAll(io.LimitReader(resp.Body, 64*1024)) + if err != nil { + return nil, fmt.Errorf("failed to read support redeem response body: %w", err) + } + + if resp.StatusCode == http.StatusNotFound { + return nil, ErrSupportCodeInvalid + } + if resp.StatusCode != http.StatusOK { + return nil, &ErrHTTPStatus{StatusCode: resp.StatusCode, Body: string(bodyBytes)} + } + + var result SupportRedeemResponse + if err := json.Unmarshal(bodyBytes, &result); err != nil { + return nil, fmt.Errorf("failed to decode support redeem response: %w", err) + } + return &result, nil +} + // UninstallIntentResponse mirrors the server's POST // /agents/:id/uninstall-intent 200 response body. type UninstallIntentResponse struct { From 44f1a6e9fa6a517359d6907770aa49d4d28e182a Mon Sep 17 00:00:00 2001 From: Todd Hebebrand Date: Tue, 4 Aug 2026 19:31:07 -0500 Subject: [PATCH 18/28] feat(web): quick support technician page + public /quick landing page MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Technician page: create dialog, one-time code shown large and copyable, 3s polling that stops permanently on terminal states (pinned by a test asserting the poll count freezes), ConnectDesktopButton once the ephemeral device is online, and End via runAction. The org picker is labelled "Reporting only — this does not grant or change access to that customer's data" so attribution cannot be mistaken for a tenancy control. Public /quick page: unauthenticated, code entry with normalization, soft validity check, Windows download, macOS marked coming soon. A dropped connection renders as a distinct "check failed" state rather than "invalid code" — telling someone their code is dead because the wifi blipped sends them back to the technician for nothing. The Windows publisher line sets an honest expectation and tells the user to STOP if the publisher is unknown or unexpected, rather than coaching them past the prompt. The signer name is isolated in one interpolated key, defaulted to a neutral phrase, because the Azure Trusted Signing cert profile display name is not in the repo and must not be guessed. All 41 + 26 strings are literal-key t() across all seven locales with real translations (fr-CA distinguished from fr-FR, es-419 formal usted). Co-Authored-By: Claude Opus 5 (1M context) --- .../quick/QuickLandingPage.test.tsx | 141 ++++++ .../src/components/quick/QuickLandingPage.tsx | 212 ++++++++ .../remote/QuickSupportPage.test.tsx | 338 +++++++++++++ .../components/remote/QuickSupportPage.tsx | 474 ++++++++++++++++++ .../components/remote/RemoteAccessPage.tsx | 16 +- .../lib/__tests__/no-silent-mutations.test.ts | 6 +- .../src/lib/i18n/translationCoverage.test.ts | 18 + apps/web/src/lib/routeScope.ts | 3 + apps/web/src/locales/de-DE/quick.json | 47 ++ apps/web/src/locales/de-DE/remote.json | 59 +++ apps/web/src/locales/en/quick.json | 47 ++ apps/web/src/locales/en/remote.json | 59 +++ apps/web/src/locales/es-419/quick.json | 47 ++ apps/web/src/locales/es-419/remote.json | 59 +++ apps/web/src/locales/fr-CA/quick.json | 47 ++ apps/web/src/locales/fr-CA/remote.json | 63 ++- apps/web/src/locales/fr-FR/quick.json | 47 ++ apps/web/src/locales/fr-FR/remote.json | 59 +++ apps/web/src/locales/it-IT/quick.json | 47 ++ apps/web/src/locales/it-IT/remote.json | 59 +++ apps/web/src/locales/pt-BR/quick.json | 47 ++ apps/web/src/locales/pt-BR/remote.json | 59 +++ apps/web/src/pages/quick.astro | 11 + apps/web/src/pages/remote/quick-support.astro | 8 + 24 files changed, 1969 insertions(+), 4 deletions(-) create mode 100644 apps/web/src/components/quick/QuickLandingPage.test.tsx create mode 100644 apps/web/src/components/quick/QuickLandingPage.tsx create mode 100644 apps/web/src/components/remote/QuickSupportPage.test.tsx create mode 100644 apps/web/src/components/remote/QuickSupportPage.tsx create mode 100644 apps/web/src/locales/de-DE/quick.json create mode 100644 apps/web/src/locales/en/quick.json create mode 100644 apps/web/src/locales/es-419/quick.json create mode 100644 apps/web/src/locales/fr-CA/quick.json create mode 100644 apps/web/src/locales/fr-FR/quick.json create mode 100644 apps/web/src/locales/it-IT/quick.json create mode 100644 apps/web/src/locales/pt-BR/quick.json create mode 100644 apps/web/src/pages/quick.astro create mode 100644 apps/web/src/pages/remote/quick-support.astro diff --git a/apps/web/src/components/quick/QuickLandingPage.test.tsx b/apps/web/src/components/quick/QuickLandingPage.test.tsx new file mode 100644 index 000000000..9b941cd3e --- /dev/null +++ b/apps/web/src/components/quick/QuickLandingPage.test.tsx @@ -0,0 +1,141 @@ +import { render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import QuickLandingPage from './QuickLandingPage'; + +function mockFetch(implementation: (url: string) => unknown) { + const fetchMock = vi.fn(async (input: RequestInfo | URL) => implementation(String(input))); + vi.stubGlobal('fetch', fetchMock); + return fetchMock; +} + +function jsonResponse(body: unknown, ok = true) { + return { ok, json: async () => body } as unknown as Response; +} + +function requestedUrls(fetchMock: ReturnType): string[] { + return fetchMock.mock.calls.map((call) => String(call[0])); +} + +describe('QuickLandingPage', () => { + beforeEach(() => { + window.history.replaceState({}, '', '/quick'); + vi.unstubAllGlobals(); + }); + + it('shows the code entry form and no download when the URL carries no code', async () => { + const fetchMock = mockFetch(() => jsonResponse({ valid: true })); + + render(); + + expect(await screen.findByTestId('quick-code-input')).toBeInTheDocument(); + expect(screen.getByTestId('quick-code-submit')).toBeInTheDocument(); + expect(screen.queryByTestId('quick-download-windows')).not.toBeInTheDocument(); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it('checks the normalized code from the URL and offers the Windows download', async () => { + const fetchMock = mockFetch(() => jsonResponse({ valid: true })); + window.history.replaceState({}, '', '/quick?code=ktm-4h7-p2x'); + + render(); + + const download = await screen.findByTestId('quick-download-windows'); + expect(requestedUrls(fetchMock)).toEqual(['/api/v1/support/check/KTM4H7P2X']); + expect(download.getAttribute('href')).toBe( + '/api/v1/support/download/windows?code=KTM4H7P2X', + ); + expect(screen.queryByTestId('quick-invalid-code')).not.toBeInTheDocument(); + }); + + it('shows the manual fallback line with the dashed code', async () => { + mockFetch(() => jsonResponse({ valid: true })); + window.history.replaceState({}, '', '/quick?code=KTM4H7P2X'); + + render(); + + expect( + await screen.findByText('If the download prompts for a code, enter: KTM-4H7-P2X'), + ).toBeInTheDocument(); + }); + + it('hides the download button and explains when the code is rejected', async () => { + mockFetch(() => jsonResponse({ valid: false })); + window.history.replaceState({}, '', '/quick?code=KTM-4H7-P2X'); + + render(); + + expect(await screen.findByTestId('quick-invalid-code')).toBeInTheDocument(); + expect(screen.queryByTestId('quick-download-windows')).not.toBeInTheDocument(); + // The user can still try a fresh code the technician reads out. + expect(screen.getByTestId('quick-code-input')).toBeInTheDocument(); + }); + + it('normalizes lower-case spaced input typed into the form', async () => { + const user = userEvent.setup(); + const fetchMock = mockFetch(() => jsonResponse({ valid: true })); + + render(); + + await user.type(screen.getByTestId('quick-code-input'), 'ktm 4h7 p2x'); + await user.click(screen.getByTestId('quick-code-submit')); + + await waitFor(() => + expect(requestedUrls(fetchMock)).toEqual(['/api/v1/support/check/KTM4H7P2X']), + ); + expect(await screen.findByTestId('quick-download-windows')).toBeInTheDocument(); + }); + + it('rejects an incomplete code locally without calling the API', async () => { + const user = userEvent.setup(); + const fetchMock = mockFetch(() => jsonResponse({ valid: true })); + + render(); + + await user.type(screen.getByTestId('quick-code-input'), 'ktm-4h7'); + await user.click(screen.getByTestId('quick-code-submit')); + + expect(await screen.findByTestId('quick-code-format-error')).toBeInTheDocument(); + expect(fetchMock).not.toHaveBeenCalled(); + expect(screen.queryByTestId('quick-download-windows')).not.toBeInTheDocument(); + }); + + it('does not claim the code is dead when the network request fails', async () => { + mockFetch(() => { + throw new Error('offline'); + }); + window.history.replaceState({}, '', '/quick?code=KTM-4H7-P2X'); + + render(); + + expect(await screen.findByTestId('quick-check-error')).toBeInTheDocument(); + expect(screen.queryByTestId('quick-invalid-code')).not.toBeInTheDocument(); + expect(screen.queryByTestId('quick-download-windows')).not.toBeInTheDocument(); + }); + + it('shows a disabled macOS row marked coming soon', async () => { + mockFetch(() => jsonResponse({ valid: true })); + window.history.replaceState({}, '', '/quick?code=KTM-4H7-P2X'); + + render(); + + const macRow = await screen.findByTestId('quick-download-macos'); + expect(macRow).toHaveAttribute('aria-disabled', 'true'); + expect(macRow).toHaveTextContent('Coming soon'); + expect(macRow.querySelector('a')).toBeNull(); + }); + + it('sets an honest Windows publisher expectation without naming a company', async () => { + mockFetch(() => jsonResponse({ valid: true })); + window.history.replaceState({}, '', '/quick?code=KTM-4H7-P2X'); + + render(); + + await screen.findByTestId('quick-download-windows'); + expect( + screen.getByText(/Windows shows a prompt asking whether you want to allow it to run/), + ).toBeInTheDocument(); + expect(screen.getByText(/close the prompt and call the person helping you/)).toBeInTheDocument(); + }); +}); diff --git a/apps/web/src/components/quick/QuickLandingPage.tsx b/apps/web/src/components/quick/QuickLandingPage.tsx new file mode 100644 index 000000000..ee8d8c370 --- /dev/null +++ b/apps/web/src/components/quick/QuickLandingPage.tsx @@ -0,0 +1,212 @@ +import { useCallback, useEffect, useState, type FormEvent } from 'react'; +import { useTranslation } from 'react-i18next'; +import { formatSupportCode, normalizeSupportCode } from '@breeze/shared'; +// Initializes the shared i18next singleton. This page's layout has no Sidebar +// (which is what pulls i18n in elsewhere), so without this every t() call here +// renders its raw key. It is also the only page a logged-out stranger sees. +import '@/lib/i18n'; + +const API_BASE = (import.meta.env.PUBLIC_API_URL || '').trim(); + +/** + * The check endpoint is deliberately unauthenticated — the one-time code IS the + * credential — so this uses a plain `fetch`, never `fetchWithAuth`: an end user + * on this page has no Breeze account and no token. + */ +type CheckState = + | { phase: 'idle' } + | { phase: 'checking' } + | { phase: 'valid'; code: string } + | { phase: 'invalid' } + | { phase: 'unreachable'; code: string }; + +function downloadUrl(code: string): string { + return `${API_BASE}/api/v1/support/download/windows?code=${encodeURIComponent(code)}`; +} + +export default function QuickLandingPage() { + const { t } = useTranslation('quick'); + const [state, setState] = useState({ phase: 'idle' }); + const [entry, setEntry] = useState(''); + const [formatError, setFormatError] = useState(false); + + const checkCode = useCallback(async (code: string) => { + setState({ phase: 'checking' }); + try { + const response = await fetch(`${API_BASE}/api/v1/support/check/${encodeURIComponent(code)}`); + const body = (await response.json()) as { valid?: boolean } | null; + setState( + response.ok && body?.valid === true ? { phase: 'valid', code } : { phase: 'invalid' }, + ); + } catch { + // A network failure is not the same as a rejected code: telling the user + // their code is dead when the connection dropped sends them back to the + // technician for nothing. + setState({ phase: 'unreachable', code }); + } + }, []); + + useEffect(() => { + const raw = new URLSearchParams(window.location.search).get('code'); + if (!raw) return; + const normalized = normalizeSupportCode(raw); + if (!normalized) { + setEntry(raw); + setFormatError(true); + return; + } + setEntry(formatSupportCode(normalized)); + void checkCode(normalized); + }, [checkCode]); + + const handleSubmit = (event: FormEvent) => { + event.preventDefault(); + const normalized = normalizeSupportCode(entry); + if (!normalized) { + setFormatError(true); + return; + } + setFormatError(false); + setEntry(formatSupportCode(normalized)); + void checkCode(normalized); + }; + + const showForm = state.phase !== 'valid' && state.phase !== 'checking'; + + return ( + + ); +} diff --git a/apps/web/src/components/remote/QuickSupportPage.test.tsx b/apps/web/src/components/remote/QuickSupportPage.test.tsx new file mode 100644 index 000000000..d5b4bb9be --- /dev/null +++ b/apps/web/src/components/remote/QuickSupportPage.test.tsx @@ -0,0 +1,338 @@ +import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import QuickSupportPage, { type SupportSessionView } from './QuickSupportPage'; +import { fetchWithAuth } from '@/stores/auth'; +import { runAction } from '@/lib/runAction'; +import { showToast } from '@/components/shared/Toast'; + +vi.mock('@/stores/auth', () => ({ + fetchWithAuth: vi.fn(), +})); + +vi.mock('@/stores/orgStore', () => ({ + useOrgStore: () => ({ organizations: [{ id: 'org-1', name: 'Acme Dental' }] }), +})); + +vi.mock('@/components/shared/Toast', () => ({ + showToast: vi.fn(), +})); + +// The real button opens sessions/deep links; only its presence matters here. +vi.mock('./ConnectDesktopButton', () => ({ + default: ({ deviceId }: { deviceId: string }) => ( + + ), +})); + +// Wrap (not replace) runAction so the real success/failure semantics still run +// while the test can assert that mutations actually went through it. +vi.mock('@/lib/runAction', async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, runAction: vi.fn(actual.runAction) }; +}); + +const fetchMock = vi.mocked(fetchWithAuth); +const runActionMock = vi.mocked(runAction); +const showToastMock = vi.mocked(showToast); + +const SESSION_ID = 'ss-1'; +const LANDING_URL = 'https://app.example.com/quick?code=ABC-DEF-GHI'; + +const CREATED = { + id: SESSION_ID, + code: 'ABC-DEF-GHI', + codeExpiresAt: '2026-08-04T12:10:00.000Z', + hardExpiresAt: '2026-08-04T14:00:00.000Z', + landingUrl: LANDING_URL, +}; + +function view(overrides: Partial = {}): SupportSessionView { + return { + id: SESSION_ID, + status: 'pending', + createdAt: '2026-08-04T12:00:00.000Z', + codeExpiresAt: CREATED.codeExpiresAt, + hardExpiresAt: CREATED.hardExpiresAt, + deviceId: null, + deviceOnline: false, + attributedOrgId: null, + attributionLabel: null, + endedAt: null, + endedReason: null, + createdByUserId: 'user-1', + ...overrides, + }; +} + +const makeResponse = (payload: unknown = {}, ok = true, status = ok ? 200 : 500): Response => + ({ + ok, + status, + json: vi.fn().mockResolvedValue(payload), + }) as unknown as Response; + +const LIST_URL = '/remote/support-sessions?limit=50'; +const DETAIL_URL = `/remote/support-sessions/${SESSION_ID}`; + +const writeText = vi.fn().mockResolvedValue(undefined); + +/** + * Routes every request the page makes. `detailStatuses` is consumed one entry + * per poll; the last entry sticks so a test can assert the poll stopped. + */ +function installFetch(options: { + detail?: SupportSessionView[]; + list?: SupportSessionView[]; + endResponse?: Response; +} = {}) { + const detailQueue = [...(options.detail ?? [view()])]; + let lastDetail = detailQueue[0]; + fetchMock.mockImplementation(async (url: string, opts?: RequestInit) => { + if (url === LIST_URL) return makeResponse({ sessions: options.list ?? [] }); + if (url === '/remote/support-sessions' && opts?.method === 'POST') { + return makeResponse(CREATED, true, 201); + } + if (url === `${DETAIL_URL}/end` && opts?.method === 'POST') { + return options.endResponse ?? makeResponse({ success: true }); + } + if (url === DETAIL_URL) { + if (detailQueue.length > 0) lastDetail = detailQueue.shift()!; + return makeResponse(lastDetail); + } + return makeResponse({}); + }); +} + +function detailCallCount(): number { + return fetchMock.mock.calls.filter(([url]) => url === DETAIL_URL).length; +} + +/** Opens the dialog and submits it, returning once the code panel is on screen. */ +async function createSession(label?: string) { + fireEvent.click(screen.getByTestId('quick-support-new')); + if (label !== undefined) { + fireEvent.change(screen.getByLabelText('Reference label (optional)'), { + target: { value: label }, + }); + } + fireEvent.click(screen.getByTestId('quick-support-create')); + await screen.findByTestId('quick-support-code'); +} + +beforeEach(() => { + fetchMock.mockReset(); + runActionMock.mockClear(); + showToastMock.mockClear(); + writeText.mockClear(); + Object.defineProperty(navigator, 'clipboard', { + value: { writeText }, + configurable: true, + }); +}); + +afterEach(() => { + vi.useRealTimers(); +}); + +describe('QuickSupportPage', () => { + it('creates a session through runAction and shows the one-time code', async () => { + installFetch(); + render(); + + fireEvent.click(screen.getByTestId('quick-support-new')); + fireEvent.change(screen.getByLabelText('Attribute to customer (optional)'), { + target: { value: 'org-1' }, + }); + // The org picker must not read as a tenancy control. + expect( + screen.getByText(/Reporting only .* does not grant or change access/i), + ).toBeInTheDocument(); + fireEvent.change(screen.getByLabelText('Reference label (optional)'), { + target: { value: "Jane's laptop" }, + }); + fireEvent.click(screen.getByTestId('quick-support-create')); + + expect(await screen.findByTestId('quick-support-code')).toHaveTextContent('ABC-DEF-GHI'); + + expect(runActionMock).toHaveBeenCalledWith( + expect.objectContaining({ errorFallback: 'Could not create the support session' }), + ); + expect(fetchMock).toHaveBeenCalledWith( + '/remote/support-sessions', + expect.objectContaining({ + method: 'POST', + body: JSON.stringify({ attributedOrgId: 'org-1', attributionLabel: "Jane's laptop" }), + }), + ); + // The "you only get this once" warning must be on screen with the code. + expect(screen.getByText(/This code appears only once/i)).toBeInTheDocument(); + }); + + it('copies the landing URL from the copy-link button', async () => { + installFetch(); + render(); + await createSession(); + + fireEvent.click(screen.getByTestId('quick-support-copy-link')); + + await waitFor(() => expect(writeText).toHaveBeenCalledWith(LANDING_URL)); + expect(writeText).not.toHaveBeenCalledWith('ABC-DEF-GHI'); + }); + + it('polls every 3s and stops permanently once the session is terminal', async () => { + vi.useFakeTimers({ shouldAdvanceTime: true }); + installFetch({ + detail: [ + view({ status: 'pending' }), + view({ status: 'claimed' }), + view({ status: 'ended', endedAt: '2026-08-04T12:30:00.000Z', endedReason: 'tech_ended' }), + ], + }); + render(); + await createSession(); + + await waitFor(() => expect(detailCallCount()).toBe(1)); + expect(screen.getByTestId('quick-support-status')).toHaveTextContent( + 'Waiting for the user to run the client', + ); + + await act(async () => { + await vi.advanceTimersByTimeAsync(3000); + }); + expect(detailCallCount()).toBe(2); + expect(screen.getByTestId('quick-support-status')).toHaveTextContent('Client connecting'); + + await act(async () => { + await vi.advanceTimersByTimeAsync(3000); + }); + expect(detailCallCount()).toBe(3); + await waitFor(() => + expect(screen.getByTestId('quick-support-status')).toHaveTextContent('Session ended'), + ); + + // Terminal: no further polls no matter how much time passes. + await act(async () => { + await vi.advanceTimersByTimeAsync(30000); + }); + expect(detailCallCount()).toBe(3); + // …and the terminal session offers no End button. + expect(screen.queryByTestId('quick-support-end')).not.toBeInTheDocument(); + }); + + it('stops polling when the page unmounts', async () => { + vi.useFakeTimers({ shouldAdvanceTime: true }); + installFetch({ detail: [view({ status: 'pending' })] }); + const { unmount } = render(); + await createSession(); + + await waitFor(() => expect(detailCallCount()).toBe(1)); + unmount(); + + await act(async () => { + await vi.advanceTimersByTimeAsync(30000); + }); + expect(detailCallCount()).toBe(1); + }); + + it('renders the connect button only once a device is enrolled and online', async () => { + vi.useFakeTimers({ shouldAdvanceTime: true }); + installFetch({ + detail: [ + view({ status: 'claimed', deviceId: 'dev-9', deviceOnline: false }), + view({ status: 'ready', deviceId: 'dev-9', deviceOnline: true }), + ], + }); + render(); + await createSession(); + + await waitFor(() => expect(detailCallCount()).toBe(1)); + expect(screen.queryByTestId('quick-support-connect')).not.toBeInTheDocument(); + + await act(async () => { + await vi.advanceTimersByTimeAsync(3000); + }); + await waitFor(() => + expect(screen.getByTestId('quick-support-connect')).toBeInTheDocument(), + ); + expect(screen.getByTestId('quick-support-connect')).toHaveTextContent('connect-dev-9'); + expect(screen.getByTestId('quick-support-status')).toHaveTextContent('Ready to connect'); + }); + + it('ends the session through runAction and toasts the outcome', async () => { + installFetch({ detail: [view({ status: 'pending' })] }); + render(); + await createSession(); + + runActionMock.mockClear(); + fireEvent.click(screen.getByTestId('quick-support-end')); + + await waitFor(() => + expect(fetchMock).toHaveBeenCalledWith( + `${DETAIL_URL}/end`, + expect.objectContaining({ method: 'POST' }), + ), + ); + expect(runActionMock).toHaveBeenCalledWith( + expect.objectContaining({ + errorFallback: 'Could not end the support session', + successMessage: 'Support session ended', + }), + ); + // runAction (the real implementation) is what surfaces the outcome. + await waitFor(() => + expect(showToastMock).toHaveBeenCalledWith({ + message: 'Support session ended', + type: 'success', + }), + ); + await waitFor(() => + expect(screen.getByTestId('quick-support-status')).toHaveTextContent('Session ended'), + ); + }); + + it('reconciles a 409 from End instead of leaving a dead button', async () => { + installFetch({ + detail: [view({ status: 'pending' })], + endResponse: makeResponse({ error: 'already_ended' }, false, 409), + }); + render(); + await createSession(); + + fireEvent.click(screen.getByTestId('quick-support-end')); + + await waitFor(() => + expect(showToastMock).toHaveBeenCalledWith({ + message: 'That support session had already ended', + type: 'warning', + }), + ); + }); + + it('lists recent sessions with their status and label', async () => { + installFetch({ + list: [ + view({ + id: 'ss-old', + status: 'expired', + attributionLabel: 'Reception PC', + createdAt: '2026-08-01T09:00:00.000Z', + }), + ], + }); + render(); + + const list = await screen.findByTestId('quick-support-list'); + await waitFor(() => expect(list).toHaveTextContent('Reception PC')); + expect(list).toHaveTextContent('Session expired'); + }); + + it('surfaces a list load failure', async () => { + fetchMock.mockResolvedValue(makeResponse({ error: 'nope' }, false)); + render(); + + expect( + await screen.findByText('Could not load recent support sessions'), + ).toBeInTheDocument(); + }); +}); diff --git a/apps/web/src/components/remote/QuickSupportPage.tsx b/apps/web/src/components/remote/QuickSupportPage.tsx new file mode 100644 index 000000000..edba3d608 --- /dev/null +++ b/apps/web/src/components/remote/QuickSupportPage.tsx @@ -0,0 +1,474 @@ +import { useCallback, useEffect, useRef, useState } from 'react'; +import { AlertTriangle, Copy, Headphones, Link2, Plus, X } from 'lucide-react'; +import { fetchWithAuth } from '@/stores/auth'; +import { useOrgStore } from '@/stores/orgStore'; +import { runAction, ActionError } from '@/lib/runAction'; +import { showToast } from '@/components/shared/Toast'; +import { formatDateTime, formatTime } from '@/lib/dateTimeFormat'; +import { useTranslation } from 'react-i18next'; +import '@/lib/i18n'; +import ConnectDesktopButton from './ConnectDesktopButton'; + +export type SupportSessionStatus = + | 'pending' + | 'claimed' + | 'ready' + | 'active' + | 'ended' + | 'expired'; + +export interface SupportSessionView { + id: string; + status: SupportSessionStatus; + createdAt: string; + codeExpiresAt: string; + hardExpiresAt: string; + deviceId: string | null; + deviceOnline: boolean; + attributedOrgId: string | null; + attributionLabel: string | null; + endedAt: string | null; + endedReason: string | null; + createdByUserId: string | null; +} + +/** The create response — `code` is returned exactly once and is never retrievable again. */ +interface CreatedSupportSession { + id: string; + code: string; + codeExpiresAt: string; + hardExpiresAt: string; + landingUrl: string; +} + +const TERMINAL_STATUSES: ReadonlySet = new Set([ + 'ended', + 'expired', +]); + +const POLL_INTERVAL_MS = 3000; + +/** + * Technician-facing Quick Support console. A tech mints a one-time code, reads + * it to the end user, and connects with the normal remote desktop viewer once + * the ephemeral client has enrolled. + * + * Polling uses a recursive `setTimeout` held in a ref (same shape as + * ConnectDesktopButton) rather than setInterval: it self-terminates on a + * terminal status and never overlaps requests, so a session that ends while + * the page stays open cannot leave a timer polling forever. + */ +export default function QuickSupportPage() { + const { t } = useTranslation('remote'); + const { organizations } = useOrgStore(); + + const [sessions, setSessions] = useState([]); + const [listError, setListError] = useState(null); + const [dialogOpen, setDialogOpen] = useState(false); + const [attributedOrgId, setAttributedOrgId] = useState(''); + const [attributionLabel, setAttributionLabel] = useState(''); + const [creating, setCreating] = useState(false); + const [ending, setEnding] = useState(false); + const [created, setCreated] = useState(null); + const [activeSession, setActiveSession] = useState(null); + + const pollTimerRef = useRef | null>(null); + const activeSessionId = created?.id ?? null; + + const loadSessions = useCallback(async () => { + try { + const response = await fetchWithAuth('/remote/support-sessions?limit=50'); + if (!response.ok) throw new Error('list_failed'); + const data = await response.json(); + setSessions(Array.isArray(data?.sessions) ? data.sessions : []); + setListError(null); + } catch { + setListError(t('quickSupport.errors.list')); + } + }, [t]); + + useEffect(() => { + loadSessions(); + }, [loadSessions]); + + // Poll the freshly created session until it reaches a terminal state. + useEffect(() => { + if (!activeSessionId) return; + let cancelled = false; + + const stopTimer = () => { + if (pollTimerRef.current) { + clearTimeout(pollTimerRef.current); + pollTimerRef.current = null; + } + }; + + const poll = async () => { + let terminal = false; + try { + const response = await fetchWithAuth(`/remote/support-sessions/${activeSessionId}`); + if (response.ok) { + const data = (await response.json()) as SupportSessionView; + if (cancelled) return; + if (data && typeof data.status === 'string') { + setActiveSession(data); + terminal = TERMINAL_STATUSES.has(data.status); + } + } + } catch { + /* transient network error — keep polling */ + } + if (cancelled) return; + if (terminal) { + // Terminal: stop polling entirely and refresh the history list once. + stopTimer(); + loadSessions(); + return; + } + pollTimerRef.current = setTimeout(poll, POLL_INTERVAL_MS); + }; + + poll(); + + return () => { + cancelled = true; + stopTimer(); + }; + }, [activeSessionId, loadSessions]); + + const copy = useCallback( + async (value: string, successMessage: string) => { + try { + await navigator.clipboard.writeText(value); + showToast({ message: successMessage, type: 'success' }); + } catch { + showToast({ message: t('quickSupport.code.copyFailed'), type: 'error' }); + } + }, + [t], + ); + + const handleCreate = useCallback(async () => { + setCreating(true); + try { + const trimmedLabel = attributionLabel.trim(); + const body: { attributedOrgId?: string; attributionLabel?: string } = {}; + if (attributedOrgId) body.attributedOrgId = attributedOrgId; + if (trimmedLabel) body.attributionLabel = trimmedLabel; + + const session = await runAction({ + request: () => + fetchWithAuth('/remote/support-sessions', { + method: 'POST', + body: JSON.stringify(body), + }), + errorFallback: t('quickSupport.errors.create'), + parseSuccess: (data) => { + const parsed = data as CreatedSupportSession | null; + if (!parsed?.id || !parsed?.code) throw new Error('malformed_create_response'); + return parsed; + }, + }); + + setCreated(session); + setActiveSession(null); + setDialogOpen(false); + setAttributionLabel(''); + loadSessions(); + } catch (err) { + if (err instanceof ActionError && err.status === 401) return; + if (!(err instanceof ActionError)) { + showToast({ message: t('quickSupport.errors.create'), type: 'error' }); + } + } finally { + setCreating(false); + } + }, [attributedOrgId, attributionLabel, loadSessions, t]); + + const handleEnd = useCallback( + async (sessionId: string) => { + setEnding(true); + try { + await runAction({ + request: () => + fetchWithAuth(`/remote/support-sessions/${sessionId}/end`, { method: 'POST' }), + errorFallback: t('quickSupport.end.error'), + successMessage: t('quickSupport.end.success'), + }); + setActiveSession((current) => + current && current.id === sessionId + ? { ...current, status: 'ended' as const } + : current, + ); + loadSessions(); + } catch (err) { + if (err instanceof ActionError && err.status === 401) return; + if (err instanceof ActionError && err.status === 409) { + // Already ended server-side — reconcile the view instead of leaving a + // stale "in progress" panel with a dead End button. + showToast({ message: t('quickSupport.end.alreadyEnded'), type: 'warning' }); + loadSessions(); + return; + } + if (!(err instanceof ActionError)) { + showToast({ message: t('quickSupport.end.error'), type: 'error' }); + } + } finally { + setEnding(false); + } + }, + [loadSessions, t], + ); + + const statusLabel = (status: SupportSessionStatus): string => { + switch (status) { + case 'pending': + return t('quickSupport.status.pending'); + case 'claimed': + return t('quickSupport.status.claimed'); + case 'ready': + return t('quickSupport.status.ready'); + case 'active': + return t('quickSupport.status.active'); + case 'ended': + return t('quickSupport.status.ended'); + case 'expired': + return t('quickSupport.status.expired'); + default: + return status; + } + }; + + const statusClass = (status: SupportSessionStatus): string => { + if (status === 'ready' || status === 'active') return 'text-green-600 dark:text-green-500'; + if (status === 'ended' || status === 'expired') return 'text-muted-foreground'; + return 'text-amber-600 dark:text-amber-500'; + }; + + const currentStatus: SupportSessionStatus = activeSession?.status ?? 'pending'; + const isTerminal = TERMINAL_STATUSES.has(currentStatus); + const canConnect = Boolean(activeSession?.deviceId && activeSession.deviceOnline && !isTerminal); + + return ( +
+
+
+

{t('quickSupport.title')}

+

{t('quickSupport.subtitle')}

+
+ +
+ + {dialogOpen && ( +
+
+
+

+ + {t('quickSupport.form.title')} +

+ +
+ +
+
+ + +

+ {t('quickSupport.form.attributionOrgHint')} +

+
+ +
+ + setAttributionLabel(e.target.value)} + placeholder={t('quickSupport.form.attributionLabelPlaceholder')} + className="mt-1 w-full rounded-md border bg-background px-3 py-2 text-sm" + /> +
+
+ +
+ + +
+
+
+ )} + + {created && ( +
+

{t('quickSupport.code.heading')}

+ +
+ {created.code} +
+ +
+ + {t('quickSupport.code.shownOnce')} +
+ +

+ {t('quickSupport.code.expires', { time: formatDateTime(created.codeExpiresAt) })} +

+

+ {t('quickSupport.code.instructions')} +

+ +
+ + +
+ +
+

+ {t('quickSupport.status.heading')} +

+

+ {statusLabel(currentStatus)} +

+ +
+ {canConnect && activeSession?.deviceId ? ( + + + + ) : ( + !isTerminal && ( + + {t('quickSupport.connect.waitingForDevice')} + + ) + )} + {!isTerminal && ( + + )} +
+
+
+ )} + +
+
+

{t('quickSupport.list.heading')}

+
+
+ {listError &&

{listError}

} + {!listError && sessions.length === 0 && ( +

+ {t('quickSupport.list.empty')} +

+ )} + {sessions.map((session) => ( +
+
+

+ {session.attributionLabel || t('quickSupport.list.unlabeled')} +

+

+ {t('quickSupport.list.columnCreated')}: {formatDateTime(session.createdAt)} + {session.endedAt + ? ` · ${t('quickSupport.list.columnEnded')}: ${formatTime(session.endedAt)}` + : ''} +

+
+ + {statusLabel(session.status)} + +
+ ))} +
+
+
+ ); +} diff --git a/apps/web/src/components/remote/RemoteAccessPage.tsx b/apps/web/src/components/remote/RemoteAccessPage.tsx index 46a858568..21cc2fe3d 100644 --- a/apps/web/src/components/remote/RemoteAccessPage.tsx +++ b/apps/web/src/components/remote/RemoteAccessPage.tsx @@ -12,7 +12,7 @@ export default function RemoteAccessPage() {

{t('remoteAccessPage.subtitle')}

- ); diff --git a/apps/web/src/lib/__tests__/no-silent-mutations.test.ts b/apps/web/src/lib/__tests__/no-silent-mutations.test.ts index 75730048e..273e6cda2 100644 --- a/apps/web/src/lib/__tests__/no-silent-mutations.test.ts +++ b/apps/web/src/lib/__tests__/no-silent-mutations.test.ts @@ -118,6 +118,10 @@ const TARGET_GLOBS = [ 'src/components/billing/InvoiceActions.tsx', 'src/components/billing/quotes/QuoteHeaderMeta.tsx', 'src/components/billing/quotes/QuoteLineRows.tsx', + // Quick Support: the create/end mutations mint and revoke live remote-access + // codes, so a silent failure would leave a tech reading out a dead code or + // believing a session was torn down when it wasn't. + 'src/components/remote/QuickSupportPage.tsx', ]; const absoluteFiles: string[] = TARGET_GLOBS.map((rel) => resolve(WEB_ROOT, '..', rel)); @@ -309,7 +313,7 @@ describe('migration backlog integrity', () => { // ─── Main guard ───────────────────────────────────────────────────────────── describe('no silent mutations in targeted set', () => { it('finds files to scan', () => { - expect(absoluteFiles.length).toBe(80); + expect(absoluteFiles.length).toBe(81); for (const f of absoluteFiles) { expect(() => statSync(f)).not.toThrow(); } diff --git a/apps/web/src/lib/i18n/translationCoverage.test.ts b/apps/web/src/lib/i18n/translationCoverage.test.ts index 67931f198..b28aae822 100644 --- a/apps/web/src/lib/i18n/translationCoverage.test.ts +++ b/apps/web/src/lib/i18n/translationCoverage.test.ts @@ -40,6 +40,9 @@ const namespaceDuplicateBaselines = { 'peripherals.json': 4, 'policies.json': 357, 'portal.json': 3, + // +1: the input placeholder "XXX-XXX-XXX" is a code-shape mask, not + // wording — it is intentionally identical in every catalog. + 'quick.json': 1, 'remote.json': 12, 'reports.json': 39, 'scripts.json': 55, @@ -74,6 +77,9 @@ const namespaceDuplicateBaselines = { 'peripherals.json': 4, 'policies.json': 241, 'portal.json': 4, + // +1: the input placeholder "XXX-XXX-XXX" is a code-shape mask, not + // wording — it is intentionally identical in every catalog. + 'quick.json': 1, 'remote.json': 12, 'reports.json': 32, 'scripts.json': 57, @@ -111,6 +117,9 @@ const namespaceDuplicateBaselines = { 'peripherals.json': 9, 'policies.json': 204, 'portal.json': 4, + // +1: the input placeholder "XXX-XXX-XXX" is a code-shape mask, not + // wording — it is intentionally identical in every catalog. + 'quick.json': 1, 'remote.json': 18, 'reports.json': 43, 'scripts.json': 60, @@ -144,6 +153,9 @@ const namespaceDuplicateBaselines = { 'peripherals.json': 9, 'policies.json': 204, 'portal.json': 4, + // +1: the input placeholder "XXX-XXX-XXX" is a code-shape mask, not + // wording — it is intentionally identical in every catalog. + 'quick.json': 1, 'remote.json': 17, 'reports.json': 43, 'scripts.json': 60, @@ -179,6 +191,9 @@ const namespaceDuplicateBaselines = { 'peripherals.json': 4, 'policies.json': 205, 'portal.json': 4, + // +1: the input placeholder "XXX-XXX-XXX" is a code-shape mask, not + // wording — it is intentionally identical in every catalog. + 'quick.json': 1, 'remote.json': 14, 'reports.json': 53, 'scripts.json': 53, @@ -207,6 +222,9 @@ const namespaceDuplicateBaselines = { 'peripherals.json': 4, 'policies.json': 363, 'portal.json': 9, + // +1: the input placeholder "XXX-XXX-XXX" is a code-shape mask, not + // wording — it is intentionally identical in every catalog. + 'quick.json': 1, 'remote.json': 14, 'reports.json': 51, 'scripts.json': 57, diff --git a/apps/web/src/lib/routeScope.ts b/apps/web/src/lib/routeScope.ts index ff7344c0b..b8554f267 100644 --- a/apps/web/src/lib/routeScope.ts +++ b/apps/web/src/lib/routeScope.ts @@ -125,6 +125,9 @@ export const ROUTE_SCOPES: Array<{ pattern: RegExp; kind: RouteScopeKind }> = [ { pattern: /^\/admin(\/.*)?$/, kind: 'platform' }, { pattern: /^\/(login|register|register-partner|forgot-password|reset-password|accept-invite|setup|auth|404|500)(\/.*)?$/, kind: 'auth' }, { pattern: /^\/oauth(\/.*)?$/, kind: 'auth' }, + // Public Quick Support landing page. The one-time code in the URL is the only + // credential — an end user reaching it has no Breeze account at all. + { pattern: /^\/quick$/, kind: 'auth' }, ]; function normalize(pathname: string): string { diff --git a/apps/web/src/locales/de-DE/quick.json b/apps/web/src/locales/de-DE/quick.json new file mode 100644 index 000000000..e6c2b7207 --- /dev/null +++ b/apps/web/src/locales/de-DE/quick.json @@ -0,0 +1,47 @@ +{ + "page": { + "title": "Support-Sitzung starten", + "intro": "Jemand, der Ihnen mit diesem Computer hilft, hat Sie gebeten, diese Seite zu öffnen. Es passiert nichts, solange Sie das Programm nicht selbst herunterladen und starten." + }, + "code": { + "heading": "Geben Sie den Code ein, den Sie erhalten haben", + "help": "Die Person, die Ihnen hilft, nennt Ihnen einen Code aus 9 Buchstaben und Zahlen, zum Beispiel KTM-4H7-P2X. Groß- und Kleinschreibung sowie Bindestriche spielen keine Rolle.", + "label": "Support-Code", + "placeholder": "XXX-XXX-XXX", + "submit": "Weiter", + "formatError": "Das ist noch kein vollständiger Code. Ein Code besteht aus 9 Buchstaben und Zahlen, zum Beispiel KTM-4H7-P2X." + }, + "checking": "Code wird geprüft…", + "invalid": { + "title": "Dieser Code ist nicht mehr gültig", + "body": "Ein Support-Code verfällt kurz nach seiner Erstellung und lässt sich nur einmal verwenden. Bitten Sie die Person, die Ihnen hilft, um einen neuen Code, und geben Sie ihn oben ein." + }, + "checkFailed": { + "title": "Der Code konnte nicht geprüft werden", + "body": "Möglicherweise ist die Internetverbindung abgebrochen. Prüfen Sie Ihre Verbindung und versuchen Sie es mit dem Code noch einmal.", + "retry": "Erneut versuchen" + }, + "ready": { + "title": "Ihr Code ist gültig", + "codeIntro": "Ihr Code lautet {{code}}." + }, + "download": { + "windows": "Für Windows herunterladen", + "manualFallback": "Falls beim Herunterladen nach einem Code gefragt wird, geben Sie ein: {{code}}", + "macosLabel": "macOS (Mac-Computer)", + "macosBadge": "Demnächst verfügbar", + "macosBody": "Für Mac-Computer ist das Programm noch nicht verfügbar. Fragen Sie die Person, die Ihnen hilft, wie Sie stattdessen vorgehen." + }, + "windowsPrompt": { + "title": "Windows fragt Sie vor dem Start", + "body": "Wenn Sie die Datei öffnen, zeigt Windows eine Abfrage, ob das Programm ausgeführt werden darf. Die Datei ist digital signiert, deshalb nennt diese Abfrage einen Herausgeber: dort sollte {{publisher}} stehen. Steht dort, dass der Herausgeber unbekannt ist, oder ein Name, den Sie nicht erwartet haben, schließen Sie die Abfrage und rufen Sie die Person an, die Ihnen hilft, statt fortzufahren.", + "publisher": "der Herausgeber von Breeze" + }, + "trust": { + "title": "Was das Programm tut", + "seeScreen": "Während die Sitzung läuft, kann die Person, die Ihnen hilft, Ihren Bildschirm sehen.", + "temporary": "Das Programm läuft einmal und entfernt sich danach selbst. Auf diesem Computer bleibt nichts installiert.", + "stopAnyTime": "Sie können die Freigabe jederzeit beenden, indem Sie das Programmfenster schließen.", + "onlyIfExpected": "Wenn Sie das nicht erwartet haben, starten Sie es nicht. Schließen Sie diese Seite und wenden Sie sich an Ihren gewohnten Support-Kontakt." + } +} diff --git a/apps/web/src/locales/de-DE/remote.json b/apps/web/src/locales/de-DE/remote.json index f80847e35..72a86f081 100644 --- a/apps/web/src/locales/de-DE/remote.json +++ b/apps/web/src/locales/de-DE/remote.json @@ -13,6 +13,10 @@ "sessions": { "title": "Sitzungsverlauf", "description": "Vergangene Sitzungen anzeigen" + }, + "quickSupport": { + "title": "Schnellsupport", + "description": "Einmalcode für spontanen Support" } }, "connectDesktopButton": { @@ -761,5 +765,60 @@ "obtainTicket": "Ticket konnte nicht abgerufen werden" }, "sessionTitle": "Sitzungstitel" + }, + "quickSupport": { + "title": "Schnellsupport", + "subtitle": "Erzeugen Sie einen Einmalcode für spontanen Support auf einem Gerät, das nicht registriert ist.", + "newSession": "Neue Supportsitzung", + "form": { + "title": "Supportsitzung starten", + "attributionOrg": "Kunden zuordnen (optional)", + "attributionOrgHint": "Nur für Auswertungen — der Zugriff auf die Daten dieses Kunden ändert sich dadurch nicht.", + "attributionOrgNone": "Nicht zugeordnet", + "attributionLabel": "Bezeichnung (optional)", + "attributionLabelPlaceholder": "Für wen oder wofür ist diese Sitzung?", + "submit": "Code erzeugen", + "submitting": "Code wird erzeugt…" + }, + "code": { + "heading": "Lesen Sie diesen Code der Person vor, der Sie helfen", + "shownOnce": "Dieser Code erscheint nur einmal. Kopieren Sie ihn, bevor Sie diese Seite verlassen — er kann nie wieder angezeigt werden.", + "expires": "Läuft ab um {{time}}", + "instructions": "Senden Sie den Link, lassen Sie den heruntergeladenen Client ausführen und den Code eingeben.", + "copyCode": "Code kopieren", + "copyLink": "Download-Link kopieren", + "copiedCode": "Code in die Zwischenablage kopiert", + "copiedLink": "Download-Link in die Zwischenablage kopiert", + "copyFailed": "Kopieren in die Zwischenablage nicht möglich" + }, + "status": { + "heading": "Sitzungsfortschritt", + "pending": "Warten darauf, dass der Benutzer den Client startet…", + "claimed": "Client verbindet sich…", + "ready": "Bereit zum Verbinden", + "active": "Sitzung läuft", + "ended": "Sitzung beendet", + "expired": "Sitzung abgelaufen" + }, + "connect": { + "waitingForDevice": "Das Gerät wird registriert. Die Schaltfläche zum Verbinden erscheint, sobald es online ist." + }, + "end": { + "button": "Sitzung beenden", + "success": "Supportsitzung beendet", + "error": "Supportsitzung konnte nicht beendet werden", + "alreadyEnded": "Diese Supportsitzung war bereits beendet" + }, + "errors": { + "create": "Supportsitzung konnte nicht erstellt werden", + "list": "Aktuelle Supportsitzungen konnten nicht geladen werden" + }, + "list": { + "heading": "Letzte Supportsitzungen", + "empty": "Noch keine Supportsitzungen.", + "columnCreated": "Gestartet", + "columnEnded": "Beendet", + "unlabeled": "Sitzung ohne Bezeichnung" + } } } diff --git a/apps/web/src/locales/en/quick.json b/apps/web/src/locales/en/quick.json new file mode 100644 index 000000000..532da9c90 --- /dev/null +++ b/apps/web/src/locales/en/quick.json @@ -0,0 +1,47 @@ +{ + "page": { + "title": "Start a support session", + "intro": "Someone helping you with this computer asked you to open this page. Nothing happens until you download and run the program yourself." + }, + "code": { + "heading": "Enter the code you were given", + "help": "The person helping you will read out a code of 9 letters and numbers, like KTM-4H7-P2X. Capitals and dashes do not matter.", + "label": "Support code", + "placeholder": "XXX-XXX-XXX", + "submit": "Continue", + "formatError": "That is not a complete code yet. A code has 9 letters and numbers, for example KTM-4H7-P2X." + }, + "checking": "Checking that code…", + "invalid": { + "title": "That code no longer works", + "body": "A support code stops working a short time after it is created, and it can only be used once. Ask the person helping you to read out a new code, then type it in above." + }, + "checkFailed": { + "title": "The code could not be checked", + "body": "The connection to the internet may have dropped. Check your connection and try the code again.", + "retry": "Try again" + }, + "ready": { + "title": "Your code works", + "codeIntro": "Your code is {{code}}." + }, + "download": { + "windows": "Download for Windows", + "manualFallback": "If the download prompts for a code, enter: {{code}}", + "macosLabel": "macOS (Mac computers)", + "macosBadge": "Coming soon", + "macosBody": "The program is not ready for Mac computers yet. Ask the person helping you what to do instead." + }, + "windowsPrompt": { + "title": "Windows will ask you before it runs", + "body": "When you open the file, Windows shows a prompt asking whether you want to allow it to run. The file is digitally signed, so that prompt names a publisher: it should read {{publisher}}. If it says the publisher is unknown, or names anyone you were not expecting, close the prompt and call the person helping you instead of continuing.", + "publisher": "the publisher of Breeze" + }, + "trust": { + "title": "What the program does", + "seeScreen": "While the session is running, the person helping you can see your screen.", + "temporary": "The program runs once and then removes itself. Nothing stays installed on this computer.", + "stopAnyTime": "You can stop sharing whenever you want by closing the program window.", + "onlyIfExpected": "If you were not expecting this, do not run it. Close this page and contact the support people you normally deal with." + } +} diff --git a/apps/web/src/locales/en/remote.json b/apps/web/src/locales/en/remote.json index ed72f42f5..2fc4aebdb 100644 --- a/apps/web/src/locales/en/remote.json +++ b/apps/web/src/locales/en/remote.json @@ -13,6 +13,10 @@ "sessions": { "title": "Session History", "description": "View past sessions" + }, + "quickSupport": { + "title": "Quick Support", + "description": "One-time code for ad-hoc support" } }, "connectDesktopButton": { @@ -761,5 +765,60 @@ "obtainTicket": "Failed to obtain ticket" }, "sessionTitle": "Session Title" + }, + "quickSupport": { + "title": "Quick Support", + "subtitle": "Generate a one-time code for ad-hoc support on a device that is not enrolled.", + "newSession": "New support session", + "form": { + "title": "Start a support session", + "attributionOrg": "Attribute to customer (optional)", + "attributionOrgHint": "Reporting only — this does not grant or change access to that customer's data.", + "attributionOrgNone": "Not attributed", + "attributionLabel": "Reference label (optional)", + "attributionLabelPlaceholder": "Who or what is this session for?", + "submit": "Generate code", + "submitting": "Generating code…" + }, + "code": { + "heading": "Read this code to the person you are helping", + "shownOnce": "This code appears only once. Copy it before you leave this page — it can never be shown again.", + "expires": "Expires at {{time}}", + "instructions": "Send them the link, then have them run the downloaded client and type the code.", + "copyCode": "Copy code", + "copyLink": "Copy download link", + "copiedCode": "Code copied to the clipboard", + "copiedLink": "Download link copied to the clipboard", + "copyFailed": "Could not copy to the clipboard" + }, + "status": { + "heading": "Session progress", + "pending": "Waiting for the user to run the client…", + "claimed": "Client connecting…", + "ready": "Ready to connect", + "active": "Session in progress", + "ended": "Session ended", + "expired": "Session expired" + }, + "connect": { + "waitingForDevice": "The device is enrolling. The connect button appears as soon as it comes online." + }, + "end": { + "button": "End session", + "success": "Support session ended", + "error": "Could not end the support session", + "alreadyEnded": "That support session had already ended" + }, + "errors": { + "create": "Could not create the support session", + "list": "Could not load recent support sessions" + }, + "list": { + "heading": "Recent support sessions", + "empty": "No support sessions yet.", + "columnCreated": "Started", + "columnEnded": "Finished", + "unlabeled": "Unlabelled session" + } } } diff --git a/apps/web/src/locales/es-419/quick.json b/apps/web/src/locales/es-419/quick.json new file mode 100644 index 000000000..63bd72399 --- /dev/null +++ b/apps/web/src/locales/es-419/quick.json @@ -0,0 +1,47 @@ +{ + "page": { + "title": "Iniciar una sesión de soporte", + "intro": "Alguien que le está ayudando con esta computadora le pidió que abriera esta página. No ocurre nada hasta que usted mismo descargue y ejecute el programa." + }, + "code": { + "heading": "Escriba el código que le dieron", + "help": "La persona que le está ayudando le dictará un código de 9 letras y números, por ejemplo KTM-4H7-P2X. Las mayúsculas y los guiones no importan.", + "label": "Código de soporte", + "placeholder": "XXX-XXX-XXX", + "submit": "Continuar", + "formatError": "Ese código todavía no está completo. Un código tiene 9 letras y números, por ejemplo KTM-4H7-P2X." + }, + "checking": "Verificando el código…", + "invalid": { + "title": "Este código ya no funciona", + "body": "Un código de soporte vence poco después de crearse y solo se puede usar una vez. Pídale un código nuevo a la persona que le está ayudando y escríbalo arriba." + }, + "checkFailed": { + "title": "No se pudo verificar el código", + "body": "Es posible que se haya cortado la conexión a internet. Revise su conexión e intente el código de nuevo.", + "retry": "Intentar de nuevo" + }, + "ready": { + "title": "Su código es válido", + "codeIntro": "Su código es {{code}}." + }, + "download": { + "windows": "Descargar para Windows", + "manualFallback": "Si la descarga pide un código, escriba: {{code}}", + "macosLabel": "macOS (computadoras Mac)", + "macosBadge": "Muy pronto", + "macosBody": "El programa todavía no está listo para computadoras Mac. Pregúntele a la persona que le está ayudando qué hacer en ese caso." + }, + "windowsPrompt": { + "title": "Windows le preguntará antes de ejecutarlo", + "body": "Al abrir el archivo, Windows muestra un aviso que pregunta si permite ejecutarlo. El archivo está firmado digitalmente, así que ese aviso indica un editor: debería decir {{publisher}}. Si dice que el editor es desconocido, o muestra un nombre que usted no esperaba, cierre el aviso y llame a la persona que le está ayudando en lugar de continuar.", + "publisher": "el editor de Breeze" + }, + "trust": { + "title": "Qué hace el programa", + "seeScreen": "Mientras la sesión está activa, la persona que le está ayudando puede ver su pantalla.", + "temporary": "El programa se ejecuta una vez y luego se elimina solo. No queda nada instalado en esta computadora.", + "stopAnyTime": "Puede dejar de compartir cuando quiera cerrando la ventana del programa.", + "onlyIfExpected": "Si no esperaba esto, no ejecute el programa. Cierre esta página y comuníquese con el soporte con el que trata habitualmente." + } +} diff --git a/apps/web/src/locales/es-419/remote.json b/apps/web/src/locales/es-419/remote.json index 629a94402..87b79f846 100644 --- a/apps/web/src/locales/es-419/remote.json +++ b/apps/web/src/locales/es-419/remote.json @@ -13,6 +13,10 @@ "sessions": { "title": "Historial de sesiones", "description": "Ver sesiones pasadas" + }, + "quickSupport": { + "title": "Soporte rápido", + "description": "Código de un solo uso para soporte puntual" } }, "connectDesktopButton": { @@ -761,5 +765,60 @@ "obtainTicket": "No se pudo obtener el ticket" }, "sessionTitle": "Título de la sesión" + }, + "quickSupport": { + "title": "Soporte rápido", + "subtitle": "Genere un código de un solo uso para dar soporte puntual a un equipo que no está inscrito.", + "newSession": "Nueva sesión de soporte", + "form": { + "title": "Iniciar una sesión de soporte", + "attributionOrg": "Atribuir a un cliente (opcional)", + "attributionOrgHint": "Solo para informes: esto no otorga ni cambia el acceso a los datos de ese cliente.", + "attributionOrgNone": "Sin atribuir", + "attributionLabel": "Etiqueta de referencia (opcional)", + "attributionLabelPlaceholder": "¿Para quién o para qué es esta sesión?", + "submit": "Generar código", + "submitting": "Generando el código…" + }, + "code": { + "heading": "Léale este código a la persona que está ayudando", + "shownOnce": "Este código aparece una sola vez. Cópielo antes de salir de esta página: no se podrá volver a mostrar.", + "expires": "Vence a las {{time}}", + "instructions": "Envíele el enlace, pídale que ejecute el cliente descargado y que escriba el código.", + "copyCode": "Copiar código", + "copyLink": "Copiar enlace de descarga", + "copiedCode": "Código copiado al portapapeles", + "copiedLink": "Enlace de descarga copiado al portapapeles", + "copyFailed": "No se pudo copiar al portapapeles" + }, + "status": { + "heading": "Avance de la sesión", + "pending": "Esperando a que el usuario ejecute el cliente…", + "claimed": "El cliente se está conectando…", + "ready": "Listo para conectar", + "active": "Sesión en curso", + "ended": "Sesión finalizada", + "expired": "Sesión vencida" + }, + "connect": { + "waitingForDevice": "El equipo se está inscribiendo. El botón para conectar aparecerá en cuanto esté en línea." + }, + "end": { + "button": "Finalizar sesión", + "success": "Sesión de soporte finalizada", + "error": "No se pudo finalizar la sesión de soporte", + "alreadyEnded": "Esa sesión de soporte ya había finalizado" + }, + "errors": { + "create": "No se pudo crear la sesión de soporte", + "list": "No se pudieron cargar las sesiones de soporte recientes" + }, + "list": { + "heading": "Sesiones de soporte recientes", + "empty": "Todavía no hay sesiones de soporte.", + "columnCreated": "Inicio", + "columnEnded": "Fin", + "unlabeled": "Sesión sin etiqueta" + } } } diff --git a/apps/web/src/locales/fr-CA/quick.json b/apps/web/src/locales/fr-CA/quick.json new file mode 100644 index 000000000..768aba93b --- /dev/null +++ b/apps/web/src/locales/fr-CA/quick.json @@ -0,0 +1,47 @@ +{ + "page": { + "title": "Démarrer une session de soutien technique", + "intro": "Une personne qui vous aide avec cet ordinateur vous a demandé d’ouvrir cette page. Rien ne démarre tant que vous n’avez pas téléchargé et lancé le programme vous-même." + }, + "code": { + "heading": "Entrez le code qu’on vous a donné", + "help": "La personne qui vous aide va vous dicter un code de 9 lettres et chiffres, par exemple KTM-4H7-P2X. Les majuscules et les traits d’union n’ont aucune importance.", + "label": "Code de soutien", + "placeholder": "XXX-XXX-XXX", + "submit": "Poursuivre", + "formatError": "Ce code n’est pas encore complet. Un code compte 9 lettres et chiffres, par exemple KTM-4H7-P2X." + }, + "checking": "Vérification du code en cours…", + "invalid": { + "title": "Ce code ne fonctionne plus", + "body": "Un code de soutien cesse de fonctionner peu de temps après sa création et ne peut servir qu’une seule fois. Demandez un nouveau code à la personne qui vous aide, puis entrez-le ci-dessus." + }, + "checkFailed": { + "title": "Impossible de vérifier ce code", + "body": "Votre connexion à Internet a peut-être été coupée. Vérifiez la connexion, puis essayez le code de nouveau.", + "retry": "Essayer de nouveau" + }, + "ready": { + "title": "Votre code est valide", + "codeIntro": "Votre code est {{code}}." + }, + "download": { + "windows": "Télécharger pour Windows", + "manualFallback": "Si le téléchargement demande un code, entrez : {{code}}", + "macosLabel": "macOS (ordinateurs Mac)", + "macosBadge": "Bientôt offert", + "macosBody": "Le programme n’est pas encore offert pour les ordinateurs Mac. Demandez à la personne qui vous aide comment procéder autrement." + }, + "windowsPrompt": { + "title": "Windows vous demandera votre accord", + "body": "Au moment où vous ouvrez le fichier, Windows affiche une demande d’autorisation avant de l’exécuter. Le fichier porte une signature numérique : la demande nomme donc un éditeur, et ce nom doit être {{publisher}}. Si l’on y lit que l’éditeur est inconnu, ou un nom auquel vous ne vous attendiez pas, fermez la fenêtre et téléphonez à la personne qui vous aide plutôt que de poursuivre.", + "publisher": "l’éditeur de Breeze" + }, + "trust": { + "title": "Ce que fait le programme", + "seeScreen": "Pendant la session, la personne qui vous aide peut voir votre écran.", + "temporary": "Le programme s’exécute une seule fois, puis il s’efface. Rien ne demeure installé sur cet ordinateur.", + "stopAnyTime": "Vous pouvez cesser le partage quand vous le voulez en fermant la fenêtre du programme.", + "onlyIfExpected": "Si vous ne vous attendiez pas à cette demande, ne lancez pas le programme. Fermez cette page et communiquez avec votre personne-ressource habituelle." + } +} diff --git a/apps/web/src/locales/fr-CA/remote.json b/apps/web/src/locales/fr-CA/remote.json index 5d4019cf6..1d2eef7f7 100644 --- a/apps/web/src/locales/fr-CA/remote.json +++ b/apps/web/src/locales/fr-CA/remote.json @@ -4,7 +4,7 @@ "subtitle": "Lancez des outils à distance pour les appareils en ligne.", "terminal": { "title": "Démarrer le terminal", - "description": "Sélectionnez un appareil auquel vous connecter" + "description": "Sélectionnez un appareil auquel vous connecter" }, "files": { "title": "Transfert de fichiers", @@ -13,6 +13,10 @@ "sessions": { "title": "Historique des sessions", "description": "Voir les sessions précédentes" + }, + "quickSupport": { + "title": "Soutien rapide", + "description": "Code à usage unique pour un dépannage sur-le-champ" } }, "connectDesktopButton": { @@ -209,7 +213,7 @@ "browser_cache": "Cache navigateur", "package_cache": "Cache de paquets", "temp_files": "Fichiers temporaires", - "trash": "Corbeille" + "trash": "Corbeille" }, "cleanupPreview": "Aperçu du nettoyage", "cleanupResult": "Nettoyage {{status}} : récupéré {{size}} de {{count}} cible(s), {{failed}} échoué.", @@ -761,5 +765,60 @@ "obtainTicket": "Impossible de récupérer le ticket" }, "sessionTitle": "Titre de la session" + }, + "quickSupport": { + "title": "Soutien rapide", + "subtitle": "Créez un code à usage unique pour dépanner sur-le-champ un appareil qui n'est pas inscrit.", + "newSession": "Nouvelle séance de soutien", + "form": { + "title": "Démarrer une séance de soutien", + "attributionOrg": "Attribuer à un client (facultatif)", + "attributionOrgHint": "Uniquement pour les rapports : l'accès aux données de ce client demeure inchangé.", + "attributionOrgNone": "Non attribuée", + "attributionLabel": "Étiquette de référence (facultatif)", + "attributionLabelPlaceholder": "À qui ou à quoi sert cette séance?", + "submit": "Générer le code", + "submitting": "Génération du code en cours…" + }, + "code": { + "heading": "Transmettez ce code à la personne que vous dépannez", + "shownOnce": "Ce code ne s'affiche qu'une seule fois. Copiez-le avant de quitter cette page, car il ne pourra plus jamais être affiché.", + "expires": "Expire à {{time}}", + "instructions": "Envoyez-lui le lien, puis demandez-lui de lancer le client téléchargé et d'entrer le code.", + "copyCode": "Copier le code", + "copyLink": "Copier le lien de téléchargement", + "copiedCode": "Code copié dans le presse-papiers", + "copiedLink": "Lien de téléchargement copié dans le presse-papiers", + "copyFailed": "Impossible de copier dans le presse-papiers" + }, + "status": { + "heading": "Progression de la séance", + "pending": "En attente que l'utilisateur lance le client…", + "claimed": "Connexion du client en cours…", + "ready": "Prêt à se connecter", + "active": "Séance en cours", + "ended": "Séance terminée", + "expired": "Séance expirée" + }, + "connect": { + "waitingForDevice": "L'appareil s'inscrit. Le bouton de connexion apparaîtra dès qu'il sera en ligne." + }, + "end": { + "button": "Mettre fin à la séance", + "success": "Séance de soutien terminée", + "error": "Impossible de mettre fin à la séance de soutien", + "alreadyEnded": "Cette séance de soutien était déjà terminée" + }, + "errors": { + "create": "Impossible de créer la séance de soutien", + "list": "Impossible de charger les séances de soutien récentes" + }, + "list": { + "heading": "Séances de soutien récentes", + "empty": "Aucune séance de soutien pour le moment.", + "columnCreated": "Début", + "columnEnded": "Fin", + "unlabeled": "Séance sans étiquette" + } } } diff --git a/apps/web/src/locales/fr-FR/quick.json b/apps/web/src/locales/fr-FR/quick.json new file mode 100644 index 000000000..c194f5ca5 --- /dev/null +++ b/apps/web/src/locales/fr-FR/quick.json @@ -0,0 +1,47 @@ +{ + "page": { + "title": "Démarrer une session d’assistance", + "intro": "Une personne qui vous aide sur cet ordinateur vous a demandé d’ouvrir cette page. Rien ne se passe tant que vous n’avez pas téléchargé et lancé le programme vous-même." + }, + "code": { + "heading": "Saisissez le code qui vous a été communiqué", + "help": "La personne qui vous aide va vous dicter un code de 9 lettres et chiffres, par exemple KTM-4H7-P2X. Les majuscules et les tirets n’ont pas d’importance.", + "label": "Code d’assistance", + "placeholder": "XXX-XXX-XXX", + "submit": "Continuer", + "formatError": "Ce code n’est pas encore complet. Un code comporte 9 lettres et chiffres, par exemple KTM-4H7-P2X." + }, + "checking": "Vérification du code…", + "invalid": { + "title": "Ce code ne fonctionne plus", + "body": "Un code d’assistance expire peu de temps après sa création et ne peut servir qu’une seule fois. Demandez un nouveau code à la personne qui vous aide, puis saisissez-le ci-dessus." + }, + "checkFailed": { + "title": "Impossible de vérifier ce code", + "body": "La connexion à Internet a peut-être été interrompue. Vérifiez votre connexion, puis réessayez avec le code.", + "retry": "Réessayer" + }, + "ready": { + "title": "Votre code est valide", + "codeIntro": "Votre code est {{code}}." + }, + "download": { + "windows": "Télécharger pour Windows", + "manualFallback": "Si le téléchargement demande un code, saisissez : {{code}}", + "macosLabel": "macOS (ordinateurs Mac)", + "macosBadge": "Bientôt disponible", + "macosBody": "Le programme n’est pas encore prêt pour les ordinateurs Mac. Demandez à la personne qui vous aide comment procéder à la place." + }, + "windowsPrompt": { + "title": "Windows vous demandera confirmation", + "body": "À l’ouverture du fichier, Windows affiche une demande de confirmation avant de l’exécuter. Le fichier est signé numériquement : cette demande indique donc un éditeur, et il doit s’agir de {{publisher}}. S’il est indiqué que l’éditeur est inconnu, ou s’il s’agit d’un nom auquel vous ne vous attendiez pas, fermez la fenêtre et appelez la personne qui vous aide au lieu de poursuivre.", + "publisher": "l’éditeur de Breeze" + }, + "trust": { + "title": "Ce que fait le programme", + "seeScreen": "Pendant la session, la personne qui vous aide peut voir votre écran.", + "temporary": "Le programme s’exécute une seule fois, puis se supprime. Rien ne reste installé sur cet ordinateur.", + "stopAnyTime": "Vous pouvez arrêter le partage à tout moment en fermant la fenêtre du programme.", + "onlyIfExpected": "Si vous ne vous attendiez pas à cette demande, ne lancez pas le programme. Fermez cette page et contactez votre interlocuteur habituel." + } +} diff --git a/apps/web/src/locales/fr-FR/remote.json b/apps/web/src/locales/fr-FR/remote.json index 25273ddbe..e9dcabe18 100644 --- a/apps/web/src/locales/fr-FR/remote.json +++ b/apps/web/src/locales/fr-FR/remote.json @@ -13,6 +13,10 @@ "sessions": { "title": "Historique des sessions", "description": "Voir les sessions précédentes" + }, + "quickSupport": { + "title": "Assistance rapide", + "description": "Code à usage unique pour un dépannage ponctuel" } }, "connectDesktopButton": { @@ -761,5 +765,60 @@ "obtainTicket": "Impossible de récupérer le ticket" }, "sessionTitle": "Titre de la session" + }, + "quickSupport": { + "title": "Assistance rapide", + "subtitle": "Générez un code à usage unique pour dépanner ponctuellement un appareil qui n'est pas inscrit.", + "newSession": "Nouvelle session d'assistance", + "form": { + "title": "Démarrer une session d'assistance", + "attributionOrg": "Attribuer à un client (facultatif)", + "attributionOrgHint": "À des fins de reporting uniquement : cela ne modifie pas l'accès aux données de ce client.", + "attributionOrgNone": "Non attribuée", + "attributionLabel": "Libellé de référence (facultatif)", + "attributionLabelPlaceholder": "À qui ou à quoi cette session est-elle destinée ?", + "submit": "Générer le code", + "submitting": "Génération du code…" + }, + "code": { + "heading": "Communiquez ce code à la personne que vous dépannez", + "shownOnce": "Ce code n'apparaît qu'une seule fois. Copiez-le avant de quitter cette page : il ne pourra plus jamais être affiché.", + "expires": "Expire à {{time}}", + "instructions": "Envoyez-lui le lien, puis demandez-lui d'exécuter le client téléchargé et de saisir le code.", + "copyCode": "Copier le code", + "copyLink": "Copier le lien de téléchargement", + "copiedCode": "Code copié dans le presse-papiers", + "copiedLink": "Lien de téléchargement copié dans le presse-papiers", + "copyFailed": "Impossible de copier dans le presse-papiers" + }, + "status": { + "heading": "Avancement de la session", + "pending": "En attente du lancement du client par l'utilisateur…", + "claimed": "Connexion du client en cours…", + "ready": "Prêt à se connecter", + "active": "Session en cours", + "ended": "Session terminée", + "expired": "Session expirée" + }, + "connect": { + "waitingForDevice": "L'appareil s'inscrit. Le bouton de connexion apparaîtra dès qu'il sera en ligne." + }, + "end": { + "button": "Terminer la session", + "success": "Session d'assistance terminée", + "error": "Impossible de terminer la session d'assistance", + "alreadyEnded": "Cette session d'assistance était déjà terminée" + }, + "errors": { + "create": "Impossible de créer la session d'assistance", + "list": "Impossible de charger les sessions d'assistance récentes" + }, + "list": { + "heading": "Sessions d'assistance récentes", + "empty": "Aucune session d'assistance pour l'instant.", + "columnCreated": "Début", + "columnEnded": "Fin", + "unlabeled": "Session sans libellé" + } } } diff --git a/apps/web/src/locales/it-IT/quick.json b/apps/web/src/locales/it-IT/quick.json new file mode 100644 index 000000000..7d277ad28 --- /dev/null +++ b/apps/web/src/locales/it-IT/quick.json @@ -0,0 +1,47 @@ +{ + "page": { + "title": "Avvia una sessione di assistenza", + "intro": "Chi ti sta aiutando con questo computer ti ha chiesto di aprire questa pagina. Non succede nulla finché non scarichi e avvii tu stesso il programma." + }, + "code": { + "heading": "Inserisci il codice che ti è stato dato", + "help": "La persona che ti sta aiutando ti detterà un codice di 9 lettere e numeri, ad esempio KTM-4H7-P2X. Maiuscole e trattini non fanno differenza.", + "label": "Codice di assistenza", + "placeholder": "XXX-XXX-XXX", + "submit": "Continua", + "formatError": "Questo codice non è ancora completo. Un codice è formato da 9 lettere e numeri, ad esempio KTM-4H7-P2X." + }, + "checking": "Verifica del codice in corso…", + "invalid": { + "title": "Questo codice non è più valido", + "body": "Un codice di assistenza scade poco dopo essere stato creato e può essere usato una sola volta. Chiedi un nuovo codice alla persona che ti sta aiutando e digitalo qui sopra." + }, + "checkFailed": { + "title": "Non è stato possibile verificare il codice", + "body": "La connessione a Internet potrebbe essersi interrotta. Controlla la connessione e riprova con il codice.", + "retry": "Riprova" + }, + "ready": { + "title": "Il tuo codice è valido", + "codeIntro": "Il tuo codice è {{code}}." + }, + "download": { + "windows": "Scarica per Windows", + "manualFallback": "Se durante lo scaricamento ti viene chiesto un codice, inserisci: {{code}}", + "macosLabel": "macOS (computer Mac)", + "macosBadge": "Presto disponibile", + "macosBody": "Il programma non è ancora pronto per i computer Mac. Chiedi alla persona che ti sta aiutando come procedere." + }, + "windowsPrompt": { + "title": "Windows ti chiederà conferma", + "body": "Quando apri il file, Windows mostra una richiesta di conferma prima di eseguirlo. Il file è firmato digitalmente, quindi quella richiesta indica un autore: dovrebbe riportare {{publisher}}. Se indica che l’autore è sconosciuto, o riporta un nome che non ti aspetti, chiudi la finestra e chiama la persona che ti sta aiutando invece di proseguire.", + "publisher": "l’autore di Breeze" + }, + "trust": { + "title": "Che cosa fa il programma", + "seeScreen": "Mentre la sessione è in corso, la persona che ti sta aiutando può vedere il tuo schermo.", + "temporary": "Il programma viene eseguito una sola volta e poi si rimuove da solo. Su questo computer non resta installato nulla.", + "stopAnyTime": "Puoi interrompere la condivisione quando vuoi chiudendo la finestra del programma.", + "onlyIfExpected": "Se non te lo aspettavi, non avviarlo. Chiudi questa pagina e contatta le persone dell’assistenza a cui ti rivolgi di solito." + } +} diff --git a/apps/web/src/locales/it-IT/remote.json b/apps/web/src/locales/it-IT/remote.json index bd2e079db..514e470a2 100644 --- a/apps/web/src/locales/it-IT/remote.json +++ b/apps/web/src/locales/it-IT/remote.json @@ -13,6 +13,10 @@ "sessions": { "title": "Cronologia sessioni", "description": "Visualizza le sessioni precedenti" + }, + "quickSupport": { + "title": "Supporto rapido", + "description": "Codice monouso per assistenza al volo" } }, "connectDesktopButton": { @@ -761,5 +765,60 @@ "obtainTicket": "Impossibile ottenere il ticket" }, "sessionTitle": "Titolo sessione" + }, + "quickSupport": { + "title": "Supporto rapido", + "subtitle": "Genera un codice monouso per assistere al volo un dispositivo non registrato.", + "newSession": "Nuova sessione di supporto", + "form": { + "title": "Avvia una sessione di supporto", + "attributionOrg": "Attribuisci a un cliente (facoltativo)", + "attributionOrgHint": "Solo a fini di rendicontazione: non concede né modifica l'accesso ai dati di quel cliente.", + "attributionOrgNone": "Non attribuita", + "attributionLabel": "Etichetta di riferimento (facoltativa)", + "attributionLabelPlaceholder": "A chi o a cosa serve questa sessione?", + "submit": "Genera codice", + "submitting": "Generazione del codice…" + }, + "code": { + "heading": "Comunica questo codice alla persona che stai assistendo", + "shownOnce": "Questo codice compare una sola volta. Copialo prima di lasciare questa pagina: non potrà più essere mostrato.", + "expires": "Scade alle {{time}}", + "instructions": "Invia il link, poi chiedi di eseguire il client scaricato e di digitare il codice.", + "copyCode": "Copia il codice", + "copyLink": "Copia il link di download", + "copiedCode": "Codice copiato negli appunti", + "copiedLink": "Link di download copiato negli appunti", + "copyFailed": "Impossibile copiare negli appunti" + }, + "status": { + "heading": "Avanzamento della sessione", + "pending": "In attesa che l'utente avvii il client…", + "claimed": "Connessione del client in corso…", + "ready": "Pronto per la connessione", + "active": "Sessione in corso", + "ended": "Sessione terminata", + "expired": "Sessione scaduta" + }, + "connect": { + "waitingForDevice": "Il dispositivo si sta registrando. Il pulsante di connessione comparirà appena sarà online." + }, + "end": { + "button": "Termina sessione", + "success": "Sessione di supporto terminata", + "error": "Impossibile terminare la sessione di supporto", + "alreadyEnded": "Questa sessione di supporto era già terminata" + }, + "errors": { + "create": "Impossibile creare la sessione di supporto", + "list": "Impossibile caricare le sessioni di supporto recenti" + }, + "list": { + "heading": "Sessioni di supporto recenti", + "empty": "Nessuna sessione di supporto finora.", + "columnCreated": "Inizio", + "columnEnded": "Fine", + "unlabeled": "Sessione senza etichetta" + } } } diff --git a/apps/web/src/locales/pt-BR/quick.json b/apps/web/src/locales/pt-BR/quick.json new file mode 100644 index 000000000..30969a58c --- /dev/null +++ b/apps/web/src/locales/pt-BR/quick.json @@ -0,0 +1,47 @@ +{ + "page": { + "title": "Iniciar uma sessão de suporte", + "intro": "Alguém que está ajudando você com este computador pediu para abrir esta página. Nada acontece até que você mesmo baixe e execute o programa." + }, + "code": { + "heading": "Digite o código que você recebeu", + "help": "A pessoa que está ajudando você vai ditar um código de 9 letras e números, por exemplo KTM-4H7-P2X. Maiúsculas e hifens não fazem diferença.", + "label": "Código de suporte", + "placeholder": "XXX-XXX-XXX", + "submit": "Continuar", + "formatError": "Esse código ainda não está completo. Um código tem 9 letras e números, por exemplo KTM-4H7-P2X." + }, + "checking": "Verificando o código…", + "invalid": { + "title": "Este código não funciona mais", + "body": "Um código de suporte expira pouco depois de ser criado e só pode ser usado uma vez. Peça um código novo a quem está ajudando você e digite-o acima." + }, + "checkFailed": { + "title": "Não foi possível verificar o código", + "body": "A conexão com a internet pode ter caído. Verifique sua conexão e tente o código novamente.", + "retry": "Tentar novamente" + }, + "ready": { + "title": "Seu código é válido", + "codeIntro": "Seu código é {{code}}." + }, + "download": { + "windows": "Baixar para Windows", + "manualFallback": "Se o download pedir um código, digite: {{code}}", + "macosLabel": "macOS (computadores Mac)", + "macosBadge": "Em breve", + "macosBody": "O programa ainda não está pronto para computadores Mac. Pergunte a quem está ajudando você o que fazer nesse caso." + }, + "windowsPrompt": { + "title": "O Windows vai perguntar antes de executar", + "body": "Ao abrir o arquivo, o Windows mostra um aviso perguntando se você permite a execução. O arquivo é assinado digitalmente, então esse aviso mostra um fornecedor: deve aparecer {{publisher}}. Se disser que o fornecedor é desconhecido, ou mostrar um nome que você não esperava, feche o aviso e ligue para quem está ajudando você em vez de continuar.", + "publisher": "o fornecedor do Breeze" + }, + "trust": { + "title": "O que o programa faz", + "seeScreen": "Enquanto a sessão estiver ativa, quem está ajudando você pode ver a sua tela.", + "temporary": "O programa é executado uma vez e depois se remove sozinho. Nada fica instalado neste computador.", + "stopAnyTime": "Você pode parar o compartilhamento quando quiser, fechando a janela do programa.", + "onlyIfExpected": "Se você não estava esperando por isso, não execute o programa. Feche esta página e fale com o suporte com quem você costuma falar." + } +} diff --git a/apps/web/src/locales/pt-BR/remote.json b/apps/web/src/locales/pt-BR/remote.json index 23b4bd1bb..ad4221678 100644 --- a/apps/web/src/locales/pt-BR/remote.json +++ b/apps/web/src/locales/pt-BR/remote.json @@ -13,6 +13,10 @@ "sessions": { "title": "Histórico de sessões", "description": "Veja as sessões anteriores" + }, + "quickSupport": { + "title": "Suporte rápido", + "description": "Código de uso único para atendimento pontual" } }, "connectDesktopButton": { @@ -761,5 +765,60 @@ "obtainTicket": "Falha ao obter o ticket de conexão" }, "sessionTitle": "Sessão VNC" + }, + "quickSupport": { + "title": "Suporte rápido", + "subtitle": "Gere um código de uso único para atender pontualmente um dispositivo que não está inscrito.", + "newSession": "Nova sessão de suporte", + "form": { + "title": "Iniciar uma sessão de suporte", + "attributionOrg": "Atribuir a um cliente (opcional)", + "attributionOrgHint": "Somente para relatórios: isso não concede nem altera o acesso aos dados desse cliente.", + "attributionOrgNone": "Sem atribuição", + "attributionLabel": "Rótulo de referência (opcional)", + "attributionLabelPlaceholder": "Para quem ou para que serve esta sessão?", + "submit": "Gerar código", + "submitting": "Gerando o código…" + }, + "code": { + "heading": "Passe este código para a pessoa que você está atendendo", + "shownOnce": "Este código aparece uma única vez. Copie-o antes de sair desta página: ele não poderá ser exibido de novo.", + "expires": "Expira às {{time}}", + "instructions": "Envie o link, peça para executar o cliente baixado e digitar o código.", + "copyCode": "Copiar código", + "copyLink": "Copiar link de download", + "copiedCode": "Código copiado para a área de transferência", + "copiedLink": "Link de download copiado para a área de transferência", + "copyFailed": "Não foi possível copiar para a área de transferência" + }, + "status": { + "heading": "Andamento da sessão", + "pending": "Aguardando o usuário executar o cliente…", + "claimed": "Conectando o cliente…", + "ready": "Pronto para conectar", + "active": "Sessão em andamento", + "ended": "Sessão encerrada", + "expired": "Sessão expirada" + }, + "connect": { + "waitingForDevice": "O dispositivo está se inscrevendo. O botão de conexão aparece assim que ele ficar on-line." + }, + "end": { + "button": "Encerrar sessão", + "success": "Sessão de suporte encerrada", + "error": "Não foi possível encerrar a sessão de suporte", + "alreadyEnded": "Essa sessão de suporte já havia sido encerrada" + }, + "errors": { + "create": "Não foi possível criar a sessão de suporte", + "list": "Não foi possível carregar as sessões de suporte recentes" + }, + "list": { + "heading": "Sessões de suporte recentes", + "empty": "Ainda não há sessões de suporte.", + "columnCreated": "Início", + "columnEnded": "Término", + "unlabeled": "Sessão sem rótulo" + } } } diff --git a/apps/web/src/pages/quick.astro b/apps/web/src/pages/quick.astro new file mode 100644 index 000000000..4498520bd --- /dev/null +++ b/apps/web/src/pages/quick.astro @@ -0,0 +1,11 @@ +--- +// Public, unauthenticated end-user landing page for Quick Support. The one-time +// code in the URL is the only credential, so this page must render for a +// logged-out stranger — deliberately NO auth guard. +import AuthLayout from '../layouts/AuthLayout.astro'; +import QuickLandingPage from '../components/quick/QuickLandingPage'; +--- + + + + diff --git a/apps/web/src/pages/remote/quick-support.astro b/apps/web/src/pages/remote/quick-support.astro new file mode 100644 index 000000000..352a8ba68 --- /dev/null +++ b/apps/web/src/pages/remote/quick-support.astro @@ -0,0 +1,8 @@ +--- +import DashboardLayout from '../../layouts/DashboardLayout.astro'; +import QuickSupportPage from '../../components/remote/QuickSupportPage'; +--- + + + + From abd64957c424a948a15a2ae586bcae27228a65b2 Mon Sep 17 00:00:00 2001 From: Todd Hebebrand Date: Tue, 4 Aug 2026 19:33:05 -0500 Subject: [PATCH 19/28] feat(api): grant the hidden quick support org to partner-scope callers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A partner user with orgAccess='selected' had no way to reach their own Quick Support sessions: the hidden 'quick_support' org is deliberately absent from every org picker, so it can never appear in the curated partnerUsers.orgIds list, and RLS then returned zero rows — silently. The session created fine (creation writes under a system context) and the technician's status panel simply stayed blank forever. Both resolution paths are updated together. auth.ts and bearerTokenAuth.ts must agree or session-JWT and OAuth/MCP callers behave differently for the same user. Chosen over the alternative of reading support sessions under a system context: that would have left the CONNECT path broken for the same users, since POST /remote/sessions authorizes the device through org scope, and special-casing remote-access authorization is a worse place to carry the exception than a single explicit org grant. Accepted trade-off, deliberately: the grant is partner-wide, so a technician can see and connect to Quick Support sessions raised by colleagues at the same partner. Per-creator scoping would need a bound connect capability and is a follow-up, not a silent default. Co-Authored-By: Claude Opus 5 (1M context) --- apps/api/src/middleware/auth.test.ts | 32 ++++++++++++++++++++++ apps/api/src/middleware/auth.ts | 25 +++++++++++++---- apps/api/src/middleware/bearerTokenAuth.ts | 15 ++++++++-- 3 files changed, 65 insertions(+), 7 deletions(-) diff --git a/apps/api/src/middleware/auth.test.ts b/apps/api/src/middleware/auth.test.ts index b1a515123..40694d7bd 100644 --- a/apps/api/src/middleware/auth.test.ts +++ b/apps/api/src/middleware/auth.test.ts @@ -386,6 +386,38 @@ describe('authMiddleware', () => { expect(body.auth.accessibleOrgIds).toEqual(['org-a']); }); + // Quick Support: the hidden 'quick_support' org can never be in the curated + // orgIds list (it is absent from every org picker), so a 'selected' partner + // user with an empty list must still hit the database to pick it up. Before + // this, the empty list short-circuited to [] and the technician's own + // support sessions came back as a silent zero-row read. + // + // This pins that the query HAPPENS; that it resolves the right org through + // real RLS is covered by supportSessionsRls.integration.test.ts. + it('still resolves orgs for partner orgAccess=selected with an empty list', async () => { + const app = buildAuthApp(); + vi.mocked(verifyToken).mockResolvedValue({ + ...basePayload, + scope: 'partner', + orgId: null + }); + + vi.mocked(db.select) + .mockReturnValueOnce(selectWithLimit([activeUser]) as any) + .mockReturnValueOnce(selectWithLimit([{ orgAccess: 'selected', orgIds: [] }]) as any) + .mockReturnValueOnce(selectWithWhere([{ id: 'hidden-quick-support-org' }]) as any); + + const res = await app.request('/test', { + headers: { Authorization: 'Bearer token' } + }); + + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.auth.accessibleOrgIds).toEqual(['hidden-quick-support-org']); + // user lookup + membership lookup + the org query that used to be skipped + expect(vi.mocked(db.select)).toHaveBeenCalledTimes(3); + }); + it('enforces partner orgAccess=none as no accessible organizations', async () => { const app = buildAuthApp(); vi.mocked(verifyToken).mockResolvedValue({ diff --git a/apps/api/src/middleware/auth.ts b/apps/api/src/middleware/auth.ts index 20fb49bd0..c4b3aca8a 100644 --- a/apps/api/src/middleware/auth.ts +++ b/apps/api/src/middleware/auth.ts @@ -5,7 +5,7 @@ import { getUserPermissions, hasPermission, canAccessOrg, canAccessSite, UserPer import { isTokenIssuedBeforePasswordChange, isUserTokenRevoked } from '../services/tokenRevocation'; import { db, withDbAccessContext, withSystemDbAccessContext, type DbAccessContext, type DbAccessScope } from '../db'; import { users, partnerUsers, organizations } from '../db/schema'; -import { and, eq, inArray, isNull, SQL } from 'drizzle-orm'; +import { and, eq, inArray, isNull, or, SQL } from 'drizzle-orm'; import type { PgColumn } from 'drizzle-orm/pg-core'; import { ENABLE_2FA } from '../routes/auth/schemas'; import { assertActiveTenantContext, TenantInactiveError } from '../services/tenantStatus'; @@ -317,9 +317,24 @@ async function computeAccessibleOrgIds( (value): value is string => typeof value === 'string' && value.length > 0 ); - if (selectedOrgIds.length === 0) { - return { orgIds: [], partnerOrgAccess: 'selected' }; - } + // The partner's hidden 'quick_support' org is granted regardless of the + // curated list. It holds no customer data — only this partner's own + // ad-hoc support sessions and their ephemeral devices — and it is + // deliberately absent from every org picker, so it can never appear in + // partnerUsers.orgIds. Without this a 'selected'-access technician + // creates a Quick Support session and then reads back zero rows: RLS + // denies it, silently, and their status panel stays blank forever. + // + // Note this is partner-wide: a technician can see (and connect to) + // Quick Support sessions raised by their colleagues at the same + // partner. That is the accepted trade-off for keeping authorization on + // the normal audited path rather than special-casing the connect flow. + const orgFilter = selectedOrgIds.length > 0 + ? or( + inArray(organizations.id, selectedOrgIds), + eq(organizations.type, 'quick_support') + ) + : eq(organizations.type, 'quick_support'); const partnerOrgs = await db .select({ id: organizations.id }) @@ -327,7 +342,7 @@ async function computeAccessibleOrgIds( .where( and( eq(organizations.partnerId, partnerId), - inArray(organizations.id, selectedOrgIds), + orgFilter, inArray(organizations.status, ['active', 'trial']), isNull(organizations.deletedAt) ) diff --git a/apps/api/src/middleware/bearerTokenAuth.ts b/apps/api/src/middleware/bearerTokenAuth.ts index 155f1a229..9feaedb32 100644 --- a/apps/api/src/middleware/bearerTokenAuth.ts +++ b/apps/api/src/middleware/bearerTokenAuth.ts @@ -250,14 +250,25 @@ export async function resolvePartnerAccessibleOrgIds( const selected = (partnerMembership.orgIds ?? []).filter( (v): v is string => typeof v === 'string' && v.length > 0, ); - if (selected.length === 0) return []; + // Mirrors computeAccessibleOrgIds in auth.ts — see the long comment + // there. The partner's hidden 'quick_support' org is always granted, + // because it can never appear in the curated orgIds list and its absence + // shows up as a silent zero-row read of the technician's own sessions. + // Both paths must agree, or session-JWT and OAuth/MCP callers behave + // differently for the same user. + const orgFilter = selected.length > 0 + ? or( + inArray(organizations.id, selected), + eq(organizations.type, 'quick_support'), + ) + : eq(organizations.type, 'quick_support'); const rows = await db .select({ id: organizations.id }) .from(organizations) .where( and( eq(organizations.partnerId, partnerId), - inArray(organizations.id, selected), + orgFilter, inArray(organizations.status, ['active', 'trial']), isNull(organizations.deletedAt), ), From d6f1b053dd888340d6f64c3f7730f488f5ee7c97 Mon Sep 17 00:00:00 2001 From: Todd Hebebrand Date: Tue, 4 Aug 2026 20:19:12 -0500 Subject: [PATCH 20/28] fix(billing,vuln,abuse): keep quick support devices out of invoices, findings and abuse evidence MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit countContractDevices feeds contract line quantities AND invoice line quantities, so an ephemeral device reaching it would bill a customer for a machine that existed for twenty minutes and was never theirs. Vulnerability correlation now bails at the org level for a quick_support org: correlating a stranger's software inventory would raise findings — and critical-detected alerts — against their home PC inside an org the MSP never onboarded. Checked once per entry point rather than filtered into each join, since a whole hidden org is never a legitimate correlation target. The abuse-signal invariant counting live agents per partner excludes them too; a burst of ad-hoc sessions should not read as agent sprawl. Co-Authored-By: Claude Opus 5 (1M context) --- .../src/services/abuseSignals/invariants.ts | 4 +++ apps/api/src/services/contractQuantities.ts | 13 +++++++-- .../src/services/vulnerabilityCorrelation.ts | 28 +++++++++++++++++++ 3 files changed, 43 insertions(+), 2 deletions(-) diff --git a/apps/api/src/services/abuseSignals/invariants.ts b/apps/api/src/services/abuseSignals/invariants.ts index 3ccebcb30..43fa1ff71 100644 --- a/apps/api/src/services/abuseSignals/invariants.ts +++ b/apps/api/src/services/abuseSignals/invariants.ts @@ -45,6 +45,10 @@ export async function computeInvariantSignals(): Promise { JOIN devices d ON d.org_id = o.id WHERE p.status IN ('pending', 'suspended') AND d.status NOT IN ('decommissioned', 'quarantined') + -- Quick Support ephemeral devices are transient and belong to no + -- customer; counting them inflates the device_count evidence field and + -- would let a burst of ad-hoc sessions look like agent sprawl. + AND d.is_ephemeral = false -- Intentionally omit deleted_at IS NULL: a soft-deleted partner with live devices is itself the anomaly GROUP BY p.id, p.name, p.status `)) as unknown as Array<{ id: string; name: string; status: string; device_count: string }>; diff --git a/apps/api/src/services/contractQuantities.ts b/apps/api/src/services/contractQuantities.ts index 44df42daa..12466d02e 100644 --- a/apps/api/src/services/contractQuantities.ts +++ b/apps/api/src/services/contractQuantities.ts @@ -3,9 +3,18 @@ import { db } from '../db'; import { devices, organizationUsers, users } from '../db/schema'; /** Billable device count for an org, optionally narrowed to a site. Excludes decommissioned. - * Must be called inside a db access context (system for the worker, request otherwise). */ + * Must be called inside a db access context (system for the worker, request otherwise). + * + * Also excludes Quick Support ephemeral devices. This function feeds contract + * line quantities AND invoice line quantities, so an ad-hoc support session + * reaching here would bill a customer for a machine that existed for twenty + * minutes and was never theirs. */ export async function countContractDevices(orgId: string, siteId: string | null): Promise { - const conds = [eq(devices.orgId, orgId), ne(devices.status, 'decommissioned' as never)]; + const conds = [ + eq(devices.orgId, orgId), + ne(devices.status, 'decommissioned' as never), + eq(devices.isEphemeral, false), + ]; if (siteId) conds.push(eq(devices.siteId, siteId)); const [row] = await db.select({ n: count() }).from(devices).where(and(...conds)); return Number(row?.n ?? 0); diff --git a/apps/api/src/services/vulnerabilityCorrelation.ts b/apps/api/src/services/vulnerabilityCorrelation.ts index 0c5614e9a..be56e16e4 100644 --- a/apps/api/src/services/vulnerabilityCorrelation.ts +++ b/apps/api/src/services/vulnerabilityCorrelation.ts @@ -4,6 +4,7 @@ import { db, withSystemDbAccessContext } from '../db'; import { devices, deviceVulnerabilities, + organizations, osVulnerabilities, softwareInventory, softwareProductResolutions, @@ -220,12 +221,35 @@ async function upsertDeviceVulnerability(args: { * treated as "all devices", which would resolve every open finding). Omitting * `opts` preserves the legacy whole-org behavior. */ +/** + * Quick Support ephemeral devices live in a hidden per-partner 'quick_support' + * org and are a stranger's personal machine borrowed for one short session. + * Correlating their software inventory would raise vulnerability findings — + * and critical-detected alerts — against someone else's home PC, inside an org + * the MSP never onboarded. + * + * Checked once at the org level rather than filtered into each join: both + * entry points are already org-scoped, and a whole hidden org is never a + * legitimate correlation target. + */ +async function isQuickSupportOrg(orgId: string): Promise { + const [org] = await db + .select({ type: organizations.type }) + .from(organizations) + .where(eq(organizations.id, orgId)) + .limit(1); + return org?.type === 'quick_support'; +} + export async function correlateOrg( orgId: string, opts?: { deviceIds?: string[] } ): Promise<{ created: number; resolved: number }> { const deviceIds = opts?.deviceIds; if (deviceIds && deviceIds.length === 0) return { created: 0, resolved: 0 }; + if (await withSystemDbAccessContext(() => isQuickSupportOrg(orgId))) { + return { created: 0, resolved: 0 }; + } const inventoryDeviceFilter = deviceIds ? inArray(softwareInventory.deviceId, deviceIds) : undefined; const findingDeviceFilter = deviceIds ? inArray(deviceVulnerabilities.deviceId, deviceIds) : undefined; @@ -413,6 +437,10 @@ export async function correlateOsVulns( ): Promise<{ created: number; resolved: number }> { const deviceIds = opts?.deviceIds; if (deviceIds && deviceIds.length === 0) return { created: 0, resolved: 0 }; + // See isQuickSupportOrg — same reasoning as correlateOrg. + if (await withSystemDbAccessContext(() => isQuickSupportOrg(orgId))) { + return { created: 0, resolved: 0 }; + } const macDeviceFilter = deviceIds ? inArray(devices.id, deviceIds) : undefined; const { created, resolved, criticals, remediated } = await withSystemDbAccessContext(async () => { From ba489b0d2458c9533e3c7c42b59f9819b400a184 Mon Sep 17 00:00:00 2001 From: Todd Hebebrand Date: Tue, 4 Aug 2026 20:19:40 -0500 Subject: [PATCH 21/28] fix(jobs): keep quick support ephemeral devices out of every fleet worker MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit These workers act ON devices. An ephemeral device is a stranger's personal machine borrowed for one ~20-minute session, so reaching them means rebooting a home PC, patching it, CIS-hardening it, backing it up, shipping its serial to a warranty vendor, or paging an on-call technician. The most serious hole was queueEventTriggers in automationWorker: events raised by an ephemeral device carry the hidden org, the legacy branch matches every automation with org_id NULL under that partner, and the config-policy branch then executes against payload.deviceId — the MSP's whole automation library running scripts on a stranger's machine, reachable from any event the device emitted. It bypasses resolveDeviceIdsForAssignment entirely, so filtering that function alone would not have closed it. Three sweeps beyond the original inventory were found and closed the same way: snmpWorker and discoveryWorker both pick "any online device in the org" to run network scans (which would scan the end user's own LAN), and userRiskJobs' org fan-out rested on an undocumented invariant rather than an explicit filter. offlineDetector is deliberately NOT filtered — ephemeral devices must keep flowing through the offline transition because the end-user-stop detection depends on it; only its alerting is suppressed. audit_chain anchoring and verification are also deliberately left covering the hidden org: excluding them would create a tamper blind spot on the most sensitive session trail in the product. The cost is that a P1 divergence incident can name the hidden org. Co-Authored-By: Claude Opus 5 (1M context) --- apps/api/src/jobs/alertWorker.test.ts | 11 ++++- apps/api/src/jobs/alertWorker.ts | 17 +++++-- apps/api/src/jobs/auditBaselineJobs.ts | 14 ++++-- ...automationWorker.resolveAssignment.test.ts | 8 ++- apps/api/src/jobs/automationWorker.ts | 49 ++++++++++++++++--- apps/api/src/jobs/backupWorker.ts | 13 ++++- apps/api/src/jobs/cisJobs.ts | 6 +++ apps/api/src/jobs/discoveryWorker.ts | 10 +++- apps/api/src/jobs/maintenanceRebootWorker.ts | 9 ++++ apps/api/src/jobs/metricAnomalies.ts | 8 ++- apps/api/src/jobs/metricRollups.ts | 8 ++- apps/api/src/jobs/monitorWorker.ts | 24 +++++++-- .../src/jobs/patchComplianceReportWorker.ts | 7 ++- .../api/src/jobs/patchSchedulerWorker.test.ts | 8 ++- apps/api/src/jobs/patchSchedulerWorker.ts | 41 +++++++++++++--- apps/api/src/jobs/peripheralJobs.ts | 14 +++++- apps/api/src/jobs/reliabilityWorker.ts | 8 ++- apps/api/src/jobs/securityPostureWorker.ts | 8 ++- apps/api/src/jobs/sensitiveDataJobs.ts | 13 ++++- apps/api/src/jobs/snmpWorker.ts | 9 +++- apps/api/src/jobs/softwareComplianceWorker.ts | 33 ++++++++----- .../api/src/jobs/softwareRemediationWorker.ts | 17 ++++++- apps/api/src/jobs/userRiskJobs.test.ts | 9 +++- apps/api/src/jobs/userRiskJobs.ts | 11 ++++- apps/api/src/services/auditBaselineService.ts | 14 +++++- apps/api/src/services/dnsThreatAlerts.ts | 15 +++++- .../src/services/warrantyAlertEvaluator.ts | 8 +++ apps/api/src/services/warrantySync.ts | 14 +++++- 28 files changed, 344 insertions(+), 62 deletions(-) diff --git a/apps/api/src/jobs/alertWorker.test.ts b/apps/api/src/jobs/alertWorker.test.ts index aea014e63..c6be15f20 100644 --- a/apps/api/src/jobs/alertWorker.test.ts +++ b/apps/api/src/jobs/alertWorker.test.ts @@ -5,13 +5,20 @@ const { addBulkMock, addMock, getJobMock, warnSpy, devicesSchema, organizationsS addMock: vi.fn(async () => ({ id: 'queued-job-1' })), getJobMock: vi.fn(async () => null), warnSpy: vi.fn(), - devicesSchema: { id: 'devices.id', orgId: 'devices.orgId', status: 'devices.status', lastSeenAt: 'devices.lastSeenAt' } as const, - organizationsSchema: { id: 'organizations.id', status: 'organizations.status' } as const, + devicesSchema: { + id: 'devices.id', + orgId: 'devices.orgId', + status: 'devices.status', + lastSeenAt: 'devices.lastSeenAt', + isEphemeral: 'devices.isEphemeral' + } as const, + organizationsSchema: { id: 'organizations.id', status: 'organizations.status', type: 'organizations.type' } as const, fleetState: { fleet: [] as { id: string; orgId: string }[], chunkCalls: 0 } })); vi.mock('drizzle-orm', () => ({ eq: (col: unknown, val: unknown) => ({ op: 'eq', col, val }), + ne: (col: unknown, val: unknown) => ({ op: 'ne', col, val }), and: (...args: unknown[]) => ({ op: 'and', args }), gte: (col: unknown, val: unknown) => ({ op: 'gte', col, val }), gt: (col: unknown, val: unknown) => ({ op: 'gt', col, val }), diff --git a/apps/api/src/jobs/alertWorker.ts b/apps/api/src/jobs/alertWorker.ts index 2d2f70f68..b27e0e0dd 100644 --- a/apps/api/src/jobs/alertWorker.ts +++ b/apps/api/src/jobs/alertWorker.ts @@ -8,7 +8,7 @@ import { Queue, Worker, Job } from 'bullmq'; import * as dbModule from '../db'; import { devices, deviceMetrics, organizations, alerts } from '../db/schema'; -import { eq, and, gte, gt, desc, asc, inArray, isNotNull } from 'drizzle-orm'; +import { eq, ne, and, gte, gt, desc, asc, inArray, isNotNull } from 'drizzle-orm'; import { getBullMQConnection } from '../services/redis'; import { evaluateDeviceAlerts, @@ -124,11 +124,21 @@ export async function processEvaluateAll(data: EvaluateAllJobData): Promise<{ const cap = data.batchSize ?? envInt('ALERT_WORKER_MAX_DEVICES_PER_RUN', 5000); const chunkSize = Math.max(1, envInt('ALERT_WORKER_CHUNK_SIZE', 500)); - // Get all active organizations + // Get all active organizations. + // + // Quick Support exclusion: the hidden per-partner 'quick_support' org and the + // ephemeral devices inside it are a stranger's personal machine borrowed for a + // single ~20-minute session. That org stays inside technicians' accessibleOrgIds + // for RLS reasons, so it is NOT filtered out for us — every fleet sweep has to + // exclude it explicitly. Alert evaluation is the path that pages on-call staff, + // so ephemeral devices are excluded at both the org and the device level. const orgs = await db .select({ id: organizations.id }) .from(organizations) - .where(eq(organizations.status, 'active')); + .where(and( + eq(organizations.status, 'active'), + ne(organizations.type, 'quick_support') + )); if (orgs.length === 0) { return { queued: 0, skipped: 0, durationMs: Date.now() - startTime }; @@ -157,6 +167,7 @@ export async function processEvaluateAll(data: EvaluateAllJobData): Promise<{ const conditions = [ inArray(devices.orgId, orgIds), + eq(devices.isEphemeral, false), eq(devices.status, 'online'), gte(devices.lastSeenAt, recentThreshold) ]; diff --git a/apps/api/src/jobs/auditBaselineJobs.ts b/apps/api/src/jobs/auditBaselineJobs.ts index 05f2ff485..2cc59262e 100644 --- a/apps/api/src/jobs/auditBaselineJobs.ts +++ b/apps/api/src/jobs/auditBaselineJobs.ts @@ -46,9 +46,15 @@ export function getAuditBaselineQueue(): Queue { async function processCollectAuditPolicy( data: CollectAuditPolicyJobData ): Promise<{ attempted: number; queued: number; skipped: number }> { + // Quick Support exclusion (both sweeps in this file): ephemeral devices + // (`devices.isEphemeral`) live in the hidden per-partner 'quick_support' org + // and are a stranger's personal machine borrowed for one ~20-minute session. + // That org stays inside technicians' accessibleOrgIds for RLS reasons, so this + // fleet-wide sweep is NOT filtered for us — we must not push audit-policy + // collection commands onto a home PC, nor count it toward the work estimate. const where = data.orgId - ? and(eq(devices.orgId, data.orgId), eq(devices.status, 'online')) - : eq(devices.status, 'online'); + ? and(eq(devices.isEphemeral, false), eq(devices.orgId, data.orgId), eq(devices.status, 'online')) + : and(eq(devices.isEphemeral, false), eq(devices.status, 'online')); const rows = await db .selectDistinct({ id: devices.id }) @@ -235,8 +241,8 @@ export async function enqueueAuditDriftEvaluation(orgId?: string): Promise { const deviceStatusFilter = orgId - ? and(eq(devices.status, 'online'), eq(devices.orgId, orgId)) - : eq(devices.status, 'online'); + ? and(eq(devices.isEphemeral, false), eq(devices.status, 'online'), eq(devices.orgId, orgId)) + : and(eq(devices.isEphemeral, false), eq(devices.status, 'online')); const [row] = await db .select({ count: sql`count(distinct ${devices.id})::int` }) diff --git a/apps/api/src/jobs/automationWorker.resolveAssignment.test.ts b/apps/api/src/jobs/automationWorker.resolveAssignment.test.ts index c77000639..1c3a5485c 100644 --- a/apps/api/src/jobs/automationWorker.resolveAssignment.test.ts +++ b/apps/api/src/jobs/automationWorker.resolveAssignment.test.ts @@ -62,7 +62,7 @@ vi.mock('./workerObservability', () => ({ attachWorkerObservability: vi.fn() })) import { __testOnly } from './automationWorker'; import { db } from '../db'; -import { organizations } from '../db/schema'; +import { organizations, devices } from '../db/schema'; const { resolveDeviceIdsForAssignment, processTriggerConfigPolicySchedule } = __testOnly; @@ -167,8 +167,12 @@ describe('automationWorker resolveDeviceIdsForAssignment — partner re-clamp (# const ids = await resolveDeviceIdsForAssignment('device_group', 'group-x', null, 'partner-123'); expect(ids).toEqual(['dev-a']); - expect(chain.innerJoin).toHaveBeenCalledTimes(1); + // Two joins: organizations for the partner re-clamp, devices for the + // Quick Support ephemeral exclusion (group membership rows carry no + // is_ephemeral of their own). + expect(chain.innerJoin).toHaveBeenCalledTimes(2); expect(chain.innerJoin.mock.calls[0][0]).toBe(organizations); + expect(chain.innerJoin.mock.calls[1][0]).toBe(devices); const whereArgs = collectSqlLeafStrings(chain.where.mock.calls[0][0]); expect(whereArgs).toContain('group-x'); expect(whereArgs).toContain('partner-123'); diff --git a/apps/api/src/jobs/automationWorker.ts b/apps/api/src/jobs/automationWorker.ts index 92a7f2084..e77121fde 100644 --- a/apps/api/src/jobs/automationWorker.ts +++ b/apps/api/src/jobs/automationWorker.ts @@ -508,6 +508,14 @@ async function processExecuteRun(data: ExecuteRunJobData): Promise<{ runId: stri * - site: all devices at the site * - organization: all devices in the org * - partner: all devices across all orgs belonging to the partner + * + * Quick Support exclusion: ephemeral devices (`devices.isEphemeral`) live in the + * hidden per-partner 'quick_support' org and are a stranger's personal machine + * borrowed for one ~20-minute session. That org stays inside technicians' + * accessibleOrgIds for RLS reasons, so the partner-wide fan-out would otherwise + * resolve them and run the MSP's automation scripts on a home PC. Every branch + * that resolves a SET of devices filters them out; the explicit device-level + * branches are by-id lookups of an operator-chosen target and are left alone. */ async function resolveDeviceIdsForAssignment( assignmentLevel: string, @@ -528,7 +536,10 @@ async function resolveDeviceIdsForAssignment( // A partner-wide policy (policyOrgId null, #1724) resolves EVERY device // under the assigned partner. A legacy org-owned policy at partner level // (now rejected at assign time) still clamps to its own org as a backstop. - const conditions = [eq(organizations.partnerId, assignmentTargetId)]; + const conditions = [ + eq(organizations.partnerId, assignmentTargetId), + eq(devices.isEphemeral, false), + ]; if (policyOrgId) conditions.push(eq(devices.orgId, policyOrgId)); const partnerDevices = await db .select({ id: devices.id }) @@ -580,19 +591,25 @@ async function resolveDeviceIdsForAssignment( .select({ deviceId: deviceGroupMemberships.deviceId }) .from(deviceGroupMemberships) .innerJoin(organizations, eq(deviceGroupMemberships.orgId, organizations.id)) + .innerJoin(devices, eq(deviceGroupMemberships.deviceId, devices.id)) .where( and( eq(deviceGroupMemberships.groupId, assignmentTargetId), eq(organizations.partnerId, policyPartnerId!), + eq(devices.isEphemeral, false), ), ); return members.map((m) => m.deviceId); } - const conditions = [eq(deviceGroupMemberships.groupId, assignmentTargetId)]; + const conditions = [ + eq(deviceGroupMemberships.groupId, assignmentTargetId), + eq(devices.isEphemeral, false), + ]; if (policyOrgId) conditions.push(eq(deviceGroupMemberships.orgId, policyOrgId)); const members = await db .select({ deviceId: deviceGroupMemberships.deviceId }) .from(deviceGroupMemberships) + .innerJoin(devices, eq(deviceGroupMemberships.deviceId, devices.id)) .where(and(...conditions)); return members.map((m) => m.deviceId); } @@ -603,10 +620,14 @@ async function resolveDeviceIdsForAssignment( .select({ id: devices.id }) .from(devices) .innerJoin(organizations, eq(devices.orgId, organizations.id)) - .where(and(eq(devices.siteId, assignmentTargetId), eq(organizations.partnerId, policyPartnerId!))); + .where(and( + eq(devices.siteId, assignmentTargetId), + eq(organizations.partnerId, policyPartnerId!), + eq(devices.isEphemeral, false), + )); return siteDevices.map((d) => d.id); } - const conditions = [eq(devices.siteId, assignmentTargetId)]; + const conditions = [eq(devices.siteId, assignmentTargetId), eq(devices.isEphemeral, false)]; if (policyOrgId) conditions.push(eq(devices.orgId, policyOrgId)); const siteDevices = await db .select({ id: devices.id }) @@ -621,10 +642,14 @@ async function resolveDeviceIdsForAssignment( .select({ id: devices.id }) .from(devices) .innerJoin(organizations, eq(devices.orgId, organizations.id)) - .where(and(eq(devices.orgId, assignmentTargetId), eq(organizations.partnerId, policyPartnerId!))); + .where(and( + eq(devices.orgId, assignmentTargetId), + eq(organizations.partnerId, policyPartnerId!), + eq(devices.isEphemeral, false), + )); return orgDevices.map((d) => d.id); } - const conditions = [eq(devices.orgId, assignmentTargetId)]; + const conditions = [eq(devices.orgId, assignmentTargetId), eq(devices.isEphemeral, false)]; if (policyOrgId) conditions.push(eq(devices.orgId, policyOrgId)); const orgDevices = await db .select({ id: devices.id }) @@ -835,11 +860,21 @@ export async function queueEventTriggers(event: BreezeEvent
+
+

{t('page.title')}

+

{t('page.intro')}

+
+ + {state.phase === 'checking' && ( +

+ {t('checking')} +

+ )} + + {state.phase === 'invalid' && ( +
+

{t('invalid.title')}

+

{t('invalid.body')}

+
+ )} + + {state.phase === 'unreachable' && ( +
+

{t('checkFailed.title')}

+

{t('checkFailed.body')}

+ +
+ )} + + {showForm && ( +
+
+ +

{t('code.help')}

+
+ { + setEntry(event.target.value); + setFormatError(false); + }} + className="h-11 w-full rounded-md border bg-background px-3 text-center font-mono text-lg tracking-widest" + /> + {formatError && ( +

+ {t('code.formatError')} +

+ )} + +
+ )} + + {state.phase === 'valid' && ( +
+
+

{t('ready.title')}

+

+ {t('ready.codeIntro', { code: formatSupportCode(state.code) })} +

+
+ +
+ {t('download.windows')} + + +

+ {t('download.manualFallback', { code: formatSupportCode(state.code) })} +

+ +
+

+ {t('download.macosLabel')} + + {t('download.macosBadge')} + +

+

{t('download.macosBody')}

+
+ +
+

{t('windowsPrompt.title')}

+

+ {t('windowsPrompt.body', { publisher: t('windowsPrompt.publisher') })} +

+
+
+ )} + +
+

{t('trust.title')}

+
    +
  • {t('trust.seeScreen')}
  • +
  • {t('trust.temporary')}
  • +
  • {t('trust.stopAnyTime')}
  • +
  • {t('trust.onlyIfExpected')}
  • +
+
+