Standalone Mode for craig-intake
On this page
- Status
- Context
- Design: Enum-Based Sink (No New Dependencies)
- Files to Create
- Files to Modify
- 3.
services/craig-intake/src/config.rs - 4.
services/craig-intake/src/api/public.rs - 5.
services/craig-intake/src/api/partner.rs - 6.
services/craig-intake/src/api/api_key_auth.rs - 7.
services/craig-intake/src/api/mod.rs - 8.
services/craig-intake/src/main.rs - 9.
services/craig-intake/src/api/internal.rs - 10. No changes to
store/,events.rs,transitions.rs,confirmation.rs
- 3.
- Embedded Public Report UI
- Test Plan
- Implementation Order
- Environment Variables (Standalone Deployment)
- Verification
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 frompublic.rslines 116-155 —store::reports::create_report()+events::publish_report_submitted() -
ForwardingSink: build JSON envelope → POST toconfig.forward_urlwith configured auth → return confirmation
ReportSink::check_status() — dispatches to variant:
-
DatabaseSink:store::reports::get_report_status() -
ForwardingSink: ifforward_status_urlconfigured, 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 fromapi_key_auth.rslines 45-68 — query DB, check expiry, fire-and-forgetupdate_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_urlallOption<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 ifpartner_keys_fileis 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: extractExtension<ReportSink>instead ofState<AppState>+Extension<Publisher>, callsink.accept(&body, "api", …) -
check_status_partner: extractExtension<ReportSink>, callsink.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()staysRouter<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 theOption<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
ForwardingConfigfrom settings -
Build
ReportSink::Forwarding(ForwardingSink { http, config }) -
Build
Arc<ApiKeyLookup::Config(…)>frompartner_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.
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 viafetch() -
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 |
|---|---|---|
|
Redirect to |
302 redirect |
|
Serve |
|
|
Serve |
|
|
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()— returnsHtml(include_str!("../static/report.html")) -
serve_status_form()— returnsHtml(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, callaccept(), verify mock received correct JSON envelope with all report fields -
forwarding_sink_returns_error_on_target_failure()— mock returns 500 →accept()returnsErr -
forwarding_sink_applies_bearer_auth()— verifyAuthorization: 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()— omitrate_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
-
Create
sink.rs—ReportSinkenum,DatabaseSink(extract logic from public.rs),ForwardingSink,ForwardingConfig,TargetAuth,ForwardedReport. Unit tests for forwarding sink with mock server. -
Create
api/api_key_lookup.rs—ApiKeyLookupenum,DbApiKeyLookup(extract logic from api_key_auth.rs),ConfigApiKeyLookup,PartnerKeyConfig. Unit tests. -
Refactor
public.rs+partner.rs— change handlers fromState<AppState>toExtension<ReportSink>, change routes toRouter<()>. -
Refactor
api_key_auth.rs— change middleware fromExtension<ApiKeyState>toExtension<Arc<ApiKeyLookup>>. RemoveApiKeyState. -
Update
api/mod.rs— add module, fix return types, removeApiKeyStatere-export. -
Update
config.rs— addIntakeMode, make DB/MQ/KC fields optional, add forwarding + partner config, addvalidate(), addcaptcha_site_key. Unit tests. -
Refactor
main.rs— split intoboot_integrated()/boot_standalone(), wire up sinks and lookups. Add mode to health check. -
Create embedded UI —
static/report.html,static/status.html,src/ui.rsmodule, wire UI routes intoboot_standalone()only. -
Docker Compose — add
craig-intake-standaloneservice (port 8009), addCRAIG_INTAKE_STANDALONE_URLto craig-e2e, update devstack health checks. -
Playwright tests — new spec file
intake-embedded-ui.spec.ts, newintake-uiproject in playwright config. -
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
-
cargo test --workspace --lib— all unit tests pass (existing + new sink/lookup/config tests) -
cargo xtask dev restart— devstack healthy with both craig-intake (8008) and craig-intake-standalone (8009) -
cargo test --workspace— all 42+ integration tests pass (integrated mode unchanged) -
cargo xtask e2e— all 103+ E2E tests pass (existing + ~6 new embedded UI tests) -
Manual: visit
http://localhost:8009/report, submit a report, check status, verify forwarding to integrated instance