Plan: craig-intake Portal — Phase 2 (Integration Aids)

On this page

Status

Phase 2 of the portal-consolidation program (epic &60). Depends on Phase 1 (theming foundation — complete). All three aids are standalone-SHINES-profile only; they do not touch the integrated path or any other deployment. This plan was iterated through contextless + external review (plan mode) before any code; the design below is the authority.

Step Description Status

P2.1 (#713)

JSON debug panel — emit the exact CpsRequest POSTed to SHINES, with a copy button. PII guarded: explicit-ack enablement (CRAIG_INTAKE__DEBUG_EMIT_CPS_REQUEST must equal EXPOSE_SSN_PII), Shines-only, no-store, never logged, kept out of the OpenAPI/SDK contract.

Done (2026-06-26) — debug CpsRequest panel landed (#713)

P2.2 (#714)

View-keys endpoint — a new keyring GET /keys (metadata-only, default-off gate) + a same-origin intake proxy (GET /signed/v1/keys), mirroring the register proxy.

Done (2026-06-26) — keyring list + intake proxy landed (#714)

P2.3 (#715)

View-keys page + nav — a /keys page listing registered keys, and per-profile top-of-page nav across the standalone-SHINES pages.

Done (2026-06-26) — view-keys page + per-profile nav landed (#715)

Epic: &60
Phase 2 issues: #713 (panel), #714 (view-keys endpoint), #715 (page + nav)
Branch convention: feature/Phase 2 of the craig-intake single-public-portal program (epic &60). Three integration aids for the standalone-SHINES edge — a PII-guarded debug JSON panel showing the exact CpsRequest, top-of-page nav, and a view-keys page (a new gated keyring list + same-origin proxy). All Shines-profile-only. per issue.

Context

Phase 1 made the standalone edge themed; Phase 2 adds the integration/testing aids the edge needs to be a usable SHINES-integration surface, all gated and Shines-only:

  1. A debug JSON panel (the originating feature request): after a SHINES submission, show the exact CpsRequest JSON that intake POSTed to SHINES, with a one-click copy button — so an integrator can see/replay the conformed payload against the (not-yet-built) SHINES intake API.

  2. Top-of-page nav — today you can reach /report from the keygen page but not vice versa, and there is no link to a key list. A small per-profile nav across the standalone-SHINES pages.

  3. A view-registered-keys page — for testing the signed-API onboarding (register → approve → sign), an operator needs to see which keys exist + their lifecycle status. The keyring (sidecar) holds the keys but exposes no list route today; Phase 2 adds a gated metadata-only GET /keys, a same-origin intake proxy, and a page.

Scope

In scope: the three aids above, Shines-profile-only, each its own MR.

Out of scope:

  • The integrated path + non-SHINES deployments (the aids are Shines-only).

  • Mandated-reporter signing in the portal (Phase 4) — view-keys here is read-only.

  • Any change to what is POSTed to SHINES (the panel only reflects the existing CpsRequest).

  • A persisted/audited key-management UI — view-keys is a read-only testing aid behind a gate.

Design

P2.1 — JSON debug panel (#713)

The form (report.html) posts to POST /public/v1/reports (submit_report, services/craig-intake/src/api/public.rs:105), which calls intake_sink.forward(…​) and returns the confirmation. Under the Shines profile the sink is ShinesSink, whose forward (backend/shines/sink.rs:115) builds the CpsRequest via mapper::map (:130) and POSTs it. The panel surfaces that CpsRequest.

PII posture (decided)

The payload contains third-party and minor PII — the child and adult SSNs the reporter entered about others (backend/shines/mapper.rs:264,289,317), not "the reporter’s own data". Reflecting it to the person who typed it adds ~no exposure, and in the intended dev/integration use the values are test data. The real, modest risk is (a) the SSNs now ride the HTTP response channel (previously the response was only {id, submitted_at}), and (b) accidental enablement in a real deployment echoing real minors' SSNs at scale. No compile-time production signal exists in IntakeSettings, so L3’s "refuse-in-production" is realized as:

  • Explicit-ack enablement. The flag is Option<String>, active only when it equals the literal EXPOSE_SSN_PII — a stray =true does not enable it (it logs a warning that the value did not match). Env CRAIG_INTAKE__DEBUG_EMIT_CPS_REQUEST.

  • validate() additionally requires backend_profile == Shines when active; a loud boot tracing::warn! names the SSN exposure; the debug response carries Cache-Control: no-store; the value is never passed to tracing::*, and no response-body logging layer may observe it.

Three seams (keep the PII contained)

  1. Capture (input side). Add a borrowed slot to ForwardRequest (sink/mod.rs, beside the existing raw: &'a serde_json::Value):

    /// Debug-only capture for the conformed backend payload (L3, P2.1). `Some` ONLY when the
    /// debug ack value is set (Shines profile); the backend writes its conformed request here for
    /// the handler to surface. A borrow (no Arc), `std::sync::Mutex` (no new dep — `parking_lot`
    /// is not one); the guard drops synchronously after the write, never held across `await`, so
    /// the handler future stays `Send`. Default `None`.
    pub debug_capture: Option<&'a std::sync::Mutex<Option<String>>>,

    All five ForwardRequest construction sites add the fieldSome(&cap) at the public handler (api/public.rs:186); None at the other four (api/partner.rs:213, backend/shines/signed_submit.rs:141, and the two backend/shines/sink.rs test sites :279/:351).

  2. Populate (Shines only). In ShinesSink::forward right after let cps = mapper::map(&input)? (sink.rs:130). No unwrap/expect; never tracing:: the value*:

    if let Some(slot) = req.debug_capture {
        if let Ok(json) = serde_json::to_string_pretty(&cps) {
            if let Ok(mut g) = slot.lock() { *g = Some(json); }
        }
    }
  3. Attach (contract-clean). The shared ReportConfirmation wire shape (api/public.rs:31; the SDK mirrors it in crates/craig-intake-sdk/src/types/dto.rs) is not modified — the SDK keeps deserializing the normal response unchanged. A craig-intake-local superset carries the debug field and derives serde::Serialize onlynot utoipa::ToSchema, and not referenced in the endpoint’s #[utoipa::path(responses(…​))] — so the field never enters the published OpenAPI/SDK contract:

    // craig-intake-local (NOT a shared contract): kept out of the published OpenAPI/SDK.
    #[derive(serde::Serialize)] // NOT utoipa::ToSchema
    struct DebugReportConfirmation {
        #[serde(flatten)]
        base: ReportConfirmation,
        /// DEBUG ONLY (L3): the exact CpsRequest POSTed to SHINES. Contains PII.
        debug_cps_request: String,
    }

Handler + config

submit_report (api/public.rs:105) changes from Result<Json<ReportConfirmation>, ApiError> to Result<Response, ApiError>. A DebugEmitCps(bool) newtype — resolved at boot to (ack value matched + profile == Shines) — is layered as an Extension on the public routes in main.rs, mirroring the existing BackendProfile / CaptchaVerifier extensions. When set, the handler creates a std::sync::Mutex::new(None), passes Some(&cap) into ForwardRequest, then builds the response so the no-store header rides the debug arm only:

match cap.lock().ok().and_then(|mut g| g.take()) {
    Some(cps_json) => Ok((
        [(header::CACHE_CONTROL, "no-store")],
        Json(DebugReportConfirmation { base: confirmation, debug_cps_request: cps_json }),
    ).into_response()),
    None => Ok(Json(confirmation).into_response()),
}

Config (config.rs): #[serde(default)] pub debug_emit_cps_request: Option<String> (mirror the shape of require_captcha, config.rs:142); validate() (:210) rejects the ack value when backend_profile != Shines; a loud boot warn! when active. Devstack sets the ack value on craig-intake-standalone-shines.

UI

report-form.js renders a panel below the success card when the response carries debug_cps_request, with a Copy button using a new CSP-safe copy-helper.js (Clipboard API
execCommand('copy') fallback for non-secure contexts like host.docker.internal — see the keygen secure-context note). Its serve_copy_helper_js route mounts in the general report-asset block of ui.rs (report.html is served under all profiles, ui.rs:31) — not the keygen block. The panel styles use the Phase-1 bundle tokens (no raw hex).

P2.2 — View-keys endpoint (keyring list + intake proxy) (#714)

Access (decided): an open same-origin list when the default-off gate is on — the data is low-sensitivity (public-key metadata only; no private keys, no PII beyond a self-chosen display_name); default-off + the internal-only keyring deployment (ADR-042 §D8) are the protection.

Keyring (craig-intake-keyring).

  • KeyringState::list_all() → Vec<KeyRecord> (store.rs) — clone the Inner.keys values, sorted by created_at then kid (deterministic).

  • A new metadata DTO SignerKeyListEntry { kid, display_name, status, created_at, expires_at } (Serialize) — omit public_key_jwk and user_identifier (metadata only). Include expires_at because lookup_active treats an expired-but-approved key as inactive (store.rs:237); the view computes an effective status (approved + past expires_at → "expired") so the list is not misleading.

  • #[serde(default)] pub enable_key_list: bool on KeyringSettings (config.rs:14), env CRAIG_INTAKE_KEYRING__ENABLE_KEY_LIST.

  • Gate = conditional route mount. The keyring AppState (api.rs) carries only the store
    admin, not settings, so the flag is threaded as a build_router(state, admin, enable_key_list) parameter (a signature change), then if enable_key_list { router = router.route("/keys", get(list_keys)) }. When off the route is simply absent → 404 (no existence signal; no 403). The route is unauthenticated like register (the keyring is internal-only, never host-published in prod — ADR-042 §D8); the gate + the deployment boundary are the protection.

Intake proxy. Add list_signer_keys (backend/shines/signed_submit.rs) → forwards to keyring GET /keys, mirroring register_signer_key (signed_submit.rs:244): uses the Extension<reqwest::Client> + Extension<KeyringUrl> already on the signed nest; relays the keyring’s JSON on success; relays a gate-off 404; a keyring outage is a redacted 500 (redact_upstream_error("keyring", …)). Mount .route("/keys", get(list_signer_keys)) in signed_routes() (api/mod.rs:123) — Shines-gated by construction (the signed nest mounts at /signed/v1 only under Shines, main.rs:453). Devstack enables the gate on the keyring.

P2.3 — View-keys page + nav (#715)

Per-profile nav. Only report.html carries the relevance store (it loads intake-schema.js; keygen/status load neither — static/js/intake-schema.js:21), and status lookup is not mounted under SHINES (mount_status:false, ADR-042 §D7 — api/public.rs:57), so a SHINES page must not link "Check Status":

Page Served under Nav

report.html

all profiles (has store)

Submit · x-show shines: Register a Key · x-show shines: View Keys · x-show !shines: Check Status

keygen.html

SHINES only

Submit · Register a Key · View Keys (hard-coded; no store needed)

keys.html (new)

SHINES only

Submit · Register a Key · View Keys (hard-coded)

status.html

non-SHINES

Submit · Check Status (first link’s label normalized ReportSubmit for cross-page consistency; structure unchanged)

View-keys page. A new static keys.html + a CSP-safe Alpine factory view-keys.js that fetch`es `GET /signed/v1/keys and renders a table (kid, display name, effective-status badge, created), with empty + error states. theme.css before intake-public.css. serve_keys_form
serve_view_keys_js routes in ui.rs, mounted in the same if keygen (Shines) block (the keyring’s default-off enable_key_list is the real data gate; no separate view_keys flag).

Key-status badge CSS. Add a dedicated .key-status-badge
.key-status-approved/-pending/-revoked/-expired classes to intake-public.css. A distinct namespace from the report .status- classes (not the originally-sketched .status-approved set): .status-pending already exists as a *report status and status.html builds report classes dynamically as 'status-' + result.status (intake-public.css:91), so a key vocabulary sharing the status- prefix would collide. Reuse the Phase-1 --success/--warning/--danger/--muted tokens.

Steps

Step P2.1 — JSON debug panel (#713)

  1. Capture seam (ForwardRequest.debug_capture) + None at all five construction sites.

  2. Populate in ShinesSink::forward (never log it).

  3. Config flag (Option<String>, ack value) + validate() (Shines-only) + loud boot warn.

  4. DebugEmitCps Extension in main.rs; handler conditional debug-augmented response + no-store.

  5. UI panel + copy-helper.js (general report-asset route; CSP-safe; secure-context fallback).

  6. Tests + devstack ack value + e2e.

Step P2.2 — View-keys endpoint (#714)

  1. Keyring list_all + SignerKeyListEntry (with expires_at) + enable_key_list setting.

  2. build_router(state, admin, enable_key_list) signature change + conditional /keys mount.

  3. Intake list_signer_keys proxy + .route("/keys", …) in signed_routes().

  4. Tests (keyring list/gate/order; proxy relays list / relays 404 / redacts outage) + devstack gate.

Step P2.3 — View-keys page + nav (#715)

  1. keys.html + view-keys.js + serve_keys_form/serve_view_keys_js (Shines block).

  2. Per-profile nav across report.html/keygen.html/keys.html (status.html untouched).

  3. Key-status badge CSS.

  4. e2e (nav per profile, both SHINES + non-SHINES; /keys lists a key after register). The empty/error render branches are exercised by the view-keys.js factory but not asserted in e2e — the shared devstack keyring is stateful (prior specs register keys, so "empty" is non-deterministic) and the gate is fixed-on (so the proxy’s 404 "not enabled" path can’t be triggered in-instance); both are covered by the #714 keyring unit tests (gate-off → 404, order).

Files Touched (summary)

Area Change

sink/mod.rs + backend/shines/sink.rs + api/public.rs + config.rs + main.rs

Debug capture seam + populate + contract-clean debug response + ack flag + Extension (P2.1)

static/js/report-form.js + new copy-helper.js + ui.rs

JSON panel UI + copy button (P2.1)

craig-intake-keyring (store.rs + api.rs + config.rs)

Gated metadata-only GET /keys (P2.2)

backend/shines/signed_submit.rs + api/mod.rs (signed nest)

Same-origin GET /signed/v1/keys proxy (P2.2)

new static/keys.html + view-keys.js + ui.rs + nav in the 3 pages + badge CSS

View-keys page + per-profile nav (P2.3)

docker-compose.yml

Devstack: debug ack value on the shines instance + key-list gate on the keyring (P2.1/P2.2)

CHANGELOG.adoc + this plan Status

Per MR

Verification

cargo fmt --all
cargo clippy --workspace --all-targets --locked -- -D warnings
cargo nextest run -p craig-intake -p craig-intake-keyring
cargo xtask sdk-test          # SDK contract gate — proves ReportConfirmation / the SDKs are unchanged
cargo xtask dev reload        # CSS/HTML/flags are include_str!'d / compose-set
cargo xtask e2e --no-refresh -- --project=intake-ui-shines
cargo xtask plan-lint && cargo xtask check-docs
# (cargo xtask validate runs sdk-test + the above gates as the pre-push battery)

Assertions:

  • P2.1: flag-absent (default) → no debug_cps_request, no no-store; ack value + Shines → the response carries the pretty CpsRequest (PII) + Cache-Control: no-store, the panel renders it, the copy button works; =true (non-ack) → disabled; validate() rejects the ack value under a non-Shines profile; the value never appears in logs (PII-never-logged test); the shared ReportConfirmation contract + the SDKs are unchanged.

  • P2.2: keyring GET /keys lists registered keys' metadata (no JWK, includes expires_at) when the gate is on, an expired-approved key reads as "expired", 404 when off; the intake proxy relays the list, relays 404, redacts an outage to 500.

  • P2.3: nav links are correct per profile (SHINES → Register a Key + View Keys, no dead "Check Status"; non-SHINES → Check Status, no key links); /keys shows a key after register (effective status "pending"); no raw hex (the Phase-1 guard still passes). The empty/error UI branches are scoped out of e2e (stateful shared keyring + gate fixed-on) and covered by the #714 unit tests.

Documentation Updates

Edit this page · latest