Standalone Mode for craig-intake

On this page

Status

COMPLETE — Delivered in MR !37, issue #126 closed. All child issues (#58–#62) also closed.

Context

craig-intake currently runs as part of the full CRAIG CCWIS: PostgreSQL for report storage, RabbitMQ for events, Keycloak for caseworker auth, and an HTTP call to craig-cases for referral conversion. All of these are required at startup.

This plan adds a configuration-driven mode switch so craig-intake can be deployed independently as a stateless validation + forwarding gateway — accept public reports, validate them, and forward to a different CCWIS via HTTP. No database, no message queue, no auth infrastructure.

The SDK (craig-intake-sdk) requires zero changes — it talks to the same public/partner API surface.

When JWS integrity verification is implemented (Phase 2e), the forwarding sink must relay the X-JWS-Signature header transparently to the upstream CRAIG instance for signature verification. Standalone instances do not store signer keys, expose key management endpoints, or perform JWS verification — the central CRAIG instance is the sole authority for key approval and signature validation. Officers register their signing keys directly with the central CRAIG, not through standalone instances.

Design: Enum-Based Sink (No New Dependencies)

Instead of a dyn trait + async-trait crate, use Rust enums to dispatch between the two modes. The set of variants is known at compile time (database vs forwarding), so dynamic dispatch is unnecessary.

Two enums:

  • ReportSink — abstracts what happens after validation (store in DB vs forward over HTTP)

  • ApiKeyLookup — abstracts API key validation (query DB vs check in-memory config)

Public and partner handlers extract these via Extension<ReportSink> / Extension<Arc<ApiKeyLookup>> instead of State<AppState> + Extension<Publisher>. Internal routes (review queue, API key management) remain unchanged — only mounted in integrated mode.

Files to Create

1. services/craig-intake/src/sink.rs

pub enum ReportSink { Database(DatabaseSink), Forwarding(ForwardingSink) }

pub struct AcceptedReport { id: Uuid, confirmation_code: String, submitted_at: DateTime<Utc> }

pub struct ReportStatusResult { confirmation_code: String, status: String, submitted_at: DateTime<Utc>, updated_at: DateTime<Utc> }

ReportSink::accept() — dispatches to variant:

  • DatabaseSink: existing logic from public.rs lines 116-155 — store::reports::create_report() + events::publish_report_submitted()

  • ForwardingSink: build JSON envelope → POST to config.forward_url with configured auth → return confirmation

ReportSink::check_status() — dispatches to variant:

  • DatabaseSink: store::reports::get_report_status()

  • ForwardingSink: if forward_status_url configured, proxy request to target; otherwise return static "forwarded" status

DatabaseSink fields: db: DbPool, publisher: Publisher (both Clone)

ForwardingSink fields: http: reqwest::Client, config: ForwardingConfig

ForwardingConfig:

  • target_url: String — where to POST reports

  • target_auth: TargetAuth — enum: None, BearerToken(String), ApiKey { header: String, value: String }

  • status_url: Option<String> — URL template with {code} placeholder; None = return static "forwarded"

ForwardedReport (Serialize) — the JSON envelope sent to the target CCWIS. Contains all validated report fields plus intake_id and confirmation_code for correlation.

Derive Clone on everything — no Arc wrapper needed for Extension.

Intake Forward Adapter (API Translation Layer)

The ForwardingSink assumes the target system speaks the CRAIG intake API format. For deployments forwarding to non-CRAIG systems (state legacy intake systems, third-party CCWIS platforms), an adapter trait translates the CRAIG report format to the target’s expected API contract.

/// Trait for translating CRAIG intake reports to external system formats.
/// Implementations handle request/response mapping for a specific target system.
pub trait IntakeForwardAdapter: Send + Sync + 'static {
    /// Transform a CRAIG ForwardedReport into the target system's request format.
    /// Returns (url, headers, body) tuple for the outbound HTTP call.
    async fn translate_report(&self, report: &ForwardedReport) -> Result<AdapterRequest, AdapterError>;

    /// Transform the target system's response into a CRAIG AcceptedReport.
    async fn translate_response(&self, response: reqwest::Response) -> Result<AcceptedReport, AdapterError>;

    /// Optionally translate a status check request. Returns None if the target
    /// system doesn't support status queries (falls back to static "forwarded").
    async fn translate_status(&self, code: &str) -> Option<Result<AdapterRequest, AdapterError>>;
}

