feat: shareable reports + self-service subscriptions - #157
Merged
Conversation
…nks router, 0024 migration, journal idx 24
…gnup via existing settlement path
…incl. cross-tenant + free-plan), fail-closed, tenant scoping
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
WP4 — shareable investigation reports + self-service subscriptions (Intelius instant-report + self-serve pricing analogs).
server/shareableReports.ts(new,shareableReportsRouter):createShareLink(writeProcedure): verifies the investigation belongs toctx.tenantId(FOR SHAREinside a tx); issues abis_sl_<random>token shown exactly once — only its SHA-256 hex digest is persisted (same scheme asapiTokens/openclawEndpointsbearer validation); expiry defaults to 7 days, validated max 30; writes an HMAC-chainedaudit_logrow + publishesREPORT_SHARE_CREATED.getSharedReport(token-authed publicProcedure): hash lookup with unexpired + unrevoked enforced in a single atomicUPDATE … RETURNINGthat also incrementsview_count/last_viewed_at(revoked/expired links cannot be raced into extra views). Returns a whitelisted redacted one-pager: subject name, investigation ref, overall risk band (derived from tier/score — raw scores never serialised), per-source screening outcomes reduced to pass/consider/fail (fromscreening_resultsviascreening_orders, tenant-scoped), field-visit outcome, thin-file flag, generated-at, tenant display name. Referee identities, raw payloads, internal notes, and user IDs are never selected.revokeShareLink/listShareLinks: tenant-scoped;listShareLinksnever selectstoken_hash.server/selfServiceBilling.ts(new,selfServiceBillingRouter):listPublicPlans(public query): read-only projection of the existingbilling_planscatalogue.signup(writeProcedure): client-supplied idempotency key backed by a per-tenant UNIQUE constraintplan_signups.(tenant_id, idempotency_key)— a same-tenant replay returns the original result withidempotent: trueand never re-settles payment; a same-tenant concurrent race loses on 23505 and is re-read (tenant-scoped) as the original. A key belonging to another tenant is invisible and behaves as a new key for this tenant — replays can never leak a foreign signupId/billingRef, and tenants cannot squat each other's keys (this holds for zero-price plans too: the syntheticprovider_subscription_ref/source_referenceare tenant-namespaced asself-serve-free:<tenantId>:<key>/self-serve-signup:<tenantId>:<key>becausetenant_subscriptionsandbilling_entitlementsenforce GLOBAL uniques). Plan resolved frombilling_plans(active only). Payment goes exclusively through the existingsettlePaystackPayment(server/billingSettlement.ts): thebilling_payment_intentsrow must be server-created, tenant-bound,purpose='subscription_invoice', and amount-equal to the plan price before settling; settlement re-verifies with Paystack and posts the deterministic TigerBeetle transfer. Activation mirrorsactivateManualContractinternals transactionally (cancel current sub → inserttenant_subscriptionsactive → grantbilling_entitlementsincluded checks → insertplan_signups). Fail-closed: any payment/ledger failure → typed TRPCError, zero subscription/entitlement, attempt durably recorded asplan_signups.status='payment_failed'+ failure audit row. Audit +PLAN_SIGNUP_ACTIVATEDevent on success.mySubscription,usageSummary: tenant-scoped reads oftenant_subscriptions⋈billing_plansandbilling_entitlements/billing_usage_events.drizzle/0024_share_links_and_plan_signups.sql(new) +_journal.json= main's exact current content + idx-24 entry appended (verified byte-level: diff vs main is only the additive entry).plan_signupsuniqueness isUNIQUE (tenant_id, idempotency_key). Raw-SQL-only pattern (like the informal_verification tables — applied bypnpm db:migrate).Why
Closes the WP4 gap: no way to share a redacted investigation result with an external party, and no self-service path onto a commercial plan (today only admin-run
activateManualContract).How tested
Verified against the exact content of this branch (
feat/share-and-subscribe-v2@b48e539),pnpm install(pnpm 10.27.0), then:19 tests cover: token lifecycle (create → view ×2 counted atomically → revoke → rejected; expired rejected; unknown rejected; 30-day cap), redaction shape (exact whitelisted object equality + recursive forbidden-key scan over
riskScore|rawResult|referee|notes|createdBy|userId|agentId|token_hash|nin|bvn|…+ serialized substring checks), same-tenant idempotency replay (same signupId/subscriptionId, Paystack verify + TigerBeetle transfer called exactly once), cross-tenant idempotency-key replay non-leakage (tenant 2 presenting tenant 1's key gets a brand-new signup — distinct signupId/subscriptionId/billingRef, serialized output contains none of tenant 1's identifiers — plus an SQL-level guard asserting everyplan_signupsreplay lookup carriestenant_id = $1 AND idempotency_key = $2), free-plan cross-tenant same-key coexistence (both tenants activate independently with tenant-namespaced refs; the fake enforces the real GLOBAL uniques ontenant_subscriptions.provider_subscription_refandbilling_entitlements.source_reference), fail-closed payment failure (no sub/entitlement,payment_failedrecorded, replay returns original failure without re-settling), cross-tenant denial for share create/revoke/intent binding, and tenant-scopedmySubscription/usageSummary. Tests drive the real routers viacreateCallerwith a stateful in-memory pg handler executing the production SQL (incl.settlePaystackPayment's queries); only the external HTTP boundaries (Paystack verify, TigerBeetle, event processor) are intercepted via stubbedfetch.Regression (run on same code pre-rebranch):
billing.test.ts,billing.debitClaim.test.ts,billing.topup.idempotency.test.ts,paymentReconciliation.test.ts,smoke.comprehensive.test.ts→ 131/132 pass. The 1 failure (smoke.comprehensive›creditTenantAccount rejects an unbound legacy reference when TIGERBEETLE_URL is not set) is pre-existing on pristine main (verified against a clean main extract) and is unrelated to this change.Integration patches (large files, applied by orchestrator)
server/routers.tsanddrizzle/schema.tsare too large for MCP push; apply these exactly (verified locally with typecheck + tests):1.
server/routers.ts— imports. Anchor (exists at ~line 142):Insert immediately after it:
2.
server/routers.ts— registration. Anchor at the END of theappRouterobject (~line 7691):Change to:
3.
drizzle/schema.ts— append at END of file (optional Drizzle types; runtime code uses raw SQL against the 0024 tables, so this is for type consumers only). Anchor: the final two lines are theInsertForceCreditApprovaltype exports. Append after them:Risks
signuprequires a pre-createdbilling_payment_intentsrow withpurpose='subscription_invoice'(create it via the existingstartPaystackTopup({..., purpose: 'subscription_invoice'})— exported in billingSettlement.ts). Zero-price plans activate without payment (provider='manual_contract',billing_ref='self-serve-free:<tenantId>:<key>').payment_failedidempotency key is terminal by design (replay returns the original failure); a genuinely new attempt uses a new key — standard idempotency semantics. Keys are tenant-namespaced, so the same key string in another tenant is an independent signup.getSharedReportis deliberately unauthenticated (bearer-token-in-URL model); the token is 192 bits of entropy, hashed at rest, expiring, revocable.workflowscope); runpnpm vitest run server/share-subscribe.test.tsin existing test jobs.