Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions src/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
* Service registry for looking up services by name or URL.
*/

import { Service, SLACK, DISCORD, DROPBOX, GITHUB, LINEAR } from './services/index.js';
import { Service, SLACK, DISCORD, DROPBOX, GITHUB, LINEAR, NOTION } from './services/index.js';

export class Registry {
readonly services: readonly Service[];
Expand Down Expand Up @@ -32,4 +32,4 @@ export class Registry {
}
}

export const REGISTRY = new Registry([SLACK, DISCORD, DROPBOX, GITHUB, LINEAR]);
export const REGISTRY = new Registry([SLACK, DISCORD, DROPBOX, GITHUB, LINEAR, NOTION]);
1 change: 1 addition & 0 deletions src/services/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,3 +11,4 @@ export { Discord, DISCORD } from './discord.js';
export { Github, GITHUB } from './github.js';
export { Dropbox, DROPBOX } from './dropbox.js';
export { Linear, LINEAR } from './linear.js';
export { Notion, NOTION } from './notion.js';
142 changes: 142 additions & 0 deletions src/services/notion.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
/**
* Notion service implementation.
*
* This has some severe limitations:
*
* - It requires the UI to be in English.
* - It only grants access to the private pages that existed at the time of login.
*/

import type { Response, BrowserContext } from 'playwright';
import { ApiCredentialStatus, ApiCredentials, AuthorizationBearer } from '../apiCredentials.js';
import { runCaptured } from '../curl.js';
import { generateLatchkeyAppName } from '../playwrightUtils.js';
import { Service, BrowserFollowupServiceSession, LoginFailedError } from './base.js';

const DEFAULT_TIMEOUT_MS = 8000;

const NOTION_INTEGRATIONS_URL = 'https://www.notion.so/profile/integrations/form/new-integration';

class NotionServiceSession extends BrowserFollowupServiceSession {
private isLoggedIn = false;

onResponse(response: Response): void {
if (this.isLoggedIn) {
return;
}
if (response.request().headers()['x-notion-active-user-header']) {
this.isLoggedIn = true;
}
}

protected isLoginComplete(): boolean {
return this.isLoggedIn;
}

protected async performBrowserFollowup(context: BrowserContext): Promise<ApiCredentials | null> {
const page = context.pages()[0];
if (!page) {
throw new LoginFailedError('No page available in browser context.');
}

await page.goto(NOTION_INTEGRATIONS_URL);

// Annoyingly, Notion's DOM is devoid of IDs,
// so we have to use broad locators with nth.

// Integration name
await page.getByRole('textbox').click();
await page.getByRole('textbox').fill(generateLatchkeyAppName());
// Workspace - initially empty
await page.getByRole('button').filter({ hasText: /^$/ }).click();
// Just pick the first workspace
await page.getByRole('menuitem').click();
// Create integration
await page.getByRole('button').last().click();
// Configure integration settings
await page
.getByRole('dialog')
.getByRole('button')
.nth(0)
.click({ timeout: DEFAULT_TIMEOUT_MS });
// Token input
const tokenTextbox = page.locator('input[type="password"]');
// We have to save the element handle because the same element's type changes to text after clicking "Show".
const tokenTextboxElement = (await tokenTextbox.elementHandle())!;
// Show
await tokenTextbox
.locator('..')
.getByRole('button')
.nth(1)
.click({ timeout: DEFAULT_TIMEOUT_MS });

let token = '';
// Poll for up to 2 seconds for the token to be revealed
for (let i = 0; i < 20; i++) {
token = (await tokenTextboxElement.inputValue()).trim();
if (token !== '') {
break;
}
await page.waitForTimeout(100);
}

if (token === '') {
throw new LoginFailedError('Failed to extract token from Notion.');
}

// Grant access.
// This part of the flow is too annoying to automate without using the labels...
await page.getByRole('tab', { name: 'Access' }).click();
await page.getByRole('button', { name: 'Edit access' }).click();
await page.getByRole('button', { name: 'Private' }).click();
await page.getByRole('button', { name: 'Select all' }).click();
await page.getByRole('button', { name: 'Save' }).click();
await page.getByRole('dialog').waitFor({ state: 'hidden' });

await page.close();

return new AuthorizationBearer(token);
}
}

export class Notion implements Service {
readonly name = 'notion';
readonly baseApiUrls = ['https://api.notion.com/'] as const;
readonly loginUrl = NOTION_INTEGRATIONS_URL;

readonly credentialCheckCurlArguments = [
'-H',
'Notion-Version: 2022-06-28',
'https://api.notion.com/v1/users/me',
] as const;

getSession(): NotionServiceSession {
return new NotionServiceSession(this);
}

checkApiCredentials(apiCredentials: ApiCredentials): ApiCredentialStatus {
if (!(apiCredentials instanceof AuthorizationBearer)) {
return ApiCredentialStatus.Invalid;
}

const result = runCaptured(
[
'-s',
'-o',
'/dev/null',
'-w',
'%{http_code}',
...apiCredentials.asCurlArguments(),
...this.credentialCheckCurlArguments,
],
10
);

if (result.stdout === '200') {
return ApiCredentialStatus.Valid;
}
return ApiCredentialStatus.Invalid;
}
}

export const NOTION = new Notion();
21 changes: 9 additions & 12 deletions tests/registry.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { describe, it, expect } from 'vitest';
import { Registry, REGISTRY } from '../src/registry.js';
import { SLACK, DISCORD, GITHUB, DROPBOX, LINEAR } from '../src/services/index.js';
import { SLACK, DISCORD, GITHUB, DROPBOX, LINEAR, NOTION } from '../src/services/index.js';

describe('Registry', () => {
describe('getByName', () => {
Expand All @@ -24,6 +24,10 @@ describe('Registry', () => {
expect(REGISTRY.getByName('linear')).toBe(LINEAR);
});

it('should find Notion by name', () => {
expect(REGISTRY.getByName('notion')).toBe(NOTION);
});

it('should return null for unknown service', () => {
expect(REGISTRY.getByName('unknown')).toBeNull();
});
Expand Down Expand Up @@ -62,6 +66,10 @@ describe('Registry', () => {
expect(REGISTRY.getByUrl('https://api.linear.app/graphql')).toBe(LINEAR);
});

it('should find Notion by API URL', () => {
expect(REGISTRY.getByUrl('https://api.notion.com/v1/users/me')).toBe(NOTION);
});

it('should return null for unknown URL', () => {
expect(REGISTRY.getByUrl('https://example.com/api')).toBeNull();
expect(REGISTRY.getByUrl('https://google.com')).toBeNull();
Expand All @@ -73,17 +81,6 @@ describe('Registry', () => {
});
});

describe('services', () => {
it('should contain all services', () => {
expect(REGISTRY.services).toHaveLength(5);
expect(REGISTRY.services).toContain(SLACK);
expect(REGISTRY.services).toContain(DISCORD);
expect(REGISTRY.services).toContain(GITHUB);
expect(REGISTRY.services).toContain(DROPBOX);
expect(REGISTRY.services).toContain(LINEAR);
});
});

describe('custom registry', () => {
it('should work with custom service list', () => {
const customRegistry = new Registry([SLACK, GITHUB]);
Expand Down