# ADR-108: A Generic, Permanent Authenticated-Site Capture & AI-Analysis Service (Codename: SiteCap)

## Status

Accepted, 2026-07-03.

## Status History

```yaml
status_history:
  - date: 2026-07-03
    status: Proposed
    changed_by: RESEARCH session (hkl requested, escalated from a one-off eFlexo capture script)
    reason: |
      Started as a single Playwright script to capture ~6 screens from a 45-day
      eFlexo trial for MDD_print_estimation.md. hkl requested it become a
      permanent, general-purpose, multi-site tool instead of a throwaway script.
      That changes the risk profile materially (permanent storage of full,
      unscoped third-party account passwords, for arbitrary future sites, not
      one scoped credential for one trial) — drafted as an ADR before writing
      code, per this project's own convention for anything touching shared
      infra (Traefik) or introducing a new credential-storing service.
    changed_via: manual (adr-kit conventions followed by hand)
  - date: 2026-07-03
    status: Accepted
    changed_by: hkl (via RESEARCH session, interactive Q&A)
    reason: |
      All 3 open questions resolved: (1) subdomain confirmed as
      sitecap.srv1111289.hstgr.cloud; (2) admin allowlist confirmed as
      harish+pramod only; (3) credential-storage hardening — hkl asked for a
      stronger option than a plain env-stored master key. Ruled out PIN-derived
      keys as a false "stronger" option (this platform's PINs are deliberately
      low-entropy per ADR-011, would make the key brute-forceable, not
      stronger). Of the three genuine options offered (no persistence at all;
      harden current design via Docker secret + root-only permissions +
      rotation runbook; full self-hosted secrets manager), hkl chose the
      middle option — see revised §3 below.
    changed_via: manual (adr-kit conventions followed by hand)
```

## Context

The immediate need: capture screens from an eFlexo trial account to answer open questions in
`MDD_print_estimation.md` §9 (estimation form behavior, job-card fields, material masters,
etc.). A one-off Playwright CLI script (`inspect-login` / `discover` / `capture` modes) was
built first and worked, but required editing a local `.env` file over SSH — fiddly in
practice (terminal/editor friction, easy to silently overwrite with blank values).

hkl requested this become instead: **a permanent, general-purpose PWA** that can log into
*any* site (not just eFlexo), remember the credentials, and — per the "AI & Automation
services for SMEs" future business line already identified in the 2026-07-03 ERP-Lite
research — analyze captured content via the existing AI pipeline pattern (ADR-062).

**Why this deserves an ADR, not just code:** this is meaningfully different from every other
credential-holding service already in this codebase.

