@sena-ai/platform-core 1.4.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/dist/app.d.ts +9 -0
- package/dist/app.d.ts.map +1 -0
- package/dist/app.js +147 -0
- package/dist/app.js.map +1 -0
- package/dist/auth/handler.d.ts +19 -0
- package/dist/auth/handler.d.ts.map +1 -0
- package/dist/auth/handler.js +213 -0
- package/dist/auth/handler.js.map +1 -0
- package/dist/auth/session.d.ts +16 -0
- package/dist/auth/session.d.ts.map +1 -0
- package/dist/auth/session.js +54 -0
- package/dist/auth/session.js.map +1 -0
- package/dist/db/d1/index.d.ts +14 -0
- package/dist/db/d1/index.d.ts.map +1 -0
- package/dist/db/d1/index.js +252 -0
- package/dist/db/d1/index.js.map +1 -0
- package/dist/db/d1/schema.d.ts +610 -0
- package/dist/db/d1/schema.d.ts.map +1 -0
- package/dist/db/d1/schema.js +58 -0
- package/dist/db/d1/schema.js.map +1 -0
- package/dist/db/mysql/index.d.ts +14 -0
- package/dist/db/mysql/index.d.ts.map +1 -0
- package/dist/db/mysql/index.js +248 -0
- package/dist/db/mysql/index.js.map +1 -0
- package/dist/db/mysql/schema.d.ts +562 -0
- package/dist/db/mysql/schema.d.ts.map +1 -0
- package/dist/db/mysql/schema.js +61 -0
- package/dist/db/mysql/schema.js.map +1 -0
- package/dist/db/postgresql/index.d.ts +14 -0
- package/dist/db/postgresql/index.d.ts.map +1 -0
- package/dist/db/postgresql/index.js +246 -0
- package/dist/db/postgresql/index.js.map +1 -0
- package/dist/db/postgresql/schema.d.ts +591 -0
- package/dist/db/postgresql/schema.d.ts.map +1 -0
- package/dist/db/postgresql/schema.js +64 -0
- package/dist/db/postgresql/schema.js.map +1 -0
- package/dist/index.d.ts +6 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +3 -0
- package/dist/index.js.map +1 -0
- package/dist/relay/api-proxy.d.ts +10 -0
- package/dist/relay/api-proxy.d.ts.map +1 -0
- package/dist/relay/api-proxy.js +40 -0
- package/dist/relay/api-proxy.js.map +1 -0
- package/dist/runtime/cf/crypto.d.ts +7 -0
- package/dist/runtime/cf/crypto.d.ts.map +1 -0
- package/dist/runtime/cf/crypto.js +48 -0
- package/dist/runtime/cf/crypto.js.map +1 -0
- package/dist/runtime/cf/index.d.ts +20 -0
- package/dist/runtime/cf/index.d.ts.map +1 -0
- package/dist/runtime/cf/index.js +14 -0
- package/dist/runtime/cf/index.js.map +1 -0
- package/dist/runtime/cf/relay.d.ts +11 -0
- package/dist/runtime/cf/relay.d.ts.map +1 -0
- package/dist/runtime/cf/relay.js +57 -0
- package/dist/runtime/cf/relay.js.map +1 -0
- package/dist/runtime/cf/vault.d.ts +7 -0
- package/dist/runtime/cf/vault.d.ts.map +1 -0
- package/dist/runtime/cf/vault.js +68 -0
- package/dist/runtime/cf/vault.js.map +1 -0
- package/dist/runtime/node/crypto.d.ts +6 -0
- package/dist/runtime/node/crypto.d.ts.map +1 -0
- package/dist/runtime/node/crypto.js +26 -0
- package/dist/runtime/node/crypto.js.map +1 -0
- package/dist/runtime/node/index.d.ts +17 -0
- package/dist/runtime/node/index.d.ts.map +1 -0
- package/dist/runtime/node/index.js +14 -0
- package/dist/runtime/node/index.js.map +1 -0
- package/dist/runtime/node/relay.d.ts +6 -0
- package/dist/runtime/node/relay.d.ts.map +1 -0
- package/dist/runtime/node/relay.js +73 -0
- package/dist/runtime/node/relay.js.map +1 -0
- package/dist/runtime/node/vault.d.ts +7 -0
- package/dist/runtime/node/vault.d.ts.map +1 -0
- package/dist/runtime/node/vault.js +41 -0
- package/dist/runtime/node/vault.js.map +1 -0
- package/dist/slack/events.d.ts +15 -0
- package/dist/slack/events.d.ts.map +1 -0
- package/dist/slack/events.js +63 -0
- package/dist/slack/events.js.map +1 -0
- package/dist/slack/oauth.d.ts +13 -0
- package/dist/slack/oauth.d.ts.map +1 -0
- package/dist/slack/oauth.js +90 -0
- package/dist/slack/oauth.js.map +1 -0
- package/dist/slack/provisioner.d.ts +60 -0
- package/dist/slack/provisioner.d.ts.map +1 -0
- package/dist/slack/provisioner.js +156 -0
- package/dist/slack/provisioner.js.map +1 -0
- package/dist/types/crypto.d.ts +15 -0
- package/dist/types/crypto.d.ts.map +1 -0
- package/dist/types/crypto.js +2 -0
- package/dist/types/crypto.js.map +1 -0
- package/dist/types/index.d.ts +6 -0
- package/dist/types/index.d.ts.map +1 -0
- package/dist/types/index.js +2 -0
- package/dist/types/index.js.map +1 -0
- package/dist/types/platform.d.ts +25 -0
- package/dist/types/platform.d.ts.map +1 -0
- package/dist/types/platform.js +2 -0
- package/dist/types/platform.js.map +1 -0
- package/dist/types/relay.d.ts +16 -0
- package/dist/types/relay.d.ts.map +1 -0
- package/dist/types/relay.js +2 -0
- package/dist/types/relay.js.map +1 -0
- package/dist/types/repository.d.ts +78 -0
- package/dist/types/repository.d.ts.map +1 -0
- package/dist/types/repository.js +6 -0
- package/dist/types/repository.js.map +1 -0
- package/dist/types/vault.d.ts +9 -0
- package/dist/types/vault.d.ts.map +1 -0
- package/dist/types/vault.js +2 -0
- package/dist/types/vault.js.map +1 -0
- package/dist/web/api.d.ts +9 -0
- package/dist/web/api.d.ts.map +1 -0
- package/dist/web/api.js +144 -0
- package/dist/web/api.js.map +1 -0
- package/dist/web/pages.d.ts +4 -0
- package/dist/web/pages.d.ts.map +1 -0
- package/dist/web/pages.js +401 -0
- package/dist/web/pages.js.map +1 -0
- package/dist/web/setup.d.ts +5 -0
- package/dist/web/setup.d.ts.map +1 -0
- package/dist/web/setup.js +208 -0
- package/dist/web/setup.js.map +1 -0
- package/package.json +46 -0
- package/src/app.ts +221 -0
- package/src/auth/handler.ts +343 -0
- package/src/auth/session.ts +89 -0
- package/src/db/d1/index.ts +304 -0
- package/src/db/d1/schema.ts +62 -0
- package/src/db/mysql/index.ts +301 -0
- package/src/db/mysql/schema.ts +78 -0
- package/src/db/postgresql/index.ts +311 -0
- package/src/db/postgresql/schema.ts +82 -0
- package/src/index.ts +21 -0
- package/src/relay/api-proxy.ts +61 -0
- package/src/runtime/cf/crypto.ts +74 -0
- package/src/runtime/cf/index.ts +31 -0
- package/src/runtime/cf/relay.ts +74 -0
- package/src/runtime/cf/vault.ts +99 -0
- package/src/runtime/node/crypto.ts +33 -0
- package/src/runtime/node/index.ts +28 -0
- package/src/runtime/node/relay.ts +98 -0
- package/src/runtime/node/vault.ts +50 -0
- package/src/slack/events.ts +92 -0
- package/src/slack/oauth.ts +127 -0
- package/src/slack/provisioner.ts +256 -0
- package/src/types/crypto.ts +14 -0
- package/src/types/index.ts +14 -0
- package/src/types/platform.ts +31 -0
- package/src/types/relay.ts +16 -0
- package/src/types/repository.ts +93 -0
- package/src/types/vault.ts +8 -0
- package/src/web/api.ts +204 -0
- package/src/web/pages.ts +458 -0
- package/src/web/setup.ts +270 -0
- package/tsconfig.json +19 -0
- package/tsconfig.tsbuildinfo +1 -0
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
import type { Vault } from '../types/vault.js'
|
|
2
|
+
import type {
|
|
3
|
+
BotRepository,
|
|
4
|
+
ConfigTokenRepository,
|
|
5
|
+
} from '../types/repository.js'
|
|
6
|
+
|
|
7
|
+
const SLACK_MANIFEST_TEMPLATE = (opts: {
|
|
8
|
+
botName: string
|
|
9
|
+
botUsername: string
|
|
10
|
+
eventUrl: string
|
|
11
|
+
redirectUrl: string
|
|
12
|
+
}) => ({
|
|
13
|
+
display_information: {
|
|
14
|
+
name: opts.botName,
|
|
15
|
+
description: `${opts.botName} -- powered by sena-ai`,
|
|
16
|
+
background_color: '#1a1a2e',
|
|
17
|
+
},
|
|
18
|
+
features: {
|
|
19
|
+
bot_user: {
|
|
20
|
+
display_name: opts.botUsername,
|
|
21
|
+
always_online: true,
|
|
22
|
+
},
|
|
23
|
+
},
|
|
24
|
+
oauth_config: {
|
|
25
|
+
redirect_urls: [opts.redirectUrl],
|
|
26
|
+
scopes: {
|
|
27
|
+
bot: [
|
|
28
|
+
'app_mentions:read',
|
|
29
|
+
'chat:write',
|
|
30
|
+
'chat:write.public',
|
|
31
|
+
'channels:history',
|
|
32
|
+
'channels:read',
|
|
33
|
+
'channels:join',
|
|
34
|
+
'groups:history',
|
|
35
|
+
'groups:read',
|
|
36
|
+
'im:history',
|
|
37
|
+
'im:read',
|
|
38
|
+
'im:write',
|
|
39
|
+
'reactions:read',
|
|
40
|
+
'reactions:write',
|
|
41
|
+
'files:read',
|
|
42
|
+
'files:write',
|
|
43
|
+
'users:read',
|
|
44
|
+
],
|
|
45
|
+
},
|
|
46
|
+
},
|
|
47
|
+
settings: {
|
|
48
|
+
event_subscriptions: {
|
|
49
|
+
request_url: opts.eventUrl,
|
|
50
|
+
bot_events: [
|
|
51
|
+
'app_mention',
|
|
52
|
+
'message.channels',
|
|
53
|
+
'message.groups',
|
|
54
|
+
'message.im',
|
|
55
|
+
'reaction_added',
|
|
56
|
+
],
|
|
57
|
+
},
|
|
58
|
+
interactivity: {
|
|
59
|
+
is_enabled: false,
|
|
60
|
+
},
|
|
61
|
+
org_deploy_enabled: false,
|
|
62
|
+
socket_mode_enabled: false,
|
|
63
|
+
},
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
type ManifestCreateResponse = {
|
|
67
|
+
ok: boolean
|
|
68
|
+
app_id?: string
|
|
69
|
+
credentials?: {
|
|
70
|
+
client_id?: string
|
|
71
|
+
client_secret?: string
|
|
72
|
+
signing_secret?: string
|
|
73
|
+
}
|
|
74
|
+
error?: string
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
type TokenRotateResponse = {
|
|
78
|
+
ok: boolean
|
|
79
|
+
token?: string
|
|
80
|
+
refresh_token?: string
|
|
81
|
+
exp?: number
|
|
82
|
+
error?: string
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export interface Provisioner {
|
|
86
|
+
rotateConfigToken(workspaceId: string): Promise<boolean>
|
|
87
|
+
createApp(
|
|
88
|
+
workspaceId: string,
|
|
89
|
+
botId: string,
|
|
90
|
+
botName: string,
|
|
91
|
+
botUsername: string,
|
|
92
|
+
): Promise<{
|
|
93
|
+
ok: boolean
|
|
94
|
+
appId?: string
|
|
95
|
+
clientId?: string
|
|
96
|
+
clientSecret?: string
|
|
97
|
+
signingSecret?: string
|
|
98
|
+
error?: string
|
|
99
|
+
}>
|
|
100
|
+
deleteApp(
|
|
101
|
+
workspaceId: string,
|
|
102
|
+
appId: string,
|
|
103
|
+
): Promise<{ ok: boolean; error?: string }>
|
|
104
|
+
SLACK_MANIFEST_TEMPLATE: typeof SLACK_MANIFEST_TEMPLATE
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Slack App Configuration Token management.
|
|
109
|
+
* One Config Token per workspace manages multiple apps.
|
|
110
|
+
*/
|
|
111
|
+
export function createProvisioner(
|
|
112
|
+
botRepo: BotRepository,
|
|
113
|
+
configTokenRepo: ConfigTokenRepository,
|
|
114
|
+
vault: Vault,
|
|
115
|
+
platformBaseUrl: string,
|
|
116
|
+
): Provisioner {
|
|
117
|
+
async function rotateConfigToken(workspaceId: string): Promise<boolean> {
|
|
118
|
+
const row = await configTokenRepo.findByWorkspaceId(workspaceId)
|
|
119
|
+
if (!row) return false
|
|
120
|
+
|
|
121
|
+
const refreshToken = await vault.decrypt(row.refreshTokenEnc)
|
|
122
|
+
|
|
123
|
+
const res = await fetch('https://slack.com/api/tooling.tokens.rotate', {
|
|
124
|
+
method: 'POST',
|
|
125
|
+
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
|
126
|
+
body: new URLSearchParams({ refresh_token: refreshToken }),
|
|
127
|
+
})
|
|
128
|
+
|
|
129
|
+
const data = (await res.json()) as TokenRotateResponse
|
|
130
|
+
|
|
131
|
+
if (!data.ok || !data.token || !data.refresh_token) {
|
|
132
|
+
console.error(
|
|
133
|
+
`[provisioner] rotate failed for ${workspaceId}:`,
|
|
134
|
+
data.error,
|
|
135
|
+
)
|
|
136
|
+
return false
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
await configTokenRepo.upsert({
|
|
140
|
+
workspaceId,
|
|
141
|
+
accessTokenEnc: await vault.encrypt(data.token),
|
|
142
|
+
refreshTokenEnc: await vault.encrypt(data.refresh_token),
|
|
143
|
+
expiresAt: new Date((data.exp ?? 0) * 1000),
|
|
144
|
+
})
|
|
145
|
+
|
|
146
|
+
return true
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
async function createApp(
|
|
150
|
+
workspaceId: string,
|
|
151
|
+
botId: string,
|
|
152
|
+
botName: string,
|
|
153
|
+
botUsername: string,
|
|
154
|
+
): Promise<{
|
|
155
|
+
ok: boolean
|
|
156
|
+
appId?: string
|
|
157
|
+
clientId?: string
|
|
158
|
+
clientSecret?: string
|
|
159
|
+
signingSecret?: string
|
|
160
|
+
error?: string
|
|
161
|
+
}> {
|
|
162
|
+
const tokenRow = await configTokenRepo.findByWorkspaceId(workspaceId)
|
|
163
|
+
if (!tokenRow) {
|
|
164
|
+
return { ok: false, error: 'no config token for workspace' }
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
const configToken = await vault.decrypt(tokenRow.accessTokenEnc)
|
|
168
|
+
const eventUrl = `${platformBaseUrl}/slack/events/${botId}`
|
|
169
|
+
const redirectUrl = `${platformBaseUrl}/oauth/callback`
|
|
170
|
+
const manifest = SLACK_MANIFEST_TEMPLATE({
|
|
171
|
+
botName,
|
|
172
|
+
botUsername,
|
|
173
|
+
eventUrl,
|
|
174
|
+
redirectUrl,
|
|
175
|
+
})
|
|
176
|
+
|
|
177
|
+
const res = await fetch('https://slack.com/api/apps.manifest.create', {
|
|
178
|
+
method: 'POST',
|
|
179
|
+
headers: {
|
|
180
|
+
Authorization: `Bearer ${configToken}`,
|
|
181
|
+
'Content-Type': 'application/json',
|
|
182
|
+
},
|
|
183
|
+
body: JSON.stringify({ manifest }),
|
|
184
|
+
})
|
|
185
|
+
|
|
186
|
+
const data = (await res.json()) as ManifestCreateResponse
|
|
187
|
+
|
|
188
|
+
if (!data.ok) {
|
|
189
|
+
console.error(
|
|
190
|
+
`[provisioner] Slack API error detail:`,
|
|
191
|
+
JSON.stringify(data),
|
|
192
|
+
)
|
|
193
|
+
return { ok: false, error: data.error }
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
await botRepo.update(botId, {
|
|
197
|
+
slackAppId: data.app_id ?? null,
|
|
198
|
+
clientId: data.credentials?.client_id ?? null,
|
|
199
|
+
clientSecretEnc: data.credentials?.client_secret
|
|
200
|
+
? await vault.encrypt(data.credentials.client_secret)
|
|
201
|
+
: null,
|
|
202
|
+
signingSecretEnc: data.credentials?.signing_secret
|
|
203
|
+
? await vault.encrypt(data.credentials.signing_secret)
|
|
204
|
+
: null,
|
|
205
|
+
manifestJson: JSON.stringify(manifest),
|
|
206
|
+
})
|
|
207
|
+
|
|
208
|
+
return {
|
|
209
|
+
ok: true,
|
|
210
|
+
appId: data.app_id,
|
|
211
|
+
clientId: data.credentials?.client_id,
|
|
212
|
+
clientSecret: data.credentials?.client_secret,
|
|
213
|
+
signingSecret: data.credentials?.signing_secret,
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
async function deleteApp(
|
|
218
|
+
workspaceId: string,
|
|
219
|
+
appId: string,
|
|
220
|
+
): Promise<{ ok: boolean; error?: string }> {
|
|
221
|
+
const tokenRow = await configTokenRepo.findByWorkspaceId(workspaceId)
|
|
222
|
+
if (!tokenRow) {
|
|
223
|
+
return { ok: false, error: 'no config token for workspace' }
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
const configToken = await vault.decrypt(tokenRow.accessTokenEnc)
|
|
227
|
+
|
|
228
|
+
const res = await fetch('https://slack.com/api/apps.manifest.delete', {
|
|
229
|
+
method: 'POST',
|
|
230
|
+
headers: {
|
|
231
|
+
Authorization: `Bearer ${configToken}`,
|
|
232
|
+
'Content-Type': 'application/json',
|
|
233
|
+
},
|
|
234
|
+
body: JSON.stringify({ app_id: appId }),
|
|
235
|
+
})
|
|
236
|
+
|
|
237
|
+
const data = (await res.json()) as { ok: boolean; error?: string }
|
|
238
|
+
|
|
239
|
+
if (!data.ok) {
|
|
240
|
+
console.error(
|
|
241
|
+
`[provisioner] Slack app delete failed for ${appId}:`,
|
|
242
|
+
JSON.stringify(data),
|
|
243
|
+
)
|
|
244
|
+
return { ok: false, error: data.error }
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
return { ok: true }
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
return {
|
|
251
|
+
rotateConfigToken,
|
|
252
|
+
createApp,
|
|
253
|
+
deleteApp,
|
|
254
|
+
SLACK_MANIFEST_TEMPLATE,
|
|
255
|
+
}
|
|
256
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CryptoProvider interface: platform-agnostic crypto operations.
|
|
3
|
+
* Node.js uses node:crypto, CF Workers uses Web Crypto API.
|
|
4
|
+
*/
|
|
5
|
+
export interface CryptoProvider {
|
|
6
|
+
/** Generate a random hex string of the given byte length. */
|
|
7
|
+
randomHex(byteLength: number): Promise<string>
|
|
8
|
+
/** Generate a UUID v4. */
|
|
9
|
+
uuid(): string
|
|
10
|
+
/** Compute HMAC-SHA256 and return hex digest. */
|
|
11
|
+
hmacSha256(key: string, data: string): Promise<string>
|
|
12
|
+
/** Constant-time string comparison. */
|
|
13
|
+
timingSafeEqual(a: string, b: string): Promise<boolean>
|
|
14
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
export type { Vault } from './vault.js'
|
|
2
|
+
export type { RelayHub } from './relay.js'
|
|
3
|
+
export type { CryptoProvider } from './crypto.js'
|
|
4
|
+
export type {
|
|
5
|
+
BotRow,
|
|
6
|
+
ConfigTokenRow,
|
|
7
|
+
OAuthStateRow,
|
|
8
|
+
WorkspaceAdminConfigRow,
|
|
9
|
+
BotRepository,
|
|
10
|
+
ConfigTokenRepository,
|
|
11
|
+
OAuthStateRepository,
|
|
12
|
+
WorkspaceAdminConfigRepository,
|
|
13
|
+
} from './repository.js'
|
|
14
|
+
export type { Platform, AppConfig } from './platform.js'
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import type { Vault } from './vault.js'
|
|
2
|
+
import type { RelayHub } from './relay.js'
|
|
3
|
+
import type { CryptoProvider } from './crypto.js'
|
|
4
|
+
import type {
|
|
5
|
+
BotRepository,
|
|
6
|
+
ConfigTokenRepository,
|
|
7
|
+
OAuthStateRepository,
|
|
8
|
+
WorkspaceAdminConfigRepository,
|
|
9
|
+
} from './repository.js'
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Main Platform interface composing all platform-specific services.
|
|
13
|
+
* Implemented by platform-node and platform-cf.
|
|
14
|
+
*/
|
|
15
|
+
export interface Platform {
|
|
16
|
+
vault: Vault
|
|
17
|
+
relay: RelayHub
|
|
18
|
+
crypto: CryptoProvider
|
|
19
|
+
bots: BotRepository
|
|
20
|
+
configTokens: ConfigTokenRepository
|
|
21
|
+
oauthStates: OAuthStateRepository
|
|
22
|
+
workspaceAdminConfig: WorkspaceAdminConfigRepository
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Application configuration shared across runtimes.
|
|
27
|
+
*/
|
|
28
|
+
export interface AppConfig {
|
|
29
|
+
platformBaseUrl: string
|
|
30
|
+
workspaceId: string
|
|
31
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import type { Context } from 'hono'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* RelayHub interface: manages connections between the platform and local bot runtimes.
|
|
5
|
+
* Node.js uses SSE, CF Workers uses WebSocket via Durable Objects.
|
|
6
|
+
*/
|
|
7
|
+
export interface RelayHub {
|
|
8
|
+
/** Handle a new streaming connection from a bot runtime. */
|
|
9
|
+
handleStream(c: Context, botId: string, connectKey: string): Promise<Response>
|
|
10
|
+
/** Dispatch a Slack event to the connected bot runtime. */
|
|
11
|
+
dispatch(botId: string, event: unknown): boolean
|
|
12
|
+
/** Check if a specific bot is connected. */
|
|
13
|
+
isConnected(botId: string): boolean
|
|
14
|
+
/** List all connected bot IDs. */
|
|
15
|
+
connectedBots(): string[]
|
|
16
|
+
}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Database row types and repository interfaces.
|
|
3
|
+
* These abstract away the underlying DB (MySQL, PostgreSQL, D1/SQLite) and ORM (Drizzle).
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
export interface BotRow {
|
|
7
|
+
id: string
|
|
8
|
+
name: string
|
|
9
|
+
botUsername: string
|
|
10
|
+
profileImageUrl: string | null
|
|
11
|
+
connectKey: string
|
|
12
|
+
slackAppId: string | null
|
|
13
|
+
slackTeamId: string | null
|
|
14
|
+
botTokenEnc: string | null
|
|
15
|
+
signingSecretEnc: string | null
|
|
16
|
+
clientId: string | null
|
|
17
|
+
clientSecretEnc: string | null
|
|
18
|
+
manifestJson: string | null
|
|
19
|
+
status: 'pending' | 'active' | 'disabled'
|
|
20
|
+
createdAt: Date
|
|
21
|
+
updatedAt: Date
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface ConfigTokenRow {
|
|
25
|
+
workspaceId: string
|
|
26
|
+
accessTokenEnc: string
|
|
27
|
+
refreshTokenEnc: string
|
|
28
|
+
expiresAt: Date
|
|
29
|
+
updatedAt: Date
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface OAuthStateRow {
|
|
33
|
+
state: string
|
|
34
|
+
botId: string
|
|
35
|
+
expiresAt: Date
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface WorkspaceAdminConfigRow {
|
|
39
|
+
workspaceId: string
|
|
40
|
+
slackClientId: string | null
|
|
41
|
+
slackClientSecretEnc: string | null
|
|
42
|
+
dCookieEnc: string | null
|
|
43
|
+
xoxcTokenEnc: string | null
|
|
44
|
+
workspaceDomain: string | null
|
|
45
|
+
updatedAt: Date
|
|
46
|
+
updatedByUserId: string | null
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export interface BotRepository {
|
|
50
|
+
findById(id: string): Promise<BotRow | null>
|
|
51
|
+
findByConnectKey(connectKey: string): Promise<BotRow | null>
|
|
52
|
+
findByConnectKeyAndStatus(
|
|
53
|
+
connectKey: string,
|
|
54
|
+
status: BotRow['status'],
|
|
55
|
+
): Promise<BotRow | null>
|
|
56
|
+
findByIdAndStatus(
|
|
57
|
+
id: string,
|
|
58
|
+
status: BotRow['status'],
|
|
59
|
+
): Promise<BotRow | null>
|
|
60
|
+
findAll(): Promise<BotRow[]>
|
|
61
|
+
findAllSummary(): Promise<
|
|
62
|
+
Array<{
|
|
63
|
+
id: string
|
|
64
|
+
name: string
|
|
65
|
+
profileImageUrl: string | null
|
|
66
|
+
slackAppId: string | null
|
|
67
|
+
slackTeamId: string | null
|
|
68
|
+
status: BotRow['status']
|
|
69
|
+
createdAt: Date
|
|
70
|
+
}>
|
|
71
|
+
>
|
|
72
|
+
create(bot: Omit<BotRow, 'createdAt' | 'updatedAt'>): Promise<void>
|
|
73
|
+
update(id: string, data: Partial<Omit<BotRow, 'id'>>): Promise<void>
|
|
74
|
+
delete(id: string): Promise<void>
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export interface ConfigTokenRepository {
|
|
78
|
+
findByWorkspaceId(id: string): Promise<ConfigTokenRow | null>
|
|
79
|
+
findAll(): Promise<ConfigTokenRow[]>
|
|
80
|
+
upsert(row: Omit<ConfigTokenRow, 'updatedAt'>): Promise<void>
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export interface OAuthStateRepository {
|
|
84
|
+
create(row: OAuthStateRow): Promise<void>
|
|
85
|
+
consume(state: string): Promise<OAuthStateRow | null>
|
|
86
|
+
deleteExpired(): Promise<void>
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export interface WorkspaceAdminConfigRepository {
|
|
90
|
+
findByWorkspaceId(workspaceId: string): Promise<WorkspaceAdminConfigRow | null>
|
|
91
|
+
findAll(): Promise<WorkspaceAdminConfigRow[]>
|
|
92
|
+
upsert(config: Omit<WorkspaceAdminConfigRow, 'updatedAt'>): Promise<void>
|
|
93
|
+
}
|
package/src/web/api.ts
ADDED
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
import { Hono } from 'hono'
|
|
2
|
+
import type { BotRepository } from '../types/repository.js'
|
|
3
|
+
import type { CryptoProvider } from '../types/crypto.js'
|
|
4
|
+
import type { Provisioner } from '../slack/provisioner.js'
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Web API endpoints for bot management.
|
|
8
|
+
*/
|
|
9
|
+
export function createWebApi(
|
|
10
|
+
botRepo: BotRepository,
|
|
11
|
+
provisioner: Provisioner,
|
|
12
|
+
crypto: CryptoProvider,
|
|
13
|
+
workspaceId: string,
|
|
14
|
+
) {
|
|
15
|
+
const app = new Hono()
|
|
16
|
+
|
|
17
|
+
// POST /api/bots - Create a new bot
|
|
18
|
+
app.post('/api/bots', async (c) => {
|
|
19
|
+
const body = await c.req.json<{
|
|
20
|
+
name: string
|
|
21
|
+
botUsername: string
|
|
22
|
+
profileImage?: string | null
|
|
23
|
+
}>()
|
|
24
|
+
|
|
25
|
+
if (
|
|
26
|
+
!body.name ||
|
|
27
|
+
typeof body.name !== 'string' ||
|
|
28
|
+
body.name.trim().length === 0
|
|
29
|
+
) {
|
|
30
|
+
return c.json({ error: '봇 이름을 입력해주세요.' }, 400)
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const botUsername = (body.botUsername ?? '').trim()
|
|
34
|
+
if (
|
|
35
|
+
!botUsername ||
|
|
36
|
+
botUsername.length < 2 ||
|
|
37
|
+
botUsername.length > 80 ||
|
|
38
|
+
!/^[a-z0-9][a-z0-9-]*$/.test(botUsername)
|
|
39
|
+
) {
|
|
40
|
+
return c.json(
|
|
41
|
+
{ error: '봇 유저네임은 영문 소문자, 숫자, 하이픈만 사용 가능하며 2-80자여야 합니다.' },
|
|
42
|
+
400,
|
|
43
|
+
)
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const botId = crypto.uuid()
|
|
47
|
+
const connectKey = `cpk_${await crypto.randomHex(20)}`
|
|
48
|
+
const name = body.name.trim()
|
|
49
|
+
const profileImageUrl =
|
|
50
|
+
typeof body.profileImage === 'string' &&
|
|
51
|
+
body.profileImage.startsWith('data:image/')
|
|
52
|
+
? body.profileImage
|
|
53
|
+
: null
|
|
54
|
+
|
|
55
|
+
// Insert bot record
|
|
56
|
+
await botRepo.create({
|
|
57
|
+
id: botId,
|
|
58
|
+
name,
|
|
59
|
+
botUsername,
|
|
60
|
+
profileImageUrl,
|
|
61
|
+
connectKey,
|
|
62
|
+
slackAppId: null,
|
|
63
|
+
slackTeamId: null,
|
|
64
|
+
botTokenEnc: null,
|
|
65
|
+
signingSecretEnc: null,
|
|
66
|
+
clientId: null,
|
|
67
|
+
clientSecretEnc: null,
|
|
68
|
+
manifestJson: null,
|
|
69
|
+
status: 'pending',
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
// Provision Slack app asynchronously (CF Workers needs waitUntil to keep alive)
|
|
73
|
+
const provisionPromise = provisionSlackApp(
|
|
74
|
+
botId,
|
|
75
|
+
name,
|
|
76
|
+
botUsername,
|
|
77
|
+
).catch((err: unknown) => {
|
|
78
|
+
console.error(
|
|
79
|
+
`[api] failed to provision Slack app for bot ${botId}:`,
|
|
80
|
+
err,
|
|
81
|
+
)
|
|
82
|
+
})
|
|
83
|
+
if (c.executionCtx?.waitUntil) {
|
|
84
|
+
c.executionCtx.waitUntil(provisionPromise)
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
return c.json({ ok: true, botId, connectKey })
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
// GET /api/bots/:botId - Get bot status
|
|
91
|
+
app.get('/api/bots/:botId', async (c) => {
|
|
92
|
+
const botId = c.req.param('botId')
|
|
93
|
+
const bot = await botRepo.findById(botId)
|
|
94
|
+
|
|
95
|
+
if (!bot) {
|
|
96
|
+
return c.json({ error: 'bot not found' }, 404)
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
return c.json({
|
|
100
|
+
bot: {
|
|
101
|
+
id: bot.id,
|
|
102
|
+
name: bot.name,
|
|
103
|
+
profileImageUrl: bot.profileImageUrl,
|
|
104
|
+
slackAppId: bot.slackAppId,
|
|
105
|
+
slackTeamId: bot.slackTeamId,
|
|
106
|
+
clientId: bot.clientId,
|
|
107
|
+
status: bot.status,
|
|
108
|
+
connectKey: bot.connectKey,
|
|
109
|
+
createdAt: bot.createdAt,
|
|
110
|
+
updatedAt: bot.updatedAt,
|
|
111
|
+
},
|
|
112
|
+
})
|
|
113
|
+
})
|
|
114
|
+
|
|
115
|
+
// POST /api/bots/:botId/provision - Retry provisioning
|
|
116
|
+
app.post('/api/bots/:botId/provision', async (c) => {
|
|
117
|
+
const botId = c.req.param('botId')
|
|
118
|
+
const bot = await botRepo.findById(botId)
|
|
119
|
+
if (!bot) return c.json({ error: 'bot not found' }, 404)
|
|
120
|
+
if (bot.slackAppId) return c.json({ ok: true, appId: bot.slackAppId })
|
|
121
|
+
|
|
122
|
+
const provisionPromise = provisionSlackApp(botId, bot.name, bot.botUsername).catch(
|
|
123
|
+
(err: unknown) => {
|
|
124
|
+
console.error(`[api] retry provision failed for ${botId}:`, err)
|
|
125
|
+
},
|
|
126
|
+
)
|
|
127
|
+
if (c.executionCtx?.waitUntil) {
|
|
128
|
+
c.executionCtx.waitUntil(provisionPromise)
|
|
129
|
+
}
|
|
130
|
+
return c.json({ ok: true, message: 'provisioning started' })
|
|
131
|
+
})
|
|
132
|
+
|
|
133
|
+
// DELETE /api/bots/:botId - Delete a bot and its Slack app
|
|
134
|
+
app.delete('/api/bots/:botId', async (c) => {
|
|
135
|
+
const botId = c.req.param('botId')
|
|
136
|
+
const bot = await botRepo.findById(botId)
|
|
137
|
+
if (!bot) return c.json({ error: 'bot not found' }, 404)
|
|
138
|
+
|
|
139
|
+
// Delete Slack app if it exists
|
|
140
|
+
if (bot.slackAppId) {
|
|
141
|
+
const result = await provisioner.deleteApp(workspaceId, bot.slackAppId)
|
|
142
|
+
if (!result.ok) {
|
|
143
|
+
console.error(`[api] failed to delete Slack app ${bot.slackAppId}: ${result.error}`)
|
|
144
|
+
// Continue to delete from DB even if Slack deletion fails
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
await botRepo.delete(botId)
|
|
149
|
+
return c.json({ ok: true, deleted: botId })
|
|
150
|
+
})
|
|
151
|
+
|
|
152
|
+
// POST /api/bots/:botId/icon - Update bot profile image (DB only, Slack 아이콘은 수동 설정 필요)
|
|
153
|
+
app.post('/api/bots/:botId/icon', async (c) => {
|
|
154
|
+
const botId = c.req.param('botId')
|
|
155
|
+
const bot = await botRepo.findById(botId)
|
|
156
|
+
if (!bot) return c.json({ error: 'bot not found' }, 404)
|
|
157
|
+
|
|
158
|
+
const formData = await c.req.formData()
|
|
159
|
+
const file = formData.get('image')
|
|
160
|
+
if (!file || typeof file === 'string' || !('arrayBuffer' in file)) {
|
|
161
|
+
return c.json({ error: 'image file is required' }, 400)
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
const imageBuffer = await (file as Blob).arrayBuffer()
|
|
165
|
+
const bytes = new Uint8Array(imageBuffer)
|
|
166
|
+
let binaryStr = ''
|
|
167
|
+
for (let i = 0; i < bytes.length; i++) {
|
|
168
|
+
binaryStr += String.fromCharCode(bytes[i])
|
|
169
|
+
}
|
|
170
|
+
const base64 = btoa(binaryStr)
|
|
171
|
+
const blob = file as Blob
|
|
172
|
+
const mimeType = blob.type || 'image/png'
|
|
173
|
+
await botRepo.update(botId, {
|
|
174
|
+
profileImageUrl: `data:${mimeType};base64,${base64}`,
|
|
175
|
+
})
|
|
176
|
+
|
|
177
|
+
return c.json({ ok: true })
|
|
178
|
+
})
|
|
179
|
+
|
|
180
|
+
async function provisionSlackApp(
|
|
181
|
+
botId: string,
|
|
182
|
+
botName: string,
|
|
183
|
+
botUsername: string,
|
|
184
|
+
) {
|
|
185
|
+
const result = await provisioner.createApp(
|
|
186
|
+
workspaceId,
|
|
187
|
+
botId,
|
|
188
|
+
botName,
|
|
189
|
+
botUsername,
|
|
190
|
+
)
|
|
191
|
+
if (!result.ok) {
|
|
192
|
+
console.error(
|
|
193
|
+
`[provisioner] failed to create Slack app: ${result.error}`,
|
|
194
|
+
)
|
|
195
|
+
return
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
console.log(
|
|
199
|
+
`[provisioner] Slack app created for bot ${botId}: ${result.appId}`,
|
|
200
|
+
)
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
return app
|
|
204
|
+
}
|