The Chat Loop

Shared sessions

Every session, canvas, file, note, or agreement in Ultron can be published as a public read-only view at /s/[slug]. The link supports six independent gates, view tracking, owner notifications, an analytics endpoint, watermarking, and a one-click revoke. The link owner keeps the link id stable so analytics survive a slug rotation.

Updated today

Overview

At a glance
Public route
/s/[slug]
Backing table
shared_links
Analytics
shared_link_views
OTP store
shared_link_verifications
Slug shape
10 char base62, ambiguous chars excluded
Gate types
password, expiry, email allow / deny, OTP, max views, agreement
Library
src/lib/sharing.ts
Modal
src/components/share/ShareModal.tsx

Sharing is implemented as a single primitive that wraps every shareable artifact. The user opens the share modal on a chat, canvas, file, note, or agreement; the modal calls POST /api/share with the resource type and id; the server writes a row and returns a slug. From there, every setting on the modal is a column on the row that the public route reads on access.

Two on-by-default toggles do the heavy lifting. allow_download controls whether viewers can pull binary content. enable_notification sends the owner an email each time a new viewer lands. Everything else (password, expiry, OTP, view caps) is off by default and switches on per link.

What you can share

Each resource type has its own dedicated viewer component. The public route dispatches based on resource_type after gates pass.

Resource typeViewer componentSource of truth
chatSharedChatViewer.tsxconversations + messages (live)
canvasSharedCanvasViewer.tsxshared_links.metadata snapshot taken at create time
fileSharedFileViewer.tsxbrain_files + signed Storage URL (60 s TTL)
noteSharedNoteViewer.tsxagent_memories rows tagged note
agreementSharedAgreementViewer.tsxagreements (live)
Note
Canvas shares snapshot at create time. Editing the source canvas after the share is created does not change the public view. Every other resource type reads live so updates flow through.

Data model

Three tables. One holds the link, two hold the analytics and OTP state.

shared_links

supabase/migrations/20260415_shared_links.sqlsql
4create table shared_links (
5 id uuid primary key default gen_random_uuid(),
6 user_id uuid not null references auth.users(id) on delete cascade,
7 resource_type text not null, -- chat | canvas | file | note | agreement
8 resource_id text not null,
9 slug text unique not null,
10 name text,
11 password_hash text, -- salted sha256
12 expires_at timestamptz,
13 email_protected boolean default false,
14 email_verified boolean default false, -- OTP gate
15 allow_list text[] default '{}',
16 deny_list text[] default '{}',
17 allow_download boolean default true,
18 enable_notification boolean default true,
19 enable_watermark boolean default false,
20 watermark_config jsonb,
21 enable_agreement boolean default false,
22 agreement_id uuid,
23 max_views integer,
24 view_count integer default 0,
25 is_archived boolean default false,
26 metadata jsonb,
27 created_at, updated_at timestamptz default now()
28);

shared_link_views

One row per access event. Captures the viewer email (if collected), an IP hash, the resolved country and city, the user agent, the referer, and a download timestamp slot. Read by the owner analytics endpoint.

shared_link_verifications

One row per OTP issuance. Stores the link id, the viewer email, a hashed 6-digit code, an expiry, and a verified-at timestamp. Codes live 15 minutes by default.

Slug generation

Slugs are short, URL safe, and visually unambiguous.

src/lib/sharing.tsts
167function generateSlug(len = 10): string {
168 // Base62 minus visually ambiguous characters: no 0/O/1/l/I
169 const alpha = "abcdefghijkmnpqrstuvwxyzABCDEFGHJKLMNPQRSTUVWXYZ23456789"
170 const bytes = crypto.randomBytes(len)
171 let out = ""
172 for (let i = 0; i < len; i++) out += alpha[bytes[i] % alpha.length]
173 return out
174}
Tip
Collision handling retries up to five times at 10 characters, then expands to 12 characters as a fallback. In practice the 10 character space is large enough that retries are rare.

Gate types

Six independent gates compose on a single link. Each gate is just a column on shared_links plus a check inside validateAccess.

GateStored asChecksFailure code
Archive (soft revoke)is_archivedBoolean flagarchived
Expiryexpires_atnow() < expires_atexpired
Max viewsmax_views, view_countview_count < max_viewsmax_views_reached
Passwordpassword_hashsalted SHA-256 matchpassword_required, password_wrong
Email (allow / deny)email_protected, allow_list, deny_listCase insensitive email + domain wildcardsemail_required, email_denied
OTPemail_verified + shared_link_verificationsHashed 6 digit code, 15 minute TTLverification_required, verification_wrong
Agreementenable_agreement, agreement_idUI gate, requires viewer acceptancerequiresAgreement payload

