Skip to content

feat: shareable reports + self-service subscriptions - #153

Closed
munisp wants to merge 9 commits into
mainfrom
feat/share-and-subscribe
Closed

munisp wants to merge 9 commits into
mainfrom
feat/share-and-subscribe

Conversation

@munisp

@munisp munisp commented Sep 13, 2026

Copy link
Copy Markdown
Owner

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 to ctx.tenantId (FOR SHARE inside a tx); issues a bis_sl_<random> token shown exactly once — only its SHA-256 hex digest is persisted (same scheme as apiTokens/openclawEndpoints bearer validation); expiry defaults to 7 days, validated max 30; writes an HMAC-chained audit_log row + publishes REPORT_SHARE_CREATED.
  • getSharedReport (token-authed publicProcedure): hash lookup with unexpired + unrevoked enforced in a single atomic UPDATE … RETURNING that also increments view_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 (from screening_results via screening_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; listShareLinks never selects token_hash.

server/selfServiceBilling.ts (new, selfServiceBillingRouter):

  • listPublicPlans (public query): read-only projection of the existing billing_plans catalogue.
  • signup (writeProcedure): client-supplied idempotency key backed by a per-tenant UNIQUE constraint plan_signups.(tenant_id, idempotency_key) — a same-tenant replay returns the original result with idempotent: true and 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 synthetic provider_subscription_ref/source_reference are tenant-namespaced as self-serve-free:<tenantId>:<key> / self-serve-signup:<tenantId>:<key> because tenant_subscriptions and billing_entitlements enforce GLOBAL uniques). Plan resolved from billing_plans (active only). Payment goes exclusively through the existing settlePaystackPayment (server/billingSettlement.ts): the billing_payment_intents row 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 mirrors activateManualContract internals transactionally (cancel current sub → insert tenant_subscriptions active → grant billing_entitlements included checks → insert plan_signups). Fail-closed: any payment/ledger failure → typed TRPCError, zero subscription/entitlement, attempt durably recorded as plan_signups.status='payment_failed' + failure audit row. Audit + PLAN_SIGNUP_ACTIVATED event on success.
  • mySubscription, usageSummary: tenant-scoped reads of tenant_subscriptionsbilling_plans and billing_entitlements/billing_usage_events.

drizzle/0024_share_links_and_plan_signups.sql (new) + journal entry idx 24; branch carries main's drizzle/0023_subject_portal.sql unchanged and main's _journal.json verbatim + the idx-24 entry appended, so the diff vs main is purely additive (no merge conflict). plan_signups uniqueness is UNIQUE (tenant_id, idempotency_key). Raw-SQL-only pattern (like the informal_verification tables — applied by pnpm 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

pnpm install (pnpm 10.27.0), then:

$ pnpm vitest run server/share-subscribe.test.ts
 ✓ server/share-subscribe.test.ts (19 tests) 60ms
 Test Files  1 passed (1)
      Tests  19 passed (19)

$ pnpm check   # tsc --noEmit — clean

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 every plan_signups replay lookup carries tenant_id = $1 AND idempotency_key = $2), free-plan cross-tenant same-key coexistence (both tenants activate independently with tenant-namespaced refs; fake enforces the real GLOBAL uniques on tenant_subscriptions.provider_subscription_ref and billing_entitlements.source_reference), fail-closed payment failure (no sub/entitlement, payment_failed recorded, replay returns original failure without re-settling), cross-tenant denial for share create/revoke/intent binding, and tenant-scoped mySubscription/usageSummary. Tests drive the real routers via createCaller with 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 stubbed fetch.

Regression: 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.comprehensivecreditTenantAccount 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.ts and drizzle/schema.ts are too large for MCP push; apply these exactly (verified locally with typecheck + tests):

1. server/routers.ts — imports. Anchor (exists at ~line 142):

import { piiKeyCustodyRouter } from "./piiKeyCustody";

Insert immediately after it:

import { shareableReportsRouter } from "./shareableReports";
import { selfServiceBillingRouter } from "./selfServiceBilling";

2. server/routers.ts — registration. Anchor at the END of the appRouter object (~line 7691):

  fieldEvidence: fieldEvidenceRouter,
  kycDocumentEvidence: kycDocumentEvidenceRouter,
});

Change to:

  fieldEvidence: fieldEvidenceRouter,
  kycDocumentEvidence: kycDocumentEvidenceRouter,
  shareableReports: shareableReportsRouter,
  selfServiceBilling: selfServiceBillingRouter,
});

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 the InsertForceCreditApproval type exports. Append after them:

