diff --git a/functions/send-sms/__tests__/handler.test.ts b/functions/send-sms/__tests__/handler.test.ts new file mode 100644 index 0000000..8e45fe2 --- /dev/null +++ b/functions/send-sms/__tests__/handler.test.ts @@ -0,0 +1,199 @@ +import handler from '../handler'; + +jest.mock('twilio', () => { + const mockCreate = jest.fn(); + return jest.fn(() => ({ + messages: { create: mockCreate }, + })); +}); + +import twilio from 'twilio'; +const mockTwilioCreate = (twilio as jest.Mock)().messages.create; + +const mockLog = { + info: jest.fn(), + error: jest.fn(), +}; + +const mockClient = { + request: jest.fn(), +}; + +const mockMeta = { + request: jest.fn(), +}; + +const createContext = (overrides = {}) => ({ + client: mockClient as any, + meta: mockMeta as any, + job: { + jobId: 'test-job-1', + workerId: 'test-worker', + databaseId: 'test-database-id', + actorId: null, + }, + log: mockLog, + env: { + SMS_PROVIDER: 'stub', + SEND_SMS_DRY_RUN: 'false', + ...overrides, + }, +}); + +describe('send-sms handler', () => { + beforeEach(() => { + jest.clearAllMocks(); + + mockMeta.request.mockResolvedValue({ + databases: { + nodes: [ + { + sites: { + nodes: [ + { + title: 'Test App', + siteModules: { + nodes: [ + { + data: { + company: { nick: 'TestApp' }, + }, + }, + ], + }, + }, + ], + }, + }, + ], + }, + }); + }); + + it('should return error when sms_type is missing', async () => { + const context = createContext(); + const params = { phone_number: '+1234567890', otp_code: '123456' } as any; + + await expect(handler(params, context)).rejects.toThrow('Missing required field: sms_type'); + }); + + it('should return error when phone_number is missing', async () => { + const context = createContext(); + const params = { sms_type: 'sign_in_sms_otp', otp_code: '123456' } as any; + + await expect(handler(params, context)).rejects.toThrow('Missing required field: phone_number'); + }); + + it('should return error when otp_code is missing', async () => { + const context = createContext(); + const params = { sms_type: 'sign_in_sms_otp', phone_number: '+1234567890' } as any; + + await expect(handler(params, context)).rejects.toThrow('Missing required field: otp_code'); + }); + + it('should send SMS successfully with stub provider', async () => { + const context = createContext(); + const params = { + sms_type: 'sign_in_sms_otp' as const, + phone_number: '+1234567890', + otp_code: '123456', + }; + + const result = await handler(params, context); + + expect(result).toEqual({ complete: true }); + expect(mockLog.info).toHaveBeenCalledWith( + '[send-sms] Processing request', + expect.any(Object) + ); + }); + + it('should handle dry run mode', async () => { + const context = createContext({ SEND_SMS_DRY_RUN: 'true' }); + const params = { + sms_type: 'sign_in_sms_otp' as const, + phone_number: '+1234567890', + otp_code: '123456', + }; + + const result = await handler(params, context); + + expect(result).toEqual({ complete: true, dryRun: true }); + expect(mockLog.info).toHaveBeenCalledWith( + '[send-sms] DRY RUN - SMS not sent', + expect.any(Object) + ); + }); + + it('should return error when databaseId is missing', async () => { + const context = { + ...createContext(), + job: { jobId: 'test', workerId: 'test', databaseId: null, actorId: null }, + }; + const params = { + sms_type: 'sign_in_sms_otp' as const, + phone_number: '+1234567890', + otp_code: '123456', + }; + + const result = await handler(params, context as any); + + expect(result).toEqual({ error: 'Missing X-Database-Id header or DEFAULT_DATABASE_ID' }); + }); + + it('should send SMS via Twilio when configured', async () => { + mockTwilioCreate.mockResolvedValue({ sid: 'SM123456789' }); + + const context = createContext({ + SMS_PROVIDER: 'twilio', + TWILIO_ACCOUNT_SID: 'ACtest123', + TWILIO_AUTH_TOKEN: 'token123', + TWILIO_FROM_NUMBER: '+15551234567', + }); + const params = { + sms_type: 'sign_in_sms_otp' as const, + phone_number: '+1234567890', + otp_code: '123456', + }; + + const result = await handler(params, context); + + expect(result).toEqual({ complete: true }); + expect(mockLog.info).toHaveBeenCalledWith( + '[send-sms] SMS sent via Twilio', + expect.objectContaining({ messageId: 'SM123456789' }) + ); + }); + + it('should return error when Twilio credentials are missing', async () => { + const context = createContext({ + SMS_PROVIDER: 'twilio', + // Missing credentials + }); + const params = { + sms_type: 'sign_in_sms_otp' as const, + phone_number: '+1234567890', + otp_code: '123456', + }; + + await expect(handler(params, context)).rejects.toThrow('Twilio credentials not configured'); + }); + + it('should handle Twilio API errors gracefully', async () => { + mockTwilioCreate.mockRejectedValue(new Error('Invalid phone number')); + + const context = createContext({ + SMS_PROVIDER: 'twilio', + TWILIO_ACCOUNT_SID: 'ACtest123', + TWILIO_AUTH_TOKEN: 'token123', + TWILIO_FROM_NUMBER: '+15551234567', + }); + const params = { + sms_type: 'sign_in_sms_otp' as const, + phone_number: 'invalid', + otp_code: '123456', + }; + + await expect(handler(params, context)).rejects.toThrow('Invalid phone number'); + }); +}); diff --git a/functions/send-sms/handler.json b/functions/send-sms/handler.json new file mode 100644 index 0000000..fa1b205 --- /dev/null +++ b/functions/send-sms/handler.json @@ -0,0 +1,15 @@ +{ + "name": "send-sms", + "version": "1.0.0", + "type": "node-graphql", + "port": 8084, + "taskIdentifier": "sms:send_verification_code", + "description": "Sends SMS verification codes for OTP sign-in/sign-up and MFA", + "dependencies": { + "@pgpmjs/env": "^2.15.3", + "@pgpmjs/logger": "^2.4.3", + "graphql-request": "^7.1.2", + "graphql-tag": "^2.12.6", + "twilio": "^5.5.0" + } +} diff --git a/functions/send-sms/handler.ts b/functions/send-sms/handler.ts new file mode 100644 index 0000000..f80e2a4 --- /dev/null +++ b/functions/send-sms/handler.ts @@ -0,0 +1,261 @@ +import type { FunctionHandler } from '@constructive-io/fn-runtime'; +import { parseEnvBoolean } from '@pgpmjs/env'; +import type { GraphQLClient } from 'graphql-request'; +import gql from 'graphql-tag'; +import twilio from 'twilio'; + +const GetUser = gql` + query GetUser($userId: UUID!) { + users(where: { id: { equalTo: $userId } }, first: 1) { + nodes { + username + displayName + } + } + } +`; + +const GetDatabaseInfo = gql` + query GetDatabaseInfo($databaseId: UUID!) { + databases(where: { id: { equalTo: $databaseId } }, first: 1) { + nodes { + sites { + nodes { + title + siteModules(where: { name: { equalTo: "legal_terms_module" } }) { + nodes { + data + } + } + } + } + } + } + } +`; + +type SendSmsParams = { + sms_type: + | 'sign_in_sms_otp' + | 'sign_up_sms' + | 'enable_sms_mfa' + | 'mfa_challenge_sms' + | 'phone_verification' + // DB procedure naming (for compatibility) + | 'sms_otp_code' + | 'mfa_verification_code'; + phone_number: string; + user_id?: string; + otp_code?: string; + code?: string; + expires_in_minutes?: number; +}; + +type SendSmsContext = { + client: GraphQLClient; + meta: GraphQLClient; + databaseId: string; + env: Record; + log: { info: (...args: any[]) => void; error: (...args: any[]) => void }; +}; + +type SmsProvider = 'twilio' | 'aws_sns' | 'vonage' | 'stub'; + +interface SmsProviderConfig { + provider: SmsProvider; + twilio?: { + accountSid: string; + authToken: string; + fromNumber: string; + }; + awsSns?: { + region: string; + accessKeyId: string; + secretAccessKey: string; + }; + vonage?: { + apiKey: string; + apiSecret: string; + fromNumber: string; + }; +} + +const getProviderConfig = (env: Record): SmsProviderConfig => { + const provider = (env.SMS_PROVIDER || 'stub') as SmsProvider; + + switch (provider) { + case 'twilio': + return { + provider, + twilio: { + accountSid: env.TWILIO_ACCOUNT_SID || '', + authToken: env.TWILIO_AUTH_TOKEN || '', + fromNumber: env.TWILIO_FROM_NUMBER || '', + }, + }; + case 'aws_sns': + return { + provider, + awsSns: { + region: env.AWS_REGION || 'us-east-1', + accessKeyId: env.AWS_ACCESS_KEY_ID || '', + secretAccessKey: env.AWS_SECRET_ACCESS_KEY || '', + }, + }; + case 'vonage': + return { + provider, + vonage: { + apiKey: env.VONAGE_API_KEY || '', + apiSecret: env.VONAGE_API_SECRET || '', + fromNumber: env.VONAGE_FROM_NUMBER || '', + }, + }; + default: + return { provider: 'stub' }; + } +}; + +const sendSmsViaProvider = async ( + to: string, + message: string, + config: SmsProviderConfig, + log: SendSmsContext['log'] +): Promise<{ success: boolean; messageId?: string; error?: string }> => { + switch (config.provider) { + case 'twilio': { + if (!config.twilio?.accountSid || !config.twilio?.authToken || !config.twilio?.fromNumber) { + return { success: false, error: 'Twilio credentials not configured' }; + } + try { + const client = twilio(config.twilio.accountSid, config.twilio.authToken); + const result = await client.messages.create({ + body: message, + from: config.twilio.fromNumber, + to, + }); + log.info('[send-sms] SMS sent via Twilio', { to, messageId: result.sid }); + return { success: true, messageId: result.sid }; + } catch (err: any) { + log.error('[send-sms] Twilio error', { to, error: err.message }); + return { success: false, error: err.message || 'Twilio send failed' }; + } + } + + case 'aws_sns': { + // TODO: Implement AWS SNS integration + // const sns = new SNSClient({ region: config.awsSns.region, credentials: {...} }); + // const result = await sns.send(new PublishCommand({ PhoneNumber: to, Message: message })); + log.info('[send-sms] AWS SNS provider not yet implemented', { to }); + return { success: false, error: 'AWS SNS provider not yet implemented' }; + } + + case 'vonage': { + // TODO: Implement Vonage integration + // const vonage = new Vonage({ apiKey: config.vonage.apiKey, apiSecret: config.vonage.apiSecret }); + // const result = await vonage.sms.send({ to, from: config.vonage.fromNumber, text: message }); + log.info('[send-sms] Vonage provider not yet implemented', { to }); + return { success: false, error: 'Vonage provider not yet implemented' }; + } + + case 'stub': + default: { + log.info('[send-sms] STUB MODE - SMS not actually sent', { to, message }); + return { success: true, messageId: `stub-${Date.now()}` }; + } + } +}; + +const sendSms = async ( + params: SendSmsParams, + context: SendSmsContext +): Promise<{ complete?: boolean; dryRun?: boolean; error?: string; missing?: string }> => { + const { meta, databaseId, env, log } = context; + const isDryRun = parseEnvBoolean(env.SEND_SMS_DRY_RUN) ?? false; + + if (!params.sms_type) { + return { missing: 'sms_type' }; + } + if (!params.phone_number) { + return { missing: 'phone_number' }; + } + + const otpCode = params.otp_code || params.code; + if (!otpCode) { + return { missing: 'otp_code' }; + } + + const databaseInfo = await meta.request(GetDatabaseInfo, { databaseId }); + const site = databaseInfo?.databases?.nodes?.[0]?.sites?.nodes?.[0]; + if (!site) { + throw new Error('Site not found for database'); + } + + const legalTermsModule = site.siteModules?.nodes?.[0]; + const appName = site.title || legalTermsModule?.data?.company?.nick || 'App'; + const expiresIn = params.expires_in_minutes ?? 10; + + const smsMessages: Record = { + sign_in_sms_otp: `Your ${appName} sign-in code is: ${otpCode}. Expires in ${expiresIn} min.`, + sign_up_sms: `Your ${appName} verification code is: ${otpCode}. Expires in ${expiresIn} min.`, + enable_sms_mfa: `Your ${appName} MFA setup code is: ${otpCode}. Expires in ${expiresIn} min.`, + mfa_challenge_sms: `Your ${appName} verification code is: ${otpCode}. Expires in ${expiresIn} min.`, + phone_verification: `Your ${appName} phone verification code is: ${otpCode}. Expires in ${expiresIn} min.`, + // DB procedure naming (for compatibility) + sms_otp_code: `Your ${appName} verification code is: ${otpCode}. Expires in ${expiresIn} min.`, + mfa_verification_code: `Your ${appName} verification code is: ${otpCode}. Expires in ${expiresIn} min.`, + }; + + const message = smsMessages[params.sms_type]; + if (!message) { + return { error: `Unknown sms_type: ${params.sms_type}` }; + } + + if (isDryRun) { + log.info('[send-sms] DRY RUN - SMS not sent', { + sms_type: params.sms_type, + phone_number: params.phone_number, + message, + }); + return { complete: true, dryRun: true }; + } + + const providerConfig = getProviderConfig(env); + const result = await sendSmsViaProvider(params.phone_number, message, providerConfig, log); + + if (!result.success) { + throw new Error(result.error || 'Failed to send SMS'); + } + + return { complete: true }; +}; + +const handler: FunctionHandler = async (params, context) => { + const { client, meta, job, log, env } = context; + + const databaseId = job.databaseId; + if (!databaseId) { + return { error: 'Missing X-Database-Id header or DEFAULT_DATABASE_ID' }; + } + + log.info('[send-sms] Processing request', { + sms_type: params.sms_type, + databaseId, + }); + + const result = await sendSms(params, { + client, + meta, + databaseId, + env, + log, + }); + + if (result && typeof result === 'object' && 'missing' in result) { + throw new Error(`Missing required field: ${result.missing}`); + } + + return result; +}; + +export default handler; diff --git a/functions/send-sms/types.d.ts b/functions/send-sms/types.d.ts new file mode 100644 index 0000000..92af790 --- /dev/null +++ b/functions/send-sms/types.d.ts @@ -0,0 +1,7 @@ +declare module 'simple-smtp-server' { + export function send(options: { + to: string; + subject: string; + html: string; + }): Promise; +} diff --git a/functions/send-verification-link/handler.ts b/functions/send-verification-link/handler.ts index 4e81bd7..105c093 100644 --- a/functions/send-verification-link/handler.ts +++ b/functions/send-verification-link/handler.ts @@ -59,7 +59,21 @@ const GetDatabaseInfo = gql` `; type SendEmailParams = { - email_type: 'invite_email' | 'forgot_password' | 'email_verification'; + email_type: + | 'invite_email' + | 'forgot_password' + | 'email_verification' + // New passwordless auth types (function naming) + | 'magic_link_sign_in' + | 'magic_link_sign_up' + | 'email_otp' + | 'email_mfa_setup' + | 'mfa_challenge' + // DB procedure naming (for compatibility) + | 'magic_link_email' + | 'email_otp_code' + | 'mfa_verification_code' + | 'account_deletion'; email: string; invite_type?: number | string; invite_token?: string; @@ -68,6 +82,11 @@ type SendEmailParams = { reset_token?: string; email_id?: string; verification_token?: string; + magic_link_token?: string; + token?: string; // DB uses 'token' for magic link + otp_code?: string; + code?: string; // DB uses 'code' for OTP + expires_in_minutes?: number; }; type SendEmailContext = { @@ -106,6 +125,30 @@ const sendEmailLink = async ( return { missing: 'email_id_or_verification_token' }; } return null; + case 'magic_link_sign_in': + case 'magic_link_sign_up': + case 'magic_link_email': // DB naming + // Accept both 'magic_link_token' and 'token' (DB naming) + if (!params.magic_link_token && !params.token) { + return { missing: 'magic_link_token' }; + } + return null; + case 'email_otp': + case 'email_mfa_setup': + case 'mfa_challenge': + case 'email_otp_code': // DB naming + case 'mfa_verification_code': // DB naming + // Accept both 'otp_code' and 'code' (DB naming) + if (!params.otp_code && !params.code) { + return { missing: 'otp_code' }; + } + return null; + case 'account_deletion': + // Account deletion email - requires user_id + if (!params.user_id) { + return { missing: 'user_id' }; + } + return null; default: return { missing: 'email_type' }; } @@ -229,6 +272,121 @@ const sendEmailLink = async ( linkText = 'Confirm Email'; break; } + case 'magic_link_sign_in': + case 'magic_link_email': { // DB naming - treat as sign_in + const magicToken = params.magic_link_token || params.token; + if (!magicToken) { + return { missing: 'magic_link_token' }; + } + url.pathname = 'auth/magic-link'; + url.searchParams.append('token', magicToken); + url.searchParams.append('email', params.email); + subject = `Sign in to ${nick}`; + subMessage = 'Click the button below to sign in to your account'; + linkText = 'Sign In'; + break; + } + case 'magic_link_sign_up': { + const magicToken = params.magic_link_token || params.token; + if (!magicToken) { + return { missing: 'magic_link_token' }; + } + url.pathname = 'auth/magic-link'; + url.searchParams.append('token', magicToken); + url.searchParams.append('email', params.email); + url.searchParams.append('signup', 'true'); + subject = `Complete your ${nick} registration`; + subMessage = 'Click the button below to complete your registration'; + linkText = 'Complete Registration'; + break; + } + case 'email_otp': + case 'email_mfa_setup': + case 'mfa_challenge': + case 'email_otp_code': // DB naming + case 'mfa_verification_code': { // DB naming + // Accept both 'otp_code' and 'code' (DB naming) + const otpCode = params.otp_code || params.code; + if (!otpCode) { + return { missing: 'otp_code' }; + } + const expiresIn = params.expires_in_minutes ?? 10; + const otpSubjects: Record = { + email_otp: `Your ${nick} verification code`, + email_otp_code: `Your ${nick} verification code`, // DB naming + email_mfa_setup: `${nick} MFA Setup Code`, + mfa_challenge: `Your ${nick} sign-in code`, + mfa_verification_code: `Your ${nick} sign-in code` // DB naming + }; + const otpMessages: Record = { + email_otp: 'Enter this code to verify your email', + email_otp_code: 'Enter this code to verify your email', // DB naming + email_mfa_setup: 'Enter this code to enable two-factor authentication', + mfa_challenge: 'Enter this code to complete your sign-in', + mfa_verification_code: 'Enter this code to complete your sign-in' // DB naming + }; + subject = otpSubjects[params.email_type]; + subMessage = `${otpMessages[params.email_type]}. This code expires in ${expiresIn} minutes.`; + + const otpHtml = generate({ + title: subject, + link: '', + linkText: otpCode, + message: subject, + subMessage, + bodyBgColor: 'white', + headerBgColor: 'white', + messageBgColor: 'white', + messageTextColor: '#414141', + messageButtonBgColor: primary, + messageButtonTextColor: 'white', + companyName: name, + supportEmail, + website, + logo, + headerImageProps: { + alt: 'logo', + align: 'center', + border: 'none', + width: '162px', + paddingLeft: '0px', + paddingRight: '0px', + paddingBottom: '0px', + paddingTop: '0' + } + }); + + if (isDryRun) { + log.info('DRY RUN email (skipping send)', { + email_type: params.email_type, + email: params.email, + subject, + otp_code: otpCode + }); + } else { + const sendEmail = useSmtp ? sendSmtp : sendPostmaster; + await sendEmail({ + to: params.email, + subject, + html: otpHtml + }); + } + + return { + complete: true, + ...(isDryRun ? { dryRun: true } : null) + }; + } + case 'account_deletion': { + if (!params.user_id) { + return { missing: 'user_id' }; + } + subject = `${nick} Account Deletion Confirmation`; + subMessage = 'Your account deletion request has been processed'; + linkText = 'View Details'; + url.pathname = 'account/deleted'; + break; + } default: return false; } diff --git a/k8s/base/functions/send-sms.yaml b/k8s/base/functions/send-sms.yaml new file mode 100644 index 0000000..5fe1703 --- /dev/null +++ b/k8s/base/functions/send-sms.yaml @@ -0,0 +1,77 @@ +apiVersion: serving.knative.dev/v1 +kind: Service +metadata: + name: send-sms + labels: + app.kubernetes.io/name: send-sms + app.kubernetes.io/component: function + app.kubernetes.io/part-of: constructive-jobs + networking.knative.dev/visibility: cluster-local +spec: + template: + metadata: + labels: + app.kubernetes.io/name: send-sms + app.kubernetes.io/component: function + app.kubernetes.io/part-of: constructive-jobs + annotations: + autoscaling.knative.dev/minScale: "1" + autoscaling.knative.dev/maxScale: "10" + autoscaling.knative.dev/target: "50" + serving.knative.dev/timeout: "300s" + run.googleapis.com/cpu-throttling: "false" + spec: + containerConcurrency: 10 + timeoutSeconds: 300 + + containers: + - name: function + image: ghcr.io/constructive-io/constructive:e0b55cc + imagePullPolicy: Always + + command: ["node"] + args: ["functions/send-sms/dist/index.js"] + + ports: + - containerPort: 8080 + protocol: TCP + + envFrom: + - secretRef: + name: twilio-credentials + + env: + - name: NODE_ENV + value: "production" + - name: LOG_LEVEL + value: "debug" + - name: LOG_TIMESTAMP + value: "true" + - name: SMS_PROVIDER + value: "twilio" + - name: GRAPHQL_URL + value: "http://constructive-server-admin.interweb.svc.cluster.local:3000/graphql" + - name: META_GRAPHQL_URL + value: "http://constructive-server-admin.interweb.svc.cluster.local:3000/graphql" + - name: GRAPHQL_API_NAME + value: "private" + + resources: + requests: + memory: "128Mi" + cpu: "100m" + limits: + memory: "512Mi" + cpu: "500m" + + volumeMounts: + - name: tmp + mountPath: /tmp + + volumes: + - name: tmp + emptyDir: {} + + traffic: + - percent: 100 + latestRevision: true diff --git a/k8s/base/kustomization.yaml b/k8s/base/kustomization.yaml index d7c2eec..93166c9 100644 --- a/k8s/base/kustomization.yaml +++ b/k8s/base/kustomization.yaml @@ -23,8 +23,10 @@ resources: # Function workloads - ./functions/send-email.yaml - ./functions/send-verification-link.yaml + - ./functions/send-sms.yaml # Required Secrets are intentionally not committed in the shared base. Create # pg-credentials, postgres-superuser, pgadmin-credentials, mailgun-credentials, -# constructive-uploads, and any environment-specific cloud credentials with a -# secret manager, ExternalSecrets, SOPS, or kubectl before applying dev/staging. +# twilio-credentials, constructive-uploads, and any environment-specific cloud +# credentials with a secret manager, ExternalSecrets, SOPS, or kubectl before +# applying dev/staging. diff --git a/packages/fn-runtime/src/graphql.ts b/packages/fn-runtime/src/graphql.ts index a279f1c..32180c9 100644 --- a/packages/fn-runtime/src/graphql.ts +++ b/packages/fn-runtime/src/graphql.ts @@ -66,9 +66,11 @@ export const createClients = ( // X-Api-Name causes the server to load every schema registered for that API // (both *_public and *_private variants), which collides on duplicate codec // names like identityProviders. Use X-Meta-Schema instead. + // Include databaseId so the server can build the correct cache key. const meta = createGraphQLClient(metaGraphqlUrl, env, { hostHeaderEnvVar: 'META_GRAPHQL_HOST_HEADER', useMetaSchema: true, + databaseId, }); return { client, meta }; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 661e94a..eccc67e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -101,6 +101,77 @@ importers: specifier: ^5.1.6 version: 5.9.3 + generated/send-email-link: + dependencies: + '@constructive-io/fn-runtime': + specifier: workspace:^ + version: link:../../packages/fn-runtime + '@constructive-io/postmaster': + specifier: ^1.5.2 + version: 1.6.2 + '@launchql/mjml': + specifier: 0.1.1 + version: 0.1.1(@babel/core@7.28.5)(react-dom@16.14.0(react@16.14.0))(react-is@18.3.1)(react@16.14.0) + '@launchql/styled-email': + specifier: 0.1.0 + version: 0.1.0(@babel/core@7.28.5)(react-dom@16.14.0(react@16.14.0))(react-is@18.3.1)(react@16.14.0) + '@pgpmjs/env': + specifier: ^2.15.3 + version: 2.17.0 + '@pgpmjs/logger': + specifier: ^2.4.3 + version: 2.5.2 + graphql-request: + specifier: ^7.1.2 + version: 7.4.0(graphql@16.12.0) + graphql-tag: + specifier: ^2.12.6 + version: 2.12.6(graphql@16.12.0) + simple-smtp-server: + specifier: ^0.7.3 + version: 0.7.3 + devDependencies: + '@types/node': + specifier: ^22.10.4 + version: 22.19.3 + makage: + specifier: ^0.1.10 + version: 0.1.12 + typescript: + specifier: ^5.1.6 + version: 5.9.3 + + generated/send-sms: + dependencies: + '@constructive-io/fn-runtime': + specifier: workspace:^ + version: link:../../packages/fn-runtime + '@pgpmjs/env': + specifier: ^2.15.3 + version: 2.17.0 + '@pgpmjs/logger': + specifier: ^2.4.3 + version: 2.5.2 + graphql-request: + specifier: ^7.1.2 + version: 7.4.0(graphql@16.12.0) + graphql-tag: + specifier: ^2.12.6 + version: 2.12.6(graphql@16.12.0) + twilio: + specifier: ^5.5.0 + version: 5.13.1 + devDependencies: + '@types/node': + specifier: ^22.10.4 + version: 22.19.3 + makage: + specifier: ^0.1.10 + version: 0.1.12 + typescript: + specifier: ^5.1.6 + version: 5.9.3 + generated/send-verification-link: dependencies: '@constructive-io/fn-runtime': @@ -141,6 +212,34 @@ importers: specifier: ^5.1.6 version: 5.9.3 + generated/simple-email: + dependencies: + '@constructive-io/fn-runtime': + specifier: workspace:^ + version: link:../../packages/fn-runtime + '@constructive-io/postmaster': + specifier: ^1.5.2 + version: 1.6.2 + '@pgpmjs/env': + specifier: ^2.15.3 + version: 2.17.0 + '@pgpmjs/logger': + specifier: ^2.4.3 + version: 2.5.2 + simple-smtp-server: + specifier: ^0.7.3 + version: 0.7.3 + devDependencies: + '@types/node': + specifier: ^22.10.4 + version: 22.19.3 + makage: + specifier: ^0.1.10 + version: 0.1.12 + typescript: + specifier: ^5.1.6 + version: 5.9.3 + job/server: dependencies: '@constructive-io/job-pg': @@ -1381,6 +1480,10 @@ packages: engines: {node: '>=0.4.0'} hasBin: true + agent-base@6.0.2: + resolution: {integrity: sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==} + engines: {node: '>= 6.0.0'} + ajv@6.12.6: resolution: {integrity: sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==} @@ -1530,6 +1633,9 @@ packages: bser@2.1.1: resolution: {integrity: sha512-gQxTNE/GAfIIrmHLUE3oJyp5FO6HRBfhjnw4/wMmA63ZGDJnWBmgY/lyQBpnDUkGmAhbSe39tx2d/iTOAfglwQ==} + buffer-equal-constant-time@1.0.1: + resolution: {integrity: sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==} + buffer-from@1.1.2: resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==} @@ -1708,6 +1814,9 @@ packages: resolution: {integrity: sha512-u/O3vwbptzhMs3L1fQE82ZSLHQQfto5gyZzwteVIEyeaY5Fc7R4dapF/BvRoSYFeqfBk4m0V1Vafq5Pjv25wvA==} engines: {node: '>= 6'} + dayjs@1.11.20: + resolution: {integrity: sha512-YbwwqR/uYpeoP4pu043q+LTDLFBLApUP6VxRihdfNTqu4ubqMlGDLd6ErXhEgsyvY0K6nCs7nggYumAN+9uEuQ==} + debug@4.4.3: resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} engines: {node: '>=6.0'} @@ -1804,6 +1913,9 @@ packages: eastasianwidth@0.2.0: resolution: {integrity: sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==} + ecdsa-sig-formatter@1.0.11: + resolution: {integrity: sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==} + editorconfig@1.0.4: resolution: {integrity: sha512-L9Qe08KWTlqYMVvMcTIvMAdl1cDUubzRNYL+WfA4bLDMHe4nemKkpmYzkznE1FwLKu0EEmy6obgQKzMJrg4x9Q==} engines: {node: '>=14'} @@ -2211,6 +2323,10 @@ packages: resolution: {integrity: sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==} engines: {node: '>= 0.8'} + https-proxy-agent@5.0.1: + resolution: {integrity: sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==} + engines: {node: '>= 6'} + human-signals@2.1.0: resolution: {integrity: sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==} engines: {node: '>=10.17.0'} @@ -2627,11 +2743,21 @@ packages: engines: {node: '>=6'} hasBin: true + jsonwebtoken@9.0.3: + resolution: {integrity: sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g==} + engines: {node: '>=12', npm: '>=6'} + juice@7.0.0: resolution: {integrity: sha512-AjKQX31KKN+uJs+zaf+GW8mBO/f/0NqSh2moTMyvwBY+4/lXIYTU8D8I2h6BAV3Xnz6GGsbalUyFqbYMe+Vh+Q==} engines: {node: '>=10.0.0'} hasBin: true + jwa@2.0.1: + resolution: {integrity: sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==} + + jws@4.0.1: + resolution: {integrity: sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==} + keyv@4.5.4: resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==} @@ -2658,12 +2784,33 @@ packages: resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==} engines: {node: '>=10'} + lodash.includes@4.3.0: + resolution: {integrity: sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==} + + lodash.isboolean@3.0.3: + resolution: {integrity: sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==} + + lodash.isinteger@4.0.4: + resolution: {integrity: sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==} + + lodash.isnumber@3.0.3: + resolution: {integrity: sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==} + + lodash.isplainobject@4.0.6: + resolution: {integrity: sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==} + + lodash.isstring@4.0.1: + resolution: {integrity: sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==} + lodash.memoize@4.1.2: resolution: {integrity: sha512-t7j+NzmgnQzTAYXcsHYLgimltOV1MXHtlOWf6GjL9Kj8GK5FInw5JotxvbOs+IvV1/Dzo04/fCGfLVs7aXb4Ag==} lodash.merge@4.6.2: resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==} + lodash.once@4.1.1: + resolution: {integrity: sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==} + lodash@4.17.21: resolution: {integrity: sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==} @@ -3262,6 +3409,10 @@ packages: scheduler@0.19.1: resolution: {integrity: sha512-n/zwRWRYSUj0/3g/otKDRPMh6qv2SYMWNq85IEa8iZyAv8od9zDYpGSnpBEjNgcMNq6Scbu5KfIPxNF72R/2EA==} + scmp@2.1.0: + resolution: {integrity: sha512-o/mRQGk9Rcer/jEEw/yw4mwo3EU/NvYvp577/Btqrym9Qy5/MdWGBqipbALgd2lrdWTJ5/gqDusxfnQBxOxT2Q==} + deprecated: Just use Node.js's crypto.timingSafeEqual() + semver@6.3.1: resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==} hasBin: true @@ -3486,6 +3637,10 @@ packages: engines: {node: '>=18.0.0'} hasBin: true + twilio@5.13.1: + resolution: {integrity: sha512-sT+PkhptF4Mf7t8eXFFvPQx4w5VHnBIPXbltGPMFRe+R2GxfRdMuFbuNA/cEm0aQR6LFQOn33+fhClg+TjRVqQ==} + engines: {node: '>=14.0'} + type-check@0.4.0: resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==} engines: {node: '>= 0.8.0'} @@ -3630,6 +3785,10 @@ packages: resolution: {integrity: sha512-+QU2zd6OTD8XWIJCbffaiQeH9U73qIqafo1x6V1snCWYGJf6cVE0cDR4D8xRzcEnfI21IFrUPzPGtcPf8AC+Rw==} engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0} + xmlbuilder@13.0.2: + resolution: {integrity: sha512-Eux0i2QdDYKbdbA6AM6xE4m6ZTZr4G4xF9kahI2ukSEMCzwce2eX9WlTI5J3s+NU7hpasFsr8hWIONae7LluAQ==} + engines: {node: '>=6.0'} + xtend@4.0.2: resolution: {integrity: sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==} engines: {node: '>=0.4'} @@ -4865,6 +5024,12 @@ snapshots: acorn@8.15.0: {} + agent-base@6.0.2: + dependencies: + debug: 4.4.3(supports-color@5.5.0) + transitivePeerDependencies: + - supports-color + ajv@6.12.6: dependencies: fast-deep-equal: 3.1.3 @@ -5075,6 +5240,8 @@ snapshots: dependencies: node-int64: 0.4.0 + buffer-equal-constant-time@1.0.1: {} + buffer-from@1.1.2: {} bytes@3.1.2: {} @@ -5272,6 +5439,8 @@ snapshots: css-what@6.2.2: {} + dayjs@1.11.20: {} + debug@4.4.3(supports-color@5.5.0): dependencies: ms: 2.1.3 @@ -5366,6 +5535,10 @@ snapshots: eastasianwidth@0.2.0: {} + ecdsa-sig-formatter@1.0.11: + dependencies: + safe-buffer: 5.2.1 + editorconfig@1.0.4: dependencies: '@one-ini/wasm': 0.1.1 @@ -5850,6 +6023,13 @@ snapshots: statuses: 2.0.2 toidentifier: 1.0.1 + https-proxy-agent@5.0.1: + dependencies: + agent-base: 6.0.2 + debug: 4.4.3(supports-color@5.5.0) + transitivePeerDependencies: + - supports-color + human-signals@2.1.0: {} iconv-lite@0.6.3: @@ -6633,6 +6813,19 @@ snapshots: json5@2.2.3: {} + jsonwebtoken@9.0.3: + dependencies: + jws: 4.0.1 + lodash.includes: 4.3.0 + lodash.isboolean: 3.0.3 + lodash.isinteger: 4.0.4 + lodash.isnumber: 3.0.3 + lodash.isplainobject: 4.0.6 + lodash.isstring: 4.0.1 + lodash.once: 4.1.1 + ms: 2.1.3 + semver: 7.7.3 + juice@7.0.0: dependencies: cheerio: 1.1.2 @@ -6643,6 +6836,17 @@ snapshots: transitivePeerDependencies: - encoding + jwa@2.0.1: + dependencies: + buffer-equal-constant-time: 1.0.1 + ecdsa-sig-formatter: 1.0.11 + safe-buffer: 5.2.1 + + jws@4.0.1: + dependencies: + jwa: 2.0.1 + safe-buffer: 5.2.1 + keyv@4.5.4: dependencies: json-buffer: 3.0.1 @@ -6666,10 +6870,24 @@ snapshots: dependencies: p-locate: 5.0.0 + lodash.includes@4.3.0: {} + + lodash.isboolean@3.0.3: {} + + lodash.isinteger@4.0.4: {} + + lodash.isnumber@3.0.3: {} + + lodash.isplainobject@4.0.6: {} + + lodash.isstring@4.0.1: {} + lodash.memoize@4.1.2: {} lodash.merge@4.6.2: {} + lodash.once@4.1.1: {} + lodash@4.17.21: {} long-timeout@0.1.1: {} @@ -7412,6 +7630,8 @@ snapshots: loose-envify: 1.4.0 object-assign: 4.1.1 + scmp@2.1.0: {} + semver@6.3.1: {} semver@7.7.3: {} @@ -7677,6 +7897,19 @@ snapshots: optionalDependencies: fsevents: 2.3.3 + twilio@5.13.1: + dependencies: + axios: 1.13.5 + dayjs: 1.11.20 + https-proxy-agent: 5.0.1 + jsonwebtoken: 9.0.3 + qs: 6.14.1 + scmp: 2.1.0 + xmlbuilder: 13.0.2 + transitivePeerDependencies: + - debug + - supports-color + type-check@0.4.0: dependencies: prelude-ls: 1.2.1 @@ -7839,6 +8072,8 @@ snapshots: imurmurhash: 0.1.4 signal-exit: 4.1.0 + xmlbuilder@13.0.2: {} + xtend@4.0.2: {} y18n@4.0.3: {} diff --git a/skaffold.yaml b/skaffold.yaml index 32d0dc7..960fac5 100644 --- a/skaffold.yaml +++ b/skaffold.yaml @@ -89,7 +89,7 @@ profiles: resourceName: python-example namespace: constructive-functions port: 80 - localPort: 8084 + localPort: 8085 - resourceType: service resourceName: knative-job-service namespace: constructive-functions @@ -149,6 +149,50 @@ profiles: namespace: constructive-functions port: 3000 localPort: 3002 + - name: send-sms + build: + artifacts: + - image: constructive-functions + context: . + docker: + dockerfile: Dockerfile.dev + sync: + manual: + - src: 'functions/**/*.ts' + dest: /usr/src/app + local: + push: false + manifests: + kustomize: + paths: + - k8s/overlays/local-simple + rawYaml: + - generated/send-sms/k8s/local-deployment.yaml + - generated/send-sms/k8s/functions-configmap.yaml + deploy: + kubectl: + defaultNamespace: constructive-functions + portForward: + - resourceType: service + resourceName: send-sms + namespace: constructive-functions + port: 80 + localPort: 8084 + - resourceType: service + resourceName: knative-job-service + namespace: constructive-functions + port: 8080 + localPort: 8080 + - resourceType: service + resourceName: postgres + namespace: constructive-functions + port: 5432 + localPort: 5432 + - resourceType: service + resourceName: constructive-server + namespace: constructive-functions + port: 3000 + localPort: 3002 - name: send-verification-link build: artifacts: @@ -226,6 +270,7 @@ profiles: - generated/example/k8s/local-deployment.yaml - generated/python-example/k8s/local-deployment.yaml - generated/send-email/k8s/local-deployment.yaml + - generated/send-sms/k8s/local-deployment.yaml - generated/send-verification-link/k8s/local-deployment.yaml - generated/functions-configmap.yaml deploy: @@ -241,12 +286,17 @@ profiles: resourceName: python-example namespace: constructive-functions port: 80 - localPort: 8084 + localPort: 8085 - resourceType: service resourceName: send-email namespace: constructive-functions port: 80 localPort: 8081 + - resourceType: service + resourceName: send-sms + namespace: constructive-functions + port: 80 + localPort: 8084 - resourceType: service resourceName: send-verification-link namespace: constructive-functions @@ -296,12 +346,17 @@ profiles: resourceName: python-example namespace: constructive-functions port: 80 - localPort: 8084 + localPort: 8085 - resourceType: service resourceName: send-email namespace: constructive-functions port: 80 localPort: 8081 + - resourceType: service + resourceName: send-sms + namespace: constructive-functions + port: 80 + localPort: 8084 - resourceType: service resourceName: send-verification-link namespace: constructive-functions