| | Blogsmith (ADR-097) | SiteCap (this ADR) |
|---|---|---|
| Credential type | WordPress **Application Password** — scoped, revocable, API-only | Full account **login password** — whatever the target site's login form accepts |
| Blast radius if leaked | Attacker can post/delete blog content on one site | Attacker has full account access on any registered site |
| Number of sites | One (per user's own WordPress) | Unbounded — "generic, multi-purpose" |
| Storage | Plaintext DB column (accepted risk, documented in ADR-097) | **Must not** be plaintext at this blast radius — see Decision §3 |

## Decision

### 1. Isolation (follows ADR-086, same as Blogsmith/Health Tracker)

- Standalone app, **not** hub-registered, **not** in the `lm360` schema.
- Own PostgreSQL database (`sitecap`) and role (`sitecap_app`), zero cross-schema grants —
  same pattern as Blogsmith (ADR-078 precedent).
- Own directory: `/var/www/Others/Automation/sitecap/` (alongside Blogsmith, per the existing
  `Automation/` grouping folder convention).
- Own auth: PIN-based, server-side bcrypt (Blogsmith pattern), **restricted to an explicit
  admin allowlist** (v1: harish + pramod only — this is a materially more sensitive store than
  Blogsmith's per-user WordPress settings, so it does not get the "any registered user" model
  Blogsmith uses). Widening access to more users is a v2 decision, made deliberately, not a
  default.

### 2. Core data model

```sql
CREATE TABLE sites (
  id                BIGSERIAL PRIMARY KEY,
  label             TEXT NOT NULL,              -- e.g. "eFlexo"
  login_url         TEXT NOT NULL,
  username          TEXT NOT NULL,
  password_enc      BYTEA NOT NULL,              -- AES-256-GCM ciphertext, see §3
  password_enc_iv   BYTEA NOT NULL,              -- per-row nonce
  login_selectors   JSONB,                       -- {userField, passField, submitButton} from inspect-login
  added_by          BIGINT NOT NULL REFERENCES users(id),
  authorized_note   TEXT NOT NULL,               -- required free-text: why this user is authorized
                                                  -- to automate login to this account (§6)
  created_at        TIMESTAMPTZ NOT NULL DEFAULT now(),
  active            BOOLEAN NOT NULL DEFAULT true
);

CREATE TABLE capture_runs (
  id            BIGSERIAL PRIMARY KEY,
  site_id       BIGINT NOT NULL REFERENCES sites(id),
  mode          TEXT NOT NULL CHECK (mode IN ('discover','capture')),
  status        TEXT NOT NULL DEFAULT 'running' CHECK (status IN ('running','done','failed')),
  triggered_by  BIGINT NOT NULL REFERENCES users(id),  -- always a human click, never automated (§5)
  started_at    TIMESTAMPTZ NOT NULL DEFAULT now(),
  completed_at  TIMESTAMPTZ,
  error         TEXT
);

CREATE TABLE capture_pages (
  id              BIGSERIAL PRIMARY KEY,
  run_id          BIGINT NOT NULL REFERENCES capture_runs(id),
  label           TEXT NOT NULL,
  url             TEXT NOT NULL,
  screenshot_path TEXT,
  html_path       TEXT,
  text_path       TEXT,
  created_at      TIMESTAMPTZ NOT NULL DEFAULT now()
);

CREATE TABLE analysis_jobs (
  id                BIGSERIAL PRIMARY KEY,
  capture_page_id   BIGINT NOT NULL REFERENCES capture_pages(id),
  question          TEXT NOT NULL,               -- e.g. "Does this form auto-calculate plate cost?"
  ai_tier_used      TEXT,                         -- oauth | openrouter | anthropic_rest
  structured_result JSONB,
  reviewed          BOOLEAN NOT NULL DEFAULT false,   -- ADR-062 mandatory human-review gate
  reviewed_by       BIGINT REFERENCES users(id),
  reviewed_at       TIMESTAMPTZ,
  created_at        TIMESTAMPTZ NOT NULL DEFAULT now()
);
```

### 3. Credential storage — the decision that matters most in this ADR

**AES-256-GCM, application-level encryption, not plaintext — key hardened beyond a bare env var
(revised 2026-07-03, hkl requested a stronger option than the original draft).**

- `SITECAP_MASTER_KEY` (32 random bytes, generated once via `openssl rand -hex 32`) is provided
  to the container as a **Docker secret** (`docker secret create sitecap_master_key -`, mounted
  read-only at `/run/secrets/sitecap_master_key`), not a plain Compose `.env` variable. This
  keeps it out of `docker inspect`/`docker-compose config` output and any process listing that
  dumps environment variables, unlike a bare env var.
- The secret file itself is **root-only permissions (`chmod 600`, root:root)** on the host.
- **Rotation runbook** (written before first production use, not deferred): generate a new key,
  decrypt every `sites.password_enc` row with the old key, re-encrypt with the new key in the
  same transaction, replace the Docker secret, restart the container. Triggered on any suspected
  leak, and as routine hygiene at a cadence to be decided once the tool has real usage data.
- This does **not** protect against a fully root-compromised host — nothing on this VPS does,
  including every other proxy's API keys. That residual risk is named explicitly, not implied
  away (see Risks table).

This is a deliberate step up from Blogsmith's plaintext `wp_app_password` column — justified by
the blast-radius table in Context. If the master key itself is compromised, all stored
passwords are compromised; this is the same trust model as any application-level encryption
scheme without a separate KMS/HSM, and is explicitly named as a residual risk (§ Risks) rather
than glossed over.

### 4. AI analysis pipeline (ADR-062 compliance, mandatory)

Reuses the existing 3-tier chain exactly as specified in ADR-062:

```
Tier 1: claude -p (Claude OAuth, /root/.claude credentials, no cost)
Tier 2: OpenRouter (OPENROUTER_API_KEY)
Tier 3: Anthropic REST (only with explicit user approval, per-request)
```

`POST /api/analyze` takes a `capture_page_id` and a `question`, runs it through the chain, and
writes an `analysis_jobs` row with `reviewed = false`. **The human-review gate is not
optional here** — unlike Blogsmith (which explicitly accepted skipping review for speed on
low-stakes blog content, ADR-097 §"No Review Gate"), SiteCap's output is meant to inform real
business decisions (e.g., "does eFlexo auto-calculate plate cost"). An unreviewed hallucinated
answer is actively harmful, not just low-quality content. The PWA must show every
`analysis_jobs` result with explicit Confirm/Edit/Reject actions before it's treated as ground
truth anywhere else (e.g. before it's copied into an MDD).

### 5. What triggers a login — the safety boundary, restated precisely

Every `capture_runs` row requires `triggered_by` = an authenticated human user of *this app*,
clicking a "Run Discover" / "Run Capture" button in the PWA. **No cron, no scheduled job, no
API caller other than the PWA's own authenticated UI may trigger a login in v1.** This is the
same distinction discussed earlier in this session: a human clicking a button in a tool they're
using is the proximate cause of the authentication, not an AI agent invoking it directly.
Scheduled/unattended runs are an explicit, separate future decision (§ Alternatives) — not
included by default, because an unattended job silently logging into third-party accounts on a
schedule is a different (and larger) risk decision than a human-triggered one.

### 6. Legal / ToS — operator responsibility, stated explicitly

Automating login against a third-party commercial site's login form (as opposed to an official
API) commonly falls outside that site's Terms of Service, regardless of which site is
registered. This tool makes doing that easy for *any* site, which is precisely why
`sites.authorized_note` is a **required, non-empty field** — whoever registers a site must
state, in their own words, why they're authorized to automate access to that account (e.g.
"our own paid eFlexo trial, personal use for internal reference only"). This is a lightweight
accountability mechanism, not a legal shield — the operator remains responsible for each site
they register, same as if they'd done it by hand.

