mirror of
https://github.com/hanzoai/extension.git
synced 2026-10-31 21:57:06 +00:00
Notion has no in-app UI-extension surface, so this ships a public OAuth
integration + REST v1 client and a small web app that operates on the
user's pages/databases. Four actions over a page:
- Summarize: recursively read blocks -> text, summarize via @hanzo/ai,
append as a callout.
- Draft/expand: generate content continuing the page.
- Extract -> database: pull {task, owner, due} action items and create
one database row per item.
- Ask: freeform Q&A over page context.
Architecture mirrors @hanzo/clio: pure, unit-tested request/blocks/chat
modules with thin DOM + http glue. The Notion client_secret AND bot token
stay server-side (server.ts) — the browser holds only an opaque session id
and proxies every Notion call through an allow-listed /notion/* proxy.
Model calls route through the createAiClient contract to api.hanzo.ai/v1.
104 vitest tests (OAuth shaping, REST wrappers + pagination, blocks<->text
both directions, @hanzo/ai request shaping, context assembly/truncation,
action-item parsing, proxy allow-list). tsc --noEmit clean; esbuild builds.
90 lines
3.0 KiB
TypeScript
90 lines
3.0 KiB
TypeScript
import { describe, it, expect } from 'vitest';
|
|
import {
|
|
readEnv,
|
|
memoryStore,
|
|
newSessionId,
|
|
proxyPathAllowed,
|
|
} from '../src/server';
|
|
import type { TokenSet } from '../src/notion-oauth';
|
|
|
|
describe('readEnv', () => {
|
|
it('requires client id, secret, and redirect', () => {
|
|
expect(() => readEnv({} as any)).toThrow(/required/);
|
|
expect(() =>
|
|
readEnv({ NOTION_CLIENT_ID: 'a', NOTION_CLIENT_SECRET: 'b' } as any),
|
|
).toThrow(/required/);
|
|
});
|
|
it('reads all four fields with a default port', () => {
|
|
const env = readEnv({
|
|
NOTION_CLIENT_ID: 'cid',
|
|
NOTION_CLIENT_SECRET: 'sec',
|
|
NOTION_REDIRECT_URI: 'https://notion.hanzo.ai/oauth/callback',
|
|
} as any);
|
|
expect(env).toEqual({
|
|
clientId: 'cid',
|
|
clientSecret: 'sec',
|
|
redirectUri: 'https://notion.hanzo.ai/oauth/callback',
|
|
port: 8788,
|
|
});
|
|
});
|
|
it('honours PORT', () => {
|
|
const env = readEnv({
|
|
NOTION_CLIENT_ID: 'c',
|
|
NOTION_CLIENT_SECRET: 's',
|
|
NOTION_REDIRECT_URI: 'r',
|
|
PORT: '9000',
|
|
} as any);
|
|
expect(env.port).toBe(9000);
|
|
});
|
|
});
|
|
|
|
describe('session store + id', () => {
|
|
it('mints unguessable, unique, 64-hex ids', () => {
|
|
const a = newSessionId();
|
|
const b = newSessionId();
|
|
expect(a).toMatch(/^[0-9a-f]{64}$/);
|
|
expect(a).not.toBe(b);
|
|
});
|
|
it('stores and retrieves a token set', () => {
|
|
const store = memoryStore();
|
|
const tok: TokenSet = {
|
|
accessToken: 'bot',
|
|
tokenType: 'bearer',
|
|
botId: '',
|
|
workspaceId: 'ws',
|
|
workspaceName: 'Acme',
|
|
workspaceIcon: '',
|
|
};
|
|
const id = newSessionId();
|
|
store.put(id, tok);
|
|
expect(store.get(id)).toBe(tok);
|
|
expect(store.get('nope')).toBeUndefined();
|
|
});
|
|
});
|
|
|
|
describe('proxyPathAllowed — only the exact Notion v1 subpaths this app uses', () => {
|
|
it('allows the reads', () => {
|
|
expect(proxyPathAllowed('GET', '/pages/abc123')).toBe(true);
|
|
expect(proxyPathAllowed('GET', '/databases/db1')).toBe(true);
|
|
expect(proxyPathAllowed('GET', '/blocks/pg/children')).toBe(true);
|
|
});
|
|
it('allows the writes', () => {
|
|
expect(proxyPathAllowed('POST', '/search')).toBe(true);
|
|
expect(proxyPathAllowed('POST', '/pages')).toBe(true);
|
|
expect(proxyPathAllowed('POST', '/databases/db1/query')).toBe(true);
|
|
expect(proxyPathAllowed('PATCH', '/blocks/pg/children')).toBe(true);
|
|
});
|
|
it('rejects anything else — no general Notion credential from a session', () => {
|
|
expect(proxyPathAllowed('GET', '/users')).toBe(false);
|
|
expect(proxyPathAllowed('GET', '/users/me')).toBe(false);
|
|
expect(proxyPathAllowed('DELETE', '/blocks/pg')).toBe(false);
|
|
expect(proxyPathAllowed('GET', '/blocks/pg')).toBe(false); // read a block, not its children
|
|
expect(proxyPathAllowed('POST', '/pages/abc/anything')).toBe(false);
|
|
expect(proxyPathAllowed('GET', '/pages/abc/children')).toBe(false);
|
|
});
|
|
it('is method-scoped: a write method on a read path is rejected', () => {
|
|
expect(proxyPathAllowed('POST', '/pages/abc123')).toBe(false);
|
|
expect(proxyPathAllowed('GET', '/search')).toBe(false);
|
|
});
|
|
});
|