pub struct AdapterRequest {
    pub url: String,
    pub method: reqwest::Method,
    pub headers: reqwest::header::HeaderMap,
    pub body: serde_json::Value,
}

Built-in adapters:

  • CraigAdapter — identity transform (current behavior, CRAIG-to-CRAIG forwarding)

  • Future: state-specific adapters (e.g., GeorgiaShinesAdapter, SacwisAdapter) implemented as separate crates or configured via JSON mapping files

Configuration:

[intake]
mode = "standalone"
forward_url = "https://legacy-system.example.gov/api/intake"
forward_adapter = "craig"  # "craig" (default) | "json_mapping" | custom
# For json_mapping adapter:
# forward_mapping_file = "/etc/craig/legacy-mapping.json"

The ForwardingSink receives a Box<dyn IntakeForwardAdapter> at construction time. The CraigAdapter is used by default. Custom adapters can be loaded at startup based on configuration.

See Phase 2e/2f for the relationship between JWS header forwarding and the adapter layer — adapters must preserve the X-JWS-Signature header for upstream verification.

2. services/craig-intake/src/api/api_key_lookup.rs

pub enum ApiKeyLookup { Database(DbApiKeyLookup), Config(ConfigApiKeyLookup) }

pub struct DbApiKeyLookup { db: DbPool }
pub struct ConfigApiKeyLookup { keys: HashMap<String, ValidatedApiKey> }

pub struct PartnerKeyConfig { api_key: String, organization: String, rate_limit_rpm: Option<i32> }

ApiKeyLookup::get_by_hash() — dispatches:

  • DbApiKeyLookup: existing logic from api_key_auth.rs lines 45-68 — query DB, check expiry, fire-and-forget update_last_used

  • ConfigApiKeyLookup: HashMap lookup by key hash

ConfigApiKeyLookup::from_config(partners: &[PartnerKeyConfig]) — hashes each key via hashing::hash_api_key(), builds HashMap. Called once at startup.

ConfigApiKeyLookup::from_file(path: &str) — reads JSON file, deserializes to Vec<PartnerKeyConfig>, calls from_config().

Files to Modify

3. services/craig-intake/src/config.rs

Add IntakeMode enum:

#[derive(Clone, Debug, Default, PartialEq, Eq, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum IntakeMode { #[default] Integrated, Standalone }

Change IntakeSettings:

  • Add #[serde(default)] pub mode: IntakeMode

  • Make database_url, rabbitmq_url, oidc_issuer, cases_url all Option<String> (required in integrated, ignored in standalone)

  • Add standalone config fields:

    • forward_url: Option<String> — target CCWIS URL (required in standalone)

    • forward_auth_type: String"none" | "bearer" | "api_key" (default: "none")

    • forward_auth_value: Option<String> — token or key value

    • forward_auth_header: String — header name for api_key type (default: "X-Api-Key")

    • forward_status_url: Option<String> — status proxy URL template

    • partner_keys_file: Option<String> — path to JSON file with partner API keys

Add IntakeSettings::validate():

  • Integrated: require database_url, rabbitmq_url, oidc_issuer, cases_url

  • Standalone: require forward_url; warn if partner_keys_file is None

Update Debug impl to include new fields (mask forward_auth_value).

4. services/craig-intake/src/api/public.rs

Change handler signatures — replace State<AppState> + Extension<Publisher> with Extension<ReportSink>:

pub async fn submit_report(
    Extension(sink): Extension<ReportSink>,
    Extension(captcha): Extension<CaptchaVerifier>,
    ConnectInfo(addr): ConnectInfo<SocketAddr>,
    Json(body): Json<SubmitReportRequest>,
) -> Result<Json<ReportConfirmation>, ApiError>

Handler body: honeypot check → CAPTCHA → validation (unchanged) → sink.accept(&body, "web", Some(&ip_hash)) → return confirmation.

pub async fn check_status(
    Extension(sink): Extension<ReportSink>,
    Path(code): Path<String>,
) -> Result<Json<ReportStatus>, ApiError>

Handler body: sink.check_status(&code) → return status or 404.

Change routes() return type from Router<AppState> to Router<()>.

