Visitors can ask questions without leaving the deck. A lightweight widget is injected into the gated view; the owner answers from a panel in the project's Q&A tab. Questions are scoped to the asking visitor (a visitor only ever sees their own thread); the owner sees every question for the project.
The widget is link-scoped — it posts to /api/<slug>/qa, which keys off the visitor's link JWT. It is therefore not injected into the space viewer (which has no link slug); space Q&A is out of scope today.
qa_items#apps/memo/src/lib/db/schema.ts:
| Column | Type | Notes |
|---|---|---|
id | uuid PK | |
visit_id | uuid → visits.id ON DELETE CASCADE | The asking session. A visitor sees only rows with their own visit_id. |
link_id | uuid → links.id ON DELETE CASCADE | The link the question was asked through. |
question | text | Visitor-submitted, 1–2000 chars (zod-enforced). |
answer | text? | Owner reply; NULL until answered. |
answered_at | timestamptz? | Set when the owner answers. |
page_path | text? | Which page/section the question was asked from (≤500 chars). |
created_at | timestamptz | now() default. |
Indexes: qa_items_visit_idx on visit_id, qa_items_link_idx on link_id.
public/qa-widget.js#Vanilla JS (no React — visitor pages ship zero framework), injected before </body> by injectVisitorChrome() via the QA_WIDGET_SNIPPET whenever qaWidget !== false. It reads the memo-visit + memo-slug metas the chrome also injects, renders a floating launcher + thread panel, and talks to /api/<slug>/qa.
XSS posture (load-bearing). Every server/visitor-controlled string (question, answer, visitorEmail) is escaped through esc() before it touches innerHTML. esc() builds a text node and reads back innerHTML, which encodes & < >. All values are placed in element text content (never into an attribute), so quote-encoding is unnecessary and the widget is safe as written. There is no postMessage surface. Keep new interpolations routed through esc().
/api/[slug]/qa#src/pages/api/[slug]/qa/index.ts. Visitor-facing, gated by the link JWT (not Clerk).
GET#Returns the asking visitor's own thread:
loadLink(slug) must be ok.readClaims(cookie, slug) must yield a sub.SELECT … WHERE visit_id = claims.sub AND link_id = link.id ORDER BY created_at DESC LIMIT 100.A visitor can never read another visitor's questions — the visit_id = claims.sub filter is server-derived from the (unspoofable) JWT.
POST#Submits a question:
loadLink + readClaims (must have sub).rateLimit helper, keyed on link.id + claims.sub (visit) + IP, 10 / 60s ⇒ 429 rate_limited when exceeded. (A valid session could otherwise insert unlimited rows — this closes the spam/DB-flood vector.)question: z.string().trim().min(1).max(2000), pagePath: z.string().max(500).optional(). Invalid ⇒ 400 via jsonValidationError.visit_id + link_id (never from the client body).The owner manages questions from the project Q&A tab — src/pages/u/[username]/projects/[projectId]/qa.astro, which mounts the OwnerQaPanel React island (src/components/qa/OwnerQaPanel.tsx).
Owner endpoints (behind withOwner):
| Method + path | Purpose |
|---|---|
GET /api/owner/qa?linkId=<id> | List questions for a link the caller owns |
POST /api/owner/qa/[id] | Write the answer (sets answered_at) |
Ownership is verified by walking qa_item → link → project.owner_id = caller before any read/answer. The panel fetches per link the project exposes.
Cross-references: the widget is wired through
injectVisitorChrome; rate-limiting uses the sharedrateLimithelper.