### 7. Deployment

- Node.js service (reuses the already-written and field-tested `capture.js` Playwright logic
  as its core engine — login selectors for eFlexo already confirmed working this session).
- Docker Compose, own container, mirrors Blogsmith's compose shape (mounts Claude binary +
  `.claude/` credentials read-write for Tier 1 OAuth, per ADR-062).
- Traefik: new host route `sitecap.srv1111289.hstgr.cloud` (or similar — final subdomain to
  confirm with hkl), TLS via zerossl, matching Blogsmith's exact label pattern (ADR-073).
  **This touches shared Traefik config — TRAEFIK zone lock claimed on the session board before
  any docker-compose/Traefik edit is made** (per this project's parallel-session protocol).

## Alternatives Considered

- **Session-only credentials, never persisted (safer, but contradicts "permanent").** Rejected
  per hkl's explicit request for a permanent tool — but noted here as the strictly-safer
  alternative if the calculus changes later: encrypt-at-rest is a real, accepted risk, not a
  solved problem.
- **Plaintext storage, matching Blogsmith exactly.** Rejected — Blogsmith's risk acceptance was
  scoped to one WordPress Application Password per user; this store holds unscoped passwords
  for arbitrary sites, a materially larger blast radius that doesn't get the same pass.
  Justification in §3's comparison table.
  before hkl's own review/edit step. Justification in §4.
- **Open to all hub-authenticated employees.** Rejected for v1 — restricted to an explicit
  admin allowlist (harish/pramod) given the sensitivity of what's being stored. Widening is a
  deliberate future decision, not a default.
- **Scheduled/unattended capture runs.** Rejected for v1 — every run must be human-triggered
  (§5). Revisit only as its own explicit decision if a recurring monitoring need is confirmed.
- **Python (Blogsmith's language) instead of Node.** Rejected — the Playwright automation core
  already exists and is validated in Node (`capture.js`); rewriting it in Python for
  consistency with Blogsmith buys nothing and adds risk of re-introducing bugs already fixed
  (e.g. the confirmed eFlexo login selectors).

## Consequences

**Positive:**
- Removes the CLI/SSH/`.env`-editing friction that caused real problems in the eFlexo capture
  session (wrong terminal, nano save-prompt confusion, multi-line paste going into the wrong
  program).
- Reusable beyond eFlexo — directly productizes the "AI & Automation services for SMEs" future
  business line already identified in the ERP-Lite research, rather than being single-purpose
  throwaway code.
- Encryption-at-rest, admin-only access, and mandatory human review are designed in from v1,
  not retrofitted after an incident.

**Negative / Trade-offs:**
- New standalone service to operate, patch, and back up independently (same operational
  overhead category as Blogsmith/Health Tracker — accepted there, same reasoning applies here).
- `SITECAP_MASTER_KEY` becomes a new single point of catastrophic risk: whoever holds it can
  decrypt every stored credential. Standard application-level-encryption trade-off, not unique
  to this design, but worth hkl consciously accepting rather than assuming away.
- Genuinely more code than the one-off script would have needed for the eFlexo task alone —
  accepted explicitly because hkl asked for permanent + general-purpose, not because it's
  required to finish the immediate MDD work.

**Risks and mitigations:**

| Risk | Mitigation |
|---|---|
| `SITECAP_MASTER_KEY` leaks (env dump, backup exposure, etc.) | Never in git; document a rotation runbook before this holds more than 2-3 site credentials; treat rotation as mandatory if leak is ever suspected |
| A registered site's ToS prohibits this kind of access | `authorized_note` makes this an explicit, attributable decision per site, not a silent default (§6) |
| AI analysis hallucinates a "fact" that gets treated as true | Mandatory review gate (§4) — `reviewed=false` rows must never be read by anything else as ground truth |
| Scope creep — this becomes the path of least resistance for every future credential-needing automation, without individual review | Each new site registered still requires its own `authorized_note`; this ADR does not pre-approve unlimited use, only the mechanism |
| Traefik misconfiguration affects other PWAs | TRAEFIK zone lock claimed before editing shared docker-compose/Traefik labels, per parallel-session protocol |

## Related Decisions

- **ADR-086** — Isolated PWA Architecture. SiteCap follows this pattern exactly (own DB, own
  auth, own domain), same as Blogsmith and Health Tracker.
- **ADR-097** — Blogsmith AI Content Pipeline. Closest existing precedent; this ADR's Context
  table exists specifically to explain why SiteCap's credential-storage decision differs from
  it.
- **ADR-062** — Live AI Pipeline Contract. SiteCap's `/api/analyze` must comply with the 3-tier
  auth chain and (unlike Blogsmith) does NOT take the "skip review gate" exception — the
  review gate here is mandatory, not optional.
- **ADR-078** — Paperclip Isolation. Same database-role isolation pattern (own role, zero
  lm360 grants) reused here.
- **ADR-073** — Traefik Docker Label Conventions. Governs the new Host-based route.
- **ADR-105 / ADR-106 / ADR-107** — Same session's broader push on honest, verified security
  posture (signed-JWT auth, grant-gating reality, checklist-driven ADR compliance) — this ADR
  is written in that same spirit: name the real risk, don't paper over it with a plaintext
  column because that was fine for a lower-stakes case.

## Open Questions — all resolved 2026-07-03 (interactive Q&A)

1. ~~Final subdomain name~~ — **Resolved:** `sitecap.srv1111289.hstgr.cloud`, as drafted.
2. ~~Admin allowlist~~ — **Resolved:** harish + pramod only, as drafted.
3. ~~`SITECAP_MASTER_KEY` risk~~ — **Resolved:** hardened beyond the original draft — Docker
   secret (not bare env var) + root-only file permissions + a documented rotation runbook.
   See revised §3. hkl explicitly did not choose the "no persistence at all" (strongest) or
   "self-hosted secrets manager" (most infrastructure) alternatives — moderate hardening was
   the chosen trade-off.

## References

- `docs/adr/ADR-097-blogsmith-ai-content-pipeline.md` — closest precedent, credential model
  contrast is the basis for §3.
- `docs/adr/ADR-062-live-ai-pipeline-contract.md` — governs `/api/analyze`.
- The eFlexo capture script this ADR generalizes: session scratchpad
  `eflexo-capture/capture.js` (login selectors already confirmed working against the real
  eFlexo login page, 2026-07-03).
- `docs/MDD_print_estimation.md` §9 — the concrete first use case this tool serves.
