Skip to content

Recipient Portal ​

The Recipient Portal (luke-portal) is the recipient persona's capability-agnostic hub. An external recipient — a customer or client, not a tenant member — authenticates once and sees and acts on every item assigned to them within a tenant. Forms is the first item type; signatures, documents, and others plug in without changing the portal core.

Partial · built green, not yet deployed

Repository: luke-portal · Type: Application (standalone Render static site) · Stack: React 19 / Vite 6 / TypeScript / Tailwind v4 · Backend module: core-engine com.luke.engine.recipient

Why it's separate ​

The recipient is a first-class persona that spans capabilities, so the portal is not a forms sub-feature. It owns recipient identity; each capability contributes items through a provider SPI. That split lets new capabilities appear in the portal by implementing one interface + one renderer — no portal rewrite.

  • Front end — luke-portal, a standalone app deployed as its own Render static site (platform-<env>-portal, defined in luke-platform/render.yaml). It calls the PUBLIC portal API cross-origin at the gateway (VITE_PORTAL_API_URL); /api/public/** is any-origin/no-cred CORS, so this needs no credentials.
  • Back end — core-engine module com.luke.engine.recipient (kept in the monolith per the capability→core merge decision), which owns the account-less identity flow and aggregates items. It builds the /portal + magic-link URLs it emails from FORMS_PORTAL_BASE_URL (the static site's origin); core no longer serves the /portal page itself.

Architecture ​

Identity flow (account-less, per-tenant) ​

The portal is served by its own static site at /portal/{tenantToken} (SPA; /* → /index.html), where tenantToken is a signed per-tenant handle (PortalTenantTokens, mirroring embed tokens — a raw tenant id never appears). Core-engine builds that URL (and the magic-link URL) from FORMS_PORTAL_BASE_URL when it emails a recipient. A recipient proves control of their email via one of three channels, then a short-lived, email-scoped session (PortalAccessTokens, binds tenant + email + expiry) is minted:

ChannelHowStatus
Email OTP6-digit code mailed to the sender-asserted email; salted-hashed, expiring, attempt-capped, single-useLive
Magic linksingle-use, high-entropy link (SHA-256 at rest, atomic consume), 15-min TTLLive
SMS OTPsame OTP over the PortalOtpSender SMS seamSeam — refused until an SMS gateway is configured (luke.forms.sms.enabled + a real SmsPortalOtpSender)

Privacy invariant: the challenge/magic-link/verify responses are identical in shape, status, and latency whether or not the email has any items — no distinguishable 429, and delivery is dispatched off-thread — so a holder of the (shareable) tenant handle can't enumerate recipients. The session then authorises each capability's own action surface, which validates it against the item's tenant + recipient email (cross-tenant / cross-email access is impossible).

Item providers — onboarding a capability ​

Each capability implements RecipientItemProvider (core-engine com.luke.engine.recipient):

java
public interface RecipientItemProvider {
    String type();                                              // "form", "signature", …
    List<RecipientItem> itemsFor(String tenantId, String email); // open items, newest first
    default Optional<String> phoneFor(String tenantId, String email) { return Optional.empty(); }
}

PortalService.listItems aggregates across all providers. Forms is the first provider (FormRecipientItemProvider → open FormInstances become "form" items).

An open form item renders the "Developed at Lukeflow" badge under the form when the fill payload says to (showBranding, resolved server-side against the tenant's plan) — the portal is a public form surface like /respond/{token}, so a recipient sees the same attribution either way. See Forms.

It is also a full fill surface, so a form requiring consent shows its agreement here too (FormConsentGate + lib/formConsent.ts, mirrors of the consumer-ui originals — the portal is a separate bundle with no shared package, like LukeflowBadge and formFonts). The gate is a courtesy: core-engine refuses a submission that arrives without agreement whichever door it came through. Keep CONSENT_DEFAULT_TEXT byte-identical to the server's, or a recipient could agree to one statement while another is recorded. See Forms → Consent record.

To add a capability:

  1. Implement RecipientItemProvider in that capability's package (backend).
  2. Ensure its public action surface accepts the portal session (like PublicFormInstanceService.authorize() does).
  3. Add one renderer to src/items/registry.tsx in luke-portal for the new item type.

Endpoints ​

The portal page itself is served by the static site (/portal/{tenantToken} → SPA). The core-engine API surface:

MethodPathPurposeAuth
POST/api/public/portal/{tenantToken}/challengeEmail/SMS OTP challengePublic (tenant handle)
POST/api/public/portal/{tenantToken}/magic-link · /magic/consumeRequest / consume magic linkPublic (tenant handle)
POST/api/public/portal/{tenantToken}/verifyVerify OTP → sessionPublic (tenant handle)
GET/api/public/portal/itemsList assigned open items across capabilitiesPublic (portal session token)

Per-item actions run on the capability's own public surface — e.g. a form item is filled via /api/public/form-instances/{token} with the portal session as the Bearer.

Status & gaps ​

Built green (backend module + tests, standalone front end) and wired into the Render blueprint (platform-dev-portal / platform-qa-portal in luke-platform/render.yaml). On merge, Render builds + deploys luke-portal as a static site — no manual vendoring step. Deploy config: the static site's VITE_PORTAL_API_URL (gateway origin) and the engine's FORMS_PORTAL_TENANT_SECRET / FORMS_PORTAL_HMAC_SECRET (generated) + FORMS_PORTAL_BASE_URL (the portal site's origin). SMS is inert until a gateway is wired.

Lukeflow Manual · documentation snapshot July 2026