@stacksjs/ts-cloud 0.1.7 → 0.1.9
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/aws/s3.d.ts +1 -1
- package/dist/bin/cli.js +223 -222
- package/dist/index.js +132 -132
- package/package.json +18 -16
- package/src/aws/acm.ts +768 -0
- package/src/aws/application-autoscaling.ts +845 -0
- package/src/aws/bedrock.ts +4074 -0
- package/src/aws/client.ts +891 -0
- package/src/aws/cloudformation.ts +896 -0
- package/src/aws/cloudfront.ts +1531 -0
- package/src/aws/cloudwatch-logs.ts +154 -0
- package/src/aws/comprehend.ts +839 -0
- package/src/aws/connect.ts +1056 -0
- package/src/aws/deploy-imap.ts +384 -0
- package/src/aws/dynamodb.ts +340 -0
- package/src/aws/ec2.ts +1385 -0
- package/src/aws/ecr.ts +621 -0
- package/src/aws/ecs.ts +615 -0
- package/src/aws/elasticache.ts +301 -0
- package/src/aws/elbv2.ts +942 -0
- package/src/aws/email.ts +928 -0
- package/src/aws/eventbridge.ts +248 -0
- package/src/aws/iam.ts +1689 -0
- package/src/aws/imap-server.ts +2100 -0
- package/src/aws/index.ts +213 -0
- package/src/aws/kendra.ts +1097 -0
- package/src/aws/lambda.ts +786 -0
- package/src/aws/opensearch.ts +158 -0
- package/src/aws/personalize.ts +977 -0
- package/src/aws/polly.ts +559 -0
- package/src/aws/rds.ts +888 -0
- package/src/aws/rekognition.ts +846 -0
- package/src/aws/route53-domains.ts +359 -0
- package/src/aws/route53.ts +1046 -0
- package/src/aws/s3.ts +2334 -0
- package/src/aws/scheduler.ts +571 -0
- package/src/aws/secrets-manager.ts +769 -0
- package/src/aws/ses.ts +1081 -0
- package/src/aws/setup-phone.ts +104 -0
- package/src/aws/setup-sms.ts +580 -0
- package/src/aws/sms.ts +1735 -0
- package/src/aws/smtp-server.ts +531 -0
- package/src/aws/sns.ts +758 -0
- package/src/aws/sqs.ts +382 -0
- package/src/aws/ssm.ts +807 -0
- package/src/aws/sts.ts +92 -0
- package/src/aws/support.ts +391 -0
- package/src/aws/test-imap.ts +86 -0
- package/src/aws/textract.ts +780 -0
- package/src/aws/transcribe.ts +108 -0
- package/src/aws/translate.ts +641 -0
- package/src/aws/voice.ts +1379 -0
- package/src/config.ts +35 -0
- package/src/deploy/index.ts +7 -0
- package/src/deploy/static-site-external-dns.ts +945 -0
- package/src/deploy/static-site.ts +1175 -0
- package/src/dns/cloudflare.ts +548 -0
- package/src/dns/godaddy.ts +412 -0
- package/src/dns/index.ts +205 -0
- package/src/dns/porkbun.ts +362 -0
- package/src/dns/route53-adapter.ts +414 -0
- package/src/dns/types.ts +119 -0
- package/src/dns/validator.ts +369 -0
- package/src/generators/index.ts +5 -0
- package/src/generators/infrastructure.ts +1660 -0
- package/src/index.ts +163 -0
- package/src/push/apns.ts +452 -0
- package/src/push/fcm.ts +506 -0
- package/src/push/index.ts +58 -0
- package/src/security/pre-deploy-scanner.ts +655 -0
- package/src/ssl/acme-client.ts +478 -0
- package/src/ssl/index.ts +7 -0
- package/src/ssl/letsencrypt.ts +747 -0
- package/src/types.ts +2 -0
- package/src/utils/cli.ts +398 -0
- package/src/validation/index.ts +5 -0
- package/src/validation/template.ts +405 -0
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import { ConnectClient } from './connect'
|
|
2
|
+
|
|
3
|
+
const connect = new ConnectClient('us-east-1')
|
|
4
|
+
const instanceId = '7deb6488-8555-4304-b893-d6c461449eda'
|
|
5
|
+
|
|
6
|
+
async function main() {
|
|
7
|
+
// First, check which phone number we have
|
|
8
|
+
console.log('Checking claimed phone numbers...')
|
|
9
|
+
const phones = await connect.listPhoneNumbers({ InstanceId: instanceId })
|
|
10
|
+
console.log('Claimed phone numbers:')
|
|
11
|
+
for (const p of phones.ListPhoneNumbersSummaryList || []) {
|
|
12
|
+
console.log(' ', p.PhoneNumber, '(ID:', p.PhoneNumberId + ')')
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
// Use the first phone number
|
|
16
|
+
const phoneNumber = phones.ListPhoneNumbersSummaryList?.[0]
|
|
17
|
+
if (!phoneNumber) {
|
|
18
|
+
console.log('No phone numbers found!')
|
|
19
|
+
process.exit(1)
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// Create a contact flow with the correct format for call forwarding
|
|
23
|
+
console.log('\nCreating contact flow with call forwarding...')
|
|
24
|
+
|
|
25
|
+
// Use the correct format for TransferToPhoneNumber
|
|
26
|
+
// According to AWS documentation, the action type is "TransferToPhoneNumber"
|
|
27
|
+
const forwardingFlow = {
|
|
28
|
+
Version: '2019-10-30',
|
|
29
|
+
StartAction: 'transfer-to-phone',
|
|
30
|
+
Metadata: {
|
|
31
|
+
entryPointPosition: { x: 40, y: 40 },
|
|
32
|
+
ActionMetadata: {
|
|
33
|
+
'transfer-to-phone': { position: { x: 190, y: 40 } },
|
|
34
|
+
'end-call': { position: { x: 440, y: 40 } },
|
|
35
|
+
},
|
|
36
|
+
},
|
|
37
|
+
Actions: [
|
|
38
|
+
{
|
|
39
|
+
Identifier: 'transfer-to-phone',
|
|
40
|
+
Type: 'TransferToPhoneNumber',
|
|
41
|
+
Parameters: {
|
|
42
|
+
PhoneNumber: '+18088218241',
|
|
43
|
+
ContactFlowId: '', // Will use instance default
|
|
44
|
+
},
|
|
45
|
+
Transitions: {
|
|
46
|
+
NextAction: 'end-call',
|
|
47
|
+
Errors: [
|
|
48
|
+
{ ErrorType: 'NoMatchingError', NextAction: 'end-call' },
|
|
49
|
+
{ ErrorType: 'CallFailed', NextAction: 'end-call' },
|
|
50
|
+
{ ErrorType: 'ConnectionTimeLimitExceeded', NextAction: 'end-call' },
|
|
51
|
+
],
|
|
52
|
+
},
|
|
53
|
+
},
|
|
54
|
+
{
|
|
55
|
+
Identifier: 'end-call',
|
|
56
|
+
Type: 'DisconnectParticipant',
|
|
57
|
+
Parameters: {},
|
|
58
|
+
Transitions: {},
|
|
59
|
+
},
|
|
60
|
+
],
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
try {
|
|
64
|
+
const flowResult = await connect.createContactFlow({
|
|
65
|
+
InstanceId: instanceId,
|
|
66
|
+
Name: 'Stacks Call Forwarding',
|
|
67
|
+
Type: 'CONTACT_FLOW',
|
|
68
|
+
Content: JSON.stringify(forwardingFlow),
|
|
69
|
+
Description: 'Forwards all calls to +18088218241',
|
|
70
|
+
})
|
|
71
|
+
console.log('Contact flow created!')
|
|
72
|
+
console.log('Flow ID:', flowResult.ContactFlowId)
|
|
73
|
+
console.log('Flow ARN:', flowResult.ContactFlowArn)
|
|
74
|
+
|
|
75
|
+
// Associate the phone number with the contact flow
|
|
76
|
+
console.log('\nAssociating phone number with contact flow...')
|
|
77
|
+
await connect.associatePhoneNumberContactFlow({
|
|
78
|
+
PhoneNumberId: phoneNumber.PhoneNumberId!,
|
|
79
|
+
InstanceId: instanceId,
|
|
80
|
+
ContactFlowId: flowResult.ContactFlowId!,
|
|
81
|
+
})
|
|
82
|
+
console.log('Phone number associated with contact flow!')
|
|
83
|
+
|
|
84
|
+
// Format the phone number
|
|
85
|
+
const num = phoneNumber.PhoneNumber!
|
|
86
|
+
const formatted = `+1 (${num.slice(2, 5)}) ${num.slice(5, 8)}-${num.slice(8)}`
|
|
87
|
+
|
|
88
|
+
console.log('\n========================================')
|
|
89
|
+
console.log(' STACKS PHONE NUMBER IS READY!')
|
|
90
|
+
console.log('========================================')
|
|
91
|
+
console.log('')
|
|
92
|
+
console.log(' Call: ' + formatted)
|
|
93
|
+
console.log(' Raw: ' + num)
|
|
94
|
+
console.log(' Forwards to: +1 (808) 821-8241')
|
|
95
|
+
console.log(' Hours: 11:30 AM - 8:00 PM PST')
|
|
96
|
+
console.log('')
|
|
97
|
+
console.log('========================================')
|
|
98
|
+
}
|
|
99
|
+
catch (e: any) {
|
|
100
|
+
console.log('Error:', e.message)
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
main()
|
|
@@ -0,0 +1,580 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SMS Setup Automation Module
|
|
3
|
+
* Handles complete SMS infrastructure setup during deploy
|
|
4
|
+
*
|
|
5
|
+
* This module automates:
|
|
6
|
+
* - S3 inbox setup for incoming messages
|
|
7
|
+
* - SNS topics for two-way messaging
|
|
8
|
+
* - Spending limit management
|
|
9
|
+
* - Sandbox exit requests via AWS Support
|
|
10
|
+
* - Delivery receipt configuration
|
|
11
|
+
*
|
|
12
|
+
* Note: Phone number provisioning requires AWS End User Messaging console.
|
|
13
|
+
* SNS uses a shared pool for sending unless you configure an origination number.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { SNSClient } from './sns'
|
|
17
|
+
import { S3Client } from './s3'
|
|
18
|
+
import { IAMClient } from './iam'
|
|
19
|
+
import { LambdaClient } from './lambda'
|
|
20
|
+
import { SupportClient, SupportTemplates } from './support'
|
|
21
|
+
import { AWSClient } from './client'
|
|
22
|
+
|
|
23
|
+
export interface SmsSetupConfig {
|
|
24
|
+
region?: string
|
|
25
|
+
// Account identifier (used for naming resources)
|
|
26
|
+
accountName?: string
|
|
27
|
+
// AWS account ID
|
|
28
|
+
accountId?: string
|
|
29
|
+
// S3 inbox configuration
|
|
30
|
+
inbox?: {
|
|
31
|
+
enabled?: boolean
|
|
32
|
+
bucket?: string
|
|
33
|
+
prefix?: string
|
|
34
|
+
// Create lifecycle rules for retention
|
|
35
|
+
retentionDays?: number
|
|
36
|
+
}
|
|
37
|
+
// SNS topic for incoming SMS
|
|
38
|
+
twoWay?: {
|
|
39
|
+
enabled?: boolean
|
|
40
|
+
topicName?: string
|
|
41
|
+
}
|
|
42
|
+
// Spending configuration
|
|
43
|
+
spending?: {
|
|
44
|
+
monthlyLimit?: number
|
|
45
|
+
// Auto-request increase via AWS Support
|
|
46
|
+
autoRequestIncrease?: boolean
|
|
47
|
+
}
|
|
48
|
+
// Sandbox configuration
|
|
49
|
+
sandbox?: {
|
|
50
|
+
// Auto-request sandbox exit via AWS Support
|
|
51
|
+
autoRequestExit?: boolean
|
|
52
|
+
// Company details for support ticket
|
|
53
|
+
companyName?: string
|
|
54
|
+
// Use case description for support ticket
|
|
55
|
+
useCase?: string
|
|
56
|
+
// Expected monthly SMS volume
|
|
57
|
+
expectedMonthlyVolume?: number
|
|
58
|
+
// Website URL
|
|
59
|
+
websiteUrl?: string
|
|
60
|
+
}
|
|
61
|
+
// Delivery receipts
|
|
62
|
+
deliveryReceipts?: {
|
|
63
|
+
enabled?: boolean
|
|
64
|
+
// SNS topic for delivery receipts
|
|
65
|
+
topicName?: string
|
|
66
|
+
// Store receipts in S3
|
|
67
|
+
s3Bucket?: string
|
|
68
|
+
s3Prefix?: string
|
|
69
|
+
}
|
|
70
|
+
// Lambda function for processing incoming SMS
|
|
71
|
+
inboxLambda?: {
|
|
72
|
+
enabled?: boolean
|
|
73
|
+
functionName?: string
|
|
74
|
+
// Code location (for creating new Lambda)
|
|
75
|
+
codeS3Bucket?: string
|
|
76
|
+
codeS3Key?: string
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export interface SmsSetupResult {
|
|
81
|
+
success: boolean
|
|
82
|
+
inboxBucket?: string
|
|
83
|
+
inboxPrefix?: string
|
|
84
|
+
twoWayTopicArn?: string
|
|
85
|
+
deliveryReceiptTopicArn?: string
|
|
86
|
+
inboxLambdaArn?: string
|
|
87
|
+
spendingLimit?: number
|
|
88
|
+
sandboxStatus?: 'IN_SANDBOX' | 'OUT_OF_SANDBOX' | 'EXIT_REQUESTED'
|
|
89
|
+
supportCaseId?: string
|
|
90
|
+
errors: string[]
|
|
91
|
+
warnings: string[]
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Set up complete SMS infrastructure
|
|
96
|
+
* Called automatically during `buddy deploy` when SMS is enabled
|
|
97
|
+
*/
|
|
98
|
+
export async function setupSmsInfrastructure(config: SmsSetupConfig): Promise<SmsSetupResult> {
|
|
99
|
+
const region = config.region || 'us-east-1'
|
|
100
|
+
const result: SmsSetupResult = {
|
|
101
|
+
success: true,
|
|
102
|
+
errors: [],
|
|
103
|
+
warnings: [],
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const sns = new SNSClient(region)
|
|
107
|
+
const s3 = new S3Client(region)
|
|
108
|
+
const support = new SupportClient(region)
|
|
109
|
+
const awsClient = new AWSClient()
|
|
110
|
+
|
|
111
|
+
console.log('Setting up SMS infrastructure...')
|
|
112
|
+
|
|
113
|
+
// 1. Check current SMS status
|
|
114
|
+
console.log(' Checking SMS account status...')
|
|
115
|
+
try {
|
|
116
|
+
const accountStatus = await checkSmsAccountStatus(sns)
|
|
117
|
+
result.sandboxStatus = accountStatus.inSandbox ? 'IN_SANDBOX' : 'OUT_OF_SANDBOX'
|
|
118
|
+
|
|
119
|
+
if (accountStatus.inSandbox) {
|
|
120
|
+
console.log(' Account is in SMS sandbox')
|
|
121
|
+
|
|
122
|
+
// Auto-request sandbox exit if configured
|
|
123
|
+
if (config.sandbox?.autoRequestExit && config.sandbox.companyName) {
|
|
124
|
+
console.log(' Requesting SMS sandbox exit via AWS Support...')
|
|
125
|
+
try {
|
|
126
|
+
const caseParams = SupportTemplates.smsSandboxExit({
|
|
127
|
+
companyName: config.sandbox.companyName,
|
|
128
|
+
useCase: config.sandbox.useCase || 'Transactional notifications and verification codes',
|
|
129
|
+
expectedMonthlyVolume: config.sandbox.expectedMonthlyVolume || 1000,
|
|
130
|
+
websiteUrl: config.sandbox.websiteUrl,
|
|
131
|
+
})
|
|
132
|
+
const caseResult = await support.createCase(caseParams)
|
|
133
|
+
result.supportCaseId = caseResult.caseId
|
|
134
|
+
result.sandboxStatus = 'EXIT_REQUESTED'
|
|
135
|
+
console.log(` Support case created: ${caseResult.caseId}`)
|
|
136
|
+
} catch (err: any) {
|
|
137
|
+
result.warnings.push(`Failed to create sandbox exit support case: ${err.message}`)
|
|
138
|
+
console.log(` Warning: Could not create support case: ${err.message}`)
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// Check spending limit
|
|
144
|
+
result.spendingLimit = accountStatus.spendingLimit
|
|
145
|
+
console.log(` Current spending limit: $${accountStatus.spendingLimit}/month`)
|
|
146
|
+
|
|
147
|
+
// Request spending limit increase if needed
|
|
148
|
+
if (
|
|
149
|
+
config.spending?.autoRequestIncrease &&
|
|
150
|
+
config.spending.monthlyLimit &&
|
|
151
|
+
accountStatus.spendingLimit < config.spending.monthlyLimit
|
|
152
|
+
) {
|
|
153
|
+
console.log(` Requesting spending limit increase to $${config.spending.monthlyLimit}/month...`)
|
|
154
|
+
try {
|
|
155
|
+
// Try to set it directly via SNS attributes
|
|
156
|
+
await setSnsSpendingLimit(awsClient, region, config.spending.monthlyLimit)
|
|
157
|
+
result.spendingLimit = config.spending.monthlyLimit
|
|
158
|
+
console.log(' Spending limit updated successfully')
|
|
159
|
+
} catch (err: any) {
|
|
160
|
+
// If direct update fails, file a support ticket
|
|
161
|
+
if (config.sandbox?.companyName) {
|
|
162
|
+
try {
|
|
163
|
+
const caseParams = SupportTemplates.smsSpendLimitIncrease({
|
|
164
|
+
companyName: config.sandbox.companyName,
|
|
165
|
+
currentLimit: accountStatus.spendingLimit,
|
|
166
|
+
requestedLimit: config.spending.monthlyLimit,
|
|
167
|
+
useCase: config.sandbox.useCase || 'Production SMS messaging',
|
|
168
|
+
})
|
|
169
|
+
const caseResult = await support.createCase(caseParams)
|
|
170
|
+
result.supportCaseId = caseResult.caseId
|
|
171
|
+
result.warnings.push(`Spending limit increase requested via support case: ${caseResult.caseId}`)
|
|
172
|
+
console.log(` Support case created for limit increase: ${caseResult.caseId}`)
|
|
173
|
+
} catch (supportErr: any) {
|
|
174
|
+
result.warnings.push(`Failed to request spending limit increase: ${supportErr.message}`)
|
|
175
|
+
}
|
|
176
|
+
} else {
|
|
177
|
+
result.warnings.push('Spending limit increase requires AWS Support ticket. Provide companyName in config.')
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
} catch (err: any) {
|
|
182
|
+
result.errors.push(`Failed to check SMS account status: ${err.message}`)
|
|
183
|
+
console.log(` Error checking status: ${err.message}`)
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// 2. Set up S3 inbox
|
|
187
|
+
if (config.inbox?.enabled && config.inbox.bucket) {
|
|
188
|
+
console.log(' Setting up S3 inbox...')
|
|
189
|
+
try {
|
|
190
|
+
await setupS3Inbox(s3, {
|
|
191
|
+
bucket: config.inbox.bucket,
|
|
192
|
+
prefix: config.inbox.prefix || 'sms/inbox/',
|
|
193
|
+
retentionDays: config.inbox.retentionDays,
|
|
194
|
+
})
|
|
195
|
+
result.inboxBucket = config.inbox.bucket
|
|
196
|
+
result.inboxPrefix = config.inbox.prefix || 'sms/inbox/'
|
|
197
|
+
console.log(` Inbox configured: s3://${config.inbox.bucket}/${result.inboxPrefix}`)
|
|
198
|
+
} catch (err: any) {
|
|
199
|
+
result.errors.push(`Failed to set up S3 inbox: ${err.message}`)
|
|
200
|
+
console.log(` Error setting up inbox: ${err.message}`)
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// 3. Set up SNS topic for two-way SMS
|
|
205
|
+
if (config.twoWay?.enabled) {
|
|
206
|
+
console.log(' Setting up two-way SMS...')
|
|
207
|
+
try {
|
|
208
|
+
const topicName = config.twoWay.topicName || `${config.accountName || 'stacks'}-sms-inbox`
|
|
209
|
+
const topicArn = await setupTwoWayTopic(sns, awsClient, region, topicName, config.accountId)
|
|
210
|
+
result.twoWayTopicArn = topicArn
|
|
211
|
+
console.log(` Two-way topic: ${topicArn}`)
|
|
212
|
+
} catch (err: any) {
|
|
213
|
+
result.errors.push(`Failed to set up two-way SMS topic: ${err.message}`)
|
|
214
|
+
console.log(` Error setting up two-way: ${err.message}`)
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
// 4. Set up delivery receipts topic
|
|
219
|
+
if (config.deliveryReceipts?.enabled) {
|
|
220
|
+
console.log(' Setting up delivery receipts...')
|
|
221
|
+
try {
|
|
222
|
+
const topicName = config.deliveryReceipts.topicName || `${config.accountName || 'stacks'}-sms-delivery-receipts`
|
|
223
|
+
const topicArn = await setupDeliveryReceiptsTopic(sns, awsClient, region, topicName, config.accountId)
|
|
224
|
+
result.deliveryReceiptTopicArn = topicArn
|
|
225
|
+
console.log(` Delivery receipts topic: ${topicArn}`)
|
|
226
|
+
} catch (err: any) {
|
|
227
|
+
result.errors.push(`Failed to set up delivery receipts: ${err.message}`)
|
|
228
|
+
console.log(` Error setting up delivery receipts: ${err.message}`)
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
// Final status
|
|
233
|
+
result.success = result.errors.length === 0
|
|
234
|
+
console.log(result.success ? ' SMS setup completed successfully!' : ' SMS setup completed with errors')
|
|
235
|
+
|
|
236
|
+
// Note about phone numbers
|
|
237
|
+
if (result.success) {
|
|
238
|
+
result.warnings.push(
|
|
239
|
+
'For dedicated phone numbers, use AWS End User Messaging console. SNS uses shared pool by default.',
|
|
240
|
+
)
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
return result
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/**
|
|
247
|
+
* Check SMS account status (sandbox, spending limits)
|
|
248
|
+
*/
|
|
249
|
+
async function checkSmsAccountStatus(sns: SNSClient): Promise<{
|
|
250
|
+
inSandbox: boolean
|
|
251
|
+
spendingLimit: number
|
|
252
|
+
usedThisMonth: number
|
|
253
|
+
}> {
|
|
254
|
+
let inSandbox = true
|
|
255
|
+
let spendingLimit = 1
|
|
256
|
+
let usedThisMonth = 0
|
|
257
|
+
|
|
258
|
+
// Check SNS sandbox status
|
|
259
|
+
try {
|
|
260
|
+
const sandboxStatus = await sns.getSMSSandboxAccountStatus()
|
|
261
|
+
inSandbox = sandboxStatus.IsInSandbox
|
|
262
|
+
} catch {
|
|
263
|
+
// Assume sandbox if we can't check
|
|
264
|
+
inSandbox = true
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
// Get spending quota from SNS attributes
|
|
268
|
+
try {
|
|
269
|
+
const smsAttrs = await sns.getSMSAttributes()
|
|
270
|
+
if (smsAttrs.MonthlySpendLimit) {
|
|
271
|
+
spendingLimit = parseFloat(smsAttrs.MonthlySpendLimit)
|
|
272
|
+
}
|
|
273
|
+
} catch {
|
|
274
|
+
// Ignore
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
return { inSandbox, spendingLimit, usedThisMonth }
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/**
|
|
281
|
+
* Set SNS SMS spending limit
|
|
282
|
+
*/
|
|
283
|
+
async function setSnsSpendingLimit(awsClient: AWSClient, region: string, limit: number): Promise<void> {
|
|
284
|
+
const params = new URLSearchParams({
|
|
285
|
+
Action: 'SetSMSAttributes',
|
|
286
|
+
Version: '2010-03-31',
|
|
287
|
+
'attributes.entry.1.key': 'MonthlySpendLimit',
|
|
288
|
+
'attributes.entry.1.value': limit.toString(),
|
|
289
|
+
})
|
|
290
|
+
|
|
291
|
+
await awsClient.request({
|
|
292
|
+
service: 'sns',
|
|
293
|
+
region,
|
|
294
|
+
method: 'POST',
|
|
295
|
+
path: '/',
|
|
296
|
+
headers: {
|
|
297
|
+
'Content-Type': 'application/x-www-form-urlencoded',
|
|
298
|
+
},
|
|
299
|
+
body: params.toString(),
|
|
300
|
+
})
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
/**
|
|
304
|
+
* Set up S3 bucket for SMS inbox
|
|
305
|
+
*/
|
|
306
|
+
async function setupS3Inbox(
|
|
307
|
+
s3: S3Client,
|
|
308
|
+
config: {
|
|
309
|
+
bucket: string
|
|
310
|
+
prefix: string
|
|
311
|
+
retentionDays?: number
|
|
312
|
+
},
|
|
313
|
+
): Promise<void> {
|
|
314
|
+
// Check if bucket exists
|
|
315
|
+
try {
|
|
316
|
+
const buckets = await s3.listBuckets()
|
|
317
|
+
const bucketExists = buckets.Buckets?.some(b => b.Name === config.bucket)
|
|
318
|
+
|
|
319
|
+
if (!bucketExists) {
|
|
320
|
+
// Create bucket
|
|
321
|
+
await s3.createBucket(config.bucket)
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
// Create placeholder files to ensure prefixes exist
|
|
325
|
+
const prefixes = [
|
|
326
|
+
`${config.prefix}.keep`,
|
|
327
|
+
'sms/sent/.keep',
|
|
328
|
+
'sms/conversations/.keep',
|
|
329
|
+
'sms/templates/.keep',
|
|
330
|
+
'sms/scheduled/.keep',
|
|
331
|
+
'sms/receipts/.keep',
|
|
332
|
+
]
|
|
333
|
+
|
|
334
|
+
for (const key of prefixes) {
|
|
335
|
+
try {
|
|
336
|
+
await s3.putObject({
|
|
337
|
+
bucket: config.bucket,
|
|
338
|
+
key,
|
|
339
|
+
body: `SMS folder created ${new Date().toISOString()}`,
|
|
340
|
+
contentType: 'text/plain',
|
|
341
|
+
})
|
|
342
|
+
} catch {
|
|
343
|
+
// Ignore if already exists
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
// Set up lifecycle rules for retention
|
|
348
|
+
if (config.retentionDays) {
|
|
349
|
+
try {
|
|
350
|
+
await s3.putBucketLifecycleConfiguration(config.bucket, [
|
|
351
|
+
{
|
|
352
|
+
ID: 'SmsInboxRetention',
|
|
353
|
+
Status: 'Enabled',
|
|
354
|
+
Filter: { Prefix: config.prefix },
|
|
355
|
+
Expiration: { Days: config.retentionDays },
|
|
356
|
+
},
|
|
357
|
+
{
|
|
358
|
+
ID: 'SmsReceiptsRetention',
|
|
359
|
+
Status: 'Enabled',
|
|
360
|
+
Filter: { Prefix: 'sms/receipts/' },
|
|
361
|
+
Expiration: { Days: config.retentionDays },
|
|
362
|
+
},
|
|
363
|
+
])
|
|
364
|
+
} catch (err: any) {
|
|
365
|
+
// Lifecycle configuration might fail if not owner, continue anyway
|
|
366
|
+
console.log(` Note: Could not set lifecycle rules: ${err.message}`)
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
} catch (err: any) {
|
|
370
|
+
throw new Error(`Failed to set up S3 inbox: ${err.message}`)
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
/**
|
|
375
|
+
* Set up SNS topic for two-way SMS
|
|
376
|
+
*/
|
|
377
|
+
async function setupTwoWayTopic(
|
|
378
|
+
sns: SNSClient,
|
|
379
|
+
awsClient: AWSClient,
|
|
380
|
+
region: string,
|
|
381
|
+
topicName: string,
|
|
382
|
+
accountId?: string,
|
|
383
|
+
): Promise<string> {
|
|
384
|
+
// Check if topic exists
|
|
385
|
+
const topics = await sns.listTopics()
|
|
386
|
+
const existingTopic = topics.Topics?.find(t => t.TopicArn?.endsWith(`:${topicName}`))
|
|
387
|
+
|
|
388
|
+
if (existingTopic) {
|
|
389
|
+
return existingTopic.TopicArn!
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
// Create new topic
|
|
393
|
+
const params = new URLSearchParams({
|
|
394
|
+
Action: 'CreateTopic',
|
|
395
|
+
Version: '2010-03-31',
|
|
396
|
+
Name: topicName,
|
|
397
|
+
})
|
|
398
|
+
|
|
399
|
+
const result = await awsClient.request({
|
|
400
|
+
service: 'sns',
|
|
401
|
+
region,
|
|
402
|
+
method: 'POST',
|
|
403
|
+
path: '/',
|
|
404
|
+
headers: {
|
|
405
|
+
'Content-Type': 'application/x-www-form-urlencoded',
|
|
406
|
+
},
|
|
407
|
+
body: params.toString(),
|
|
408
|
+
})
|
|
409
|
+
|
|
410
|
+
const topicArn = result?.CreateTopicResponse?.CreateTopicResult?.TopicArn
|
|
411
|
+
if (!topicArn) {
|
|
412
|
+
throw new Error('Failed to create SNS topic')
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
// Set up topic policy to allow SMS Voice service to publish
|
|
416
|
+
const policyParams = new URLSearchParams({
|
|
417
|
+
Action: 'SetTopicAttributes',
|
|
418
|
+
Version: '2010-03-31',
|
|
419
|
+
TopicArn: topicArn,
|
|
420
|
+
AttributeName: 'Policy',
|
|
421
|
+
AttributeValue: JSON.stringify({
|
|
422
|
+
Version: '2012-10-17',
|
|
423
|
+
Statement: [
|
|
424
|
+
{
|
|
425
|
+
Sid: 'AllowSMSVoicePublish',
|
|
426
|
+
Effect: 'Allow',
|
|
427
|
+
Principal: {
|
|
428
|
+
Service: 'sms-voice.amazonaws.com',
|
|
429
|
+
},
|
|
430
|
+
Action: 'sns:Publish',
|
|
431
|
+
Resource: topicArn,
|
|
432
|
+
},
|
|
433
|
+
],
|
|
434
|
+
}),
|
|
435
|
+
})
|
|
436
|
+
|
|
437
|
+
await awsClient.request({
|
|
438
|
+
service: 'sns',
|
|
439
|
+
region,
|
|
440
|
+
method: 'POST',
|
|
441
|
+
path: '/',
|
|
442
|
+
headers: {
|
|
443
|
+
'Content-Type': 'application/x-www-form-urlencoded',
|
|
444
|
+
},
|
|
445
|
+
body: policyParams.toString(),
|
|
446
|
+
})
|
|
447
|
+
|
|
448
|
+
return topicArn
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
/**
|
|
452
|
+
* Set up SNS topic for delivery receipts
|
|
453
|
+
*/
|
|
454
|
+
async function setupDeliveryReceiptsTopic(
|
|
455
|
+
sns: SNSClient,
|
|
456
|
+
awsClient: AWSClient,
|
|
457
|
+
region: string,
|
|
458
|
+
topicName: string,
|
|
459
|
+
accountId?: string,
|
|
460
|
+
): Promise<string> {
|
|
461
|
+
// Same as two-way topic setup
|
|
462
|
+
return setupTwoWayTopic(sns, awsClient, region, topicName, accountId)
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
/**
|
|
466
|
+
* Get complete SMS infrastructure status
|
|
467
|
+
*/
|
|
468
|
+
export async function getSmsInfrastructureStatus(config: {
|
|
469
|
+
region?: string
|
|
470
|
+
accountName?: string
|
|
471
|
+
}): Promise<{
|
|
472
|
+
sandboxStatus: 'IN_SANDBOX' | 'OUT_OF_SANDBOX' | 'UNKNOWN'
|
|
473
|
+
spendingLimit: number
|
|
474
|
+
topics: Array<{
|
|
475
|
+
name: string
|
|
476
|
+
arn: string
|
|
477
|
+
}>
|
|
478
|
+
}> {
|
|
479
|
+
const region = config.region || 'us-east-1'
|
|
480
|
+
const sns = new SNSClient(region)
|
|
481
|
+
|
|
482
|
+
// Get sandbox status
|
|
483
|
+
const accountStatus = await checkSmsAccountStatus(sns)
|
|
484
|
+
|
|
485
|
+
// Get topics
|
|
486
|
+
const topicsResult = await sns.listTopics()
|
|
487
|
+
const smsTopics = (topicsResult.Topics || [])
|
|
488
|
+
.filter(t => t.TopicArn?.includes('sms'))
|
|
489
|
+
.map(t => ({
|
|
490
|
+
name: t.TopicArn?.split(':').pop() || '',
|
|
491
|
+
arn: t.TopicArn || '',
|
|
492
|
+
}))
|
|
493
|
+
|
|
494
|
+
return {
|
|
495
|
+
sandboxStatus: accountStatus.inSandbox ? 'IN_SANDBOX' : 'OUT_OF_SANDBOX',
|
|
496
|
+
spendingLimit: accountStatus.spendingLimit,
|
|
497
|
+
topics: smsTopics,
|
|
498
|
+
}
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
/**
|
|
502
|
+
* Create SMS infrastructure for Stacks deploy
|
|
503
|
+
* This is the main entry point called by the deploy command
|
|
504
|
+
*/
|
|
505
|
+
export async function createSmsInfrastructure(smsConfig: {
|
|
506
|
+
enabled: boolean
|
|
507
|
+
provider: 'sns'
|
|
508
|
+
originationNumber?: string
|
|
509
|
+
defaultCountryCode: string
|
|
510
|
+
messageType: 'TRANSACTIONAL' | 'PROMOTIONAL'
|
|
511
|
+
maxSpendPerMonth?: number
|
|
512
|
+
inbox?: {
|
|
513
|
+
enabled: boolean
|
|
514
|
+
bucket: string
|
|
515
|
+
prefix?: string
|
|
516
|
+
retentionDays?: number
|
|
517
|
+
}
|
|
518
|
+
twoWay?: {
|
|
519
|
+
enabled: boolean
|
|
520
|
+
snsTopicArn?: string
|
|
521
|
+
}
|
|
522
|
+
optOut: {
|
|
523
|
+
enabled: boolean
|
|
524
|
+
keywords: string[]
|
|
525
|
+
}
|
|
526
|
+
}): Promise<SmsSetupResult> {
|
|
527
|
+
if (!smsConfig.enabled) {
|
|
528
|
+
return {
|
|
529
|
+
success: true,
|
|
530
|
+
errors: [],
|
|
531
|
+
warnings: ['SMS is disabled in config'],
|
|
532
|
+
}
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
// Build setup config from Stacks SMS config
|
|
536
|
+
const setupConfig: SmsSetupConfig = {
|
|
537
|
+
region: 'us-east-1',
|
|
538
|
+
accountName: 'stacks',
|
|
539
|
+
inbox: smsConfig.inbox
|
|
540
|
+
? {
|
|
541
|
+
enabled: smsConfig.inbox.enabled,
|
|
542
|
+
bucket: smsConfig.inbox.bucket,
|
|
543
|
+
prefix: smsConfig.inbox.prefix,
|
|
544
|
+
retentionDays: smsConfig.inbox.retentionDays,
|
|
545
|
+
}
|
|
546
|
+
: undefined,
|
|
547
|
+
twoWay: smsConfig.twoWay
|
|
548
|
+
? {
|
|
549
|
+
enabled: smsConfig.twoWay.enabled,
|
|
550
|
+
}
|
|
551
|
+
: undefined,
|
|
552
|
+
spending: {
|
|
553
|
+
monthlyLimit: smsConfig.maxSpendPerMonth,
|
|
554
|
+
autoRequestIncrease: true,
|
|
555
|
+
},
|
|
556
|
+
sandbox: {
|
|
557
|
+
autoRequestExit: true,
|
|
558
|
+
companyName: 'Stacks',
|
|
559
|
+
useCase:
|
|
560
|
+
'Transactional notifications, verification codes, and account alerts for web applications built with Stacks framework.',
|
|
561
|
+
expectedMonthlyVolume: 5000,
|
|
562
|
+
websiteUrl: 'https://stacksjs.com',
|
|
563
|
+
},
|
|
564
|
+
deliveryReceipts: {
|
|
565
|
+
enabled: true,
|
|
566
|
+
},
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
return setupSmsInfrastructure(setupConfig)
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
export default {
|
|
573
|
+
setupSmsInfrastructure: setupSmsInfrastructure,
|
|
574
|
+
getSmsInfrastructureStatus: getSmsInfrastructureStatus,
|
|
575
|
+
createSmsInfrastructure: createSmsInfrastructure,
|
|
576
|
+
} as {
|
|
577
|
+
setupSmsInfrastructure: typeof setupSmsInfrastructure
|
|
578
|
+
getSmsInfrastructureStatus: typeof getSmsInfrastructureStatus
|
|
579
|
+
createSmsInfrastructure: typeof createSmsInfrastructure
|
|
580
|
+
}
|