Password

src/lib/sharing.tsts
156function hashPassword(password: string): string {
157 const salt = process.env.SHARE_PASSWORD_SALT || "ultron-share-v1"
158 return sha256(password, salt)
159}
Warning
Passwords are stored as a salted SHA-256 hash, not bcrypt. The salt comes from an environment variable so the hash is stateless and migrating to a different KDF would require recomputing hashes from cleartext (not stored). Treat share passwords as a friction layer, not a vault.

Email allow and deny lists

src/lib/sharing.tsts
176function emailMatchesList(email: string, list: string[]): boolean {
177 const lower = email.toLowerCase().trim()
178 const domain = lower.includes("@") ? `@${lower.split("@")[1]}` : null
179 return list.some((entry) => {
180 const e = entry.toLowerCase().trim()
181 if (e === lower) return true // exact
182 if (e.startsWith("@") && domain === e) return true // @domain.com
183 if (e.startsWith("*@") && domain === `@${e.slice(2)}`) return true
184 return false
185 })
186}

OTP

The viewer submits an email. The server generates a six-digit numeric code with crypto.randomInt, stores its SHA-256 hash in shared_link_verifications, and ships the plaintext code by email. The viewer types the code; the server compares hashes and marks the verification record as used. Default TTL is fifteen minutes.

Lifecycle

Five operations span the life of a share link. Each is a single HTTP call.

  1. 01
    Create
    POST /api/share with the resource type, the resource id, and an optional settings object. The server asserts ownership, generates a slug, inserts the row, and returns the public URL.
  2. 02
    Update
    PATCH /api/share/[id] with any subset of settings. Special action regenerate_slug: true issues a new slug. The link id stays stable; analytics keep accumulating.
  3. 03
    Probe
    GET /api/s/[slug] returns the gate manifest only. The viewer learns what to render (password input, email form, OTP entry, agreement checkbox) without seeing any resource data.
  4. 04
    Access
    POST /api/s/[slug] with the gate payload. On success the server returns the link metadata plus the resolved resource. View tracking and notifications fire in the background.
  5. 05
    Revoke
    DELETE /api/share/[id] flips is_archived. Old URLs return a friendly archived state. ?hard=true deletes the row outright if you want the URL gone.
src/app/api/s/[slug]/route.tsts
24// Probe: gate manifest, no resource data
25export async function GET(_req: Request, { params }: { params: { slug: string } }) {
26 const link = await loadLinkBySlug(params.slug)
27 if (!link) return Response.json({ error: "not_found" }, { status: 404 })
28 return Response.json({
29 slug: link.slug,
30 name: link.name,
31 resourceType: link.resource_type,
32 requiresPassword: !!link.password_hash,
33 requiresEmail: link.email_protected || link.allow_list.length > 0 || link.deny_list.length > 0,
34 requiresOtp: link.email_verified,
35 requiresAgreement: link.enable_agreement,
36 allowDownload: link.allow_download,
37 expiresAt: link.expires_at,
38 isExpired: link.expires_at ? new Date(link.expires_at) < new Date() : false,
39 maxViewsReached: link.max_views ? link.view_count >= link.max_views : false,
40 })
41}

Tracking and notifications

Every successful access writes one row to shared_link_views and bumps view_count. If notifications are enabled, the owner gets an email.

src/app/api/s/[slug]/route.tsts
115// View recording and owner notification, both non-blocking
116recordView(sb, link, { email, ip, userAgent, referer })
117 .then(async ({ viewId }) => {
118 if (link.enable_notification) {
119 await sendShareLinkViewedNotification({ link, viewerEmail, viewId })
120 .catch((err) => console.error("[Share] notification failed:", err))
121 }
122 })
123 .catch((err) => console.error("[Share] recordView failed:", err))

The owner can pull the per link analytics rollup at GET /api/share/[id]. The response includes total views, unique viewers, total downloads, and the last twenty views with viewer name, email, country, and dwell time. Notifications are sent through the same transactional mail provider the lifecycle emails use, tagged share-view-notification for downstream filtering.

Warning
view_count is incremented with a separate update, not an atomic increment. Two concurrent first views can collide on the count. The shared_link_views table is the canonical source of truth.

Per-resource viewers

Each viewer component is tuned for its content type. Same chrome, very different inside.

Chat viewer

