T1 spec · Iris Platform
/pipeline build session, 2026-07-16.
Type / size: Engineering feature, implemented same-day. Documented after the fact because the
change touches the shared assessment/report pipeline and a related access-model RFC already exists in this repo.
Stated directly, in the order given:
Two non-functional constraints came up alongside these:
The repo already runs one working pipeline: lead → call → transcript → assessment → org plan → report,
used today from the internal /pipeline ops view (src/pipeline/Pipeline.tsx) for one lead at a time.
The work was to expose a slice of that pipeline to an external, unauthenticated visitor through a capability link,
without rebuilding it.
| Req | What already existed | Gap found | Resolution |
|---|---|---|---|
| R1 | leads table has name/email; /r/:token already proves the "random-token capability link" pattern works in this app |
No per-person share mechanism — only a static, company-wide slug list (src/company/companyReports.ts) with no individual identity |
Added shareToken to leads (+ index); leads.createInvite mints one; a new /pipeline panel creates and copies the link |
| R2 | realtime.ts already builds Iris's personalized instructions server-side from lead.name / company / notes — research content never had to touch the browser |
First cut of getByShareToken returned the entire lead document to the client (internal status, source, raw notes) |
Tightened the public query to project only { _id, name }; the researched context stays server-side, read directly by realtime.ts when it mints the call session |
| R3 | useRealtimeCall.end() → calls.complete (saves transcript) → pipelineNode.runFromTranscript (assessment → plan → report) already runs after every call |
The assessment/plan/report steps ran on "whichever assessment is most recent in the whole table" — correct for one operator running one call at a time, but a race condition for concurrent external invitees: two people finishing calls close together could receive each other's report | Threaded the specific assessmentId through pipelineNode → plansNode.generateForLatest → reportsNode.generate, so each call is scoped to its own assessment. Old "latest" behavior is preserved as the default for the internal ops buttons, so nothing there changed |
| R4 | /r/:token + Report.tsx already exist and are unauthenticated |
None | Reused as-is — reportToken from the pipeline is shown directly on the invite page |
| R5 | reportsNode.deliver (Resend + PDF attach) already existed, built for the internal "email report" button |
It had no external-facing trigger | Added an email field + submit button to the invite page's post-call state, calling the same deliver action |
| R6 | Deployed on Vercel with a SPA rewrite; no robots.txt |
A client-rendered SPA can't noindex one route via a single static index.html |
Vercel X-Robots-Tag: noindex, nofollow header scoped to /invite/* (authoritative, HTTP-layer) + a JS-injected <meta name="robots"> tag on the page itself (defense in depth) |
| R7 | — | — | No new tables beyond one field; no new external services; every backend primitive reused as-is except the two additive assessmentId params above |
calls.leadId (required), assessments.leadId,
useCases.leadId/orgPlans.leadId (via assessment.leadId), and
reports.leadId (via plan.assessment.leadId) are all set from the same
leadId that started the call. The concurrency fix above is what makes that attribution
reliable under concurrent invitees.
reports, useCases, and orgPlans had
no by_lead index, so even though every row was correctly tagged, there was no efficient way to
query "this specific person's report" back out — the only place it existed was the reportToken
held transiently in the invite page's React state during that one session. Fixed by adding a
by_lead index and reports.getByLead query, and surfacing it on each lead row in
/pipeline so staff can retrieve a person's saved report from the database after the fact, not
only in the moment the call ends.
docs/specs/t1-iris-access-and-privacy-rfc.md — an existing RFC in this repo — defines a stricter model for
exactly this problem: outbound link → public data → anonymous call → verified-email-owned
private result, with retention/deletion rules and no PII on public surfaces. This build is a lighter MVP that satisfies
R1–R7 using unguessable tokens as the capability boundary (same pattern as /r/:token
today) rather than verified identity. Concretely, it diverges in ways worth tracking:
reports.getByToken still returns deliveredTo (the delivery email) on the public report page — the RFC explicitly calls this out as something to stop doing on public surfaces. Pre-existing, but this feature adds more delivered reports through it./pipeline, where invites are created, still has no auth in front of it.Three phases: creation (operator, top), the live call (middle — the only part that talks to OpenAI), and completion (bottom — the only part that talks to Resend). The prospect's browser never talks to OpenAI's API key or Resend directly; Convex actions mediate both.
The token is the only thing that crosses the trust boundary from operator to prospect. Everything the operator
typed into the invite form (company, LinkedIn context) is dereferenced by leadId inside
a Convex action, and the result of that dereference (Iris's personalized system prompt) is consumed by OpenAI
server-to-server — it's never serialized back to the browser.
assessmentsNode.generate always created a new, correctly-attributed row — the bug was one level up:
plansNode.generateForLatest and reportsNode.generate re-derived "which assessment am I
building on" by querying order('desc').first() on the whole table instead of being told. That's
invisible with a single operator driving one call at a time (/pipeline), which is why it shipped
that way originally, but it becomes live with any concurrent external traffic — exactly what invite links
introduce. Both actions now accept an optional assessmentId; omitting it keeps the old "latest"
behavior for the internal ops buttons, so this was an additive, non-breaking change.
This build deliberately trades the verified-identity model in the access-privacy RFC for unguessable capability tokens (R7: “simple implementation that works”). That is a defensible MVP choice, but it shifts the entire security burden onto two things: keeping the tokens secret, and keeping each unauthenticated endpoint from exposing more than the token-holder is entitled to. A review of the shipped code found the token half is sound and the exposure half is not yet. This section documents the model, the concrete gaps, and the hardening that closes them. Nothing here is applied — it is the design to implement.
One architectural fact governs everything below: every Convex
query / mutation / action export is a
public API endpoint. The deployment URL ships to every browser, so
any function can be invoked directly with arbitrary arguments — not only
through the UI that happens to call it. “Only /pipeline
calls this” is therefore not an access control. The system has
two trust zones, and the invite feature widens the first one:
/invite/:token, and /r/:token. No login. Access
is gated only by possession of an unguessable token./pipeline, where invites
are created and every lead is visible. Intended for staff, but currently
has no authentication in front of it or its backing functions.crypto.randomUUID() (122-bit v4) — genuinely
unguessable. The report lives at /r/:reportToken under a
separate token from the invite’s shareToken, and
that token is only returned to the browser once a call completes — so
knowing the invite link does not, by itself, reveal the report link.
noindex is enforced at the authoritative HTTP layer (Vercel
X-Robots-Tag) with the client meta tag as defence in depth.
Secrets (OPENAI_API_KEY, RESEND_API_KEY) never
reach the browser.
The token-secrecy model is undermined by sibling endpoints that dereference an identifier or token into more data — or more action — than the token-holder should get. Severity assumes the outbound motion the feature is built for (links sent to real prospects).
| ID | Severity | Finding | Why it matters |
|---|---|---|---|
| V1 | High | R2 is not actually enforced.
getByShareToken carefully returns only
{ _id, name }, but leads.get is a public query
that returns the entire lead doc (company, the
research packet in notes, email,
status). The invite page already holds the
leadId. |
Anyone with an invite link calls leads.get({ id })
directly and reads the private research R2 says must stay server-side.
leads.list (also public) dumps every lead in the
table — a full prospect-database read. |
| V2 | High | reportsNode.deliver is an open email relay.
Public, unauthenticated, taking attacker-controlled email
and origin. Any report-token holder can send to any
address, with the link built from the caller-supplied
origin. |
Spam/relay abuse of the Beam sending domain, plus phishing: a
Beam-branded email whose “view report” link points at
evil.com/r/…. No rate limit. |
| V3 | Med-High | Report token derivable from the invite link.
reports.getByLead is a public query keyed by
leadId, and leadId is handed to the invite
page by getByShareToken. |
Defeats the “only the person who completed the flow has the report link” property: a link-holder can fetch the report token without ever taking the call. With other leadIds (V1), it reaches other people’s reports. |
| V4 | Med | calls.complete accepts any transcript for any
call. Public, no ownership or state check. |
A finished call’s transcript can be overwritten, and a fully attacker-controlled transcript can be pushed into the assessment LLM pipeline (content/prompt-injection into generated reports). |
| V5 | Med | Destructive operator mutations are public.
reports.clearLatest, leads.remove,
leads.setStatus, leads.add /
capture are all callable without auth. |
clearLatest needs no arguments — an attacker can delete
reports as they are generated. General data tampering and spam-lead
creation. Same root cause as the un-authed /pipeline. |
| V6 | Low | Delivery email leaked on the public report.
reports.getByToken returns deliveredTo on the
public /r/:token surface. Capability URLs also leak via
Referer, history, and link-preview crawlers. |
PII (the recipient email) exposed to anyone the forwarded report link reaches. Already flagged in the RFC; this feature adds more delivered reports through it. |
The fixes fall into two buckets: shrink each unauthenticated endpoint’s exposure (self-contained, no new infrastructure), and put the operator zone behind auth (the one item that needs a dependency the repo has not wired yet).
| Fix | Closes | Change |
|---|---|---|
| H1 | V1 | Make leads.get an internalQuery. Only
realtime.createSession reads the full lead, and it runs
server-side — so it can call internal.leads.get while the
function disappears from the public API. No frontend uses
api.leads.get, so this is non-breaking. The research packet
can no longer be dereferenced from a leaked leadId. |
| H2 | V1, V3, V5 | Put the operator zone behind authentication. Introduce a single
requireOperator(ctx) gate (ctx.auth.getUserIdentity(),
per CLAUDE.md) and apply it to leads.list,
getByLead, createInvite, setStatus,
remove, reports.latest /
clearLatest, and the seed/remove helpers. This is the
highest-leverage fix and the one external dependency (Clerk is
referenced in the Beam stack but not yet wired here). |
| H3 | V2 | Harden deliver: derive the link origin from a server env
var (APP_ORIGIN) instead of a client parameter; validate the
recipient as a single address; and allow exactly one delivery
per report token, claimed transactionally before the send. This
removes the phishing vector and caps the relay to one email per
unguessable token. |
| H4 | V6 | Drop deliveredTo / deliveredAt from the public
reports.getByToken projection. Operator surfaces read those
fields through internal, auth-gated queries instead. |
| H5 | V4 | Guard calls.complete so it only transitions a call that is
still in_progress; re-completion becomes a no-op. Prevents
overwriting stored transcripts and re-triggering the pipeline on a
finished call. |
| H6 | hardening + reliability | Move email delivery to an async, server-scheduled job: capture the
optional email before hang-up, and once the report is generated,
schedule delivery to that captured address via
ctx.scheduler. Delivery then survives the visitor closing
the tab, and the browser never supplies the recipient or origin —
reinforcing H3. |
| H7 | hygiene | Define a retention / expiry policy for invite tokens that are never opened and report tokens whose call never completes, so stale capability URLs do not accumulate indefinitely. |
/pipeline; until it lands, the operator-zone findings (V3, V5)
remain open by the same root cause the RFC already tracks. H6 and H7 are
follow-ups that make the external flow robust and tidy.
How each stated requirement fares under the current build, and what makes it hold.
| Req | Security property | Status & what closes it |
|---|---|---|
| R1 | Per-person link is unguessable | Holds. 122-bit random shareToken. |
| R2 | URL only reaches that person’s researched data, not the wider set | Not yet enforced — the projection in
getByShareToken is undone by public leads.get
and leads.list. Closed by H1 (+ H2 for the
operator dump). |
| R3 | Saved data is per-person and reliably attributed | Holds. Attribution via leadId is correct;
the concurrency fix (§3.3) keeps it reliable under concurrent invitees. |
| R4 | Anonymous report link, separate from the invite link | Holds by construction, but the “only the
completer has it” guarantee currently leaks via
reports.getByLead. Closed by H2. |
| R5 | Optional email delivery without abuse | Works but abusable as an open relay / phishing vector. Closed by H3 and reinforced by H6. |
| R6 | Not indexed by search engines | Holds. Authoritative HTTP header + client meta tag.
Residual: capability URLs still leak via Referer / previews
— acceptable at low volume. |
The attack chain the hardening breaks: today a single leaked invite link walks all the way to another person’s research, report token, and delivery email. H1 + H2 + H4 sever each hop.
/pipeline (where invites are created) — flagged independently by AdminReports.tsx and the access-privacy RFC.deliveredTo from reports.getByToken on the public report page.