@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,74 @@
1
+ import type { Context } from 'hono'
2
+ import type { RelayHub } from '../../types/relay.js'
3
+
4
+ /**
5
+ * Durable Objects-based RelayHub for CF Workers.
6
+ *
7
+ * This stub communicates with a Durable Object namespace.
8
+ * Each bot gets its own Durable Object instance that manages WebSocket connections.
9
+ *
10
+ * The actual Durable Object class (RelayDurableObject) is exported from apps/worker.
11
+ */
12
+ export function createCfRelay(doNamespace: DurableObjectNamespace): RelayHub {
13
+ function getStub(botId: string): DurableObjectStub {
14
+ const id = doNamespace.idFromName(botId)
15
+ return doNamespace.get(id)
16
+ }
17
+
18
+ return {
19
+ async handleStream(
20
+ c: Context,
21
+ botId: string,
22
+ _connectKey: string,
23
+ ): Promise<Response> {
24
+ const upgradeHeader = c.req.header('Upgrade')
25
+ if (upgradeHeader !== 'websocket') {
26
+ return c.json(
27
+ { error: 'expected websocket upgrade' },
28
+ { status: 426 },
29
+ )
30
+ }
31
+
32
+ // Forward the WebSocket upgrade request to the Durable Object
33
+ const stub = getStub(botId)
34
+ const url = new URL(c.req.url)
35
+ url.pathname = '/ws'
36
+ url.searchParams.set('botId', botId)
37
+
38
+ return stub.fetch(url.toString(), {
39
+ headers: c.req.raw.headers,
40
+ }) as unknown as Response
41
+ },
42
+
43
+ dispatch(botId: string, event: unknown): boolean {
44
+ // Fire and forget -- send event to the DO
45
+ const stub = getStub(botId)
46
+ const url = new URL('https://internal/dispatch')
47
+ url.searchParams.set('botId', botId)
48
+
49
+ stub
50
+ .fetch(url.toString(), {
51
+ method: 'POST',
52
+ headers: { 'Content-Type': 'application/json' },
53
+ body: JSON.stringify(event),
54
+ })
55
+ .catch((err: unknown) => {
56
+ console.error(`[cf-relay] dispatch error for bot ${botId}:`, err)
57
+ })
58
+
59
+ // We return true optimistically; the DO handles delivery
60
+ return true
61
+ },
62
+
63
+ isConnected(_botId: string): boolean {
64
+ // In CF Workers, we cannot synchronously check DO state
65
+ // This is best-effort
66
+ return true
67
+ },
68
+
69
+ connectedBots(): string[] {
70
+ // Cannot enumerate Durable Objects synchronously
71
+ return []
72
+ },
73
+ }
74
+ }
@@ -0,0 +1,99 @@
1
+ import type { Vault } from '../../types/vault.js'
2
+
3
+ const ALGORITHM = 'AES-GCM'
4
+ const IV_LENGTH = 12
5
+
6
+ /**
7
+ * AES-256-GCM Vault implementation using Web Crypto API.
8
+ * Compatible with CF Workers runtime.
9
+ */
10
+ export async function createCfVault(masterKeyHex: string): Promise<Vault> {
11
+ const rawKey = hexToArrayBuffer(masterKeyHex)
12
+ if (rawKey.byteLength !== 32) {
13
+ throw new Error('VAULT_MASTER_KEY must be 32 bytes (64 hex chars)')
14
+ }
15
+
16
+ const cryptoKey = await crypto.subtle.importKey(
17
+ 'raw',
18
+ rawKey,
19
+ { name: ALGORITHM },
20
+ false,
21
+ ['encrypt', 'decrypt'],
22
+ )
23
+
24
+ return {
25
+ async encrypt(plaintext: string): Promise<string> {
26
+ const iv = crypto.getRandomValues(new Uint8Array(IV_LENGTH))
27
+ const encoded = new TextEncoder().encode(plaintext)
28
+
29
+ const ciphertext = await crypto.subtle.encrypt(
30
+ { name: ALGORITHM, iv },
31
+ cryptoKey,
32
+ encoded,
33
+ )
34
+
35
+ // Web Crypto appends authTag to ciphertext
36
+ // Format: base64(iv + ciphertext_with_tag) -- matches Node.js layout
37
+ // Node layout: iv(12) + authTag(16) + ciphertext
38
+ // WebCrypto layout: ciphertext + authTag(16)
39
+ // We need to rearrange to match Node.js format
40
+ const ctBytes = new Uint8Array(ciphertext)
41
+ const actualCiphertext = ctBytes.slice(0, ctBytes.length - 16)
42
+ const authTag = ctBytes.slice(ctBytes.length - 16)
43
+
44
+ const result = new Uint8Array(
45
+ iv.length + authTag.length + actualCiphertext.length,
46
+ )
47
+ result.set(iv, 0)
48
+ result.set(authTag, iv.length)
49
+ result.set(actualCiphertext, iv.length + authTag.length)
50
+
51
+ return uint8ArrayToBase64(result)
52
+ },
53
+
54
+ async decrypt(encoded: string): Promise<string> {
55
+ const data = base64ToUint8Array(encoded)
56
+ const iv = data.slice(0, IV_LENGTH)
57
+ const authTag = data.slice(IV_LENGTH, IV_LENGTH + 16)
58
+ const ciphertext = data.slice(IV_LENGTH + 16)
59
+
60
+ // Reconstruct WebCrypto format: ciphertext + authTag
61
+ const combined = new Uint8Array(ciphertext.length + authTag.length)
62
+ combined.set(ciphertext, 0)
63
+ combined.set(authTag, ciphertext.length)
64
+
65
+ const decrypted = await crypto.subtle.decrypt(
66
+ { name: ALGORITHM, iv },
67
+ cryptoKey,
68
+ combined,
69
+ )
70
+
71
+ return new TextDecoder().decode(decrypted)
72
+ },
73
+ }
74
+ }
75
+
76
+ function hexToArrayBuffer(hex: string): ArrayBuffer {
77
+ const bytes = new Uint8Array(hex.length / 2)
78
+ for (let i = 0; i < hex.length; i += 2) {
79
+ bytes[i / 2] = Number.parseInt(hex.substring(i, i + 2), 16)
80
+ }
81
+ return bytes.buffer
82
+ }
83
+
84
+ function uint8ArrayToBase64(bytes: Uint8Array): string {
85
+ let binary = ''
86
+ for (let i = 0; i < bytes.length; i++) {
87
+ binary += String.fromCharCode(bytes[i])
88
+ }
89
+ return btoa(binary)
90
+ }
91
+
92
+ function base64ToUint8Array(base64: string): Uint8Array {
93
+ const binary = atob(base64)
94
+ const bytes = new Uint8Array(binary.length)
95
+ for (let i = 0; i < binary.length; i++) {
96
+ bytes[i] = binary.charCodeAt(i)
97
+ }
98
+ return bytes
99
+ }
@@ -0,0 +1,33 @@
1
+ import {
2
+ createHmac,
3
+ randomBytes,
4
+ timingSafeEqual as nodeTimingSafeEqual,
5
+ } from 'node:crypto'
6
+ import { v4 as uuidv4 } from 'uuid'
7
+ import type { CryptoProvider } from '../../types/crypto.js'
8
+
9
+ /**
10
+ * CryptoProvider implementation using Node.js crypto module.
11
+ */
12
+ export function createNodeCrypto(): CryptoProvider {
13
+ return {
14
+ async randomHex(byteLength: number): Promise<string> {
15
+ return randomBytes(byteLength).toString('hex')
16
+ },
17
+
18
+ uuid(): string {
19
+ return uuidv4()
20
+ },
21
+
22
+ async hmacSha256(key: string, data: string): Promise<string> {
23
+ return createHmac('sha256', key).update(data).digest('hex')
24
+ },
25
+
26
+ async timingSafeEqual(a: string, b: string): Promise<boolean> {
27
+ const bufA = Buffer.from(a, 'utf8')
28
+ const bufB = Buffer.from(b, 'utf8')
29
+ if (bufA.length !== bufB.length) return false
30
+ return nodeTimingSafeEqual(bufA, bufB)
31
+ },
32
+ }
33
+ }
@@ -0,0 +1,28 @@
1
+ import type { Vault } from '../../types/vault.js'
2
+ import type { RelayHub } from '../../types/relay.js'
3
+ import type { CryptoProvider } from '../../types/crypto.js'
4
+ import { createNodeVault } from './vault.js'
5
+ import { createNodeCrypto } from './crypto.js'
6
+ import { createNodeRelay } from './relay.js'
7
+
8
+ export interface NodeRuntimeConfig {
9
+ vaultMasterKey: string
10
+ }
11
+
12
+ export interface NodeRuntime {
13
+ vault: Vault
14
+ relay: RelayHub
15
+ crypto: CryptoProvider
16
+ }
17
+
18
+ /**
19
+ * Create runtime services for Node.js (vault, relay, crypto).
20
+ * Does NOT include DB repositories -- those are created separately via the DB subpath.
21
+ */
22
+ export function createNodeRuntime(config: NodeRuntimeConfig): NodeRuntime {
23
+ const vault = createNodeVault(config.vaultMasterKey)
24
+ const crypto = createNodeCrypto()
25
+ const relay = createNodeRelay()
26
+
27
+ return { vault, relay, crypto }
28
+ }
@@ -0,0 +1,98 @@
1
+ import type { Context } from 'hono'
2
+ import { streamSSE } from 'hono/streaming'
3
+ import type { RelayHub } from '../../types/relay.js'
4
+
5
+ type SSEClient = {
6
+ botId: string
7
+ connectKey: string
8
+ send: (event: string, data: string, id?: string) => void
9
+ close: () => void
10
+ }
11
+
12
+ /**
13
+ * SSE-based RelayHub implementation for Node.js.
14
+ */
15
+ export function createNodeRelay(): RelayHub {
16
+ // botId -> SSEClient (1 bot = 1 active connection)
17
+ const clients = new Map<string, SSEClient>()
18
+
19
+ let eventCounter = 0
20
+
21
+ return {
22
+ async handleStream(
23
+ c: Context,
24
+ botId: string,
25
+ connectKey: string,
26
+ ): Promise<Response> {
27
+ return streamSSE(c, async (stream) => {
28
+ // Replace existing connection (reconnect scenario)
29
+ const existing = clients.get(botId)
30
+ if (existing) {
31
+ existing.close()
32
+ }
33
+
34
+ let closed = false
35
+
36
+ const client: SSEClient = {
37
+ botId,
38
+ connectKey,
39
+ send(event, data, id) {
40
+ if (closed) return
41
+ stream.writeSSE({ event, data, id }).catch(() => {
42
+ closed = true
43
+ })
44
+ },
45
+ close() {
46
+ closed = true
47
+ },
48
+ }
49
+
50
+ clients.set(botId, client)
51
+
52
+ // Connection confirmation event
53
+ client.send('connected', JSON.stringify({ botId, ts: Date.now() }))
54
+
55
+ // Heartbeat (every 30 seconds)
56
+ const heartbeatInterval = setInterval(() => {
57
+ if (closed) {
58
+ clearInterval(heartbeatInterval)
59
+ return
60
+ }
61
+ client.send('ping', JSON.stringify({ ts: Date.now() }))
62
+ }, 30_000)
63
+
64
+ // Wait for connection close
65
+ stream.onAbort(() => {
66
+ closed = true
67
+ clearInterval(heartbeatInterval)
68
+ clients.delete(botId)
69
+ })
70
+
71
+ // Keep connection alive until aborted
72
+ while (!closed) {
73
+ await new Promise((r) => setTimeout(r, 1000))
74
+ }
75
+
76
+ clearInterval(heartbeatInterval)
77
+ clients.delete(botId)
78
+ }) as unknown as Response
79
+ },
80
+
81
+ dispatch(botId: string, event: unknown): boolean {
82
+ const client = clients.get(botId)
83
+ if (!client) return false
84
+
85
+ const id = String(++eventCounter)
86
+ client.send('slack_event', JSON.stringify(event), id)
87
+ return true
88
+ },
89
+
90
+ isConnected(botId: string): boolean {
91
+ return clients.has(botId)
92
+ },
93
+
94
+ connectedBots(): string[] {
95
+ return Array.from(clients.keys())
96
+ },
97
+ }
98
+ }
@@ -0,0 +1,50 @@
1
+ import {
2
+ createCipheriv,
3
+ createDecipheriv,
4
+ randomBytes,
5
+ } from 'node:crypto'
6
+ import type { Vault } from '../../types/vault.js'
7
+
8
+ const ALGORITHM = 'aes-256-gcm'
9
+ const IV_LENGTH = 12
10
+ const AUTH_TAG_LENGTH = 16
11
+
12
+ /**
13
+ * AES-256-GCM Vault implementation using Node.js crypto.
14
+ * Returns Promises to match the Vault interface (Web Crypto compatibility).
15
+ */
16
+ export function createNodeVault(masterKeyHex: string): Vault {
17
+ const masterKey = Buffer.from(masterKeyHex, 'hex')
18
+ if (masterKey.length !== 32) {
19
+ throw new Error('VAULT_MASTER_KEY must be 32 bytes (64 hex chars)')
20
+ }
21
+
22
+ return {
23
+ async encrypt(plaintext: string): Promise<string> {
24
+ const iv = randomBytes(IV_LENGTH)
25
+ const cipher = createCipheriv(ALGORITHM, masterKey, iv, {
26
+ authTagLength: AUTH_TAG_LENGTH,
27
+ })
28
+ const encrypted = Buffer.concat([
29
+ cipher.update(plaintext, 'utf8'),
30
+ cipher.final(),
31
+ ])
32
+ const authTag = cipher.getAuthTag()
33
+ // Format: base64(iv + authTag + ciphertext)
34
+ return Buffer.concat([iv, authTag, encrypted]).toString('base64')
35
+ },
36
+
37
+ async decrypt(encoded: string): Promise<string> {
38
+ const data = Buffer.from(encoded, 'base64')
39
+ const iv = data.subarray(0, IV_LENGTH)
40
+ const authTag = data.subarray(IV_LENGTH, IV_LENGTH + AUTH_TAG_LENGTH)
41
+ const ciphertext = data.subarray(IV_LENGTH + AUTH_TAG_LENGTH)
42
+
43
+ const decipher = createDecipheriv(ALGORITHM, masterKey, iv, {
44
+ authTagLength: AUTH_TAG_LENGTH,
45
+ })
46
+ decipher.setAuthTag(authTag)
47
+ return decipher.update(ciphertext) + decipher.final('utf8')
48
+ },
49
+ }
50
+ }
@@ -0,0 +1,92 @@
1
+ import { Hono } from 'hono'
2
+ import type { Vault } from '../types/vault.js'
3
+ import type { RelayHub } from '../types/relay.js'
4
+ import type { CryptoProvider } from '../types/crypto.js'
5
+ import type { BotRepository } from '../types/repository.js'
6
+
7
+ /**
8
+ * Slack HTTP Events API receiver + relay to SSE/WebSocket Hub.
9
+ *
10
+ * Route: POST /slack/events/:botId
11
+ * - url_verification challenge auto-response
12
+ * - signing_secret signature verification
13
+ * - Relay to local runtime via RelayHub
14
+ */
15
+ export function createSlackEventsHandler(
16
+ botRepo: BotRepository,
17
+ vault: Vault,
18
+ relay: RelayHub,
19
+ crypto: CryptoProvider,
20
+ ) {
21
+ const app = new Hono()
22
+
23
+ app.post('/slack/events/:botId', async (c) => {
24
+ const botId = c.req.param('botId')
25
+ const rawBody = await c.req.text()
26
+
27
+ const bot = await botRepo.findByIdAndStatus(botId, 'active')
28
+ if (!bot) {
29
+ return c.json({ error: 'unknown bot' }, 404)
30
+ }
31
+
32
+ // Signing secret verification
33
+ if (bot.signingSecretEnc) {
34
+ const signingSecret = await vault.decrypt(bot.signingSecretEnc)
35
+ const timestamp = c.req.header('x-slack-request-timestamp')
36
+ const slackSignature = c.req.header('x-slack-signature')
37
+
38
+ if (!timestamp || !slackSignature) {
39
+ return c.json({ error: 'missing slack signature headers' }, 401)
40
+ }
41
+
42
+ // Only allow requests within 5 minutes (replay attack prevention)
43
+ const now = Math.floor(Date.now() / 1000)
44
+ if (Math.abs(now - Number(timestamp)) > 300) {
45
+ return c.json({ error: 'request too old' }, 401)
46
+ }
47
+
48
+ const basestring = `v0:${timestamp}:${rawBody}`
49
+ const hmac = await crypto.hmacSha256(signingSecret, basestring)
50
+ const computed = `v0=${hmac}`
51
+
52
+ const isValid = await crypto.timingSafeEqual(computed, slackSignature)
53
+ if (!isValid) {
54
+ return c.json({ error: 'invalid signature' }, 401)
55
+ }
56
+ }
57
+
58
+ const payload = JSON.parse(rawBody) as {
59
+ type: string
60
+ challenge?: string
61
+ event?: unknown
62
+ event_id?: string
63
+ event_time?: number
64
+ team_id?: string
65
+ }
66
+
67
+ // URL verification challenge
68
+ if (payload.type === 'url_verification') {
69
+ return c.json({ challenge: payload.challenge })
70
+ }
71
+
72
+ // event_callback -> relay to local runtime
73
+ if (payload.type === 'event_callback') {
74
+ const dispatched = relay.dispatch(botId, {
75
+ type: payload.type,
76
+ event: payload.event,
77
+ event_id: payload.event_id,
78
+ event_time: payload.event_time,
79
+ team_id: payload.team_id,
80
+ })
81
+
82
+ if (!dispatched) {
83
+ console.warn(`[events] bot ${botId} not connected, event dropped`)
84
+ }
85
+ }
86
+
87
+ // Slack expects 200 within 3 seconds
88
+ return c.json({ ok: true })
89
+ })
90
+
91
+ return app
92
+ }
@@ -0,0 +1,127 @@
1
+ import { Hono } from 'hono'
2
+ import type { Vault } from '../types/vault.js'
3
+ import type { CryptoProvider } from '../types/crypto.js'
4
+ import type {
5
+ BotRepository,
6
+ OAuthStateRepository,
7
+ } from '../types/repository.js'
8
+
9
+ type OAuthAccessResponse = {
10
+ ok: boolean
11
+ access_token?: string
12
+ team?: { id: string; name: string }
13
+ bot_user_id?: string
14
+ error?: string
15
+ }
16
+
17
+ /**
18
+ * Slack OAuth 2.0 handler.
19
+ *
20
+ * Flow:
21
+ * 1. GET /oauth/start/:botId -> Redirect to Slack auth page
22
+ * 2. Slack approves -> GET /oauth/callback -> acquire bot_token -> save to Vault
23
+ */
24
+ export function createOAuthHandler(
25
+ botRepo: BotRepository,
26
+ vault: Vault,
27
+ crypto: CryptoProvider,
28
+ oauthStates: OAuthStateRepository,
29
+ ) {
30
+ const app = new Hono()
31
+
32
+ app.get('/oauth/start/:botId', async (c) => {
33
+ const botId = c.req.param('botId')
34
+ const bot = await botRepo.findById(botId)
35
+
36
+ if (!bot || !bot.clientId) {
37
+ return c.json({ error: 'bot not found or not provisioned' }, 404)
38
+ }
39
+
40
+ // Clean up expired states
41
+ await oauthStates.deleteExpired()
42
+
43
+ const state = await crypto.randomHex(16)
44
+ await oauthStates.create({
45
+ state,
46
+ botId,
47
+ expiresAt: new Date(Date.now() + 5 * 60 * 1000),
48
+ })
49
+
50
+ const scopes = [
51
+ 'app_mentions:read',
52
+ 'chat:write',
53
+ 'chat:write.public',
54
+ 'channels:history',
55
+ 'channels:read',
56
+ 'channels:join',
57
+ 'groups:history',
58
+ 'groups:read',
59
+ 'im:history',
60
+ 'im:read',
61
+ 'im:write',
62
+ 'reactions:read',
63
+ 'reactions:write',
64
+ 'files:read',
65
+ 'files:write',
66
+ 'users:read',
67
+ ].join(',')
68
+
69
+ const slackUrl = new URL('https://slack.com/oauth/v2/authorize')
70
+ slackUrl.searchParams.set('client_id', bot.clientId)
71
+ slackUrl.searchParams.set('scope', scopes)
72
+ slackUrl.searchParams.set('state', state)
73
+
74
+ return c.redirect(slackUrl.toString())
75
+ })
76
+
77
+ app.get('/oauth/callback', async (c) => {
78
+ const code = c.req.query('code')
79
+ const state = c.req.query('state')
80
+
81
+ if (!code || !state) {
82
+ return c.json({ error: 'missing code or state' }, 400)
83
+ }
84
+
85
+ await oauthStates.deleteExpired()
86
+ const entry = await oauthStates.consume(state)
87
+ if (!entry) {
88
+ return c.json({ error: 'invalid or expired state' }, 400)
89
+ }
90
+
91
+ const bot = await botRepo.findById(entry.botId)
92
+ if (!bot || !bot.clientId || !bot.clientSecretEnc) {
93
+ return c.json({ error: 'bot configuration incomplete' }, 500)
94
+ }
95
+
96
+ const clientSecret = await vault.decrypt(bot.clientSecretEnc)
97
+
98
+ // Exchange code for token
99
+ const res = await fetch('https://slack.com/api/oauth.v2.access', {
100
+ method: 'POST',
101
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
102
+ body: new URLSearchParams({
103
+ client_id: bot.clientId,
104
+ client_secret: clientSecret,
105
+ code,
106
+ }),
107
+ })
108
+
109
+ const data = (await res.json()) as OAuthAccessResponse
110
+
111
+ if (!data.ok || !data.access_token) {
112
+ return c.json({ error: `Slack OAuth failed: ${data.error}` }, 400)
113
+ }
114
+
115
+ // Save bot token to Vault and activate
116
+ await botRepo.update(entry.botId, {
117
+ botTokenEnc: await vault.encrypt(data.access_token),
118
+ slackTeamId: data.team?.id ?? null,
119
+ status: 'active',
120
+ })
121
+
122
+ // Redirect to completion page
123
+ return c.redirect(`/bots/${entry.botId}/complete`)
124
+ })
125
+
126
+ return app
127
+ }