overview / requirement-sharing
Requirement Sharing Workflow
A high-level walkthrough of how an organization lets one of its customers β a person with no platform login β open a public link, review a deal's requirements, and approve or reject each one, with every decision landing authoritatively in the platform and propagating live to the internal Requirements tab.
At a glance
A handful of numbers capture the shape of the feature β what the customer needs, how the link is secured, and how little data it exposes:
Overview
Requirement sharing replaces the old manual review loop β generate a document, email the customer, wait for a marked-up copy, email it back, and have an internal user re-key each decision by hand. Instead the customer opens a single public link, reviews the deal's requirements in a surface that mirrors the internal one, and approves or rejects each one; the decision is written directly into the platform. The characteristics below are grounded in the mechanisms documented in the rest of this file β they describe what the code actually does.
The /share/requirements/[token] route lives outside the auth wall (the web middleware only guards /app and /auth). The customer needs no account, no Auth0 session β only the link.
The share token is the only credential. ShareTokenGuard derives the deal and organization scope from it; every read and write is scoped to those guard-derived values, never to a client argument.
A review flips the requirement's status and appends an immutable RequirementReviewEvent in one interactive transaction β the decision is real platform state, with a full audit trail beside it.
The public surface exposes only five requirement fields plus the deal and customer name. No confidence scores, source filenames, approver identity, internal IDs, source type, or tags ever cross the boundary.
A per-(token, IP) request throttler and a per-IP cap on no-live-link resolutions bound abuse; every failure path returns an identical generic NotFound, giving an attacker no enumeration signal.
After each review commits, the existing dealRequirementChanged event publishes over Redis pub/sub, so the org's open Requirements tab refetches and shows "Approved/Rejected by {customer}" with no manual refresh.
The rest of this document walks the flow end to end, from minting the link through to the live update on the org's tab.
1. Minting a share link
An organization admin starts on the deal's Requirements Capture tab and clicks "Share with customer", which calls a single GraphQL mutation:
- Mutation:
createRequirementShareLink(dealId, expiresInDays?)β gated by@RequireRole(OrganizationAdmin)plus a deal-access verification (verifyUserDealAccess).
The API generates a 32-byte (256-bit) random token (base64url) and stores only its SHA-256 hash in a new RequirementShareLink row. The raw token is returned exactly once and is unrecoverable afterward β only its hash lives in the database.
RequirementShareLink field | Purpose |
|---|---|
tokenHash (unique) | SHA-256 of the raw token β the lookup key. The raw secret is never stored. |
dealId / organizationId | The token-derived scope every public operation is bound to. |
createdByUserId | The admin who minted the link β the accountable human proxy recorded against any resulting action-item completion. |
expiresAtUtc | Absolute expiry (30-day default; caller may pass 1β90 days). |
revokedAtUtc? | Set when the link is revoked via revokeRequirementShareLink; a revoked link is no longer live. |
lastAccessedAtUtc? | Fire-and-forget touch on each successful guard pass (a failed touch never fails the request). |
The org copies ${APP_BASE_URL}/share/requirements/{token} and sends it however they like β this is copy-link only; the platform sends no email.
2. The public route
The link opens /share/requirements/[token], a Next.js route that sits outside the auth wall. It mounts a token-less Apollo client that carries the token in an X-Requirement-Share-Token header β no Auth0 bearer, no websocket link. An identity gate collects the customer's name and email once (stored in sessionStorage, keyed by the token) and threads them into every review submission.
3. Token guard & scope
The public resolvers are @Authentication(None) (the global AccessTokenGuard does not run) and protected per-route by @UseGuards(ShareTokenThrottlerGuard, ShareTokenGuard) β the throttler runs first so a flood is capped before the token is even looked up. ShareTokenGuard reads the header, SHA-256-hashes it, and looks up a live link (not revoked, not expired). On success it stashes { dealId, organizationId, shareLinkId } for a @ShareLinkContext() decorator; the resolver scopes every action to those guard-derived values. Scope is always token-derived, never a client argument.
NotFound; only a live link yields a scope, and that scope drives every downstream read and write.4. Reading the requirements
retrievePublicSharedDealRequirements returns minimal deal context (deal name, customer name) plus a paginated requirements list. The list deliberately mirrors the internal one β the same container, a "Requirements" heading with a count badge (but no actions menu), search, and multi-select filters for Categories, Priority, and Status β with all filtering, sorting, and pagination applied server-side on top of the token-derived scope. One filter is intentionally absent: there is no Asset filter, because exposing asset IDs would leak the internal source documents.
The public surface exposes only five requirement fields:
The dedicated PublicRequirement model is deliberately not the internal RequirementModel/DealRequirementModel, so the public surface cannot accidentally leak an authenticated requirement field β no confidence %, source filenames, approver identity, internal IDs, sourceType, or tags.
5. Submitting a review
A decision is submitted through submitRequirementReview(requirementId, action, reviewerName, reviewerEmail, reason?). A reject requires a reason; an approve takes an optional note in the same reason field β both enforced at the GraphQL input DTO via class-validator (a @ValidateIf on the action). Before any write, the client-supplied requirementId is re-verified against the token-derived dealId + organizationId (the IDOR guard); a requirement from another deal or tenant is indistinguishable from a missing one β same NotFound.
The write happens in one interactive Prisma transaction with two separate statements:
- Flip
Requirement.statustoApprovedorRejected(mirroring the internal approve/unapprove exactly β only the requirement's approval state is touched;approvedByUserIdstays null because the customer is not aUser). - Append an immutable
RequirementReviewEventaudit row carryingrequirementId,dealRequirementId,dealId,organizationId,shareLinkId,reviewerName,reviewerEmail, theactionenum, an optionalreason, andcreatedAtUtc.
On approve, the same internal action-item completion logic as an in-app approval runs (completing the "map a requirement" action item); rejecting a previously-approved requirement re-evaluates and may un-complete it. After the transaction commits, the existing dealRequirementChanged Redis subscription is published.
The customer's name and email are self-asserted β they are attribution recorded on the review event, not verified identity. Anyone holding the link can type any name. A future confirm step (where the org confirms the customer's decision) is the real trust boundary; today's model treats the review as an attributed signal, not a proof of who acted.
The design is authoritative now, but event-sourced for "confirm-later". Because the status flip and the immutable review event are separate statements in one transaction, a future "customer signals, org confirms" mode is a purely additive change β nullable confirm columns plus a review-mode flag β with no data migration: the existing events stay valid, and the new mode simply withholds the flip until an org user confirms.
The two public operations contrast cleanly:
retrievePublicSharedDealRequirements β a query. Returns the deal/customer name and a server-filtered, server-sorted, server-paginated list of the five public requirement fields. No mutation, no actor recorded; the only side effect is the guard's fire-and-forget lastAccessedAtUtc touch.
submitRequirementReview β a mutation. Re-verifies the requirement against the token scope (IDOR guard), runs the status-flip + review-event transaction, reuses the internal action-item logic on approve, and publishes dealRequirementChanged. Returns only the public requirement view.
6. Real-time propagation
The org's open Requirements tab is already subscribed to dealRequirementChanged, so no new subscription module was needed. When the customer's review commits and the event publishes, that tab refetches and updates live β showing "Approved by {customer}" or "Rejected by {customer}: {reason}" on each card. The attribution is surfaced through a new reviewEvents field on DealRequirement (a lazy resolve-field, capped per parent to bound the N+1 path). The decision a customer makes on the public link is visible to the internal team the moment it lands β closing the loop the old email cycle left open.
7. Security model
This is an internet-facing, unauthenticated surface fed customer-supplied input, so the controls are layered from the token through the wire to storage:
| Control | What it does |
|---|---|
| 256-bit token | 32 random bytes, base64url-encoded. Only the SHA-256 hash is persisted; the raw token is returned exactly once and is unrecoverable afterward. |
Generic NotFound | Every failure path β missing, unknown, revoked, or expired token β returns an identical parameter-less NotFound, giving no enumeration signal and no way to distinguish a rate-limited rejection from a plain invalid token. |
| Per-(token, IP) throttler | A self-contained request throttler on the public surface (re-hashes the header token itself), needed because the global throttler skips no-JWT requests. Fails open on a Redis outage. |
| Per-IP no-live-link cap | A defense-in-depth cap on requests that resolve to no live link (counted in the guard) β stops one IP firing unlimited distinct-token guesses; the 256-bit entropy is the real defense. Fails open on a Redis outage. |
| Expiry + revocation | Checked on every request; resolved scope is never cached across requests, so a revoked or expired link stops working immediately. |
| IDOR guard | The client-supplied requirementId is re-verified against the token-derived deal + org before any write; a requirement outside the shared deal is treated as missing. |
| Data minimization | Public models expose only id / text / category / priority / status plus the deal name and customer name β nothing more. |
| Length caps on free text | Attacker-controlled strings are bounded to limit storage/DoS: reason β€ 4000, reviewerName β€ 200, reviewerEmail β€ 320 characters. |
| CORS + CSRF allowance | The anonymous cross-origin POST with the custom header is permitted by the API's CORS (it reflects the requested header) and Apollo's CSRF config (non-multipart application/json). |
| Org-side gating | Minting and revoking a link are gated by @RequireRole(OrganizationAdmin) plus a deal-access verification β only an admin who can see the deal can share or revoke it. |
Summary
End-to-end the flow looks like: