A Bucket names no company, so every procedure that takes a bucket id from
its input — document and template create, SAFE create and import, share and
option grants — refuses one whose key is not filed under the company's own
prefixes, before anything lands upstream. A GET for a company's document is
signed only for such a key: get-document, the document and data-room
previews, and presignDownload, which now serves Document rows alone. The
SAFE template the server writes is filed under the company's public id.
An investor update's blocks are cleaned on save and clone with the rule its
renderer applies: a link keeps an http(s) or mailto href or becomes its
text, and an image, video, audio or file url keeps http(s) or is blanked,
through nested children and table cells.
Every page is served with a Content-Security-Policy from middleware:
default-src 'self'; script-src 'self' 'unsafe-inline'; object-src 'none';
base-uri 'self'; frame-ancestors 'none'; connect and media reach the object
store, IAM, api.hanzo.ai and Sentry; images any https host; frames the
Office viewer. /v1 answers without it.
An API access token expires ninety days after it is made; one made without
an expiry lives ninety days from creation.
Co-authored-by: Hanzo Dev <[email protected]>
A session lives as long as the IAM access token it signed in with: the jwt
callback, which every session read runs through, ends a session whose token
is missing or expired, and an unused session lasts 24 hours. Someone IAM
moves or disables loses Captable when their token expires, and signs in
again through IAM.
The request log keeps the path alone, with invitation and signing tokens
replaced; queries are never logged.
Share links are minted only by the mutations that mail them. Reading who a
data room or update is shared with returns no links, and the copy-link
button is gone. A member recipient opens a link only while still ACTIVE,
and removing a member deletes their recipient rows.
Investor updates render from their stored blocks: text is React text,
links keep http(s) or mailto addresses, images http(s). No update HTML is
stored or shown.
Only an ADMIN writes a role, assigns one, or acts on an ADMIN; a company
keeps its last ACTIVE ADMIN; an edit that names no role keeps the member's
role; custom roles are looked up within the company.
Uploads land where the server says: profile photos under the account,
everything else under the company's public id, signed only for an ACTIVE
member (the founder's logo excepted). Public objects are PNG, JPEG, WebP or
GIF, with their type and disposition signed into the PUT. bucket.create
refuses a key outside the company's prefix.
A recipient's signing status moves forward once, so two signings cannot
both land.
Co-authored-by: Hanzo Dev <[email protected]>
Sign-in binds an account to its IAM subject only; an address that already
names another account is refused, not merged. The profile is IAM's userinfo,
and each sign-in records whether IAM has proven the address.
An invitation is accepted by its token alone: the seat and the address come
from the token row, the address must be the one IAM has proven for the
signed-in account, the seat must still be pending, and the token is spent
with every other token for that seat. Invitations seat a stand-in with no
address until accepted, expire after seven days, and leave no link in logs.
Every procedure that reads or changes company data requires an ACTIVE
member whose role grants the action; the cap table, investor updates, data
rooms, templates, contacts and API tokens all name their subject, and
`securities` and `updates` are new subjects a saved custom role holds
nothing on. Only an admin makes another admin. Company creation is
bootstrap-only, and profile edits no longer change the sign-in address.
Share links for data rooms, updates and signing expire after 30 days and
end when their recipient is removed. Signing takes the link token and fills
only the signer's own fields.
The object-store signers are server-only; the browser reaches them through
two Server Actions that require a signed-in caller and, for company files,
an active member whose company owns the file. The REST API requires an
active membership and an unexpired token.
The local email/password lane — sign-up, email verification, password
reset and password change — is removed; Hanzo IAM is the only sign-in.
Co-authored-by: Hanzo Dev <[email protected]>
git.hanzo.ai launches runners for the hanzoai namespace, so the image is
built from the hanzoai/captable mirror; hanzo-apps/captable keeps the same
commits without a build.
hanzo.yml names ghcr.io/hanzoai/captable (docker/Dockerfile, linux/amd64),
which .hanzo/workflows/cicd.yml hands to hanzoai/ci's build.yml on every
push to main. The README says which repository builds it.
sendMail posts to /v1/notify/send/email as the app's own IAM identity
(client credentials), and notify delivers with the email provider and
sender configured for that org. SMTP settings and nodemailer are gone.
notify carries no attachments, so the e-sign completion email links the
signed document with a seven-day presigned URL instead of attaching it.
The S3 client addresses objects as <endpoint>/<bucket>/<key>, so a URL
signed against the public endpoint is one host the browser can reach,
not a <bucket>.<endpoint> name nothing serves.
The page is bg-background, the card bg-card with text-card-foreground, and
the IAM button is the primary Button, so the heading and every label follow
whichever palette <html> carries instead of white text on a white card.
package.json and goja/package.json return to the ranges the lockfile was
generated from, so `pnpm i --frozen-lockfile` installs again and the image
builds the dependency set that runs in ghcr.io/hanzoai/captable:0.1.6.
The cap table is served at /v1/captable, org-scoped from the caller's own
bearer. This app kept its own Postgres copy of the same entities, so the two
could disagree about who owns what — the reason to move rather than keep both.
The tRPC procedures for share classes, equity plans, shares, options, SAFEs and
stakeholders now call one client (src/server/captable-api.ts) whose normalizers
read either spelling the surface uses and degrade to an honest empty value
rather than throwing inside a render. Each procedure keeps the shape it already
returned, so no component changed for the move.
The bearer rides the encrypted next-auth JWT and is read server-side only; it is
never put on `session`, which is served to the browser verbatim.
Paperwork stays local — buckets, uploaders, e-sign, data rooms are this app's.
Document.shareId/optionId/safeId/convertibleNoteId keep their columns and now
hold the id Hanzo Cloud assigned, so they are no longer foreign keys. Creates
answer no body, so the new row is found by the natural key each write already
enforces unique: certificateId, grantId, publicId.
The public REST routes for shares and stakeholders are removed. They were a
second write path over the same data, they authenticate with an app-issued API
key that carries no IAM principal to present upstream, and one of them declared
the stakeholder path — shadowing the real stakeholder PATCH, so PATCH on a share
never existed. api.hanzo.ai/v1/captable is the public cap-table API.
Two surfaces are left honest rather than faked: equityPlan.update refuses,
because the backend serves no update route for plans; company.getCompany stays
local, because /v1/captable/company carries six fields and that read feeds a
form needing fourteen.
The Billing settings page opens billing.hanzo.ai to manage a subscription and
pay.hanzo.ai for the plan catalog, each in a new tab. Both URLs live in
src/constants/billing.ts and read NEXT_PUBLIC_BILLING_URL / NEXT_PUBLIC_PAY_URL
when a deployment needs different hosts.
The hosted surface owns payment state, so the app stores no products, prices,
subscriptions or customers and serves no payment webhook.
Every token in the .dark palette carried hue 240 at 3.7-10% saturation — the
stock blue-grey — so the surface read blue rather than black and did not sit
beside the console. Saturation is 0 throughout now; depth comes from lightness
alone, and the background is true black.
The palette was also unreachable: there is no theme provider here, so .dark was
never applied and the light :root palette always won. The class is named on
<html>, which is also the one place it cannot disagree with itself.
hanzo.id returns an id_token. With idToken: false next-auth uses the plain
OAuth2 callback and openid-client throws on the token response it cannot
account for:
id_token detected in the response, you must use client.callback()
instead of client.oauthCallback()
A signing-in user sees only ?error=OAuthCallback.
Found before this app was ever deployed, by checking it against dataroom —
which shipped the identical line and where every sign-in has been failing. The
handoff to hanzo.id looks healthy in both cases; the break is only on the way
back, so nothing short of completing a login distinguishes them.
profile() reads sub/name/email/picture/owner, all carried by the id_token, so
the mapping is unchanged; userinfo stays for what the token does not hold.