// ── report_share_links ─────────────────────────────────────────────────────────
// Tokenised, expiring share links for redacted investigation one-pagers. The
// plaintext token is never stored — only its SHA-256 hex digest (same scheme as
// api_tokens / openclaw bearer validation).
export const reportShareLinks = pgTable("report_share_links", {
  id:               uuid("id").primaryKey(),
  tenantId:         integer("tenant_id").notNull(),
  investigationRef: text("investigation_ref").notNull(),
  tokenHash:        text("token_hash").notNull(),
  createdBy:        integer("created_by"),
  expiresAt:        timestamp("expires_at", { withTimezone: true }).notNull(),
  revokedAt:        timestamp("revoked_at", { withTimezone: true }),
  viewCount:        integer("view_count").notNull().default(0),
  lastViewedAt:     timestamp("last_viewed_at", { withTimezone: true }),
  createdAt:        timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
}, (t) => ({
  report_share_links_token_hash_idx: uniqueIndex("report_share_links_token_hash_idx").on(t.tokenHash),
  report_share_links_tenant_idx:     index("report_share_links_tenant_idx").on(t.tenantId, t.createdAt),
  report_share_links_investigation_idx: index("report_share_links_investigation_idx").on(t.tenantId, t.investigationRef),
}));
export type ReportShareLink = typeof reportShareLinks.$inferSelect;
export type InsertReportShareLink = typeof reportShareLinks.$inferInsert;

// ── plan_signups ───────────────────────────────────────────────────────────────
// Durable, tenant-scoped idempotency record for self-service plan signups. The
// per-tenant unique (tenant_id, idempotency_key) pair guarantees a retried
// signup returns the original result instead of double-charging, and prevents
// cross-tenant replay leaks / key squatting.
export const planSignups = pgTable("plan_signups", {
  id:             uuid("id").primaryKey(),
  tenantId:       integer("tenant_id").notNull(),
  planCode:       text("plan_code").notNull(),
  status:         text("status").notNull(),
  billingRef:     text("billing_ref"),
  idempotencyKey: text("idempotency_key").notNull(),
  createdBy:      integer("created_by"),
  createdAt:      timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
}, (t) => ({
  plan_signups_idempotency_key_unique: uniqueIndex("plan_signups_idempotency_key_unique").on(t.tenantId, t.idempotencyKey),
  plan_signups_tenant_idx:             index("plan_signups_tenant_idx").on(t.tenantId, t.createdAt),
}));
export type PlanSignup = typeof planSignups.$inferSelect;
export type InsertPlanSignup = typeof planSignups.$inferInsert;

Risks

  • signup requires a pre-created billing_payment_intents row with purpose='subscription_invoice' (create it via the existing startPaystackTopup({..., purpose: 'subscription_invoice'}) — exported in billingSettlement.ts). Zero-price plans activate without payment (provider='manual_contract', billing_ref='self-serve-free:<tenantId>:<key>').
  • A payment_failed idempotency 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.
  • getSharedReport is deliberately unauthenticated (bearer-token-in-URL model); the token is 192 bits of entropy, hashed at rest, expiring, revocable.
  • No CI workflow changes (token lacks workflow scope); run pnpm vitest run server/share-subscribe.test.ts in existing test jobs.

@munisp

munisp commented Sep 13, 2026

Copy link
Copy Markdown
Owner Author

Verification fixes pushed — addressing the tenant-scoping bug and the 0023 migration collision.

1. Tenant-scoped plan_signups idempotency (verifier-demonstrated leak)

  • signup replay lookup now WHERE tenant_id = $1 AND idempotency_key = $2; the 23505 race re-read is likewise tenant-scoped; the failure-record insert is ON CONFLICT (tenant_id, idempotency_key) DO NOTHING.
  • Migration: global UNIQUE (idempotency_key) replaced with per-tenant UNIQUE (tenant_id, idempotency_key).
  • Chosen semantics (documented in code + PR body): a key created by another tenant is invisible — it behaves as a brand-new key for the caller's tenant and is provisioned independently. This both prevents leakage of foreign signupId/billingRef and eliminates cross-tenant key squatting and the cross-tenant same-key race (each tenant now has its own unique pair, so no cross-tenant 23505 can strand a settled payment).

