sub-bridge 1.0.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/.cursor/commands/mcp-only.md +1 -0
- package/.github/workflows/npm-publish.yml +33 -0
- package/.github/workflows/pages.yml +40 -0
- package/.github/workflows/release-please.yml +21 -0
- package/.release-please-manifest.json +3 -0
- package/CHANGELOG.md +8 -0
- package/DEVELOPMENT.md +31 -0
- package/LICENSE +21 -0
- package/README.md +87 -0
- package/api/index.ts +12 -0
- package/bun.lock +102 -0
- package/dist/auth/oauth-flow.d.ts +24 -0
- package/dist/auth/oauth-flow.d.ts.map +1 -0
- package/dist/auth/oauth-flow.js +184 -0
- package/dist/auth/oauth-flow.js.map +1 -0
- package/dist/auth/oauth-manager.d.ts +13 -0
- package/dist/auth/oauth-manager.d.ts.map +1 -0
- package/dist/auth/oauth-manager.js +25 -0
- package/dist/auth/oauth-manager.js.map +1 -0
- package/dist/auth/provider.d.ts +42 -0
- package/dist/auth/provider.d.ts.map +1 -0
- package/dist/auth/provider.js +270 -0
- package/dist/auth/provider.js.map +1 -0
- package/dist/cli.d.ts +3 -0
- package/dist/cli.d.ts.map +1 -0
- package/dist/cli.js +91 -0
- package/dist/cli.js.map +1 -0
- package/dist/mcp/proxy.d.ts +16 -0
- package/dist/mcp/proxy.d.ts.map +1 -0
- package/dist/mcp/proxy.js +85 -0
- package/dist/mcp/proxy.js.map +1 -0
- package/dist/mcp.d.ts +3 -0
- package/dist/mcp.d.ts.map +1 -0
- package/dist/mcp.js +50 -0
- package/dist/mcp.js.map +1 -0
- package/dist/routes/auth.d.ts +6 -0
- package/dist/routes/auth.d.ts.map +1 -0
- package/dist/routes/auth.js +149 -0
- package/dist/routes/auth.js.map +1 -0
- package/dist/routes/chat.d.ts +6 -0
- package/dist/routes/chat.d.ts.map +1 -0
- package/dist/routes/chat.js +808 -0
- package/dist/routes/chat.js.map +1 -0
- package/dist/routes/tunnels.d.ts +7 -0
- package/dist/routes/tunnels.d.ts.map +1 -0
- package/dist/routes/tunnels.js +44 -0
- package/dist/routes/tunnels.js.map +1 -0
- package/dist/server.d.ts +25 -0
- package/dist/server.d.ts.map +1 -0
- package/dist/server.js +157 -0
- package/dist/server.js.map +1 -0
- package/dist/tunnel/providers/cloudflare.d.ts +9 -0
- package/dist/tunnel/providers/cloudflare.d.ts.map +1 -0
- package/dist/tunnel/providers/cloudflare.js +47 -0
- package/dist/tunnel/providers/cloudflare.js.map +1 -0
- package/dist/tunnel/providers/index.d.ts +4 -0
- package/dist/tunnel/providers/index.d.ts.map +1 -0
- package/dist/tunnel/providers/index.js +13 -0
- package/dist/tunnel/providers/index.js.map +1 -0
- package/dist/tunnel/providers/ngrok.d.ts +10 -0
- package/dist/tunnel/providers/ngrok.d.ts.map +1 -0
- package/dist/tunnel/providers/ngrok.js +52 -0
- package/dist/tunnel/providers/ngrok.js.map +1 -0
- package/dist/tunnel/providers/tailscale.d.ts +10 -0
- package/dist/tunnel/providers/tailscale.d.ts.map +1 -0
- package/dist/tunnel/providers/tailscale.js +48 -0
- package/dist/tunnel/providers/tailscale.js.map +1 -0
- package/dist/tunnel/registry.d.ts +14 -0
- package/dist/tunnel/registry.d.ts.map +1 -0
- package/dist/tunnel/registry.js +86 -0
- package/dist/tunnel/registry.js.map +1 -0
- package/dist/tunnel/types.d.ts +26 -0
- package/dist/tunnel/types.d.ts.map +1 -0
- package/dist/tunnel/types.js +6 -0
- package/dist/tunnel/types.js.map +1 -0
- package/dist/tunnel/utils.d.ts +18 -0
- package/dist/tunnel/utils.d.ts.map +1 -0
- package/dist/tunnel/utils.js +57 -0
- package/dist/tunnel/utils.js.map +1 -0
- package/dist/types.d.ts +52 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +4 -0
- package/dist/types.js.map +1 -0
- package/dist/utils/anthropic-to-openai-converter.d.ts +103 -0
- package/dist/utils/anthropic-to-openai-converter.d.ts.map +1 -0
- package/dist/utils/anthropic-to-openai-converter.js +376 -0
- package/dist/utils/anthropic-to-openai-converter.js.map +1 -0
- package/dist/utils/chat-to-responses.d.ts +59 -0
- package/dist/utils/chat-to-responses.d.ts.map +1 -0
- package/dist/utils/chat-to-responses.js +395 -0
- package/dist/utils/chat-to-responses.js.map +1 -0
- package/dist/utils/chatgpt-instructions.d.ts +3 -0
- package/dist/utils/chatgpt-instructions.d.ts.map +1 -0
- package/dist/utils/chatgpt-instructions.js +12 -0
- package/dist/utils/chatgpt-instructions.js.map +1 -0
- package/dist/utils/cli-args.d.ts +3 -0
- package/dist/utils/cli-args.d.ts.map +1 -0
- package/dist/utils/cli-args.js +10 -0
- package/dist/utils/cli-args.js.map +1 -0
- package/dist/utils/cors-bypass.d.ts +4 -0
- package/dist/utils/cors-bypass.d.ts.map +1 -0
- package/dist/utils/cors-bypass.js +30 -0
- package/dist/utils/cors-bypass.js.map +1 -0
- package/dist/utils/cursor-byok-bypass.d.ts +37 -0
- package/dist/utils/cursor-byok-bypass.d.ts.map +1 -0
- package/dist/utils/cursor-byok-bypass.js +53 -0
- package/dist/utils/cursor-byok-bypass.js.map +1 -0
- package/dist/utils/logger.d.ts +19 -0
- package/dist/utils/logger.d.ts.map +1 -0
- package/dist/utils/logger.js +192 -0
- package/dist/utils/logger.js.map +1 -0
- package/dist/utils/port.d.ts +27 -0
- package/dist/utils/port.d.ts.map +1 -0
- package/dist/utils/port.js +78 -0
- package/dist/utils/port.js.map +1 -0
- package/dist/utils/setup-instructions.d.ts +10 -0
- package/dist/utils/setup-instructions.d.ts.map +1 -0
- package/dist/utils/setup-instructions.js +49 -0
- package/dist/utils/setup-instructions.js.map +1 -0
- package/env.example +25 -0
- package/index.html +992 -0
- package/package.json +57 -0
- package/public/.nojekyll +0 -0
- package/public/assets/chat.png +0 -0
- package/public/assets/demo.gif +0 -0
- package/public/assets/demo.mp4 +0 -0
- package/public/assets/setup.png +0 -0
- package/public/assets/ui.png +0 -0
- package/public/index.html +292 -0
- package/release-please-config.json +10 -0
- package/src/auth/provider.ts +412 -0
- package/src/cli.ts +97 -0
- package/src/mcp/proxy.ts +64 -0
- package/src/mcp.ts +56 -0
- package/src/oauth/authorize.ts +270 -0
- package/src/oauth/crypto.ts +198 -0
- package/src/oauth/dcr.ts +129 -0
- package/src/oauth/metadata.ts +40 -0
- package/src/oauth/token.ts +173 -0
- package/src/routes/auth.ts +149 -0
- package/src/routes/chat.ts +983 -0
- package/src/routes/oauth.ts +220 -0
- package/src/routes/tunnels.ts +45 -0
- package/src/server.ts +204 -0
- package/src/tunnel/providers/cloudflare.ts +50 -0
- package/src/tunnel/providers/index.ts +7 -0
- package/src/tunnel/providers/ngrok.ts +56 -0
- package/src/tunnel/providers/tailscale.ts +50 -0
- package/src/tunnel/registry.ts +96 -0
- package/src/tunnel/types.ts +32 -0
- package/src/tunnel/utils.ts +59 -0
- package/src/types.ts +55 -0
- package/src/utils/anthropic-to-openai-converter.ts +578 -0
- package/src/utils/chat-to-responses.ts +512 -0
- package/src/utils/chatgpt-instructions.ts +7 -0
- package/src/utils/cli-args.ts +8 -0
- package/src/utils/cors-bypass.ts +39 -0
- package/src/utils/cursor-byok-bypass.ts +56 -0
- package/src/utils/logger.ts +174 -0
- package/src/utils/port.ts +99 -0
- package/src/utils/setup-instructions.ts +59 -0
- package/tsconfig.json +22 -0
- package/vercel.json +20 -0
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* OAuth Token Endpoint
|
|
3
|
+
*
|
|
4
|
+
* Handles token exchange and issues encrypted access tokens
|
|
5
|
+
* containing upstream provider credentials.
|
|
6
|
+
*/
|
|
7
|
+
import { createAccessToken, type SubBridgeTokenPayload } from './crypto'
|
|
8
|
+
import { validateClient } from './dcr'
|
|
9
|
+
import { exchangeAuthorizationCode, type AuthorizationCode } from './authorize'
|
|
10
|
+
|
|
11
|
+
// ============================================================================
|
|
12
|
+
// Types
|
|
13
|
+
// ============================================================================
|
|
14
|
+
|
|
15
|
+
export interface TokenRequest {
|
|
16
|
+
grant_type: string
|
|
17
|
+
code?: string
|
|
18
|
+
redirect_uri?: string
|
|
19
|
+
client_id?: string
|
|
20
|
+
client_secret?: string
|
|
21
|
+
code_verifier?: string
|
|
22
|
+
refresh_token?: string
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface TokenResponse {
|
|
26
|
+
access_token: string
|
|
27
|
+
token_type: 'Bearer'
|
|
28
|
+
expires_in: number
|
|
29
|
+
refresh_token?: string
|
|
30
|
+
scope?: string
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface TokenError {
|
|
34
|
+
error: string
|
|
35
|
+
error_description: string
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// ============================================================================
|
|
39
|
+
// Token Generation
|
|
40
|
+
// ============================================================================
|
|
41
|
+
|
|
42
|
+
const ACCESS_TOKEN_TTL_SECONDS = 3600 * 24 * 7 // 7 days
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Process a token request and return an access token or error.
|
|
46
|
+
*/
|
|
47
|
+
export function processTokenRequest(
|
|
48
|
+
request: TokenRequest
|
|
49
|
+
): TokenResponse | TokenError {
|
|
50
|
+
// Validate grant_type
|
|
51
|
+
if (request.grant_type === 'authorization_code') {
|
|
52
|
+
return handleAuthorizationCodeGrant(request)
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
if (request.grant_type === 'refresh_token') {
|
|
56
|
+
return handleRefreshTokenGrant(request)
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
return {
|
|
60
|
+
error: 'unsupported_grant_type',
|
|
61
|
+
error_description: 'Only authorization_code and refresh_token grants are supported',
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Handle authorization_code grant type.
|
|
67
|
+
*/
|
|
68
|
+
function handleAuthorizationCodeGrant(
|
|
69
|
+
request: TokenRequest
|
|
70
|
+
): TokenResponse | TokenError {
|
|
71
|
+
// Validate required parameters
|
|
72
|
+
if (!request.code) {
|
|
73
|
+
return {
|
|
74
|
+
error: 'invalid_request',
|
|
75
|
+
error_description: 'Missing authorization code',
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
if (!request.client_id) {
|
|
80
|
+
return {
|
|
81
|
+
error: 'invalid_request',
|
|
82
|
+
error_description: 'Missing client_id',
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
if (!request.redirect_uri) {
|
|
87
|
+
return {
|
|
88
|
+
error: 'invalid_request',
|
|
89
|
+
error_description: 'Missing redirect_uri',
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// Validate client
|
|
94
|
+
const client = validateClient(request.client_id, request.client_secret)
|
|
95
|
+
if (!client) {
|
|
96
|
+
return {
|
|
97
|
+
error: 'invalid_client',
|
|
98
|
+
error_description: 'Client authentication failed',
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// Exchange authorization code
|
|
103
|
+
const codeResult = exchangeAuthorizationCode(
|
|
104
|
+
request.code,
|
|
105
|
+
request.client_id,
|
|
106
|
+
request.redirect_uri,
|
|
107
|
+
request.code_verifier
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
if ('error' in codeResult) {
|
|
111
|
+
return {
|
|
112
|
+
error: codeResult.error,
|
|
113
|
+
error_description: codeResult.errorDescription,
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// Create access token
|
|
118
|
+
return createTokenResponse(codeResult, request.client_id)
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Handle refresh_token grant type.
|
|
123
|
+
* For now, we don't support refresh tokens on the Sub Bridge side.
|
|
124
|
+
* Users need to re-authenticate when tokens expire.
|
|
125
|
+
*/
|
|
126
|
+
function handleRefreshTokenGrant(
|
|
127
|
+
_request: TokenRequest
|
|
128
|
+
): TokenResponse | TokenError {
|
|
129
|
+
// TODO: Implement refresh token support
|
|
130
|
+
// This would require storing refresh tokens securely
|
|
131
|
+
return {
|
|
132
|
+
error: 'unsupported_grant_type',
|
|
133
|
+
error_description: 'Refresh tokens are not yet supported. Please re-authenticate.',
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Create a token response from an authorization code.
|
|
139
|
+
*/
|
|
140
|
+
function createTokenResponse(
|
|
141
|
+
authCode: AuthorizationCode,
|
|
142
|
+
clientId: string
|
|
143
|
+
): TokenResponse {
|
|
144
|
+
const now = Math.floor(Date.now() / 1000)
|
|
145
|
+
const expiresAt = now + ACCESS_TOKEN_TTL_SECONDS
|
|
146
|
+
|
|
147
|
+
const payload: SubBridgeTokenPayload = {
|
|
148
|
+
iss: 'sub-bridge',
|
|
149
|
+
sub: authCode.userId,
|
|
150
|
+
aud: clientId,
|
|
151
|
+
exp: expiresAt,
|
|
152
|
+
iat: now,
|
|
153
|
+
providers: authCode.providers,
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
const accessToken = createAccessToken(payload)
|
|
157
|
+
|
|
158
|
+
return {
|
|
159
|
+
access_token: accessToken,
|
|
160
|
+
token_type: 'Bearer',
|
|
161
|
+
expires_in: ACCESS_TOKEN_TTL_SECONDS,
|
|
162
|
+
scope: 'openid profile',
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Check if a response is an error.
|
|
168
|
+
*/
|
|
169
|
+
export function isTokenError(
|
|
170
|
+
response: TokenResponse | TokenError
|
|
171
|
+
): response is TokenError {
|
|
172
|
+
return 'error' in response
|
|
173
|
+
}
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Authentication routes for Claude and OpenAI OAuth
|
|
3
|
+
*/
|
|
4
|
+
import { Hono } from 'hono'
|
|
5
|
+
import { claudeProvider, openaiProvider, type AuthSession } from '../auth/provider'
|
|
6
|
+
|
|
7
|
+
// Auth session storage
|
|
8
|
+
const AUTH_SESSION_TTL_MS = 15 * 60 * 1000
|
|
9
|
+
const authSessions = new Map<string, AuthSession & { createdAt: number }>()
|
|
10
|
+
|
|
11
|
+
function storeAuthSession(session: AuthSession) {
|
|
12
|
+
authSessions.set(session.sessionId, { ...session, createdAt: Date.now() })
|
|
13
|
+
const now = Date.now()
|
|
14
|
+
for (const [id, s] of authSessions.entries()) {
|
|
15
|
+
if (now - s.createdAt > AUTH_SESSION_TTL_MS) {
|
|
16
|
+
authSessions.delete(id)
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function getAuthSession(sessionId: string): (AuthSession & { createdAt: number }) | null {
|
|
22
|
+
const session = authSessions.get(sessionId)
|
|
23
|
+
if (!session) return null
|
|
24
|
+
if (Date.now() - session.createdAt > AUTH_SESSION_TTL_MS) {
|
|
25
|
+
authSessions.delete(sessionId)
|
|
26
|
+
return null
|
|
27
|
+
}
|
|
28
|
+
return session
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function createAuthRoutes() {
|
|
32
|
+
const app = new Hono()
|
|
33
|
+
|
|
34
|
+
// OpenAI: Redirect to auth page
|
|
35
|
+
app.get('/openai', async (c) => {
|
|
36
|
+
try {
|
|
37
|
+
return c.redirect(openaiProvider.getAuthUrl())
|
|
38
|
+
} catch (error) {
|
|
39
|
+
const msg = error instanceof Error ? error.message : 'Unknown error'
|
|
40
|
+
return c.text(`Error: ${msg}`, 500)
|
|
41
|
+
}
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
// OpenAI: Debug device code endpoint
|
|
45
|
+
app.get('/openai/device-code', async (c) => {
|
|
46
|
+
try {
|
|
47
|
+
const session = await openaiProvider.startAuth()
|
|
48
|
+
storeAuthSession(session)
|
|
49
|
+
return c.json({
|
|
50
|
+
sessionId: session.sessionId,
|
|
51
|
+
userCode: session.userCode,
|
|
52
|
+
authUrl: openaiProvider.getAuthUrl(),
|
|
53
|
+
expiresAt: session.expiresAt,
|
|
54
|
+
})
|
|
55
|
+
} catch (error) {
|
|
56
|
+
const msg = error instanceof Error ? error.message : 'Unknown error'
|
|
57
|
+
return c.json({ error: msg }, 500)
|
|
58
|
+
}
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
// Claude: Redirect to auth page
|
|
62
|
+
app.get('/claude', async (c) => {
|
|
63
|
+
try {
|
|
64
|
+
const session = await claudeProvider.startAuth()
|
|
65
|
+
storeAuthSession(session)
|
|
66
|
+
return c.redirect(session.authUrl)
|
|
67
|
+
} catch (error) {
|
|
68
|
+
const msg = error instanceof Error ? error.message : 'Unknown error'
|
|
69
|
+
return c.text(`Error: ${msg}`, 500)
|
|
70
|
+
}
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
// Claude: Start auth
|
|
74
|
+
app.post('/claude/start', async (c) => {
|
|
75
|
+
try {
|
|
76
|
+
const session = await claudeProvider.startAuth()
|
|
77
|
+
storeAuthSession(session)
|
|
78
|
+
return c.redirect(session.authUrl)
|
|
79
|
+
} catch (error) {
|
|
80
|
+
const msg = error instanceof Error ? error.message : 'Unknown error'
|
|
81
|
+
return c.text(`Error: ${msg}`, 500)
|
|
82
|
+
}
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
// Claude: Complete auth (returns JSON)
|
|
86
|
+
app.post('/claude/complete', async (c) => {
|
|
87
|
+
try {
|
|
88
|
+
const body = await c.req.json().catch(() => ({})) as { code?: string; sessionId?: string }
|
|
89
|
+
const codeInput = body.code?.trim() || ''
|
|
90
|
+
const sessionId = body.sessionId || codeInput.split('#')[1]
|
|
91
|
+
if (!codeInput) {
|
|
92
|
+
return c.json({ success: false, error: 'Missing code' })
|
|
93
|
+
}
|
|
94
|
+
if (!sessionId) {
|
|
95
|
+
return c.json({ success: false, error: 'Missing state. Paste CODE#STATE.' })
|
|
96
|
+
}
|
|
97
|
+
const result = await claudeProvider.completeAuth(codeInput, sessionId)
|
|
98
|
+
return c.json({ success: true, accessToken: result.accessToken })
|
|
99
|
+
} catch (error) {
|
|
100
|
+
const msg = error instanceof Error ? error.message : 'Unknown error'
|
|
101
|
+
return c.json({ success: false, error: msg })
|
|
102
|
+
}
|
|
103
|
+
})
|
|
104
|
+
|
|
105
|
+
// OpenAI: Start auth (returns JSON)
|
|
106
|
+
app.post('/openai/start', async (c) => {
|
|
107
|
+
try {
|
|
108
|
+
const session = await openaiProvider.startAuth()
|
|
109
|
+
storeAuthSession(session)
|
|
110
|
+
return c.json({
|
|
111
|
+
sessionId: session.sessionId,
|
|
112
|
+
userCode: session.userCode,
|
|
113
|
+
authUrl: openaiProvider.getAuthUrl(),
|
|
114
|
+
})
|
|
115
|
+
} catch (error) {
|
|
116
|
+
const msg = error instanceof Error ? error.message : 'Unknown error'
|
|
117
|
+
return c.json({ error: msg }, 500)
|
|
118
|
+
}
|
|
119
|
+
})
|
|
120
|
+
|
|
121
|
+
// OpenAI: Poll for auth (returns JSON)
|
|
122
|
+
app.post('/openai/poll', async (c) => {
|
|
123
|
+
try {
|
|
124
|
+
const body = await c.req.json().catch(() => ({})) as { sessionId?: string }
|
|
125
|
+
const sessionId = body.sessionId
|
|
126
|
+
if (!sessionId) {
|
|
127
|
+
return c.json({ status: 'error', error: 'Missing session' })
|
|
128
|
+
}
|
|
129
|
+
const session = getAuthSession(sessionId)
|
|
130
|
+
if (!session) {
|
|
131
|
+
return c.json({ status: 'error', error: 'Session expired' })
|
|
132
|
+
}
|
|
133
|
+
const result = await openaiProvider.completeAuth('', sessionId)
|
|
134
|
+
return c.json({
|
|
135
|
+
status: 'success',
|
|
136
|
+
accessToken: result.accessToken,
|
|
137
|
+
accountId: result.accountId,
|
|
138
|
+
})
|
|
139
|
+
} catch (error) {
|
|
140
|
+
const msg = error instanceof Error ? error.message : 'Unknown error'
|
|
141
|
+
if (msg === 'PENDING') {
|
|
142
|
+
return c.json({ status: 'pending' })
|
|
143
|
+
}
|
|
144
|
+
return c.json({ status: 'error', error: msg })
|
|
145
|
+
}
|
|
146
|
+
})
|
|
147
|
+
|
|
148
|
+
return app
|
|
149
|
+
}
|