Skip to content

Official guest chat

Official guest chat lets an unregistered visitor contact an explicitly opted-in Official account. Before an explicit registration handoff it is isolated from Better Auth, user, conversations, and messages; browser cookies are ignored by every guest handler.

  • The browser receives a random 64-hex bearer token. PostgreSQL stores only its SHA-256 digest. The token stays in local storage under arinova-guest-thread:{accountId} and is sent in the Authorization header; it is never placed in a URL or cookie.
  • The canonical thread APIs use the fixed /api/public/official/guest-thread path, so request paths contain no bearer. Request tracing still redacts accidental requests to the retired path-token shape before creating a structured span.
  • Creation always requires a valid Cloudflare Turnstile response. A missing server secret disables the surface; there is no empty-site-key bypass. A fresh, action-bound challenge is required for every five accepted messages.
  • Entry grants are single-use, short-lived, and bound to both the selected Official account and the trusted client IP. Replays and cross-account or cross-IP use fail closed.
  • IP addresses are irreversibly salted with the server secret and the :guest-ip: domain separator. User-agent text is limited to 300 characters.
  • Guest pages can remain in a service-worker cache, but they contain no bearer token or transcript. The residual cache signal is only that the browser has visited a particular Official account.

Agent replies are billed to the Official owner. Usage rows keep the owner, Agent, wallet transaction, and guest attempt metadata, while canonical conversation_id and message_id foreign keys remain null because this surface does not create canonical conversation or message rows during the anonymous exchange.

The anonymous footer links to registration with only a validated internal /go/{accountId}?claim=1 return target. The bearer is never placed in that URL or rendered into HTML. Once a browser session exists, the bearer holder can send guestThreadToken in the authenticated canonical POST /api/accounts/{id}/private-thread request.

That single database transaction creates or restores the subscriber’s one canonical Official conversation, locks the matching guest thread, copies its messages in sequence order, and binds the claim to that user and conversation. A pending reply rejects the handoff so no response is lost. The same user’s retry returns the same conversation without copying messages twice; a different user cannot reuse an already claimed bearer. Guest messages are attributed to the new user. Official messages are attributed to the Official account with no Agent or human operator identity, and system notices remain identity-free.

After commit, the guest thread is closed and its public token endpoints return gone. The browser removes the local token only after that durable response and navigates to /chat/{conversationId}. The copied messages then follow normal authenticated conversation retention and deletion policy.

Threads expire after seven idle days and have an absolute 30-day lifetime. Each accepted guest message extends only the idle deadline, clamped to that absolute expiry. A fleet-locked cleanup closes expired threads in batches of 5,000 while skipping pending or running replies, durably records pending runaway-budget cleanup, and hard-deletes a row only after Redis cleanup succeeds. A background reconciler retries failures.

The bearer holder can also call DELETE /api/public/official/guest-thread with the bearer header for anonymous self-service closure. Deletion returns a conflict while a reply is pending or running, so the visitor can retry without racing an in-flight provider call. The closed row remains available for immutable moderation evidence until its runaway-budget cleanup succeeds. Claimed threads likewise remain closed audit anchors until cleanup; deleting the claiming user or canonical conversation cascades them immediately.

Creation, sending, polling, and general operations use distinct fail-closed IP rate-limit classes. Creation adds an IP/account bucket; sending adds a stricter IP/thread bucket. A thread accepts at most 100 guest messages and an account at most 500 active guest threads. Agent execution additionally uses per-Official- account rate and daily-spend budgets, separate from the owner’s personal chat budgets. Maintenance mode and the shared Official automation claim control both fail closed.

Guest reports snapshot the account and ordered transcript at submission time. Authorized administrators can read the queue and move reports through pending, reviewing, resolved, or dismissed states; sensitive reads and mutations are audited.

Prometheus exports only bounded, content-free labels through:

  • arinova_guest_official_requests_total{endpoint,outcome}
  • arinova_guest_official_rejects_total{reason}
  • arinova_guest_official_runs_total{mode,outcome}

No account, owner, Agent, guest token, IP hash, message content, or thread ID is used as a metric label.

The disposable local Playwright lane enables a debug-only gateway feature and an authenticated deterministic OpenRouter fixture. Release builds ignore the fixture code path, and deployment recipes compile without fixture features.

Build a7f47a5ca54ddcf7806cd48b81ce1b9827042766