@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,343 @@
1
+ import { Hono } from 'hono'
2
+ import type { Context, Next } from 'hono'
3
+ import type { ContentfulStatusCode } from 'hono/utils/http-status'
4
+ import { deleteCookie, getCookie, setCookie } from 'hono/cookie'
5
+ import type { OAuthStateRepository, WorkspaceAdminConfigRepository } from '../types/repository.js'
6
+ import type { CryptoProvider } from '../types/crypto.js'
7
+ import type { Vault } from '../types/vault.js'
8
+ import {
9
+ AUTH_SESSION_MAX_AGE_SECONDS,
10
+ createSessionCookieValue,
11
+ parseSessionCookieValue,
12
+ type AuthSession,
13
+ type AuthSessionUser,
14
+ } from './session.js'
15
+
16
+ const DEFAULT_WORKSPACE_ADMIN_CONFIG_ID = 'default'
17
+ const LOGIN_STATE_PREFIX = 'login:'
18
+
19
+ interface SlackTokenResponse {
20
+ ok: boolean
21
+ access_token?: string
22
+ error?: string
23
+ }
24
+
25
+ interface SlackUserInfoResponse {
26
+ ok: boolean
27
+ sub?: string
28
+ 'https://slack.com/team_id'?: string
29
+ name?: string
30
+ email?: string
31
+ picture?: string
32
+ error?: string
33
+ }
34
+
35
+ export interface AuthEnv {
36
+ Variables: {
37
+ user: AuthSessionUser
38
+ session: AuthSession
39
+ }
40
+ }
41
+
42
+ export function createAuthHandler(
43
+ workspaceAdminConfig: WorkspaceAdminConfigRepository,
44
+ oauthStates: OAuthStateRepository,
45
+ crypto: CryptoProvider,
46
+ vault: Vault,
47
+ config: {
48
+ platformBaseUrl: string
49
+ },
50
+ ) {
51
+ const app = new Hono<AuthEnv>()
52
+
53
+ app.get('/auth/login', async (c) => {
54
+ const creds = await getSlackLoginCredentials(workspaceAdminConfig, vault)
55
+ if (!creds) {
56
+ return c.redirect('/setup')
57
+ }
58
+
59
+ const state = await crypto.randomHex(16)
60
+ const nonce = await crypto.randomHex(8)
61
+
62
+ await oauthStates.deleteExpired()
63
+ await oauthStates.create({
64
+ state,
65
+ botId: `${LOGIN_STATE_PREFIX}${nonce}`,
66
+ expiresAt: new Date(Date.now() + 10 * 60 * 1000),
67
+ })
68
+
69
+ const params = new URLSearchParams({
70
+ response_type: 'code',
71
+ client_id: creds.clientId,
72
+ scope: 'openid profile email',
73
+ redirect_uri: `${config.platformBaseUrl}/auth/callback`,
74
+ state,
75
+ nonce,
76
+ })
77
+
78
+ return c.redirect(`https://slack.com/openid/connect/authorize?${params.toString()}`)
79
+ })
80
+
81
+ app.get('/auth/callback', async (c) => {
82
+ const error = c.req.query('error')
83
+ if (error) {
84
+ return renderErrorPage(c, 'Slack 로그인 오류', error, 400)
85
+ }
86
+
87
+ const code = c.req.query('code')
88
+ const state = c.req.query('state')
89
+ if (!code || !state) {
90
+ return renderErrorPage(c, '잘못된 요청', 'code/state가 누락됐어요.', 400)
91
+ }
92
+
93
+ await oauthStates.deleteExpired()
94
+ const storedState = await oauthStates.consume(state)
95
+ if (!storedState || !storedState.botId.startsWith(LOGIN_STATE_PREFIX)) {
96
+ return renderErrorPage(c, '잘못된 상태', '로그인 상태가 만료됐거나 유효하지 않아요.', 400)
97
+ }
98
+
99
+ const creds = await getSlackLoginCredentials(workspaceAdminConfig, vault)
100
+ if (!creds) {
101
+ return c.redirect('/setup')
102
+ }
103
+
104
+ const redirectUri = `${config.platformBaseUrl}/auth/callback`
105
+ const tokenRes = await fetch('https://slack.com/api/openid.connect.token', {
106
+ method: 'POST',
107
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
108
+ body: new URLSearchParams({
109
+ client_id: creds.clientId,
110
+ client_secret: creds.clientSecret,
111
+ code,
112
+ redirect_uri: redirectUri,
113
+ grant_type: 'authorization_code',
114
+ }),
115
+ })
116
+ const tokenData = (await tokenRes.json()) as SlackTokenResponse
117
+
118
+ if (!tokenData.ok || !tokenData.access_token) {
119
+ return renderErrorPage(
120
+ c,
121
+ 'Slack 토큰 교환 실패',
122
+ tokenData.error ?? 'unknown_error',
123
+ 400,
124
+ )
125
+ }
126
+
127
+ const userInfoRes = await fetch(
128
+ 'https://slack.com/api/openid.connect.userInfo',
129
+ {
130
+ headers: { Authorization: `Bearer ${tokenData.access_token}` },
131
+ },
132
+ )
133
+ const userInfo = (await userInfoRes.json()) as SlackUserInfoResponse
134
+
135
+ if (!userInfo.ok || !userInfo.sub) {
136
+ return renderErrorPage(
137
+ c,
138
+ 'Slack 사용자 정보 조회 실패',
139
+ userInfo.error ?? 'unknown_error',
140
+ 400,
141
+ )
142
+ }
143
+
144
+ const slackTeamId = userInfo['https://slack.com/team_id'] ?? ''
145
+ if (!slackTeamId) {
146
+ return renderErrorPage(c, 'Slack 팀 정보 누락', 'team_id를 확인할 수 없어요.', 400)
147
+ }
148
+
149
+ const configs = await listConfiguredSlackLoginConfigs(workspaceAdminConfig)
150
+ const configuredTeamIds = getConfiguredTeamIds(configs)
151
+ if (
152
+ configuredTeamIds.length > 0 &&
153
+ !configuredTeamIds.includes(slackTeamId)
154
+ ) {
155
+ return renderErrorPage(
156
+ c,
157
+ '허용되지 않은 워크스페이스',
158
+ `${slackTeamId} 워크스페이스는 이 플랫폼에 접근할 수 없어요.`,
159
+ 403,
160
+ )
161
+ }
162
+
163
+ const defaultConfig = configs.find(
164
+ (cfg) => cfg.workspaceId === DEFAULT_WORKSPACE_ADMIN_CONFIG_ID,
165
+ )
166
+ const teamConfig = configs.find((cfg) => cfg.workspaceId === slackTeamId)
167
+ if (!teamConfig && defaultConfig) {
168
+ await workspaceAdminConfig.upsert({
169
+ workspaceId: slackTeamId,
170
+ slackClientId: defaultConfig.slackClientId,
171
+ slackClientSecretEnc: defaultConfig.slackClientSecretEnc,
172
+ dCookieEnc: defaultConfig.dCookieEnc,
173
+ xoxcTokenEnc: defaultConfig.xoxcTokenEnc,
174
+ workspaceDomain: defaultConfig.workspaceDomain,
175
+ updatedByUserId: defaultConfig.updatedByUserId,
176
+ })
177
+ }
178
+
179
+ const user: AuthSessionUser = {
180
+ slackUserId: userInfo.sub,
181
+ slackTeamId,
182
+ name: userInfo.name ?? 'Slack User',
183
+ email: userInfo.email ?? null,
184
+ avatarUrl: userInfo.picture ?? null,
185
+ }
186
+ const expiresAt = new Date(
187
+ Date.now() + AUTH_SESSION_MAX_AGE_SECONDS * 1000,
188
+ )
189
+ const sessionCookie = await createSessionCookieValue(vault, user, expiresAt)
190
+
191
+ setCookie(c, 'sena_session', sessionCookie, {
192
+ httpOnly: true,
193
+ secure: true,
194
+ sameSite: 'Lax',
195
+ path: '/',
196
+ maxAge: AUTH_SESSION_MAX_AGE_SECONDS,
197
+ })
198
+
199
+ return c.redirect('/')
200
+ })
201
+
202
+ app.post('/auth/logout', (c) => {
203
+ deleteCookie(c, 'sena_session', { path: '/' })
204
+ return c.redirect('/auth/login')
205
+ })
206
+
207
+ return app
208
+ }
209
+
210
+ export function createAuthMiddleware(
211
+ workspaceAdminConfig: WorkspaceAdminConfigRepository,
212
+ vault: Vault,
213
+ ) {
214
+ const requireAuth = async (c: Context, next: Next) => {
215
+ const configs = await listConfiguredSlackLoginConfigs(workspaceAdminConfig)
216
+ if (configs.length === 0) {
217
+ return unauthenticated(c, '/setup', 'slack_login_not_configured', 503)
218
+ }
219
+
220
+ const rawSession = getCookie(c, 'sena_session')
221
+ if (!rawSession) {
222
+ return unauthenticated(c, '/auth/login', 'unauthorized', 401)
223
+ }
224
+
225
+ const session = await parseSessionCookieValue(vault, rawSession)
226
+ if (!session) {
227
+ deleteCookie(c, 'sena_session', { path: '/' })
228
+ return unauthenticated(c, '/auth/login', 'unauthorized', 401)
229
+ }
230
+
231
+ const configuredTeamIds = getConfiguredTeamIds(configs)
232
+ if (
233
+ configuredTeamIds.length > 0 &&
234
+ !configuredTeamIds.includes(session.user.slackTeamId)
235
+ ) {
236
+ deleteCookie(c, 'sena_session', { path: '/' })
237
+ return unauthenticated(c, '/auth/login', 'workspace_forbidden', 403)
238
+ }
239
+
240
+ c.set('user', session.user)
241
+ c.set('session', session)
242
+ await next()
243
+ }
244
+
245
+ return { requireAuth }
246
+ }
247
+
248
+ async function getSlackLoginCredentials(
249
+ workspaceAdminConfig: WorkspaceAdminConfigRepository,
250
+ vault: Vault,
251
+ ): Promise<{ clientId: string; clientSecret: string } | null> {
252
+ const configs = await listConfiguredSlackLoginConfigs(workspaceAdminConfig)
253
+ const preferredConfig =
254
+ configs.find((cfg) => cfg.workspaceId !== DEFAULT_WORKSPACE_ADMIN_CONFIG_ID) ??
255
+ configs[0]
256
+
257
+ if (!preferredConfig?.slackClientId || !preferredConfig.slackClientSecretEnc) {
258
+ return null
259
+ }
260
+
261
+ return {
262
+ clientId: preferredConfig.slackClientId,
263
+ clientSecret: await vault.decrypt(preferredConfig.slackClientSecretEnc),
264
+ }
265
+ }
266
+
267
+ async function listConfiguredSlackLoginConfigs(
268
+ workspaceAdminConfig: WorkspaceAdminConfigRepository,
269
+ ) {
270
+ const configs = await workspaceAdminConfig.findAll()
271
+ return configs.filter(
272
+ (config) =>
273
+ typeof config.slackClientId === 'string' &&
274
+ config.slackClientId.trim().length > 0 &&
275
+ typeof config.slackClientSecretEnc === 'string' &&
276
+ config.slackClientSecretEnc.length > 0,
277
+ )
278
+ }
279
+
280
+ function getConfiguredTeamIds(configs: Array<{ workspaceId: string }>): string[] {
281
+ return configs
282
+ .map((config) => config.workspaceId)
283
+ .filter((workspaceId) => workspaceId !== DEFAULT_WORKSPACE_ADMIN_CONFIG_ID)
284
+ }
285
+
286
+ function unauthenticated(
287
+ c: Context,
288
+ redirectPath: string,
289
+ error: string,
290
+ status: ContentfulStatusCode,
291
+ ): Response | Promise<Response> {
292
+ if (c.req.path.startsWith('/api/')) {
293
+ return c.json({ error, redirectTo: redirectPath }, { status })
294
+ }
295
+
296
+ return c.redirect(redirectPath)
297
+ }
298
+
299
+ function renderErrorPage(
300
+ c: Context,
301
+ title: string,
302
+ message: string,
303
+ status: ContentfulStatusCode,
304
+ ): Response {
305
+ return c.html(
306
+ `<!DOCTYPE html>
307
+ <html lang="ko">
308
+ <head>
309
+ <meta charset="UTF-8">
310
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
311
+ <title>${escapeHtml(title)} - Sena Platform</title>
312
+ <script src="https://cdn.tailwindcss.com"></script>
313
+ </head>
314
+ <body class="bg-gray-50 min-h-screen flex items-center justify-center px-4">
315
+ <main class="w-full max-w-md bg-white rounded-xl shadow-sm border border-gray-200 p-8 text-center">
316
+ <h1 class="text-xl font-bold text-gray-900 mb-2">${escapeHtml(title)}</h1>
317
+ <p class="text-sm text-gray-600 mb-6">${escapeHtml(message)}</p>
318
+ <a href="/auth/login" class="inline-flex items-center px-4 py-2 bg-indigo-600 text-white font-medium rounded-lg hover:bg-indigo-700 transition-colors">다시 로그인</a>
319
+ </main>
320
+ </body>
321
+ </html>`,
322
+ { status },
323
+ )
324
+ }
325
+
326
+ function escapeHtml(value: string): string {
327
+ return value.replace(/[&<>"']/g, (char) => {
328
+ switch (char) {
329
+ case '&':
330
+ return '&amp;'
331
+ case '<':
332
+ return '&lt;'
333
+ case '>':
334
+ return '&gt;'
335
+ case '"':
336
+ return '&quot;'
337
+ case "'":
338
+ return '&#39;'
339
+ default:
340
+ return char
341
+ }
342
+ })
343
+ }
@@ -0,0 +1,89 @@
1
+ import type { Vault } from '../types/vault.js'
2
+
3
+ export interface AuthSessionUser {
4
+ slackUserId: string
5
+ slackTeamId: string
6
+ name: string
7
+ email: string | null
8
+ avatarUrl: string | null
9
+ }
10
+
11
+ export interface AuthSession {
12
+ user: AuthSessionUser
13
+ expiresAt: string
14
+ }
15
+
16
+ export const AUTH_SESSION_MAX_AGE_SECONDS = 30 * 24 * 60 * 60
17
+
18
+ export async function createSessionCookieValue(
19
+ vault: Vault,
20
+ user: AuthSessionUser,
21
+ expiresAt: Date,
22
+ ): Promise<string> {
23
+ const encrypted = await vault.encrypt(
24
+ JSON.stringify({
25
+ user,
26
+ expiresAt: expiresAt.toISOString(),
27
+ } satisfies AuthSession),
28
+ )
29
+
30
+ return toBase64Url(encrypted)
31
+ }
32
+
33
+ export async function parseSessionCookieValue(
34
+ vault: Vault,
35
+ rawCookieValue: string,
36
+ ): Promise<AuthSession | null> {
37
+ try {
38
+ const decrypted = await vault.decrypt(fromBase64Url(rawCookieValue))
39
+ const parsed: unknown = JSON.parse(decrypted)
40
+
41
+ if (!isAuthSession(parsed)) {
42
+ return null
43
+ }
44
+
45
+ const expiresAt = new Date(parsed.expiresAt)
46
+ if (Number.isNaN(expiresAt.getTime()) || expiresAt <= new Date()) {
47
+ return null
48
+ }
49
+
50
+ return {
51
+ user: parsed.user,
52
+ expiresAt: expiresAt.toISOString(),
53
+ }
54
+ } catch {
55
+ return null
56
+ }
57
+ }
58
+
59
+ function isRecord(value: unknown): value is Record<string, unknown> {
60
+ return typeof value === 'object' && value !== null
61
+ }
62
+
63
+ function isAuthSessionUser(value: unknown): value is AuthSessionUser {
64
+ if (!isRecord(value)) return false
65
+
66
+ return (
67
+ typeof value.slackUserId === 'string' &&
68
+ typeof value.slackTeamId === 'string' &&
69
+ typeof value.name === 'string' &&
70
+ (value.email === null || typeof value.email === 'string') &&
71
+ (value.avatarUrl === null || typeof value.avatarUrl === 'string')
72
+ )
73
+ }
74
+
75
+ function isAuthSession(value: unknown): value is AuthSession {
76
+ if (!isRecord(value)) return false
77
+
78
+ return typeof value.expiresAt === 'string' && isAuthSessionUser(value.user)
79
+ }
80
+
81
+ function toBase64Url(base64: string): string {
82
+ return base64.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/u, '')
83
+ }
84
+
85
+ function fromBase64Url(base64Url: string): string {
86
+ const base64 = base64Url.replace(/-/g, '+').replace(/_/g, '/')
87
+ const paddingLength = (4 - (base64.length % 4 || 4)) % 4
88
+ return base64 + '='.repeat(paddingLength)
89
+ }