Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
209 changes: 199 additions & 10 deletions netlify/functions/lib/emulatable/runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,33 @@ const PGLITE_PACKAGE_VERSION = '0.5.3'
const PGLITE_SOCKET_PACKAGE_VERSION = '0.2.6'
const PGLITE_PORT = 55432
const PGLITE_DATABASE_URL = `postgresql://postgres:postgres@127.0.0.1:${PGLITE_PORT}/postgres`
const AUTH_SECRET = 'loopqa_emulated_auth_secret'

const AUTH_CLIENTS = {
google: {
clientId: 'loopqa-google-client.apps.googleusercontent.com',
clientSecret: 'loopqa-google-secret',
},
apple: {
clientId: 'com.loopqa.emulated',
clientSecret: 'loopqa-apple-secret',
teamId: 'TEAM001',
},
microsoft: {
clientId: 'loopqa-microsoft-client',
clientSecret: 'loopqa-microsoft-secret',
},
okta: {
clientId: 'loopqa-okta-client',
clientSecret: 'loopqa-okta-secret',
},
clerk: {
clientId: 'loopqa-clerk-client',
clientSecret: 'loopqa-clerk-secret',
publishableKey: 'pk_test_loopqa_clerk',
secretKey: 'sk_test_loopqa_clerk',
},
} as const