2. Migration renumber (PR #155 collision)

0023_share_links_and_plan_signups.sql0024_share_links_and_plan_signups.sql; journal entry is now {idx: 24, tag: "0024_share_links_and_plan_signups"} rebased on main's current journal (includes idx 23 0023_subject_portal). The superseded 0023 file was deleted from the branch. The schema.ts integration patch in the PR body was updated to match (per-tenant unique index).

3. Regression test added

never leaks another tenant's signup on a cross-tenant idempotency-key replay: tenant 2 presents tenant 1's key → gets its OWN new signup (distinct signupId/subscriptionId, billingRef = tenant 2's payment reference; serialized response contains neither tenant 1's signupId nor its BIS-TOP-… reference); same-tenant replays on both tenants still return their original results; each tenant settled exactly once. An SQL-level guard additionally asserts every plan_signups replay SELECT carries tenant_id = $1 AND idempotency_key = $2, so the original global-key query cannot silently regress.

Real test output (re-run after fixes)

$ pnpm vitest run server/share-subscribe.test.ts
 ✓ server/share-subscribe.test.ts (18 tests) 58ms
 Test Files  1 passed (1)
      Tests  18 passed (18)
   Duration  796ms

$ pnpm check   # tsc --noEmit — clean (no output, exit 0)

Commits: 63f0a8a (tenant-scoped idempotency + migration per-tenant UNIQUE), 611dc97 (regression test + SQL guard), a99de6d (renumber to 0024 + rebased journal), 0021524 (drop superseded 0023 file).

…ques); branch hygiene: verbatim main journal + idx-24 append + 0023_subject_portal.sql
@munisp

munisp commented Sep 13, 2026

Copy link
Copy Markdown
Owner Author

Re-verification fixes pushed — both remaining items addressed.

1. Free-plan key-squatting bug (probe-verified) — FIXED

Root cause: tenant_subscriptions has GLOBAL UNIQUE(provider, provider_subscription_ref) and billing_entitlements has GLOBAL UNIQUE(source_reference) (migration 0010), but the free-plan path synthesised self-serve-free:<key> without a tenant id — tenant 2 reusing tenant 1's key hit a raw 23505 500.

Fix in server/selfServiceBilling.ts: both synthetic references are now tenant-namespaced —

  • providerRef = self-serve-free:${tenantId}:${idempotencyKey}
  • entitlement source_reference = self-serve-signup:${tenantId}:${idempotencyKey}

with a code comment citing the two global constraints. Paid plans are unaffected (billing ref is the globally-unique server-created BIS-TOP-… reference).

Regression test allows two tenants to use the same idempotency key on a FREE plan (tenant-namespaced refs): tenant 1 + tenant 2 both activate free_monthly under the same key — tenant 2 succeeds with its own subscription and billingRef=self-serve-free:2:<key> (response contains none of tenant 1's refs); both tenants' same-tenant replays return their original results; 2 subscriptions, 2 entitlements, 2 distinct provider refs. The in-memory pg handler now enforces the real GLOBAL uniques on tenant_subscriptions.(provider, provider_subscription_ref) and billing_entitlements.source_reference (throws 23505), so this test fails against the pre-fix code.

2. Branch hygiene — mergeable again

  • Pushed drizzle/0023_subject_portal.sql (main's file, byte-unchanged) onto the branch.
  • _journal.json is now main's exact content with only the idx-24 entry appended in identical formatting — verified by diff: the only delta vs main is
+    },
+    {
+      "idx": 24,
+      "version": "7",
+      "when": 1788638400000,
+      "tag": "0024_share_links_and_plan_signups",
+      "breakpoints": true

so the branch diff vs main is purely additive and the _journal.json conflict is gone.

Real test output (re-run after fixes)

$ pnpm vitest run server/share-subscribe.test.ts
 ✓ server/share-subscribe.test.ts (19 tests) 60ms
 Test Files  1 passed (1)
      Tests  19 passed (19)
   Duration  881ms (transform 443ms, setup 0ms, import 631ms, tests 60ms)

$ pnpm check   # tsc --noEmit — clean (exit 0)

Commits: ba03cdd (code fix + journal/subject-portal hygiene), 2646285 (regression test).

@munisp

munisp commented Sep 13, 2026

Copy link
Copy Markdown
Owner Author

Superseded by #157 (feat/share-and-subscribe-v2) — identical, verified code (all fixes from this thread included: tenant-scoped plan_signups idempotency, per-tenant UNIQUE, tenant-namespaced free-plan refs, 0024 migration), re-branched from current main so _journal.json is main's exact content + the idx-24 entry appended — the diff vs main is purely additive and merge-conflict-free. Tests re-run against the actual v2 branch content: 19/19 pass, tsc --noEmit clean. Closing this one.

@munisp munisp closed this Sep 13, 2026
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