28 apps told the user to paste an `hk-` key β input placeholders, settings help text, READMEs, the error thrown when the field is empty β and five of them validated that paste against /^hk-/ before sending it as a bearer. Nothing mints that prefix any more: cloud accepts pk- and sk-, IAM mints sk-. So the guard admitted a credential that authenticates nothing, and the panel failed later at the gateway with nothing on screen to explain it. The three leak detectors keep hk- and gain sk-. Salesforce's source scan and the two Zendesk tests exist to catch a key committed to source or echoed back through a request; retired keys are still in circulation and leak exactly as badly, while a detector that only knew hk- would have gone blind to every key minted from here on. `sk-` used to be the example of the foreign key the guard screened out β an OpenAI key. It cannot mean that now, and shape alone cannot tell one vendor's sk- from another's, so those comments say what the check really proves and point at the /v1/models call that proves the rest. The three tests that pinned "sk- is rejected" now pin "hk- is rejected", which is the invariant that survived. Verified: 2029 tests pass across the 28 packages; typecheck clean on 25 of them (hubspot, jupyter and teams fail identically at HEAD on JSX and ESM type conflicts in files this does not touch); salesforce validate-source green. Co-authored-by: Hanzo Dev <[email protected]>
10 KiB
Hanzo AI for Notion
Brings Hanzo AI into a startup or ops team's Notion pages, databases, and wikis. Pick a page and Hanzo will:
- Summarize a page β read the page's blocks (recursively) and produce a tight bullet summary.
- Draft / expand β generate content that continues the page in its own tone, ready to insert.
- Extract β database β pull structured action items (task / owner / due) out of a page and create one row per item in a Notion database.
- Ask about a page/workspace β freeform Q&A over the page's content.
Every result can be written back to Notion β appended onto the page as a
labelled callout (+ paragraphs), or, for extraction, created as database
rows. Model calls route through the same api.hanzo.ai/v1 gateway as every
other Hanzo surface; Notion access is standard OAuth2 (public integration) +
REST API v1.
Notion has no in-app UI-extension surface (no plugins/panels) β the platform is API + OAuth only. So this ships as (a) a Notion public integration + API client, and (b) a small web app that operates on the user's pages and databases. Notion is also the classic case where a Model Context Protocol connector fits: this same capability pairs with Hanzo
auto's MCP servers. This package is the direct integration; the MCP path is complementary, not a replacement.
Architecture
Two credentials, kept apart by design β and unlike a token that a browser can hold, the Notion bot token never leaves the server:
- Notion β OAuth2 authorization-code grant. Both the
client_secretand the workspace bot token live only in the OAuth + proxy service (server.ts), read from / stored in the environment. The browser SPA holds only an opaque session id; every Notion call is proxied through the service, which attaches the realBearer+Notion-Versionserver-side. (Notion public-integration tokens do not expire and there is no refresh flow β so there is nothing time-based to manage.) - Hanzo β an
sk-API key pasted by the user (kept in the browser), or empty for anonymous/limited models. ThecreateAiClientcontract β the same headless client surface as@hanzo/ai.
browser SPA (app.ts) ββOAuth redirectβββΆ api.notion.com/v1/oauth/authorize
β code β
βββPOST /oauth/callbackβββΆ service (server.ts, holds client_secret + bot token)
β βββPOST api.notion.com/v1/oauth/token (Basic auth)βββΆ bot token
β βββ { session, workspace } (opaque session id only)
βββ/notion/* (x-hanzo-session)βββΆ service ββBearer bot tokenβββΆ api.notion.com/v1/{search,pages,blocks,databases}
βββPOST /v1/chat/completions (Bearer sk-)βββΆ api.hanzo.ai
The engine is small, pure, and unit-tested:
| module | responsibility |
|---|---|
config.ts |
the two backends; Notion API/version/endpoint URLs; Hanzo gateway base + default Zen model |
notion-oauth.ts |
authorize URL + token-exchange request shaping (Basic auth, JSON body); token parsing |
notion-api.ts |
every REST wrapper β URL, headers, body β retrieveBlockChildren, appendChildren, retrievePage, createPage, retrieveDatabase, queryDatabase, search; readListPage + the single paginate cursor loop |
blocks.ts |
Notion blocks β plain text/markdown, both directions (see mapping below) |
client.ts |
the createAiClient contract β non-streaming /v1/chat/completions + /v1/models |
actions.ts |
the four actions + page-context assembly/truncation + action-item parsing + write-back block/property building |
app.ts |
the DOM glue (thin) |
server.ts |
the OAuth + Notion-proxy http service (thin), the session store, the proxy allow-list |
The four actions
| action | reads | asks Hanzo for | writes back |
|---|---|---|---|
| Summarize | page blocks β text | a bullet summary | callout appended to the page |
| Draft / expand | page blocks β text | new prose continuing the page | callout + paragraphs appended |
| Extract β database | page blocks β text | a JSON array of { task, owner, due } |
one row per item in a chosen database |
| Ask | page blocks β text | an answer to the user's question | callout appended (optional) |
Notion blocks β text mapping
The single source of truth is blocks.ts. Reading a page turns its recursively
fetched block tree into markdown-ish text for the model; writing back turns the
model's text into Notion block objects.
Read (block β text):
| Notion block | text form |
|---|---|
heading_1/2/3 |
# / ## / ### + text |
paragraph |
inline markdown (bold/italic/code/strike/link) |
bulleted_list_item |
- item |
numbered_list_item |
1. item |
to_do |
- [ ] task / - [x] task (checked state) |
toggle |
βΈ text |
quote |
> text |
callout |
> {emoji} text |
code |
fenced lang β¦ with plain (un-escaped) content |
divider |
--- |
child_page / child_database |
[page] Title / [database] Title |
| unknown | flattened rich_text, never throws |
rich_text runs are flattened via plain_text (falling back to
text.content); nested children are indented by depth so list/toggle structure
survives.
Write (text β block): paragraphBlock, calloutBlock (default π€ icon),
headingBlock, and textToBlocks (one paragraph per non-empty line). Long
strings are split at Notion's 2000-char rich_text cap. Property values for
database rows: titleProperty, richTextProperty, dateProperty,
selectProperty, checkboxProperty.
The write-back flow: the AI text β resultBlocks(action, text) = a labelled
heading_2 ("Hanzo AI β Summary/Draft/Answer") + a callout holding the first
paragraph + any overflow as paragraphs β appendChildren(pageId, blocks) β
PATCH /v1/blocks/{pageId}/children. Extraction: AI JSON β parseActionItems
β actionItemProperties(item, fieldMap) β createPage({ parent: { database_id }, properties }) per item.
Notion API endpoints used
All under https://api.notion.com/v1, every request carrying
Notion-Version: 2022-06-28:
GET /blocks/{id}/childrenβ read a page/block's children (paginated:start_cursor+page_size, cap 100; responsehas_more+next_cursor).PATCH /blocks/{id}/childrenβ append blocks (write-back).GET /pages/{id}β read a page's title/properties.POST /pagesβ create a page / database row.GET /databases/{id}/POST /databases/{id}/queryβ schema / rows.POST /searchβ discover the pages & databases the integration was granted.- OAuth:
GET /oauth/authorize,POST /oauth/token(HTTP Basic auth with the client id/secret, JSON body β not form-urlencoded).
Create a Notion public integration
- Go to notion.so/my-integrations β New integration, type Public.
- Capabilities β this app needs: Read content, Insert content,
Update content. (No user-info capability is required; the
searchendpoint only returns content the user explicitly shares at consent time.) - OAuth Domain & URIs β set the Redirect URI to
https://notion.hanzo.ai/oauth/callback. - Copy the OAuth client ID (public) and OAuth client secret (server-only).
Scopes / consent: Notion has no OAuth scope strings β access is granted
per-page/database by the user on the consent screen (they pick exactly which
pages the integration can touch). The integration can only ever see and modify
what the user shared; search reflects that grant.
OAuth flow (what the code does)
- SPA β
GET /v1/oauth/authorize?client_id&response_type=code&owner=user&redirect_uri&state(CSRFstateminted + stored insessionStorage). - User consents, Notion redirects to
β¦/oauth/callback?code&state. - SPA
POST /oauth/callback { code }β the service exchanges it:POST /v1/oauth/tokenwithAuthorization: Basic base64(id:secret)and a JSON{ grant_type, code, redirect_uri }body. - The service stores the returned bot token under a fresh opaque
session id and returns only
{ session, workspace }to the SPA. - Every later Notion call goes
SPA β /notion/{subpath} (x-hanzo-session) β service β api.notion.com. Only an allow-list of the exact subpaths this app uses is forwardable (proxyPathAllowed), so a session id can never be turned into a general Notion credential.
Build & test
pnpm --filter @hanzo/notion test # vitest β 104 pure-logic tests
pnpm --filter @hanzo/notion typecheck # tsc --noEmit
pnpm --filter @hanzo/notion build # esbuild β dist/
The build stamps the Notion public client_id and redirect_uri into the
SPA (the secret is never bundled):
NOTION_CLIENT_ID=<your-client-id> \
NOTION_REDIRECT_URI=https://notion.hanzo.ai/oauth/callback \
HANZO_NOTION_BASE=https://notion.hanzo.ai \
pnpm --filter @hanzo/notion build
Run the service (it reads the secret from the environment):
NOTION_CLIENT_ID=<id> \
NOTION_CLIENT_SECRET=<secret> \
NOTION_REDIRECT_URI=https://notion.hanzo.ai/oauth/callback \
PORT=8788 \
node dist/server.js
Deploy β hanzoai/static + hanzoai/ingress at notion.hanzo.ai
dist/ is two things: static assets (index.html, app.js, styles.css,
assets/) and one small Node service (server.js).
- Serve the static assets with the
hanzoai/staticplugin. - Run
server.jsfor the two dynamic paths,POST /oauth/callbackand the/notion/*proxy, plusGET /health. - Route both through
hanzoai/ingresson the hostnotion.hanzo.ai(/oauth/*and/notion/*β the service; everything else β static). No nginx, no caddy β ingress + the static plugin only. - The
NOTION_CLIENT_SECRETcomes from KMS (never a manifest, never the bundle). Image:ghcr.io/hanzoai/notion:<tag>,--platform linux/amd64, built in CI. The service is stateless except the session store β a single-instance dev uses the in-memorymemoryStore; a multi-instance deploy backsSessionStorewithhanzoai/kv(Valkey)./healthis the k8s liveness/readiness probe.
Model calls always target api.hanzo.ai/v1 β never an /api/ prefix, never a
v2.