Skip to content

feat: subject portal + consumer self-check - #155

Merged
munisp merged 7 commits into
mainfrom
feat/subject-portal
Sep 13, 2026
Merged

feat: subject portal + consumer self-check#155
munisp merged 7 commits into
mainfrom
feat/subject-portal

Conversation

@munisp

@munisp munisp commented Sep 13, 2026

Copy link
Copy Markdown
Owner

What

WP3 — subject-facing portal + consumer self-check (Checkr candidate-portal / Intelius self-check analog).

New router subjectPortalRouter (server/subjectPortal.ts), 4 procedures:

  1. 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 → upsert candidate_profiles (matched on NIN/BVN within tenant) → insert signed, unrevoked candidate_consents (purpose consumer_self_check) → create investigations row (purpose='consumer_self_check', BIS-<year>-<rand> ref per existing generateRef convention) → issue token bis_sp_<24 random bytes base64url> storing ONLY its SHA-256 hex hash, 72h expiry. Returns { token, investigationRef, expiresAt }. Publishes SUBJECT_SELF_CHECK_REQUESTED.
  2. getMyStatus (token-authed): token resolved via SHA-256 hash lookup against subject_access_tokens.token_hash — same scheme PR fix: critical audit remediation — tenant isolation, OpenClaw auth, webhook IDOR/SSRF, document vault authz #151 established in server/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 extracted computeDataCompleteness as investigations.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.
  3. submitDispute (token-authed): inserts subject_disputes with statement_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 an informal_verification_cases row exists for the candidate, it is transitioned to 'disputed' with the SAME semantics as informalVerification.submitCorrection (identical status guard collecting/under_review/completed, identical subject_correction_submitted event + 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).
  4. 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 in drizzle/meta/_journal.json, applied by scripts/migrate-postgres.ts): subject_access_tokens, subject_disputes, two enum types, and ALTER 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)

$ pnpm exec vitest run server/subject-portal.test.ts
 ✓ server/subject-portal.test.ts (19 tests) 45ms
 Test Files  1 passed (1)
      Tests  19 passed (19)

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 disputed wiring + 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 passed
  • pnpm exec tsc --noEmit -p tsconfig.json → exit 0
  • server/smoke.comprehensive.test.ts: 99/100 pass; the single failure (TigerBeetle creditTenantAccount needs BIS_DATABASE_URL) reproduces identically on pristine main — pre-existing, unrelated.

⚠️ INTEGRATION PATCHES REQUIRED (big-file push strategy)

server/routers.ts (374KB) and drizzle/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.ts

  1. Import — insert immediately after line 31 (import { informalVerificationRouter } from "./informalVerification";):
