@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,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
|
+
}
|