Renders the messages array as a read-only transcript. System messages and empty messages are filtered out before render. Internal UI markers like wizard tokens and HTML comment quick-reply hints are stripped so internal state never leaks to a public viewer. There is no input box.

Canvas viewer

Reads a snapshot stored in shared_links.metadata shaped as { type, data } and passes it through the canvas block renderer in read-only mode. Edits to the original canvas do not propagate to the public link by design.

File viewer

Resolves the file row, asks Storage for a signed URL with a 60-second TTL, and renders it. Images go inline through <img>; PDFs go inline through <iframe>; everything else gets a download card. The card only renders when allow_download is true.

Note viewer

src/components/share/SharedNoteViewer.tsxts
46// Notes can be HTML or plain text. HTML is sanitized strictly before render.
47const safeHtml = looksLikeHtml
48 ? DOMPurify.sanitize(htmlString, {
49 ALLOWED_URI_REGEXP: /^(?:https?:|mailto:|tel:|#|\/)/i,
50 FORBID_TAGS: ["style", "script", "iframe", "object", "embed", "form"],
51 FORBID_ATTR: ["style", "onerror", "onload", "onclick", "onmouseover", "formaction"],
52 })
53 : null
Warning
Notes accept HTML so the sanitizer is the only thing between an attacker and a stored XSS on a public route. Tags and attributes are denylisted aggressively; URIs are restricted to http(s), mailto, tel, and same-origin paths.

Agreement viewer

Renders the agreement title, file name, status (Draft / Sent / Signed), and the body as preformatted text. The signing flow lives on a separate authenticated route; the share viewer is read only.

Security model

A short list of the things that actually matter.

Resource ownership is asserted on create
The create endpoint resolves the user id from the session and verifies they own the target resource before writing the share row. A user cannot publish another user's chat or file.
Password hashing is a friction layer
Salted SHA-256 with an env-derived salt. It is cheap to compute, which is fine because share passwords are not bank vaults but is worth knowing if you are setting one.
OTP codes are hashed at rest
The plaintext code only exists in the email. The row in shared_link_verifications holds the hash. A stolen database does not let an attacker reuse codes.
Notes are sanitized, others are not
Chat, canvas, file, and agreement viewers render content the owner created and we control the rendering surface. Only notes can carry arbitrary HTML, so notes are the only viewer that runs DOMPurify.

Public view chrome

Every public view inherits the same wrapper so a viewer can move between artifacts without losing context.

PublicViewLayout wraps every public viewer. The fixed header shows the Ultron mark, a title chip with the resource name and category, and Log In / Sign Up links. The body reserves space for the header at the top and an optional sticky funnel mini-chat at the bottom. Edge blur overlays soften the transitions. A hideBottomChat prop drops the funnel for compact viewers.

File map

src/app/api
share
route.tsPOST create, GET list
[id]/route.tsGET details, PATCH update, DELETE revoke
s/[slug]
route.tsGET probe, POST access
deployments/[id]/share
route.tsdeployment specific shares (separate scheme)
src/app/s/[slug]
page.tsxpublic route, gate orchestration
src/components/share
ShareModal.tsxsettings UI
PublicViewLayout.tsxshared chrome
SharedChatViewer.tsx
SharedCanvasViewer.tsx
SharedFileViewer.tsx
SharedNoteViewer.tsx
SharedAgreementViewer.tsx
src/lib
sharing.tsvalidateAccess, recordView, slug + hash helpers
email
share-notifications.tsowner notification template
supabase/migrations
20260415_shared_links.sql
shared_link_views.sql
shared_link_verifications.sql

Glossary

Share link
One row in shared_links representing one publicly accessible artifact. Owns its own slug, gates, and analytics.
Slug
A ten character base62 string excluding visually ambiguous characters. Used as the URL identifier at /s/[slug].
Gate
A check performed before the resource is returned to a viewer. Six gates ship: archive, expiry, max views, password, email, OTP.
Probe
The unauthenticated GET on /api/s/[slug] that returns the gate manifest without revealing resource content.
Access
The POST on /api/s/[slug] that submits gate inputs and returns the resource when every gate passes.
View record
A row in shared_link_views with viewer email, IP hash, geolocation, user agent, and download timestamp.
Notification
Owner email sent on every recorded view when enable_notification is true. Fires in the background.
Regenerate URL
An update action that issues a new slug while keeping the row id stable so analytics persist.
Soft revoke
Setting is_archived to true. The URL returns a friendly archived state instead of resolving.