import { subjectPortalRouter } from "./subjectPortal";
import { computeDataCompleteness } from "./dataCompleteness";
  1. Registration — at the END of the appRouter object, immediately after kycDocumentEvidence: kycDocumentEvidenceRouter, (currently the last entry, ~line 7691):
  subjectPortal: subjectPortalRouter,
  1. Extraction (anti-drift) — delete the local function getFallbackSuggestion(...) block (lines ~247–263, directly under the // ─── Investigations Router ─── banner) and replace with:
// Thin-file fallback suggestions + completeness scoring live in ./dataCompleteness
// (extracted so the subject portal reuses the exact same logic — no drift).
  1. Delegation — in getDataCompleteness, replace everything after the if (!db) return {...} line (the whole inline computation down to the return { score, ... } line) with:
      // Delegates to the shared implementation so the subject portal
      // (subjectPortal.getMyStatus) returns the identical score.
      return computeDataCompleteness(db, input.investigationRef);

(getFallbackSuggestion is only referenced from this one call site in routers.ts.)

Patch 2 — drizzle/schema.ts

  1. Extend the consent purpose enum (line ~2232):
export const consentPurposeEnum = pgEnum("consent_purpose", [
  "pre_employment", "employment", "contractor", "volunteer",
  "tenancy", "financial_services", "healthcare", "government",
  "consumer_self_check"
]);
  1. Append at END of file:
// ─── Subject Portal (consumer self-check / candidate portal) ─────────────────
// WP3: subject-facing access. Tokens are stored ONLY as SHA-256 hashes
// (same scheme as apiTokens.tokenHash, PR #151) — plaintext never persists.

export const subjectAccessTokenPurposeEnum = pgEnum("subject_access_token_purpose", [
  "self_check", "status", "dispute"
]);

export const subjectAccessTokens = pgTable("subject_access_tokens", {
  id:          uuid("id").primaryKey(),
  tenantId:    integer("tenant_id").references(() => tenants.id, { onDelete: "restrict" }).notNull(),
  candidateId: integer("candidate_id").references(() => candidateProfiles.id, { onDelete: "restrict" }).notNull(),
  tokenHash:   text("token_hash").notNull().unique(),
  purpose:     subjectAccessTokenPurposeEnum("purpose").notNull(),
  expiresAt:   timestamp("expires_at", { withTimezone: true }).notNull(),
  revokedAt:   timestamp("revoked_at", { withTimezone: true }),
  createdAt:   timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
}, (t) => ({
  sat_candidate_idx: index("sat_candidate_idx").on(t.tenantId, t.candidateId),
  sat_expiry_idx:    index("sat_expiry_idx").on(t.expiresAt),
}));
export type SubjectAccessToken       = typeof subjectAccessTokens.$inferSelect;
export type InsertSubjectAccessToken = typeof subjectAccessTokens.$inferInsert;

export const subjectDisputeStatusEnum = pgEnum("subject_dispute_status", [
  "received", "under_review", "resolved"
]);

export const subjectDisputes = pgTable("subject_disputes", {
  id:              uuid("id").primaryKey(),
  tenantId:        integer("tenant_id").references(() => tenants.id, { onDelete: "restrict" }).notNull(),
  candidateId:     integer("candidate_id").references(() => candidateProfiles.id, { onDelete: "restrict" }).notNull(),
  caseId:          uuid("case_id"),
  statementSha256: text("statement_sha256").notNull(),
  // Vault Transit envelope (JSON: {ciphertext,keyVersion,providerKeyVersion}) via
  // server/piiEnvelopeCrypto.ts; plaintext statements are never stored.
  statementEnc:    text("statement_enc"),
  status:          subjectDisputeStatusEnum("status").notNull().default("received"),
  resolution:      text("resolution"),
  createdAt:       timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
  updatedAt:       timestamp("updated_at", { withTimezone: true }).defaultNow().notNull(),
}, (t) => ({
  sd_candidate_idx: index("sd_candidate_idx").on(t.tenantId, t.candidateId),
  sd_status_idx:    index("sd_status_idx").on(t.tenantId, t.status),
}));
export type SubjectDispute       = typeof subjectDisputes.$inferSelect;
export type InsertSubjectDispute = typeof subjectDisputes.$inferInsert;

(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.createdBy is set to 0 (SYSTEM_ACTOR_ID) for self-check intake — no operator session exists and the column has no FK. Flagged in code comment.
  • requestSelfCheck takes tenantId as input (tenant-branded portal link determines the tenant); it is validated against the tenants table, and every subsequent read derives tenant from the token row, never client input.
  • Placeholder mailbox <cand-ref>@self-check.bis.internal used at intake (email is NOT NULL on candidate_profiles; frscQuickCheck precedent).
  • Rate limiter is per-process in-memory (commented); move to Redis if the BFF runs multi-replica.
  • Statement encryption requires an active Vault Transit key registry row per tenant; tenants without one get a fail-closed SERVICE_UNAVAILABLE on dispute submission (deliberate — no plaintext fallback, since the PII helper exists).
  • Branch history contains two intermediate "fix" commits (an initial push carried placeholder content by mistake); the final tree is verified byte-identical to the locally tested files. Squash-merge recommended.

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.
@munisp
munisp merged commit 8596517 into main Sep 13, 2026
8 of 10 checks passed
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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant