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.
Overview
- 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 type | Viewer component | Source of truth |
|---|---|---|
| chat | SharedChatViewer.tsx | conversations + messages (live) |
| canvas | SharedCanvasViewer.tsx | shared_links.metadata snapshot taken at create time |
| file | SharedFileViewer.tsx | brain_files + signed Storage URL (60 s TTL) |
| note | SharedNoteViewer.tsx | agent_memories rows tagged note |
| agreement | SharedAgreementViewer.tsx | agreements (live) |
Data model
Three tables. One holds the link, two hold the analytics and OTP state.
shared_links
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.
Gate types
Six independent gates compose on a single link. Each gate is just a column on shared_links plus a check inside validateAccess.
| Gate | Stored as | Checks | Failure code |
|---|---|---|---|
| Archive (soft revoke) | is_archived | Boolean flag | archived |
| Expiry | expires_at | now() < expires_at | expired |
| Max views | max_views, view_count | view_count < max_views | max_views_reached |
| Password | password_hash | salted SHA-256 match | password_required, password_wrong |
| Email (allow / deny) | email_protected, allow_list, deny_list | Case insensitive email + domain wildcards | email_required, email_denied |
| OTP | email_verified + shared_link_verifications | Hashed 6 digit code, 15 minute TTL | verification_required, verification_wrong |
| Agreement | enable_agreement, agreement_id | UI gate, requires viewer acceptance | requiresAgreement payload |
Password
Email allow and deny lists
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.
- 01Create
POST /api/sharewith 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. - 02Update
PATCH /api/share/[id]with any subset of settings. Special actionregenerate_slug: trueissues a new slug. The link id stays stable; analytics keep accumulating. - 03Probe
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. - 04Access
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. - 05Revoke
DELETE /api/share/[id]flipsis_archived. Old URLs return a friendly archived state.?hard=truedeletes the row outright if you want the URL gone.
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.
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.
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
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.
shared_link_verifications holds the hash. A stolen database does not let an attacker reuse codes.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
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.