Inbox thread visibility comes from a hand-written helper with no administrator bypass, read and pin state is kept per user, the Web channel uses Postgres and Realtime with the Chat SDK web adapter only for portal sends, and a PermDock change is proposed so the helper could be generated.
Every Inbox thread used to be visible to anyone with chat.read in the organization. The WhatsApp connection scope was stored on the installation and enforced nowhere. Read, unread, pin and archive state lived on the thread, so it was shared by the whole organization. Team participants of a chat thread were ignored by its select policy.
The Web channel changes this. Staff chat with colleagues, teams and customers, and customers read and reply from the portal. A private chat between two people must stay private, even from owners and administrators, and several people now share one thread, so each person needs their own read state.
@chat-adapter/web can only post into a browser request that is open. Its postMessage needs the request context, and proactive push and file uploads are not part of version 1. It cannot deliver a staff message to a customer who is not currently sending a request.
PermDock relations cover a row's own owner and a participant edge. They cannot express "the audience of the parent connection" together with "the customer who is a participant of the thread", which the Inbox rule needs.
public.visible_inbox_thread_ids() is a hand-written security definer function. It returns the union of four sets: threads of organization-audience connections for people with chat.read, threads of connections whose audience lists the user or one of their teams, threads where the user or one of their teams is a participant, and threads assigned to the user or one of their teams. Inbox tables use id in (select public.visible_inbox_thread_ids()). public.visible_inbox_chat_thread_ids() maps the same set to chat threads for the chat policies. There is no administrator bypass, and integration.manage grants no thread visibility. public.accessible_chat_thread_ids stays retired in favor of permdock.permitted_thread_ids.inbox_thread_states holds read_at, manually_unread, pinned_at and archived_at per user and thread. The shared columns on inbox_threads and the Inbox use of chat_threads.pinned_at and archived_at are removed. Unread means the newest message was not sent by the user and is newer than their read_at.inbox_connections.audience is organization, members or participants. The built-in Web connection of each organization starts as participants. WhatsApp connections move from the installation scope to the audience, with inbox_connection_members listing the users and teams.customer_portal_inbox_* security definer functions, so contacts get no table access.visible_inbox_thread_ids could be generated from the policy like permitted_thread_ids. Until then the helper stays hand-written, and this record is the justification.chat.read or integration.manage now see only the threads that the rules show them. Owners and administrators are no exception, which makes private chats possible and means an administrator cannot read a colleague's private chat to support them.The Chat SDK Postgres state adapter (@chat-adapter/state-pg) creates five tables in public on first use: chat_state_subscriptions, chat_state_locks, chat_state_cache, chat_state_lists and chat_state_queues. The adapter has no schema or table name option, so the tables cannot move out of public, which PostgREST exposes. Created at runtime they had RLS off and the default anon and authenticated grants, which let an anonymous client write and truncate adapter state.
supabase/schemas/conversations/chat_state_*.sql with the adapter's exact columns and indexes, RLS enabled and no policies. better-supabase.config.ts revokes every privilege from anon and authenticated (expose with authenticated: [], which also removes anon) and lists the tables in sql.modules.audit.options.exempt, because they hold no tenant rows.createInboxChatSdkState passes autoCreateSchema: false, so the runtime never creates them. The adapter then checks its privileges when it connects and fails fast when the schema is missing.POSTGRES_URL as the postgres role. That role owns the tables and holds BYPASSRLS, so RLS with no policies does not block it. No policy is needed. service_role keeps its default table privileges, as on every other table.20261103200000_chat_state_tables.sql is idempotent. Hosted databases where the adapter already created the tables keep their rows: the migration uses if not exists for the tables, sequences and indexes, and its enable row level security and revoke statements apply to the existing tables. Run select relrowsecurity from pg_class where relname like 'chat_state_%' after the deploy to confirm.Last updated on