feat: subject portal + consumer self-check - #155
Merged
Conversation
New files + migration. Router registration (server/routers.ts) and drizzle schema.ts appends are supplied as exact patches in the PR body for the integration commit, per big-file push strategy.
…mit pushed placeholders in error)
…mmit pushed a placeholder in error)
…ring, minimal disclosure)
This was referenced Sep 13, 2026
munisp
added a commit
that referenced
this pull request
Sep 14, 2026
…gration) (#160) - WP1 (#154): entitySearchRouter import + appRouter registration - WP2 (#158): monitoringRouter import + appRouter registration - WP3 (#155): subjectPortalRouter + computeDataCompleteness imports; subjectPortal registration; removed routers.ts-local getFallbackSuggestion (now shared in server/dataCompleteness.ts); getDataCompleteness delegates to computeDataCompleteness; consentPurposeEnum gains consumer_self_check; subjectAccessTokens/subjectDisputes pgTable declarations (matches drizzle/0023_subject_portal.sql) - WP4 (#157): shareableReportsRouter + selfServiceBillingRouter imports + registrations; reportShareLinks/planSignups pgTable declarations (matches drizzle/0024_share_links_and_plan_signups.sql) - WP5 (#156): lookup.phone procedure (gatewayFetch /v1/phone/:number, validated input) Co-authored-by: bis-integration <integration@bis.local>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What
WP3 — subject-facing portal + consumer self-check (Checkr candidate-portal / Intelius self-check analog).
New router
subjectPortalRouter(server/subjectPortal.ts), 4 procedures:requestSelfCheck(public, rate-limited): subject submits fullName + ninOrBvn (11-digit) + phone + signed consent text (min 40 chars, mandatory). In ONE pg transaction: tenant existence check → upsertcandidate_profiles(matched on NIN/BVN within tenant) → insert signed, unrevokedcandidate_consents(purposeconsumer_self_check) → createinvestigationsrow (purpose='consumer_self_check',BIS-<year>-<rand>ref per existinggenerateRefconvention) → issue tokenbis_sp_<24 random bytes base64url>storing ONLY its SHA-256 hex hash, 72h expiry. Returns{ token, investigationRef, expiresAt }. PublishesSUBJECT_SELF_CHECK_REQUESTED.getMyStatus(token-authed): token resolved via SHA-256 hash lookup againstsubject_access_tokens.token_hash— same scheme PR fix: critical audit remediation — tenant isolation, OpenClaw auth, webhook IDOR/SSRF, document vault authz #151 established inserver/openclawEndpoints.ts(unknown prefix / unknown hash / revoked / expired / wrong-purpose all reject, fail-closed). Tenant + candidate come from the TOKEN ROW, never client input. Minimal-disclosure response: investigation ref + status, data-completeness {score, sourcesChecked, sourcesTotal, thinFile} (computed by the SAME extractedcomputeDataCompletenessasinvestigations.getDataCompleteness— no drift), thin-file flag, and per-reference provenance LABELS only (claimed/attested/independently_confirmed/contradicted). No referee names, contacts, claim text, or subject PII.submitDispute(token-authed): insertssubject_disputeswithstatement_sha256+statement_enc. The Phase-1 PII helper exists (server/piiEnvelopeCrypto.ts+activeTenantEncryptionRegistry), so the statement is encrypted as a Vault Transit envelope keyed to the tenant's active key (AAD bound to tenant/candidate/dispute). If encryption is unavailable the dispute is REJECTED (fail-closed) — plaintext is never stored. If aninformal_verification_casesrow exists for the candidate, it is transitioned to'disputed'with the SAME semantics asinformalVerification.submitCorrection(identical status guardcollecting/under_review/completed, identicalsubject_correction_submittedevent + sha256 digest scheme, mirrored with an explanatory comment because the subject is token-authenticated, not an operator session).writeAuditLog+publishEvent('SUBJECT_DISPUTE_SUBMITTED')(helpers mirror the routers.ts-local ones — same HMAC format/envelope; comment explains why they're not imported: circular dependency).resolveDispute(protectedProcedure, admin/supervisor role, explicit tenant scope): resolution text (min 10), status →resolved, idempotent (second resolution → CONFLICT), audit +SUBJECT_DISPUTE_RESOLVED.Rate limiting: the tRPC layer has no per-procedure limiter (express-rate-limit exists at the HTTP layer only), so a dedicated per-IP in-memory fixed-window limiter (10 req / 15 min) guards this credential-issuing surface, with a comment noting the Redis upgrade path for multi-replica deployments.
Persistence: hand-crafted migration
drizzle/0023_subject_portal.sql(+ journal entry indrizzle/meta/_journal.json, applied byscripts/migrate-postgres.ts):subject_access_tokens,subject_disputes, two enum types, andALTER TYPE consent_purpose ADD VALUE IF NOT EXISTS 'consumer_self_check'. Runtime code uses raw pg SQL (same pattern as the informal_verification tables).Why
Closes the subject-portal gap: data subjects currently have no self-check intake, no status view, and no dispute channel wired into the informal-verification provenance flow.
How tested (real output)
19 tests cover: token issue/verify/expiry/revoke/unknown-token rejection, purpose mismatch (status token cannot dispute), consent gate (short/absent consent rejected before any DB connection), candidate upsert-by-NIN, unknown tenant rejection, per-IP rate limit, dispute → case
disputedwiring + immutable case event + encrypted statement (plaintext absent), no-case dispute still recorded, wrong-tenant case untouched, resolveDispute tenant isolation (cross-tenant CONFLICT), analyst FORBIDDEN, double-resolution idempotency, and a serialized minimal-disclosure assertion (forbidden keys: source_display_name, contact, referee names, nin/bvn/phone/email/fullName/subjectName/statement).Regression checks (all green):
pnpm exec vitest run server/field-visit.thinfile.test.ts server/field-visit.phase4.test.ts server/consumerDisputeDeadlineEscalation.test.ts server/openclawEndpoints.auth.test.ts server/apiTokens.tenantIsolation.test.ts→ 5 files, 77 passedpnpm exec tsc --noEmit -p tsconfig.json→ exit 0server/smoke.comprehensive.test.ts: 99/100 pass; the single failure (TigerBeetlecreditTenantAccountneedsBIS_DATABASE_URL) reproduces identically on pristinemain— pre-existing, unrelated.server/routers.ts(374KB) anddrizzle/schema.ts(205KB) are too large for MCP push. This branch intentionally does NOT include them — apply these exact patches on main in the integration commit (both verified locally: full typecheck + all tests above ran with these applied).Patch 1 —
server/routers.tsimport { informalVerificationRouter } from "./informalVerification";):appRouterobject, immediately afterkycDocumentEvidence: kycDocumentEvidenceRouter,(currently the last entry, ~line 7691):function getFallbackSuggestion(...)block (lines ~247–263, directly under the// ─── Investigations Router ───banner) and replace with:getDataCompleteness, replace everything after theif (!db) return {...}line (the whole inline computation down to thereturn { score, ... }line) with:(
getFallbackSuggestionis only referenced from this one call site in routers.ts.)Patch 2 —
drizzle/schema.ts(Runtime code does not import these drizzle table objects — raw SQL only — so the portal works even before Patch 2 lands; Patch 2 keeps the typed schema in sync.)
Risks / deviations
investigations.createdByis set to0(SYSTEM_ACTOR_ID) for self-check intake — no operator session exists and the column has no FK. Flagged in code comment.requestSelfChecktakestenantIdas input (tenant-branded portal link determines the tenant); it is validated against thetenantstable, and every subsequent read derives tenant from the token row, never client input.<cand-ref>@self-check.bis.internalused at intake (email is NOT NULL on candidate_profiles; frscQuickCheck precedent).