Section 2 — External-facing surfaces. The pages a General Contractor's Clients, subs/vendors, and prospects touch. These are public, no-staff-login surfaces: they authenticate with a per-record shareable token in the URL (optionally gated by an SMS one-time code or a static PIN), not the staff session cookie. Contrast with the internal office/field app under
/portal/*, which uses cookie auth (app/portal/deps.py).
Purpose: give each outside party a single, branded, link-driven window into exactly the slice of a Job that concerns them — progress feed and payments for the homeowner, an itemized bid form for a vendor, a walled field app for a sub — with everything they submit flowing up into the office hub.
| Portal | Path | Status | Auth (today) |
|---|---|---|---|
| Client portal | /client/{token} |
🟢 Live | Bare token; PIN/OTP optional per org |
| Proposal portal | /proposal/{token} |
🟢 Live | Bare token (+ Documenso e-sign) |
| Invoice portal | /invoice/{token} |
🟢 Live | Bare token only |
| Bid portal | /bid/{token} |
🟢 Live | Bare token |
| Sub portal | /sub/{clock_token} |
🟢 Live | Bare token; OTP optional per org |
| Landing portal | /p/{slug} + custom domains |
🟢 Live | Public (no auth); Client-login sub-flow = phone + OTP |
| Booking | /book |
🟢 Live | Public (no auth); admin under /portal/booking = staff cookie |
All seven are registered in app/main.py (lines 221–270) and actively serve traffic. No dead-ends were found, but the default auth posture is a security to-fix — see the callout below.
Current behavior (ground truth): A homeowner's money-and-project surfaces are reachable by anyone holding the URL. The token is a 128-bit secrets.token_urlsafe(16) value (app/services/kickoff.py:57, conversion.py:233), but there is no second factor by default:
/invoice/{token}) — no gate at all. invoice_portal.py has no OTP/PIN check anywhere; the token alone renders the invoice and the Stripe Pay button (invoice_portal.py:34, :107)./proposal/{token}) — no OTP/PIN gate; token alone shows the priced proposal and accept/e-sign (proposal_portal.py:59)./client/{token}) — bare token unless the org has opted into a gate. The optional gate is off by default (app/services/preferences.py:101,104 → both False).The gate, when enabled, still falls through to no protection when there's no phone on file — a deliberate "never lock a homeowner out of their own project" decision (client_portal.py:143-159, noted in-code as security report §7 "accepted").
Owner decision (to implement): OTP/PIN should become the default for client/homeowner surfaces. Treat the current bare-token default as a security to-fix, and prioritize the invoice portal, which has no gate wired at all even when the org opts in.
Both toggles live on Settings (templates/settings.html:205,210; saved in app/portal/router.py:789-790):
client_portal_pin — a static PIN = the last 6 digits of the primary contact's phone (portal_otp.expected_pin). Lighter weight, no SMS cost. Takes precedence over OTP when both are on (client_portal.py:148 checks PIN first).client_portal_otp — texts a fresh 6-digit code (10-min TTL, 5-attempt cap) that this device remembers for 30 days via a signed cportal_* cookie (app/services/portal_otp.py). Rate-limited hard against SMS-draining (client_portal.py:78-86).Important scope note: these two flags only gate /client/{token} and (for client_portal_otp) the /sub/{token} field app. They do not touch the invoice, proposal, or bid portals.
Audience: the homeowner / Client. Status: 🟢 Live — the richest external surface, fully wired (feed, payments, e-sign, AI Q&A, two-way messaging). app/routers/client_portal.py.
A public, login-optional "social feed" of a homeowner's own Job: a client-friendly kanban of client_visible schedule items with a % complete bar (:161-179), a time-ordered feed of published updates, photos, change orders and invoices (:281-291), payment milestones with inline Stripe pay links (:184-193), shared documents, product Selections the client chooses from, warranty-claim reporting, and a two-way Message thread with the office (:415-441). Change orders are approved by Documenso e-signature (:219-251); selections that carry a price delta auto-generate an approved change order (:386-404).
https://{site}/client/{token} (or the homeowner reaches it via the website Client login, below).:184-193), approve/sign a change order, choose a selection option, report a warranty issue, message the builder, or ask the scoped read-only AI about their project (:327-352).Shareable Job.client_token in the path (_job_for_token, :33). Optional PIN (/{token}/pin, :110) or SMS OTP (/{token}/otp/send|verify, :62,:94) per the org toggles above. Verified devices carry a 30-day cportal_* cookie.
Audience: a prospect / not-yet-won lead. Status: 🟢 Live. app/routers/proposal_portal.py.
Renders a branded, themed proposal (intro + sections + line items + total + phased schedule + payment schedule) at /proposal/{token} (:59-102). Opening it records a viewed event (:73). Accepting captures a real Documenso e-signature (lazily created on view so a forwarded/copied link still routes through Documenso — :85-92), and on completion conversion.convert_proposal turns the proposal into a Job + Estimate and pushes the schedule to OpenProject (:124-146). Declining marks the proposal declined and the lead lost (:149-163). A typed-name signature is the fallback only when Documenso truly can't run.
Opens the link → reviews the priced proposal → Accept (signs in the embedded Documenso frame; the page polls /{token}/sign-status, :105) or Decline. Acceptance instantly spins up their Job.
Shareable Proposal.public_token in the path (:27). No OTP/PIN. ?preview=1 suppresses actions and view-logging for the contractor previewing their own draft (:70).
Audience: the paying Client. Status: 🟢 Live — but security-flagged (no gate). app/routers/invoice_portal.py.
A public, branded, "magazine-style" themed invoice at /invoice/{token} (:34-81) with a Pay button that creates (or refreshes) a Stripe Checkout session (/{token}/pay, :107-143). ACH bank transfer is the default method (cheaper for large construction invoices — _METHODS, :103); card or "any" are alternatives. Opening records a view (:45); returning from Stripe with ?paid=1 re-confirms the session actually paid before marking the invoice paid — cards settle instantly, ACH is finalized by the webhook (:47-57). Optional Documenso sign-status polling exists (:84-98).
Opens the link → reviews the invoice → Pay (picks bank or card) → lands on Stripe Checkout → returns to a paid confirmation.
Shareable Invoice.public_token in the path (_for_token, :25). No OTP/PIN of any kind — the token is the only credential. This is the highest-priority item for the "OTP-by-default" fix.
Audience: an invited sub / vendor bidding a scope. Status: 🟢 Live. app/routers/bid_portal.py.
A vendor opens /bid/{token} and submits an itemized bid: header amount, notes, contact info, lead-time, parallel-array line items, and attached quote PDFs/images (/{token}/submit, :39-124). Uploads are hardened for a public endpoint — count/size caps and sniffed content-type via upload_guard, so a fake text/html upload can't render inline in an office user's session (:54-77). Bids land as Bid + BidLine rows tied to the BidRequest (:97-123). Submission is blocked once the request is closed (br.status != "open", :47).
Opens the link → fills the form / itemizes lines / attaches a quote PDF → Submit → sees a ?submitted=1 confirmation.
Shareable BidRequest.public_token in the path (_req, :21). No OTP/PIN — a bid invite is meant to be forwardable. (Subs already inside the sub portal see the same open bid requests listed on their home screen — sub_portal.py:146-150.)
Audience: the subcontractor's field crew. Status: 🟢 Live — a full walled field PWA. app/routers/sub_portal.py.
A subcontractor's mobile field app at /sub/{clock_token} with a hard wall: a sub never sees the homeowner, contract value, margins, quotes, or the client portal (module docstring, :1-8). Surfaces: today's assigned tasks + task RSVP/progress (:126,:432,:493), geofenced clock in/out that pushes time to OpenProject (:518-576), photo/voice/text capture that routes up to the office (:315-412), purchase-order review/approve/decline with optional Documenso sign (:254-309), open bid requests and to-dos, crew HR self-service + time-off (:200-239), and an internal-only office chat on the separate CrewMessage channel — physically distinct from the homeowner Message thread (:582-632).
Opens their private link (texted from Field access, pin.py:313) → (enters a texted code if the org requires it) → sees today's tasks → clocks in on-site → captures progress photos → approves a PO → messages the office. Everything flows up to the hub.
Reuses the crew's shareable Sub.clock_token in the path (_sub, :35) — the same token as the timeclock. Optional SMS OTP gate reusing the org's client_portal_otp flag (_otp_gate, :48-60); falls through when the sub has no phone on file. Verified devices carry the same 30-day cportal_* cookie.
Audience: prospects (public visitors) and homeowners logging in to their project. Status: 🟢 Live. app/routers/landing_portal.py.
Serves each org's published marketing site at /p/{slug}, at {subdomain}.gobuild.ca, and at attached custom domains — host → page resolution in resolve_site_host (:64), with on-demand-TLS gating for Caddy (/internal/tls-allow, :237). Renders themed builder pages, operator-authored custom pages/folder-sites, a blog, and auto-generated local-SEO pages ({service} in {city}, :536), plus per-site PWA manifest/service-worker/auto-branded icon (:281-327). Public lead-capture: the contact form and floating website chat both create a Lead (source=Website) in the partner's CRM with attribution stitching (/p/{slug}/contact, :671; /p/{slug}/chat, :764); an embeddable iframe form lives at /f/{token} (:822).
/p/{slug}/login lets a homeowner find their own project(s) by phone (:600-646). This path always sends an OTP and verifies it (find_client_jobs_by_phone + portal_otp.send_code/verify), then mints the cportal_* cookie for every matched Job and routes them to /client/{token} (or a project chooser at /p/{slug}/projects, :649). So a homeowner who arrives via the website is always phone-verified, while the same person arriving via a raw /client/{token} link is not — a real asymmetry worth closing as part of the OTP-by-default work.
Public for marketing/lead-capture (no auth). The Client-login sub-flow is phone + OTP, always.
Audience: prospects/clients booking a meeting (public) + office/PM staff configuring it (internal). Status: 🟢 Live. app/routers/booking.py.
A native scheduling page at /book — the org is resolved from the request host (marketing site / tenant subdomain, falling back to the sole org on a legacy single-tenant deploy — _resolve_org, :28-39). Visitors pick a meeting type and an open slot (/book/slots, :63) and book (POST /book, :80); the slot is re-validated server-side against double-booking before an Appointment is created (:96-98), with visitor-cookie attribution. The builder admin under /portal/booking (:117) is a month calendar + upcoming list, meeting-type/hours/availability config, and an AI page-copy drafter (:221).
/book → choose type → pick a time → enter name + email/phone → booked./portal/booking to set hours, meeting types, and edit the public page copy.Public routes: no auth (org resolved from host). Admin routes: staff session cookie, gated require_portal_role(pm, office) (:117-118) — this is the one surface here that uses staff cookie auth, not a shareable token.
pin.py (not an external portal)app/routers/pin.py is the internal field-app numpad login for staff/crew users — a device-bound (keystone_device cookie) numeric-PIN shortcut for email+password, mounted under /portal (pin.py:30). It is not a client/sub external portal. It is referenced here only because the client/sub portals' separate PIN/OTP gate is sometimes confused with it. The one crossover: the Field-access admin screen it serves texts subs their /sub/{token} links and shows the client_portal_otp state (pin.py:305-310).