5. services/craig-intake/src/api/partner.rs

Same changes as public.rs:

  • submit_report_partner: extract Extension<ReportSink> instead of State<AppState> + Extension<Publisher>, call sink.accept(&body, "api", …​)

  • check_status_partner: extract Extension<ReportSink>, call sink.check_status(&code)

  • routes() return type: Router<()>

6. services/craig-intake/src/api/api_key_auth.rs

Change middleware to extract Extension<Arc<ApiKeyLookup>> instead of Extension<ApiKeyState>:

pub async fn api_key_middleware(
    Extension(lookup): Extension<Arc<ApiKeyLookup>>,
    mut request: Request,
    next: Next,
) -> Response

Body: extract X-Api-Key header → hash → lookup.get_by_hash(&hash) → insert ValidatedApiKey extension or return 401.

Remove ApiKeyState struct. Use Arc<ApiKeyLookup> because the middleware runs on every request and HashMap cloning is expensive (unlike DbPool which is internally `Arc’d).

7. services/craig-intake/src/api/mod.rs

  • Add pub(crate) mod api_key_lookup;

  • Change public_routes() return type: Router<AppState>Router<()>

  • Change partner_routes() return type: Router<AppState>Router<()>

  • Remove pub use api_key_auth::ApiKeyState;

  • internal_routes() stays Router<AppState> (unchanged)

8. services/craig-intake/src/main.rs

Add mod sink;. Split boot into two functions:

boot_integrated(settings) (current behavior):

  • Connect DB, Keycloak, RabbitMQ (using .unwrap() on the Option<String> fields — validated)

  • Build ReportSink::Database(DatabaseSink { db, publisher })

  • Build Arc<ApiKeyLookup::Database(DbApiKeyLookup { db })>

  • Build router with all three route tiers: /v1/intake (protected, Router<AppState>), /public/v1, /partner/v1

  • .with_state(state) on the outer router

boot_standalone(settings) (new):

  • Build HTTP client

  • Build ForwardingConfig from settings

  • Build ReportSink::Forwarding(ForwardingSink { http, config })

  • Build Arc<ApiKeyLookup::Config(…​)> from partner_keys_file (if provided)

  • Build CAPTCHA verifier + rate limiter (same as integrated)

  • Build router with only /public/v1 + /partner/v1 + /healthz + UI routes (no internal routes, no AppState, no JWT)

  • No .with_state() needed

health_check: Add mode field to HealthResponse so operators can verify which mode is active.

9. services/craig-intake/src/api/internal.rs

No changes. Internal routes continue using State<AppState> + Extension<Publisher> directly. They are only mounted in integrated mode.

10. No changes to store/, events.rs, transitions.rs, confirmation.rs

These modules are called from DatabaseSink and internal.rs — both only active in integrated mode. They compile in standalone mode but are dead code there (no #[cfg] gates needed).

Embedded Public Report UI

craig-intake serves a self-contained public report form directly in standalone mode only — no template engine, no craig-web dependency. In integrated mode, craig-web already provides the public form at /report.

The form uses Alpine.js + fetch() to call the API on the same origin (no CORS).

Static Files

Embedded via include_str!() at compile time — no runtime file dependencies. Stored in services/craig-intake/static/:

report.html — main report submission form:

  • Same fields as craig-web’s report/form.html: reporter type/info (Alpine.js conditional show), admin_unit, concern_type, concern_description, incident_location/date, immediate_danger, additional_info, children (dynamic array, max 10), alleged perpetrators (dynamic array, max 10), honeypot field

  • Form submit handler: fetch('/public/v1/reports', { method: 'POST', body: JSON.stringify(…​) }) — collects all fields into API-shaped JSON, sends via fetch()

  • On success: displays confirmation code inline (Alpine.js reactive state, no page navigation)

  • On error: displays validation errors inline

  • Alpine.js included from CDN

  • Minimal self-contained CSS (not dependent on Georgia Orchard design system — standalone deployments may be for different agencies)

  • CAPTCHA: conditionally shown based on window.CRAIG_CONFIG.captchaEnabled

  • Accessible: proper labels, ARIA attributes, semantic HTML, lang="en"

status.html — status check form + display:

  • Input: confirmation code text field

  • Submit: fetch('/public/v1/reports/{code}/status') → displays status, submitted_at, updated_at

  • 404 handling: "Report not found" message

  • Links back to report form

Routes Added to craig-intake (standalone only)

Route Handler Content

GET /

Redirect to /report

302 redirect

GET /report

Serve report.html

include_str!("../static/report.html")

GET /report/status

Serve status.html

include_str!("../static/status.html")

GET /ui/config.js

Dynamic JS config

Returns runtime config (CAPTCHA settings)

GET /ui/config.js response:

window.CRAIG_CONFIG = { captchaEnabled: false };
// or when enabled:
window.CRAIG_CONFIG = { captchaEnabled: true, captchaSiteKey: "0x..." };

Generated from IntakeSettings at request time (not embedded). Add captcha_site_key: Option<String> to config.

Implementation

New services/craig-intake/src/ui.rs module:

  • serve_report_form() — returns Html(include_str!("../static/report.html"))

  • serve_status_form() — returns Html(include_str!("../static/status.html"))

  • serve_ui_config(Extension<UiConfig>) — returns (content_type_js, config_js_string)

UI routes wired into boot_standalone() only.

Test Plan

Unit Tests (new, in sink.rs)

  • forwarding_sink_posts_to_target() — start a local Axum mock server, create ForwardingSink pointing at it, call accept(), verify mock received correct JSON envelope with all report fields

  • forwarding_sink_returns_error_on_target_failure() — mock returns 500 → accept() returns Err

  • forwarding_sink_applies_bearer_auth() — verify Authorization: Bearer <token> header sent to target

  • forwarding_sink_applies_api_key_auth() — verify custom header sent to target

  • check_status_no_proxy_returns_forwarded()status_url = None → returns static "forwarded"

  • check_status_proxies_to_target() — mock status endpoint → verify response proxied

Unit Tests (new, in api_key_lookup.rs)

  • config_store_finds_valid_key() — hash a key, look it up, verify organization matches

  • config_store_returns_none_for_unknown_key() — unknown hash → None

  • config_store_default_rate_limit() — omit rate_limit_rpm → defaults to 60

Unit Tests (new, in config.rs)

  • integrated_requires_database_url() — mode=integrated, database_url=None → validate() Err

  • standalone_requires_forward_url() — mode=standalone, forward_url=None → validate() Err

  • standalone_does_not_require_database_url() — mode=standalone, forward_url=Some → validate() Ok

Existing Tests (42 integration tests)

All existing integration tests run unchanged — they test integrated mode (the default). No modifications needed.

Playwright E2E Tests (new spec: tests/e2e/specs/intake-embedded-ui.spec.ts)

The embedded UI is standalone-only, so testing it requires a standalone-mode craig-intake instance. Add to docker-compose.yml:

craig-intake-standalone — second craig-intake container on port 8009, configured as:

craig-intake-standalone:
  <<: *craig-intake  # reuse same build
  ports: ["8009:8009"]
  environment:
    CRAIG_INTAKE__MODE: standalone
    CRAIG_INTAKE__PORT: "8009"
    CRAIG_INTAKE__FORWARD_URL: http://craig-intake:8008/public/v1/reports
    CRAIG_INTAKE__CAPTCHA_SECRET: disabled
    CRAIG_INTAKE__PUBLIC_RATE_LIMIT: "100"

This forwards to the integrated instance’s public endpoint. The integrated instance accepts the ForwardedReport JSON because it includes all required SubmitReportRequest fields (plus extra fields like intake_id that are ignored by serde’s default behavior). Status checks are not proxied (no FORWARD_STATUS_URL), so they return static "forwarded".

The integrated instance receives ForwardedReport which has extra fields (intake_id, confirmation_code, submitted_at) beyond SubmitReportRequest. By default, serde’s Deserialize ignores unknown fields, so the integrated endpoint accepts it without error. Verify this during implementation.

Uses CRAIG_INTAKE_STANDALONE_URL (default http://host.docker.internal:8009) as the base URL. New Playwright project intake-ui (no auth, like the existing public project).

Tests (~6):

  • report form loads at /report — verify form fields present (reporter_type, admin_unit, concern_type, concern_description, etc.)

  • submit report via embedded form — fill required fields, submit via form button, verify confirmation code displayed inline

  • submit report with missing fields shows errors — leave concern_description empty, verify validation error

  • status check form loads at /report/status — verify code input field

  • submit report and check status shows forwarded — submit via form, take confirmation code, enter on status page, verify "forwarded" status

  • check status of invalid code shows not found — enter bogus code, verify "not found" message

Playwright config addition — new intake-ui project:

{
  name: 'intake-ui',
  testMatch: /intake-embedded-ui/,
  use: { baseURL: process.env.CRAIG_INTAKE_STANDALONE_URL || 'http://host.docker.internal:8009' },
}

No auth setup needed — these are public pages.

Docker Compose: add craig-intake-standalone service + env var CRAIG_INTAKE_STANDALONE_URL to craig-e2e container. Add health check for port 8009 to devstack scripts.

Implementation Order

  1. Create sink.rsReportSink enum, DatabaseSink (extract logic from public.rs), ForwardingSink, ForwardingConfig, TargetAuth, ForwardedReport. Unit tests for forwarding sink with mock server.

  2. Create api/api_key_lookup.rsApiKeyLookup enum, DbApiKeyLookup (extract logic from api_key_auth.rs), ConfigApiKeyLookup, PartnerKeyConfig. Unit tests.

  3. Refactor public.rs + partner.rs — change handlers from State<AppState> to Extension<ReportSink>, change routes to Router<()>.

  4. Refactor api_key_auth.rs — change middleware from Extension<ApiKeyState> to Extension<Arc<ApiKeyLookup>>. Remove ApiKeyState.

  5. Update api/mod.rs — add module, fix return types, remove ApiKeyState re-export.

  6. Update config.rs — add IntakeMode, make DB/MQ/KC fields optional, add forwarding + partner config, add validate(), add captcha_site_key. Unit tests.

  7. Refactor main.rs — split into boot_integrated() / boot_standalone(), wire up sinks and lookups. Add mode to health check.

  8. Create embedded UIstatic/report.html, static/status.html, src/ui.rs module, wire UI routes into boot_standalone() only.

  9. Docker Compose — add craig-intake-standalone service (port 8009), add CRAIG_INTAKE_STANDALONE_URL to craig-e2e, update devstack health checks.

  10. Playwright tests — new spec file intake-embedded-ui.spec.ts, new intake-ui project in playwright config.

  11. Run full test battery — unit tests, devstack restart (new container), integration tests, E2E tests.

Environment Variables (Standalone Deployment)

CRAIG_INTAKE__MODE=standalone
CRAIG_INTAKE__PORT=8008
CRAIG_INTAKE__FORWARD_URL=https://ccwis.example.gov/api/v1/intake
CRAIG_INTAKE__FORWARD_AUTH_TYPE=bearer          # none | bearer | api_key
CRAIG_INTAKE__FORWARD_AUTH_VALUE=eyJhbGc...     # token or key value
CRAIG_INTAKE__FORWARD_AUTH_HEADER=X-Api-Key     # only used when type=api_key
CRAIG_INTAKE__FORWARD_STATUS_URL=https://ccwis.example.gov/api/v1/intake/{code}/status
CRAIG_INTAKE__PARTNER_KEYS_FILE=/etc/craig/partner-keys.json
CRAIG_INTAKE__CAPTCHA_SECRET=disabled           # or real secret
CRAIG_INTAKE__PUBLIC_RATE_LIMIT=5
CRAIG_INTAKE__LOG_LEVEL=info
CRAIG_INTAKE__CORS_ORIGINS=*

partner-keys.json format:

[
  { "api_key": "actual-key-value", "organization": "Hospital A", "rate_limit_rpm": 60 },
  { "api_key": "another-key", "organization": "School District B" }
]

NOT required in standalone: DATABASE_URL, RABBITMQ_URL, OIDC_ISSUER, CASES_URL.

Verification

  1. cargo test --workspace --lib — all unit tests pass (existing + new sink/lookup/config tests)

  2. cargo xtask dev restart — devstack healthy with both craig-intake (8008) and craig-intake-standalone (8009)

  3. cargo test --workspace — all 42+ integration tests pass (integrated mode unchanged)

  4. cargo xtask e2e — all 103+ E2E tests pass (existing + ~6 new embedded UI tests)

  5. Manual: visit http://localhost:8009/report, submit a report, check status, verify forwarding to integrated instance

Edit this page · latest