@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.
Files changed (158) hide show
  1. package/dist/app.d.ts +9 -0
  2. package/dist/app.d.ts.map +1 -0
  3. package/dist/app.js +147 -0
  4. package/dist/app.js.map +1 -0
  5. package/dist/auth/handler.d.ts +19 -0
  6. package/dist/auth/handler.d.ts.map +1 -0
  7. package/dist/auth/handler.js +213 -0
  8. package/dist/auth/handler.js.map +1 -0
  9. package/dist/auth/session.d.ts +16 -0
  10. package/dist/auth/session.d.ts.map +1 -0
  11. package/dist/auth/session.js +54 -0
  12. package/dist/auth/session.js.map +1 -0
  13. package/dist/db/d1/index.d.ts +14 -0
  14. package/dist/db/d1/index.d.ts.map +1 -0
  15. package/dist/db/d1/index.js +252 -0
  16. package/dist/db/d1/index.js.map +1 -0
  17. package/dist/db/d1/schema.d.ts +610 -0
  18. package/dist/db/d1/schema.d.ts.map +1 -0
  19. package/dist/db/d1/schema.js +58 -0
  20. package/dist/db/d1/schema.js.map +1 -0
  21. package/dist/db/mysql/index.d.ts +14 -0
  22. package/dist/db/mysql/index.d.ts.map +1 -0
  23. package/dist/db/mysql/index.js +248 -0
  24. package/dist/db/mysql/index.js.map +1 -0
  25. package/dist/db/mysql/schema.d.ts +562 -0
  26. package/dist/db/mysql/schema.d.ts.map +1 -0
  27. package/dist/db/mysql/schema.js +61 -0
  28. package/dist/db/mysql/schema.js.map +1 -0
  29. package/dist/db/postgresql/index.d.ts +14 -0
  30. package/dist/db/postgresql/index.d.ts.map +1 -0
  31. package/dist/db/postgresql/index.js +246 -0
  32. package/dist/db/postgresql/index.js.map +1 -0
  33. package/dist/db/postgresql/schema.d.ts +591 -0
  34. package/dist/db/postgresql/schema.d.ts.map +1 -0
  35. package/dist/db/postgresql/schema.js +64 -0
  36. package/dist/db/postgresql/schema.js.map +1 -0
  37. package/dist/index.d.ts +6 -0
  38. package/dist/index.d.ts.map +1 -0
  39. package/dist/index.js +3 -0
  40. package/dist/index.js.map +1 -0
  41. package/dist/relay/api-proxy.d.ts +10 -0
  42. package/dist/relay/api-proxy.d.ts.map +1 -0
  43. package/dist/relay/api-proxy.js +40 -0
  44. package/dist/relay/api-proxy.js.map +1 -0
  45. package/dist/runtime/cf/crypto.d.ts +7 -0
  46. package/dist/runtime/cf/crypto.d.ts.map +1 -0
  47. package/dist/runtime/cf/crypto.js +48 -0
  48. package/dist/runtime/cf/crypto.js.map +1 -0
  49. package/dist/runtime/cf/index.d.ts +20 -0
  50. package/dist/runtime/cf/index.d.ts.map +1 -0
  51. package/dist/runtime/cf/index.js +14 -0
  52. package/dist/runtime/cf/index.js.map +1 -0
  53. package/dist/runtime/cf/relay.d.ts +11 -0
  54. package/dist/runtime/cf/relay.d.ts.map +1 -0
  55. package/dist/runtime/cf/relay.js +57 -0
  56. package/dist/runtime/cf/relay.js.map +1 -0
  57. package/dist/runtime/cf/vault.d.ts +7 -0
  58. package/dist/runtime/cf/vault.d.ts.map +1 -0
  59. package/dist/runtime/cf/vault.js +68 -0
  60. package/dist/runtime/cf/vault.js.map +1 -0
  61. package/dist/runtime/node/crypto.d.ts +6 -0
  62. package/dist/runtime/node/crypto.d.ts.map +1 -0
  63. package/dist/runtime/node/crypto.js +26 -0
  64. package/dist/runtime/node/crypto.js.map +1 -0
  65. package/dist/runtime/node/index.d.ts +17 -0
  66. package/dist/runtime/node/index.d.ts.map +1 -0
  67. package/dist/runtime/node/index.js +14 -0
  68. package/dist/runtime/node/index.js.map +1 -0
  69. package/dist/runtime/node/relay.d.ts +6 -0
  70. package/dist/runtime/node/relay.d.ts.map +1 -0
  71. package/dist/runtime/node/relay.js +73 -0
  72. package/dist/runtime/node/relay.js.map +1 -0
  73. package/dist/runtime/node/vault.d.ts +7 -0
  74. package/dist/runtime/node/vault.d.ts.map +1 -0
  75. package/dist/runtime/node/vault.js +41 -0
  76. package/dist/runtime/node/vault.js.map +1 -0
  77. package/dist/slack/events.d.ts +15 -0
  78. package/dist/slack/events.d.ts.map +1 -0
  79. package/dist/slack/events.js +63 -0
  80. package/dist/slack/events.js.map +1 -0
  81. package/dist/slack/oauth.d.ts +13 -0
  82. package/dist/slack/oauth.d.ts.map +1 -0
  83. package/dist/slack/oauth.js +90 -0
  84. package/dist/slack/oauth.js.map +1 -0
  85. package/dist/slack/provisioner.d.ts +60 -0
  86. package/dist/slack/provisioner.d.ts.map +1 -0
  87. package/dist/slack/provisioner.js +156 -0
  88. package/dist/slack/provisioner.js.map +1 -0
  89. package/dist/types/crypto.d.ts +15 -0
  90. package/dist/types/crypto.d.ts.map +1 -0
  91. package/dist/types/crypto.js +2 -0
  92. package/dist/types/crypto.js.map +1 -0
  93. package/dist/types/index.d.ts +6 -0
  94. package/dist/types/index.d.ts.map +1 -0
  95. package/dist/types/index.js +2 -0
  96. package/dist/types/index.js.map +1 -0
  97. package/dist/types/platform.d.ts +25 -0
  98. package/dist/types/platform.d.ts.map +1 -0
  99. package/dist/types/platform.js +2 -0
  100. package/dist/types/platform.js.map +1 -0
  101. package/dist/types/relay.d.ts +16 -0
  102. package/dist/types/relay.d.ts.map +1 -0
  103. package/dist/types/relay.js +2 -0
  104. package/dist/types/relay.js.map +1 -0
  105. package/dist/types/repository.d.ts +78 -0
  106. package/dist/types/repository.d.ts.map +1 -0
  107. package/dist/types/repository.js +6 -0
  108. package/dist/types/repository.js.map +1 -0
  109. package/dist/types/vault.d.ts +9 -0
  110. package/dist/types/vault.d.ts.map +1 -0
  111. package/dist/types/vault.js +2 -0
  112. package/dist/types/vault.js.map +1 -0
  113. package/dist/web/api.d.ts +9 -0
  114. package/dist/web/api.d.ts.map +1 -0
  115. package/dist/web/api.js +144 -0
  116. package/dist/web/api.js.map +1 -0
  117. package/dist/web/pages.d.ts +4 -0
  118. package/dist/web/pages.d.ts.map +1 -0
  119. package/dist/web/pages.js +401 -0
  120. package/dist/web/pages.js.map +1 -0
  121. package/dist/web/setup.d.ts +5 -0
  122. package/dist/web/setup.d.ts.map +1 -0
  123. package/dist/web/setup.js +208 -0
  124. package/dist/web/setup.js.map +1 -0
  125. package/package.json +46 -0
  126. package/src/app.ts +221 -0
  127. package/src/auth/handler.ts +343 -0
  128. package/src/auth/session.ts +89 -0
  129. package/src/db/d1/index.ts +304 -0
  130. package/src/db/d1/schema.ts +62 -0
  131. package/src/db/mysql/index.ts +301 -0
  132. package/src/db/mysql/schema.ts +78 -0
  133. package/src/db/postgresql/index.ts +311 -0
  134. package/src/db/postgresql/schema.ts +82 -0
  135. package/src/index.ts +21 -0
  136. package/src/relay/api-proxy.ts +61 -0
  137. package/src/runtime/cf/crypto.ts +74 -0
  138. package/src/runtime/cf/index.ts +31 -0
  139. package/src/runtime/cf/relay.ts +74 -0
  140. package/src/runtime/cf/vault.ts +99 -0
  141. package/src/runtime/node/crypto.ts +33 -0
  142. package/src/runtime/node/index.ts +28 -0
  143. package/src/runtime/node/relay.ts +98 -0
  144. package/src/runtime/node/vault.ts +50 -0
  145. package/src/slack/events.ts +92 -0
  146. package/src/slack/oauth.ts +127 -0
  147. package/src/slack/provisioner.ts +256 -0
  148. package/src/types/crypto.ts +14 -0
  149. package/src/types/index.ts +14 -0
  150. package/src/types/platform.ts +31 -0
  151. package/src/types/relay.ts +16 -0
  152. package/src/types/repository.ts +93 -0
  153. package/src/types/vault.ts +8 -0
  154. package/src/web/api.ts +204 -0
  155. package/src/web/pages.ts +458 -0
  156. package/src/web/setup.ts +270 -0
  157. package/tsconfig.json +19 -0
  158. 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
+ }
@@ -0,0 +1,8 @@
1
+ /**
2
+ * Vault interface for encrypting/decrypting secrets.
3
+ * All methods return Promises to support Web Crypto (async) implementations.
4
+ */
5
+ export interface Vault {
6
+ encrypt(plaintext: string): Promise<string>
7
+ decrypt(encoded: string): Promise<string>
8
+ }
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
+ }