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 |
Done (2026-06-26) — debug CpsRequest panel landed (#713) |
P2.2 (#714) |
View-keys endpoint — a new keyring |
Done (2026-06-26) — keyring list + intake proxy landed (#714) |
P2.3 (#715) |
View-keys page + nav — a |
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:
-
A debug JSON panel (the originating feature request): after a SHINES submission, show the exact
CpsRequestJSON 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. -
Top-of-page nav — today you can reach
/reportfrom 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. -
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 literalEXPOSE_SSN_PII— a stray=truedoes not enable it (it logs a warning that the value did not match). EnvCRAIG_INTAKE__DEBUG_EMIT_CPS_REQUEST. -
validate()additionally requiresbackend_profile == Shineswhen active; a loud boottracing::warn!names the SSN exposure; the debug response carriesCache-Control: no-store; the value is never passed totracing::*, and no response-body logging layer may observe it.
Three seams (keep the PII contained)
-
Capture (input side). Add a borrowed slot to
ForwardRequest(sink/mod.rs, beside the existingraw: &'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
ForwardRequestconstruction sites add the field —Some(&cap)at the public handler (api/public.rs:186);Noneat the other four (api/partner.rs:213,backend/shines/signed_submit.rs:141, and the twobackend/shines/sink.rstest sites:279/:351). -
Populate (Shines only). In
ShinesSink::forwardright afterlet cps = mapper::map(&input)?(sink.rs:130). Nounwrap/expect; nevertracing::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); } } } -
Attach (contract-clean). The shared
ReportConfirmationwire shape (api/public.rs:31; the SDK mirrors it incrates/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 derivesserde::Serializeonly — notutoipa::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 theInner.keysvalues, sorted bycreated_atthenkid(deterministic). -
A new metadata DTO
SignerKeyListEntry { kid, display_name, status, created_at, expires_at }(Serialize) — omitpublic_key_jwkanduser_identifier(metadata only). Includeexpires_atbecauselookup_activetreats an expired-but-approvedkey as inactive (store.rs:237); the view computes an effective status (approved + pastexpires_at→ "expired") so the list is not misleading. -
#[serde(default)] pub enable_key_list: boolonKeyringSettings(config.rs:14), envCRAIG_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 abuild_router(state, admin, enable_key_list)parameter (a signature change), thenif 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 |
|---|---|---|
|
all profiles (has store) |
Submit · |
|
SHINES only |
Submit · Register a Key · View Keys (hard-coded; no store needed) |
|
SHINES only |
Submit · Register a Key · View Keys (hard-coded) |
|
non-SHINES |
Submit · Check Status (first link’s label normalized |
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)
-
Capture seam (
ForwardRequest.debug_capture) +Noneat all five construction sites. -
Populate in
ShinesSink::forward(never log it). -
Config flag (
Option<String>, ack value) +validate()(Shines-only) + loud boot warn. -
DebugEmitCpsExtension inmain.rs; handler conditional debug-augmented response +no-store. -
UI panel +
copy-helper.js(general report-asset route; CSP-safe; secure-context fallback). -
Tests + devstack ack value + e2e.
Step P2.2 — View-keys endpoint (#714)
-
Keyring
list_all+SignerKeyListEntry(withexpires_at) +enable_key_listsetting. -
build_router(state, admin, enable_key_list)signature change + conditional/keysmount. -
Intake
list_signer_keysproxy +.route("/keys", …)insigned_routes(). -
Tests (keyring list/gate/order; proxy relays list / relays 404 / redacts outage) + devstack gate.
Step P2.3 — View-keys page + nav (#715)
-
keys.html+view-keys.js+serve_keys_form/serve_view_keys_js(Shines block). -
Per-profile nav across
report.html/keygen.html/keys.html(status.html untouched). -
Key-status badge CSS.
-
e2e (nav per profile, both SHINES + non-SHINES;
/keyslists a key after register). The empty/error render branches are exercised by theview-keys.jsfactory 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 |
|---|---|
|
Debug capture seam + populate + contract-clean debug response + ack flag + Extension (P2.1) |
|
JSON panel UI + copy button (P2.1) |
|
Gated metadata-only |
|
Same-origin |
new |
View-keys page + per-profile nav (P2.3) |
|
Devstack: debug ack value on the shines instance + key-list gate on the keyring (P2.1/P2.2) |
|
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, nono-store; ack value + Shines → the response carries the prettyCpsRequest(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 sharedReportConfirmationcontract + the SDKs are unchanged. -
P2.2: keyring
GET /keyslists registered keys' metadata (no JWK, includesexpires_at) when the gate is on, an expired-approved key reads as "expired",404when 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);
/keysshows 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
-
CHANGELOG.adoc—== Unreleasedper MR. -
Standalone Intake — Backend-Profile Architecture — the debug panel (L3 guards) + the view-keys endpoint/page; craig-intake-keyring — Signer-Key Registry Sidecar — the gated
GET /keys. -
Public Intake API Reference — the
GET /signed/v1/keysproxy + a prose note that, under the debug ack flag, the submit response is an out-of-OpenAPI-contract superset (the debug field is not in the generated schema, by design). -
Configuration Reference —
CRAIG_INTAKEDEBUG_EMIT_CPS_REQUEST(ack valueEXPOSE_SSN_PII, Shines-only) +CRAIG_INTAKE_KEYRINGENABLE_KEY_LIST(both default-off, dev/integration only). -
the program plan’s P2 Status rows →
Doneas each step lands (next-MR-flips-previous).