const shq = (s: string) => `'${String(s).replace(/'/g, `'\\''`)}'`
const shBlock = (lines: string[]) => lines.join('\n')
Expand Down Expand Up @@ -45,11 +72,64 @@ const EMULATE_SERVICE_ENV: Record<EmulateService, Record<string, string>> = {
BLOB_READ_WRITE_TOKEN: 'vercel_blob_rw_mystore_secret',
},
github: { GITHUB_EMULATOR_URL: 'http://127.0.0.1:4001' },
google: { GOOGLE_EMULATOR_URL: 'http://127.0.0.1:4002' },
google: {
GOOGLE_EMULATOR_URL: 'http://127.0.0.1:4002',
GOOGLE_ISSUER: 'http://127.0.0.1:4002',
GOOGLE_AUTHORIZATION_URL: 'http://127.0.0.1:4002/o/oauth2/v2/auth',
GOOGLE_TOKEN_URL: 'http://127.0.0.1:4002/oauth2/token',
GOOGLE_USERINFO_URL: 'http://127.0.0.1:4002/oauth2/v2/userinfo',
GOOGLE_CLIENT_ID: AUTH_CLIENTS.google.clientId,
GOOGLE_CLIENT_SECRET: AUTH_CLIENTS.google.clientSecret,
AUTH_GOOGLE_ID: AUTH_CLIENTS.google.clientId,
AUTH_GOOGLE_SECRET: AUTH_CLIENTS.google.clientSecret,
},
slack: { SLACK_EMULATOR_URL: 'http://127.0.0.1:4003' },
apple: { APPLE_EMULATOR_URL: 'http://127.0.0.1:4004' },
microsoft: { MICROSOFT_EMULATOR_URL: 'http://127.0.0.1:4005' },
okta: { OKTA_EMULATOR_URL: 'http://127.0.0.1:4006', OKTA_ISSUER: 'http://127.0.0.1:4006' },
apple: {
APPLE_EMULATOR_URL: 'http://127.0.0.1:4004',
APPLE_ISSUER: 'http://127.0.0.1:4004',
APPLE_AUTHORIZATION_URL: 'http://127.0.0.1:4004/auth/authorize',
APPLE_TOKEN_URL: 'http://127.0.0.1:4004/auth/token',
APPLE_JWKS_URL: 'http://127.0.0.1:4004/auth/keys',
APPLE_CLIENT_ID: AUTH_CLIENTS.apple.clientId,
APPLE_CLIENT_SECRET: AUTH_CLIENTS.apple.clientSecret,
APPLE_TEAM_ID: AUTH_CLIENTS.apple.teamId,
AUTH_APPLE_ID: AUTH_CLIENTS.apple.clientId,
AUTH_APPLE_SECRET: AUTH_CLIENTS.apple.clientSecret,
},
microsoft: {
MICROSOFT_EMULATOR_URL: 'http://127.0.0.1:4005',
MICROSOFT_ISSUER: 'http://127.0.0.1:4005',
MICROSOFT_TENANT_ID: 'common',
MICROSOFT_AUTHORITY: 'http://127.0.0.1:4005',
MICROSOFT_AUTHORIZATION_URL: 'http://127.0.0.1:4005/oauth2/v2.0/authorize',
MICROSOFT_TOKEN_URL: 'http://127.0.0.1:4005/oauth2/v2.0/token',
MICROSOFT_USERINFO_URL: 'http://127.0.0.1:4005/oidc/userinfo',
MICROSOFT_GRAPH_BASE_URL: 'http://127.0.0.1:4005/v1.0',
MICROSOFT_CLIENT_ID: AUTH_CLIENTS.microsoft.clientId,
MICROSOFT_CLIENT_SECRET: AUTH_CLIENTS.microsoft.clientSecret,
AZURE_AD_CLIENT_ID: AUTH_CLIENTS.microsoft.clientId,
AZURE_AD_CLIENT_SECRET: AUTH_CLIENTS.microsoft.clientSecret,
AZURE_AD_ISSUER: 'http://127.0.0.1:4005',
AUTH_MICROSOFT_ENTRA_ID_ID: AUTH_CLIENTS.microsoft.clientId,
AUTH_MICROSOFT_ENTRA_ID_SECRET: AUTH_CLIENTS.microsoft.clientSecret,
},
okta: {
OKTA_EMULATOR_URL: 'http://127.0.0.1:4006',
OKTA_ISSUER: 'http://127.0.0.1:4006/oauth2/default',
OKTA_AUTHORIZATION_URL: 'http://127.0.0.1:4006/oauth2/default/v1/authorize',
OKTA_TOKEN_URL: 'http://127.0.0.1:4006/oauth2/default/v1/token',
OKTA_USERINFO_URL: 'http://127.0.0.1:4006/oauth2/default/v1/userinfo',
OKTA_CLIENT_ID: AUTH_CLIENTS.okta.clientId,
OKTA_CLIENT_SECRET: AUTH_CLIENTS.okta.clientSecret,
AUTH_OKTA_ID: AUTH_CLIENTS.okta.clientId,
AUTH_OKTA_SECRET: AUTH_CLIENTS.okta.clientSecret,
AUTH_OKTA_ISSUER: 'http://127.0.0.1:4006/oauth2/default',
AUTH0_ISSUER_BASE_URL: 'http://127.0.0.1:4006/oauth2/default',
AUTH0_CLIENT_ID: AUTH_CLIENTS.okta.clientId,
AUTH0_CLIENT_SECRET: AUTH_CLIENTS.okta.clientSecret,
AUTH0_AUDIENCE: 'api://default',
AUTH0_SECRET: AUTH_SECRET,
},
aws: { AWS_EMULATOR_URL: 'http://127.0.0.1:4007' },
resend: {
RESEND_EMULATOR_URL: 'http://127.0.0.1:4008',
Expand All @@ -60,7 +140,21 @@ const EMULATE_SERVICE_ENV: Record<EmulateService, Record<string, string>> = {
},
stripe: { STRIPE_API_BASE: 'http://127.0.0.1:4009', STRIPE_SECRET_KEY: 'sk_test_emulate' },
mongoatlas: { MONGOATLAS_EMULATOR_URL: 'http://127.0.0.1:4010', MONGODB_URI: 'mongodb://127.0.0.1:4010/emulate' },
clerk: { CLERK_EMULATOR_URL: 'http://127.0.0.1:4011' },
clerk: {
CLERK_EMULATOR_URL: 'http://127.0.0.1:4011',
CLERK_ISSUER: 'http://127.0.0.1:4011',
CLERK_OIDC_ISSUER: 'http://127.0.0.1:4011',
CLERK_AUTHORIZATION_URL: 'http://127.0.0.1:4011/oauth/authorize',
CLERK_TOKEN_URL: 'http://127.0.0.1:4011/oauth/token',
CLERK_USERINFO_URL: 'http://127.0.0.1:4011/oauth/userinfo',
CLERK_CLIENT_ID: AUTH_CLIENTS.clerk.clientId,
CLERK_CLIENT_SECRET: AUTH_CLIENTS.clerk.clientSecret,
CLERK_PUBLISHABLE_KEY: AUTH_CLIENTS.clerk.publishableKey,
CLERK_SECRET_KEY: AUTH_CLIENTS.clerk.secretKey,
NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY: AUTH_CLIENTS.clerk.publishableKey,
AUTH_CLERK_ID: AUTH_CLIENTS.clerk.clientId,
AUTH_CLERK_SECRET: AUTH_CLIENTS.clerk.clientSecret,
},
linear: { LINEAR_EMULATOR_URL: 'http://127.0.0.1:4012' },
twilio: {
TWILIO_EMULATOR_URL: 'http://127.0.0.1:4013',
Expand Down Expand Up @@ -110,6 +204,94 @@ export function buildEmulatedEmailAccessPrompt(services: EmulateService[]): stri
return notes.join('\n\n')
}

function hasAuthService(services: EmulateService[]): boolean {
return services.some((service) => ['google', 'apple', 'microsoft', 'okta', 'clerk'].includes(service))
}

function callbackUris(appTargetUrl: string, slugs: string[]): string[] {
return slugs.flatMap((slug) => [
`${appTargetUrl}/api/auth/callback/${slug}`,
`${appTargetUrl}/auth/callback/${slug}`,
])
}

function buildAuthServiceSeed(service: EmulateService, appTargetUrl: string): Record<string, unknown> | null {
switch (service) {
case 'google':
return {
oauth_clients: [
{
client_id: AUTH_CLIENTS.google.clientId,
client_secret: AUTH_CLIENTS.google.clientSecret,
name: 'Loop QA Google',
redirect_uris: callbackUris(appTargetUrl, ['google']),
},
],
}
case 'apple':
return {
oauth_clients: [
{
client_id: AUTH_CLIENTS.apple.clientId,
team_id: AUTH_CLIENTS.apple.teamId,
name: 'Loop QA Apple',
redirect_uris: callbackUris(appTargetUrl, ['apple']),
},
],
}
case 'microsoft':
return {
oauth_clients: [
{
client_id: AUTH_CLIENTS.microsoft.clientId,
client_secret: AUTH_CLIENTS.microsoft.clientSecret,
name: 'Loop QA Microsoft',
redirect_uris: callbackUris(appTargetUrl, ['microsoft', 'microsoft-entra-id', 'azure-ad']),
},
],
}
case 'okta':
return {
oauth_clients: [
{
client_id: AUTH_CLIENTS.okta.clientId,
client_secret: AUTH_CLIENTS.okta.clientSecret,
name: 'Loop QA Okta',
redirect_uris: callbackUris(appTargetUrl, ['okta', 'auth0']),
auth_server_id: 'default',
},
],
}
case 'clerk':
return {
oauth_applications: [
{
client_id: AUTH_CLIENTS.clerk.clientId,
client_secret: AUTH_CLIENTS.clerk.clientSecret,
name: 'Loop QA Clerk',
redirect_uris: callbackUris(appTargetUrl, ['clerk']),
},
],
}
default:
return null
}
}

function buildEmulateServiceEnv(service: EmulateService, appTargetUrl: string): Record<string, string> {
const env = { ...EMULATE_SERVICE_ENV[service] }
if (hasAuthService([service])) {
env.AUTH_URL = appTargetUrl
env.NEXTAUTH_URL = appTargetUrl
env.AUTH_SECRET = AUTH_SECRET
env.NEXTAUTH_SECRET = AUTH_SECRET
if (service === 'okta') {
env.AUTH0_BASE_URL = appTargetUrl
}
}
return env
}

function parsePort(value: string | undefined): number | null {
if (!value) return null
const port = Number(value)
Expand Down Expand Up @@ -401,24 +583,31 @@ console.log(JSON.stringify({
/** Bash that clones the app, starts emulate + the app on a local port in the background, and waits for it. */
export function buildEmulateSetup(input: EmulateSetupInput): string {
const emuEnv: Record<string, string> = {}
for (const svc of input.services) {
Object.assign(emuEnv, EMULATE_SERVICE_ENV[svc])
}
const emuExports = Object.entries(emuEnv).map(([k, v]) => `export ${k}=${shq(v)}`).join('; ')
const startupScript = input.startupScript?.trim() || DEFAULT_STARTUP_SCRIPT
const appPort = getEmulatedAppPort(startupScript)
const appTargetUrl = `http://localhost:${appPort}`
for (const svc of input.services) {
Object.assign(emuEnv, buildEmulateServiceEnv(svc, appTargetUrl))
}
const emuExports = Object.entries(emuEnv).map(([k, v]) => `export ${k}=${shq(v)}`).join('; ')
const startupScriptB64 = Buffer.from(startupScript, 'utf8').toString('base64')
const ports = Object.fromEntries(input.services.map((service) => [service, EMULATE_SERVICE_PORT[service]]))
const serviceSeeds = Object.fromEntries(
input.services
.map((service) => [service, buildAuthServiceSeed(service, appTargetUrl)] as const)
.filter((entry): entry is readonly [EmulateService, Record<string, unknown>] => entry[1] !== null),
)
const emulatorScript = `
import { createEmulator } from 'emulate'

const services = ${JSON.stringify(input.services)}
const ports = ${JSON.stringify(ports)}
const serviceSeeds = ${JSON.stringify(serviceSeeds)}
const emulators = []

for (const service of services) {
const emulator = await createEmulator({ service, port: ports[service] })
const seed = serviceSeeds[service] ? { [service]: serviceSeeds[service] } : undefined
const emulator = await createEmulator({ service, port: ports[service], seed })
console.log(\`[emulate] \${service} \${emulator.url}\`)
emulators.push(emulator)
}
Expand Down
25 changes: 25 additions & 0 deletions scripts/check-emulatable-runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -82,3 +82,28 @@ assert.match(twilioPrompt, /use `\+15550002222`/)
assert.match(twilioPrompt, /node \/tmp\/loopqa-twilio-messages\.mjs \+15550002222/)
assert.match(twilioPrompt, /latest local Verify code/)
assert.equal(buildEmulatedEmailAccessPrompt(['stripe']), '')

const authSetup = buildEmulateSetup({
repoUrl: 'https://github.com/BLamy/GrooveCart',
services: ['google', 'apple', 'microsoft', 'okta', 'clerk'],
startupScript: netlifyStartupScript,
})

assert.match(authSetup, /GOOGLE_CLIENT_ID='loopqa-google-client\.apps\.googleusercontent\.com'/)
assert.match(authSetup, /APPLE_CLIENT_ID='com\.loopqa\.emulated'/)
assert.match(authSetup, /MICROSOFT_CLIENT_ID='loopqa-microsoft-client'/)
assert.match(authSetup, /OKTA_ISSUER='http:\/\/127\.0\.0\.1:4006\/oauth2\/default'/)
assert.match(authSetup, /AUTH0_ISSUER_BASE_URL='http:\/\/127\.0\.0\.1:4006\/oauth2\/default'/)
assert.match(authSetup, /CLERK_CLIENT_ID='loopqa-clerk-client'/)
assert.match(authSetup, /NEXTAUTH_URL='http:\/\/localhost:18080'/)
assert.match(authSetup, /AUTH_SECRET='loopqa_emulated_auth_secret'/)
assert.match(authSetup, /loopqa-google-client\.apps\.googleusercontent\.com/)

const emulatorScriptMatch = /printf %s '([^']+)' \| base64 -d > \/tmp\/loopqa-emulators\/start-emulators\.mjs/.exec(authSetup)
assert.ok(emulatorScriptMatch?.[1], 'emulator script is embedded as base64')
const emulatorScript = Buffer.from(emulatorScriptMatch[1], 'base64').toString('utf8')
assert.match(emulatorScript, /serviceSeeds\[service\] \? \{ \[service\]: serviceSeeds\[service\] \} : undefined/)
assert.match(emulatorScript, /http:\/\/localhost:18080\/api\/auth\/callback\/google/)
assert.match(emulatorScript, /http:\/\/localhost:18080\/api\/auth\/callback\/microsoft-entra-id/)
assert.match(emulatorScript, /http:\/\/localhost:18080\/api\/auth\/callback\/auth0/)
assert.match(emulatorScript, /http:\/\/localhost:18080\/api\/auth\/callback\/clerk/)
Loading