Files
zooqueenandHanzo Dev f717dd89ad keys: the key a user pastes is sk-, not the retired hk-
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]>
2026-08-13 18:07:16 -07:00

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_secret and 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 real Bearer + Notion-Version server-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. The createAiClient contract β€” 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; response has_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

  1. Go to notion.so/my-integrations β†’ New integration, type Public.
  2. Capabilities β€” this app needs: Read content, Insert content, Update content. (No user-info capability is required; the search endpoint only returns content the user explicitly shares at consent time.)
  3. OAuth Domain & URIs β€” set the Redirect URI to https://notion.hanzo.ai/oauth/callback.
  4. 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)

  1. SPA β†’ GET /v1/oauth/authorize?client_id&response_type=code&owner=user&redirect_uri&state (CSRF state minted + stored in sessionStorage).
  2. User consents, Notion redirects to …/oauth/callback?code&state.
  3. SPA POST /oauth/callback { code } β†’ the service exchanges it: POST /v1/oauth/token with Authorization: Basic base64(id:secret) and a JSON { grant_type, code, redirect_uri } body.
  4. The service stores the returned bot token under a fresh opaque session id and returns only { session, workspace } to the SPA.
  5. 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/static plugin.
  • Run server.js for the two dynamic paths, POST /oauth/callback and the /notion/* proxy, plus GET /health.
  • Route both through hanzoai/ingress on the host notion.hanzo.ai (/oauth/* and /notion/* β†’ the service; everything else β†’ static). No nginx, no caddy β€” ingress + the static plugin only.
  • The NOTION_CLIENT_SECRET comes 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-memory memoryStore; a multi-instance deploy backs SessionStore with hanzoai/kv (Valkey). /health is the k8s liveness/readiness probe.

Model calls always target api.hanzo.ai/v1 β€” never an /api/ prefix, never a v2.