openplexer 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/worker.ts ADDED
@@ -0,0 +1,187 @@
1
+ // Cloudflare Worker entrypoint for openplexer.
2
+ // Handles Notion OAuth flow: redirects user to Notion, receives callback
3
+ // with auth code, exchanges it for tokens, and stores result in KV for
4
+ // the CLI to poll. Same pattern as kimaki's gateway onboarding.
5
+
6
+ import { Spiceflow } from 'spiceflow'
7
+ import type { Env } from './env.ts'
8
+
9
+ const REDIRECT_PATH = '/auth/callback'
10
+
11
+ const app = new Spiceflow()
12
+ .state('env', {} as Env)
13
+
14
+ .onError(({ error }) => {
15
+ console.error(error)
16
+ const message = error instanceof Error ? error.message : String(error)
17
+ return new Response(message, { status: 500 })
18
+ })
19
+
20
+ .route({
21
+ method: 'GET',
22
+ path: '/',
23
+ handler() {
24
+ return new Response(null, {
25
+ status: 302,
26
+ headers: { Location: 'https://github.com/remorses/kimaki/tree/main/openplexer' },
27
+ })
28
+ },
29
+ })
30
+
31
+ .route({
32
+ method: 'GET',
33
+ path: '/health',
34
+ handler() {
35
+ return { status: 'ok' }
36
+ },
37
+ })
38
+
39
+ // Step 1: CLI opens browser to this URL. We redirect to Notion OAuth.
40
+ // The CLI passes a random `state` param to correlate the callback.
41
+ .route({
42
+ method: 'GET',
43
+ path: '/auth/notion',
44
+ handler({ request, state }) {
45
+ const url = new URL(request.url)
46
+ const stateParam = url.searchParams.get('state')
47
+ if (!stateParam) {
48
+ return new Response('Missing state parameter', { status: 400 })
49
+ }
50
+
51
+ const env = state.env
52
+ const notionAuthUrl = new URL('https://api.notion.com/v1/oauth/authorize')
53
+ notionAuthUrl.searchParams.set('client_id', env.NOTION_CLIENT_ID)
54
+ notionAuthUrl.searchParams.set('response_type', 'code')
55
+ notionAuthUrl.searchParams.set('owner', 'user')
56
+ notionAuthUrl.searchParams.set(
57
+ 'redirect_uri',
58
+ new URL(REDIRECT_PATH, url.origin).toString(),
59
+ )
60
+ notionAuthUrl.searchParams.set('state', stateParam)
61
+
62
+ return new Response(null, {
63
+ status: 302,
64
+ headers: { Location: notionAuthUrl.toString() },
65
+ })
66
+ },
67
+ })
68
+
69
+ // Step 2: Notion redirects here after user authorizes.
70
+ // We exchange the code for tokens and store in KV.
71
+ .route({
72
+ method: 'GET',
73
+ path: REDIRECT_PATH,
74
+ async handler({ request, state }) {
75
+ const url = new URL(request.url)
76
+ const code = url.searchParams.get('code')
77
+ const stateParam = url.searchParams.get('state')
78
+
79
+ if (!code || !stateParam) {
80
+ return new Response('Missing code or state parameter', { status: 400 })
81
+ }
82
+
83
+ const env = state.env
84
+ const redirectUri = new URL(REDIRECT_PATH, url.origin).toString()
85
+
86
+ // Exchange code for tokens
87
+ const encoded = btoa(`${env.NOTION_CLIENT_ID}:${env.NOTION_CLIENT_SECRET}`)
88
+ const tokenResponse = await fetch('https://api.notion.com/v1/oauth/token', {
89
+ method: 'POST',
90
+ headers: {
91
+ Accept: 'application/json',
92
+ 'Content-Type': 'application/json',
93
+ Authorization: `Basic ${encoded}`,
94
+ },
95
+ body: JSON.stringify({
96
+ grant_type: 'authorization_code',
97
+ code,
98
+ redirect_uri: redirectUri,
99
+ }),
100
+ })
101
+
102
+ if (!tokenResponse.ok) {
103
+ const errorBody = await tokenResponse.text()
104
+ console.error('Notion token exchange failed:', errorBody)
105
+ return new Response(`Notion authorization failed: ${errorBody}`, { status: 500 })
106
+ }
107
+
108
+ const tokenData = (await tokenResponse.json()) as {
109
+ access_token: string
110
+ token_type: string
111
+ bot_id: string
112
+ workspace_id: string
113
+ workspace_name: string
114
+ owner: { type: string; user?: { id: string; name: string } }
115
+ }
116
+
117
+ // Store tokens in KV with 5 minute TTL
118
+ const kvPayload = {
119
+ accessToken: tokenData.access_token,
120
+ botId: tokenData.bot_id,
121
+ workspaceId: tokenData.workspace_id,
122
+ workspaceName: tokenData.workspace_name,
123
+ notionUserId: tokenData.owner?.user?.id,
124
+ notionUserName: tokenData.owner?.user?.name,
125
+ }
126
+
127
+ await env.OPENPLEXER_KV.put(`auth:${stateParam}`, JSON.stringify(kvPayload), {
128
+ expirationTtl: 300,
129
+ })
130
+
131
+ // Show success page
132
+ return new Response(
133
+ `<!DOCTYPE html>
134
+ <html>
135
+ <head><title>openplexer - Connected</title>
136
+ <style>
137
+ body { font-family: system-ui, sans-serif; display: flex; justify-content: center;
138
+ align-items: center; height: 100vh; margin: 0; background: #1a1a2e; color: #eee; }
139
+ .card { text-align: center; padding: 48px; border-radius: 12px;
140
+ background: #16213e; max-width: 400px; }
141
+ h1 { margin: 0 0 16px; font-size: 24px; }
142
+ p { color: #999; margin: 0; }
143
+ </style>
144
+ </head>
145
+ <body>
146
+ <div class="card">
147
+ <h1>Connected to Notion</h1>
148
+ <p>You can close this tab and return to the CLI.</p>
149
+ </div>
150
+ </body>
151
+ </html>`,
152
+ { status: 200, headers: { 'Content-Type': 'text/html' } },
153
+ )
154
+ },
155
+ })
156
+
157
+ // Step 3: CLI polls this endpoint to get the tokens.
158
+ .route({
159
+ method: 'GET',
160
+ path: '/auth/status',
161
+ async handler({ request, state }) {
162
+ const url = new URL(request.url)
163
+ const stateParam = url.searchParams.get('state')
164
+ if (!stateParam) {
165
+ return new Response('Missing state parameter', { status: 400 })
166
+ }
167
+
168
+ const result = await state.env.OPENPLEXER_KV.get(`auth:${stateParam}`)
169
+ if (!result) {
170
+ return new Response(JSON.stringify({ status: 'pending' }), {
171
+ status: 404,
172
+ headers: { 'Content-Type': 'application/json' },
173
+ })
174
+ }
175
+
176
+ return new Response(result, {
177
+ status: 200,
178
+ headers: { 'Content-Type': 'application/json' },
179
+ })
180
+ },
181
+ })
182
+
183
+ export default {
184
+ fetch(request: Request, env: Env) {
185
+ return app.handle(request, { state: { env } })
186
+ },
187
+ }