Dataroom
Package dataroom is a secure document room you share by link and watch page by page.
Package dataroom is a secure document room you share by link and watch page by page.
| Base URL | https://api.hanzo.ai |
| Operations | 17 |
| Auth | Authorization: Bearer $HANZO_API_KEY |
Specification
HIP-1121 · Dataroom — Documents Shared by Link — Draft · read the specification →
/v1/dataroom is a secure document room: upload documents, group them into
rooms, share them by access-controlled link, and watch who read what, page by
page. It is implemented in hanzoai/cloud at apps/dataroom, which runs the
ported Papermark business logic in-process rather than as a separate service.
This HIP states where the bytes live, where the rows live, and how an
unauthenticated viewer is routed to exactly one tenant.
Motivation
The upstream product was a Next.js + Prisma + Postgres deployment — a pod, a
database and a framework for what is, to the cloud, one subsystem. The fold
(apps/dataroom/dataroom.go:4-9) retires that deployment: cloud serves the
surface itself, on the same per-tenant storage every folded application uses,
and the standalone pod holds nothing to migrate.
Specification
The key words MUST, MUST NOT, SHOULD, SHOULD NOT and MAY are to be interpreted as in RFC 2119.
§1 Two stores, one owner
Rows — documents, rooms, links, viewers, page-view events — live in one SQLite
file per tenant, opened by the shared goja host with one transaction per
request (apps/dataroom/dataroom.go:12-20). Document bytes never touch the
bundle or local disk: they go through the object-storage seam (deps.VFS)
under an org-scoped opaque key, and the tenant DB persists only the key
(apps/dataroom/dataroom.go:30-35). Beside the tenant files sits one
system-namespace index, link_index (apps/dataroom/index.go:26) — the link
id → org routing table, the single deliberately cross-tenant piece.
§2 The addresses
Every route is under /v1/dataroom (manifest/apps.go:336). Ten routes are
typed operations. Seven stay raw, each for a reason in the wire
(apps/dataroom/dataroom.go:159-163): the upload takes the file itself as the
raw body, the two /file routes answer a byte stream, and the four
/view/{linkId} routes carry no validated org a typed op could read — they are
the visitor's surface. Each raw route declares its prose beside the wire fact
(apps/dataroom/dataroom.go:212-236).
§3 Tenancy
Admin routes require a validated principal and resolve the org from it
(apps/dataroom/dataroom.go:344-346, HIP-0026); refusal is principal.Refused,
never the unscoped store. Viewer routes carry no principal: the link id is
resolved through the link index to the owning org before any per-tenant store
opens, and a link with a password checks it through a bcrypt host function.
Isolation is therefore a host property — the bundle is handed a database already
pinned to one tenant and cannot name another.
§4 Money, events, telemetry
dataroom is free, in those words (plugin/dataroom/main.go:21,
cloud.Free; not in spend.go:275). It publishes no events on the bus —
page views are rows in the tenant DB read back through the analytics routes,
not bus events — and it emits nothing to observability beyond the request span
every route gets.
§5 Stage
dataroom is beta: a vertical application, not the agentic-OS core. The
manifest row declares it (manifest/apps.go:336, Stage: Beta), so the
capability is reached by flag (HIP-0139 §8).
§6 Upstream
dataroom embeds github.com/hanzoai/dataroom v1.1.7 — the ESM-free port of the
Papermark API handlers, pinned and checksummed rather than copied in. The
upstream is Papermark, AGPL-3.0 outside its ee/ directories (the module's
LICENSE carries the split), and what survives in HEAD is the domain logic as a
goja bundle: documents, rooms, links, viewers, analytics. The Go leaf adds only
the tenant schema, the object-storage seam, the bcrypt host function and the
link index; zero domain logic lives in Go (apps/dataroom/dataroom.go:17-20).
Rationale
The alternative to the link index is scanning tenant files for a link id, which turns every anonymous view into a walk of every tenant's store — slow, and a cross-tenant read performed on every request instead of never. One small routing table that maps id → org keeps the cross-tenant surface to a single lookup whose answer is an org name, not data.
Security Considerations
The viewer path is an unauthenticated door into tenant data by design, so the whole exposure concentrates in the link: a guessable id is a readable dataroom. Link ids are crypto-random, a passworded link verifies through bcrypt before any page is served, and the index answers only the owning org — never rows. The other exposure is the bytes: they are keyed by org-scoped opaque keys on the object store, so a copy of one tenant's SQLite file contains no document content, only keys the store will not honour for another caller.
Four surfaces
| Surface | Reaches this capability as | Coverage |
|---|---|---|
| REST | dataroom at its own prefix | 17 operations |
| CLI | hanzo dataroom … | 17 of 17 |
| SDK | DataroomApi in every published client | 17 methods |
| MCP | tool dataroom on https://api.hanzo.ai/v1/mcp | 17 operations, 1 under the document's own id — ask describe for the rest |
Quickstart
export HANZO_API_KEY=sk-... # console.hanzo.ai → API keysThen the first call — a read that needs nothing but the key. GET /v1/dataroom/links, operation get_dataroom_links:
hanzo dataroom links getimport { Configuration, DataroomApi } from 'hanzoai';
const api = new DataroomApi(new Configuration({ accessToken: process.env.HANZO_API_KEY }));
const { data } = await api.getDataroomLinks();from hanzoai.cloud import ApiClient, Configuration
from hanzoai.cloud.api import DataroomApi
client = ApiClient(Configuration(access_token=os.environ["HANZO_API_KEY"]))
result = DataroomApi(client).get_dataroom_links()cfg := cloud.NewConfiguration()
cfg.AddDefaultHeader("Authorization", "Bearer "+os.Getenv("HANZO_API_KEY"))
client := cloud.NewAPIClient(cfg)
resp, _, err := client.DataroomAPI.GetDataroomLinks(context.Background()).Execute()
if err != nil {
return err
}use hanzo_cloud::apis::{configuration::Configuration, dataroom_api};
let mut cfg = Configuration::new();
cfg.bearer_access_token = std::env::var("HANZO_API_KEY").ok();
let result = dataroom_api::get_dataroom_links(&cfg, Default::default()).await?;import ai.hanzo.cloud.ApiClient;
import ai.hanzo.cloud.api.DataroomApi;
ApiClient client = new ApiClient();
client.setRequestInterceptor(b -> b.header("Authorization", "Bearer " + System.getenv("HANZO_API_KEY")));
var result = new DataroomApi(client).getDataroomLinks();curl https://api.hanzo.ai/v1/dataroom/links \
-H "Authorization: Bearer $HANZO_API_KEY"The door reaches dataroom through the dataroom tool, which names its 17 operations with its own verbs — this one among them, under a name only the door declares. describe explains any of them:
curl -X POST https://api.hanzo.ai/v1/mcp \
-H "Content-Type: application/json" \
-d '{
"jsonrpc": "2.0",
"id": 1,
"method": "tools/call",
"params": {
"name": "describe",
"arguments": {
"op": "get_dataroom_analytic_dataroom"
}
}
}'Answers 200 with object — ok.
Endpoints
| Endpoint | What it does |
|---|---|
GET /v1/dataroom/analytics/dataroom/{dataroomId} | Rolls up every share link pointing at one data room: session and page-view totals for the room, plus the per-page breakdown for each link beneath it. |
GET /v1/dataroom/analytics/link/{linkId} | Reports how one share link was actually read: total viewing sessions, total page views, and per page the view count, the summed dwell measure and its… |
POST /v1/dataroom/datarooms/{id}/documents | Puts an already-uploaded document into one of the caller org's data rooms and answers with the new membership id. |
GET /v1/dataroom/datarooms/{id} | Reads one of the caller org's data rooms together with every document in it, each carrying its membership id and order index. |
GET /v1/dataroom/datarooms | Returns every data room in the caller org's own store, newest first, with its short public id, name, description and timestamps. |
POST /v1/dataroom/datarooms | Opens a new data room for the caller org and answers with it, including the short public id it is addressed by. |
GET /v1/dataroom/documents/{id}/file | Download a document's bytes as its owner |
GET /v1/dataroom/documents/{id} | Reads one of the caller org's documents — its name, opaque storage key, content type, page count, size and timestamps. |
GET /v1/dataroom/documents | Returns every document in the caller org's own store, newest first — name, opaque storage key, content type, page count, size and timestamps. |
POST /v1/dataroom/documents | Upload a document's bytes and record it |
GET /v1/dataroom/health | Liveness of the dataroom subsystem |
GET /v1/dataroom/links | Returns every live share link in the caller org's own store, newest first, with the controls a visitor will meet: whether an address is required,… |
POST /v1/dataroom/links | Grants access: it mints a public share link over one data room (dataroomId) or one document (documentId) — one of the two is required — and… |
POST /v1/dataroom/view/{linkId}/authenticate | Pass a share link's gates and open a viewing session |
GET /v1/dataroom/view/{linkId}/document/{documentId}/file | Read a document's bytes as an authorised link visitor |
POST /v1/dataroom/view/{linkId}/pageview | Record one page-view against an open viewing session |
GET /v1/dataroom/view/{linkId} | What a share link's visitor sees before authenticating |
How is this guide?