serverless-plugin-module-registry 1.0.9 → 1.0.10

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.
@@ -0,0 +1,1178 @@
1
+ /*
2
+ * Module Registry Service Functions
3
+ * -------------------------------------------
4
+ * Service layer for querying module registry data from DynamoDB.
5
+ * These functions can be imported directly from the plugin package.
6
+ *
7
+ * Usage:
8
+ * import { listAllModules, getModuleFeatures } from 'serverless-plugin-module-registry'
9
+ */
10
+
11
+ import { DynamoDBClient, QueryCommand, ScanCommand, PutItemCommand, UpdateItemCommand, DeleteItemCommand } from '@aws-sdk/client-dynamodb'
12
+ import { unmarshall, marshall } from '@aws-sdk/util-dynamodb'
13
+ import { nanoid } from 'nanoid'
14
+
15
+ // Self-contained feature ID utilities (to avoid import issues in generated service)
16
+ const generateFeatureId = () => nanoid(8)
17
+ const validateFeatureId = (featureId) => {
18
+ if (featureId.length !== 8) return false
19
+ if (!/^[A-Za-z0-9_-]+$/.test(featureId)) return false
20
+ return true
21
+ }
22
+
23
+ const TABLE_NAME = 'placeholder-table-name'
24
+ const GSI1_NAME = 'GSI1'
25
+
26
+ // Validate table name at runtime
27
+ if (TABLE_NAME === 'placeholder-table-name') {
28
+ console.warn('[module-registry] Warning: TABLE_NAME not properly injected during build. Service functions may fail at runtime.')
29
+ }
30
+
31
+ // Client configuration with retry logic and connection pooling
32
+ const client = new DynamoDBClient({
33
+ maxAttempts: 3,
34
+ retryMode: 'adaptive',
35
+ // Enable connection pooling for better performance
36
+ maxSockets: 50,
37
+ requestHandler: {
38
+ requestTimeout: 30000, // 30 seconds
39
+ httpsAgent: {
40
+ keepAlive: true,
41
+ maxSockets: 50
42
+ }
43
+ }
44
+ })
45
+
46
+ // Simple in-memory cache for frequently accessed data
47
+ const cache = new Map()
48
+ const CACHE_TTL = 5 * 60 * 1000 // 5 minutes
49
+
50
+ /**
51
+ * Simple caching utility
52
+ */
53
+ const withCache = (key, ttl = CACHE_TTL) => {
54
+ return {
55
+ get: () => {
56
+ const cached = cache.get(key)
57
+ if (cached && Date.now() < cached.expires) {
58
+ return cached.value
59
+ }
60
+ cache.delete(key)
61
+ return null
62
+ },
63
+ set: (value) => {
64
+ cache.set(key, {
65
+ value,
66
+ expires: Date.now() + ttl
67
+ })
68
+ }
69
+ }
70
+ }
71
+
72
+ /**
73
+ * Creates a structured logger for module registry operations
74
+ * @param {string} context - The operation context (e.g., 'listAllModules')
75
+ * @returns {Object} Logger with info, warn, error methods
76
+ */
77
+ export const createModuleRegistryLogger = (context) => {
78
+ const prefix = `[module-registry:${context}]`
79
+
80
+ return {
81
+ info: (message, data) => console.log(prefix, message, data ? JSON.stringify(data) : ''),
82
+ warn: (message, data) => console.warn(prefix, message, data ? JSON.stringify(data) : ''),
83
+ error: (message, error) => console.error(prefix, message, error?.message || error)
84
+ }
85
+ }
86
+
87
+ /**
88
+ * Sanitize feature name to only allow lowercase letters, numbers, and hyphens [a-z0-9-]
89
+ * Converts underscores and spaces to hyphens
90
+ * @param {string} featureName - The feature name to sanitize
91
+ * @returns {string} - Sanitized feature name
92
+ * @throws {Error} - If feature name is invalid after sanitization
93
+ */
94
+ const sanitizeFeatureName = (featureName) => {
95
+ if (!featureName || typeof featureName !== 'string') {
96
+ throw new Error('Feature name is required and must be a string')
97
+ }
98
+
99
+ // Convert to lowercase, replace underscores and spaces with hyphens, then keep only [a-z0-9-] characters
100
+ const sanitized = featureName.toLowerCase()
101
+ .replace(/[_\s]+/g, '-') // Replace underscores and spaces with hyphens
102
+ .replace(/[^a-z0-9-]/g, '') // Keep only letters, numbers, and hyphens
103
+ .replace(/--+/g, '-') // Replace multiple consecutive hyphens with single hyphen
104
+ .replace(/^-+|-+$/g, '') // Remove leading and trailing hyphens
105
+
106
+ if (sanitized.length === 0) {
107
+ throw new Error('Feature name must contain at least one alphanumeric character [a-z0-9-]')
108
+ }
109
+
110
+ return sanitized
111
+ }
112
+
113
+ /**
114
+ * Validates and sanitizes input parameters
115
+ * @param {Object} params - Parameters to validate
116
+ * @param {Array} required - Required parameter names
117
+ * @throws {Error} If validation fails
118
+ */
119
+ const validateParams = (params, required) => {
120
+ for (const param of required) {
121
+ if (!params[param] || (typeof params[param] === 'string' && params[param].trim() === '')) {
122
+ throw new Error(`${param} is required and cannot be empty`)
123
+ }
124
+ }
125
+
126
+ // Sanitize string parameters to prevent injection
127
+ const sanitized = {}
128
+ for (const [key, value] of Object.entries(params)) {
129
+ if (typeof value === 'string') {
130
+ // Basic sanitization - remove potentially dangerous characters
131
+ let sanitizedValue = value.replace(/[<>\"']/g, '').trim()
132
+
133
+ // Apply feature name sanitization for featureName parameters
134
+ if (key === 'featureName') {
135
+ sanitizedValue = sanitizeFeatureName(sanitizedValue)
136
+ }
137
+
138
+ sanitized[key] = sanitizedValue
139
+ } else {
140
+ sanitized[key] = value
141
+ }
142
+ }
143
+
144
+ return sanitized
145
+ }
146
+
147
+ /**
148
+ * Wraps DynamoDB operations with error handling and retry logic
149
+ * @param {Function} operation - The DynamoDB operation to execute
150
+ * @param {string} operationName - Name for logging
151
+ * @returns {Promise} Operation result
152
+ */
153
+ const withErrorHandling = async (operation, operationName) => {
154
+ const logger = createModuleRegistryLogger(operationName)
155
+
156
+ try {
157
+ const startTime = Date.now()
158
+ const result = await operation()
159
+ const duration = Date.now() - startTime
160
+ return result
161
+
162
+ } catch (error) {
163
+ logger.error(`Operation failed`, error)
164
+
165
+ // Provide user-friendly error messages
166
+ if (error.name === 'ResourceNotFoundException') {
167
+ throw new Error(`Module registry table not found. Please ensure the plugin is properly deployed.`)
168
+ } else if (error.name === 'ValidationException') {
169
+ throw new Error(`Invalid request parameters: ${error.message}`)
170
+ } else if (error.name === 'ProvisionedThroughputExceededException') {
171
+ throw new Error(`Registry service is temporarily overloaded. Please try again in a moment.`)
172
+ } else if (error.name === 'ThrottlingException') {
173
+ throw new Error(`Request rate exceeded. Please try again in a moment.`)
174
+ } else {
175
+ throw new Error(`Registry service error: ${error.message}`)
176
+ }
177
+ }
178
+ }
179
+ /**
180
+ * Lists all deployed modules with basic metadata
181
+ * @returns {Promise<Array>} Array of module objects with clean data (no DDB internals)
182
+ */
183
+ export const listAllModules = async () => {
184
+ return withErrorHandling(async () => {
185
+ // Check cache first
186
+ const cacheKey = 'listAllModules'
187
+ const cached = withCache(cacheKey)
188
+ const cachedResult = cached.get()
189
+ if (cachedResult) {
190
+ return cachedResult
191
+ }
192
+
193
+ const command = new QueryCommand({
194
+ TableName: TABLE_NAME,
195
+ IndexName: GSI1_NAME,
196
+ KeyConditionExpression: 'gsi1pk = :pk',
197
+ FilterExpression: 'itemType = :itemType',
198
+ ExpressionAttributeValues: {
199
+ ':pk': { S: 'MODULES' },
200
+ ':itemType': { S: 'module' }
201
+ }
202
+ })
203
+
204
+ const response = await client.send(command)
205
+
206
+ if (!response.Items) {
207
+ return []
208
+ }
209
+
210
+ // Transform DDB items to clean module objects
211
+ const modules = response.Items.map(item => {
212
+ const data = unmarshall(item)
213
+
214
+ return {
215
+ moduleName: data.moduleName,
216
+ description: data.description,
217
+ version: data.version,
218
+ maintainer: data.maintainer,
219
+ tags: data.tags || [],
220
+ apiGatewayId: data.apiGatewayId,
221
+ lastUpdated: data.lastUpdated
222
+ }
223
+ })
224
+
225
+ // Cache the result
226
+ cached.set(modules)
227
+
228
+ return modules
229
+ }, 'listAllModules')
230
+ }
231
+
232
+ /**
233
+ * Gets all features for a specific module
234
+ * @param {string} moduleName - The module name (e.g., 'sign')
235
+ * @returns {Promise<Array>} Array of feature objects with endpoints
236
+ */
237
+ export const getModuleFeatures = async (moduleName) => {
238
+ const params = validateParams({ moduleName }, ['moduleName'])
239
+
240
+ return withErrorHandling(async () => {
241
+ const command = new QueryCommand({
242
+ TableName: TABLE_NAME,
243
+ KeyConditionExpression: 'pk = :pk AND begins_with(sk, :skPrefix)',
244
+ FilterExpression: 'itemType = :itemType',
245
+ ExpressionAttributeValues: {
246
+ ':pk': { S: `MODULE#${params.moduleName}` },
247
+ ':skPrefix': { S: 'FEATURE#' },
248
+ ':itemType': { S: 'feature' }
249
+ }
250
+ })
251
+
252
+ const response = await client.send(command)
253
+
254
+ if (!response.Items) {
255
+ return []
256
+ }
257
+
258
+ // Transform DDB items to clean feature objects
259
+ const features = response.Items.map(item => {
260
+ const data = unmarshall(item)
261
+
262
+ return {
263
+ featureName: data.featureName,
264
+ featureId: data.featureId, // Include featureId for ABAC tag generation
265
+ description: data.description,
266
+ version: data.version,
267
+ endpoints: data.endpoints || [],
268
+ customPolicies: data.customPolicies || [],
269
+ endpointCount: (data.endpoints || []).length,
270
+ lastUpdated: data.lastUpdated
271
+ }
272
+ })
273
+
274
+ return features
275
+ }, 'getModuleFeatures')
276
+ }
277
+
278
+ /**
279
+ * Gets detailed information about a specific feature
280
+ * @param {string} moduleName - The module name
281
+ * @param {string} featureId - The feature ID (featureId is KING!)
282
+ * @returns {Promise<Object|null>} Feature details with endpoints and policies
283
+ */
284
+ export const getFeatureDetails = async (moduleName, featureId) => {
285
+ const params = validateParams({ moduleName, featureId }, ['moduleName', 'featureId'])
286
+
287
+ return withErrorHandling(async () => {
288
+ const command = new QueryCommand({
289
+ TableName: TABLE_NAME,
290
+ KeyConditionExpression: 'pk = :pk AND sk = :sk',
291
+ ExpressionAttributeValues: {
292
+ ':pk': { S: `MODULE#${params.moduleName}` },
293
+ ':sk': { S: `FEATURE#${params.featureId}` }
294
+ }
295
+ })
296
+
297
+ const response = await client.send(command)
298
+
299
+ if (!response.Items || response.Items.length === 0) {
300
+ return null
301
+ }
302
+
303
+ // Transform DDB item to clean feature object
304
+ return unmarshall(response.Items[0])
305
+ }, 'getFeatureDetails')
306
+ }
307
+
308
+
309
+ /**
310
+ * Gets module metadata (basic info without features)
311
+ * @param {string} moduleName - The module name
312
+ * @returns {Promise<Object|null>} Module metadata or null if not found
313
+ */
314
+ export const getModuleMetadata = async (moduleName) => {
315
+ const params = validateParams({ moduleName }, ['moduleName'])
316
+
317
+ return withErrorHandling(async () => {
318
+ const command = new QueryCommand({
319
+ TableName: TABLE_NAME,
320
+ KeyConditionExpression: 'pk = :pk AND sk = :sk',
321
+ ExpressionAttributeValues: {
322
+ ':pk': { S: `MODULE#${params.moduleName}` },
323
+ ':sk': { S: 'MODULE' }
324
+ }
325
+ })
326
+
327
+ const response = await client.send(command)
328
+
329
+ if (!response.Items || response.Items.length === 0) {
330
+ return null
331
+ }
332
+
333
+ // Transform DDB item to clean module object
334
+ const data = unmarshall(response.Items[0])
335
+
336
+ const moduleMetadata = {
337
+ moduleName: data.moduleName,
338
+ description: data.description,
339
+ version: data.version,
340
+ maintainer: data.maintainer,
341
+ tags: data.tags || [],
342
+ apiGatewayId: data.apiGatewayId,
343
+ lastUpdated: data.lastUpdated
344
+ }
345
+
346
+ return moduleMetadata
347
+ }, 'getModuleMetadata')
348
+ }
349
+
350
+ /**
351
+ * Gets feature details by featureId only (without knowing module name)
352
+ * Uses GSI2 to lookup feature directly by featureId
353
+ * @param {string} featureId - The unique feature identifier
354
+ * @returns {Promise<Object|null>} Feature details or null if not found
355
+ */
356
+ export const getFeatureById = async (featureId) => {
357
+ const params = validateParams({ featureId }, ['featureId'])
358
+
359
+ return withErrorHandling(async () => {
360
+ const command = new QueryCommand({
361
+ TableName: TABLE_NAME,
362
+ IndexName: 'GSI2',
363
+ KeyConditionExpression: 'gsi2pk = :gsi2pk AND gsi2sk = :gsi2sk',
364
+ ExpressionAttributeValues: {
365
+ ':gsi2pk': { S: 'FEATURES' },
366
+ ':gsi2sk': { S: `FEATURE#${params.featureId}` }
367
+ }
368
+ })
369
+
370
+ const response = await client.send(command)
371
+
372
+ if (!response.Items || response.Items.length === 0) {
373
+ return null
374
+ }
375
+
376
+ // Transform DDB item to clean feature object
377
+ const data = unmarshall(response.Items[0])
378
+
379
+ return {
380
+ moduleName: data.moduleName,
381
+ featureName: data.featureName,
382
+ featureId: data.featureId,
383
+ description: data.description,
384
+ version: data.version,
385
+ endpoints: data.endpoints || [],
386
+ customPolicies: data.customPolicies || [],
387
+ customFeature: data.customFeature || false,
388
+ endpointCount: (data.endpoints || []).length,
389
+ policyCount: (data.customPolicies || []).length,
390
+ lastUpdated: data.lastUpdated
391
+ }
392
+ }, 'getFeatureById')
393
+ }
394
+
395
+ /**
396
+ * Gets endpoint summary across all modules for discovery
397
+ * @returns {Promise<Array>} Array of endpoints with module/feature context
398
+ */
399
+ export const getAllEndpoints = async () => {
400
+ return withErrorHandling(async () => {
401
+ const command = new ScanCommand({
402
+ TableName: TABLE_NAME,
403
+ FilterExpression: 'itemType = :itemType',
404
+ ExpressionAttributeValues: {
405
+ ':itemType': { S: 'feature' }
406
+ }
407
+ })
408
+
409
+ const response = await client.send(command)
410
+
411
+ if (!response.Items) {
412
+ return []
413
+ }
414
+
415
+ // Transform to flat endpoint list with context
416
+ const endpoints = []
417
+
418
+ for (const item of response.Items) {
419
+ const data = unmarshall(item)
420
+
421
+ if (data.endpoints && Array.isArray(data.endpoints)) {
422
+ for (const endpoint of data.endpoints) {
423
+ endpoints.push({
424
+ endpoint,
425
+ moduleName: data.moduleName,
426
+ featureName: data.featureName,
427
+ featureDescription: data.description,
428
+ version: data.version,
429
+ customFeature: data.customFeature || false
430
+ })
431
+ }
432
+ }
433
+ }
434
+
435
+ return endpoints
436
+ }, 'getAllEndpoints')
437
+ }
438
+
439
+ /**
440
+ * Gets endpoint summary from non-custom features only
441
+ * @returns {Promise<Array>} Array of endpoints from regular (non-custom) features
442
+ */
443
+ export const getNonCustomEndpoints = async () => {
444
+ return withErrorHandling(async () => {
445
+ const command = new ScanCommand({
446
+ TableName: TABLE_NAME,
447
+ FilterExpression: 'itemType = :itemType AND (attribute_not_exists(customFeature) OR customFeature = :customFeature)',
448
+ ExpressionAttributeValues: {
449
+ ':itemType': { S: 'feature' },
450
+ ':customFeature': { BOOL: false }
451
+ }
452
+ })
453
+
454
+ const response = await client.send(command)
455
+
456
+ if (!response.Items) {
457
+ return []
458
+ }
459
+
460
+ // Transform to flat endpoint list with context
461
+ const endpoints = []
462
+
463
+ for (const item of response.Items) {
464
+ const data = unmarshall(item)
465
+
466
+ if (data.endpoints && Array.isArray(data.endpoints)) {
467
+ for (const endpoint of data.endpoints) {
468
+ endpoints.push({
469
+ endpoint,
470
+ moduleName: data.moduleName,
471
+ featureName: data.featureName,
472
+ featureDescription: data.description,
473
+ version: data.version,
474
+ customFeature: false
475
+ })
476
+ }
477
+ }
478
+ }
479
+
480
+ return endpoints
481
+ }, 'getNonCustomEndpoints')
482
+ }
483
+
484
+ /**
485
+ * Creates a new custom feature in the module registry
486
+ * @param {Object} featureData - The feature data
487
+ * @param {string} featureData.moduleName - The module name
488
+ * @param {string} featureData.featureName - The feature name
489
+ * @param {string} featureData.description - Feature description
490
+ * @param {string} featureData.version - Feature version (defaults to '1.0.0')
491
+ * @param {Array} featureData.endpoints - Array of endpoints
492
+ * @param {Array} featureData.customPolicies - Array of custom policies
493
+ * @param {string} featureData.tenantId - Required tenant ID for tenant-specific custom features
494
+ * @param {string} featureData.policyArn - Optional policy ARN for the feature
495
+ * @param {string} featureData.policyName - Optional policy name for the feature
496
+ * @returns {Promise<Object>} Created feature object
497
+ */
498
+ export const createCustomFeature = async (featureData) => {
499
+ const params = validateParams(featureData, ['moduleName', 'featureName', 'description', 'tenantId'])
500
+
501
+ return withErrorHandling(async () => {
502
+ const logger = createModuleRegistryLogger('createCustomFeature')
503
+
504
+ // Use tenant-isolated partition key for custom features
505
+ const tenantModulePk = `TENANT#${params.tenantId}#MODULE#${params.moduleName}`
506
+
507
+ // Generate feature ID if not provided
508
+ const featureId = params.featureId || generateFeatureId()
509
+
510
+ // Validate feature ID
511
+ if (!validateFeatureId(featureId)) {
512
+ throw new Error(`Invalid feature ID: ${featureId} for feature: ${params.featureName}`)
513
+ }
514
+
515
+ // Prepare the feature item with tenant-isolated keys
516
+ const featureItem = {
517
+ pk: tenantModulePk,
518
+ sk: `FEATURE#${featureId}`,
519
+ gsi1pk: `TENANT#${params.tenantId}`, // For querying all tenant's custom features
520
+ gsi1sk: `MODULE#${params.moduleName}#FEATURE#${featureId}`,
521
+ itemType: 'feature',
522
+ moduleName: params.moduleName,
523
+ featureName: params.featureName,
524
+ featureId: featureId,
525
+ description: params.description,
526
+ version: params.version || '1.0.0',
527
+ endpoints: params.endpoints || [],
528
+ customPolicies: params.customPolicies || [],
529
+ customFeature: true, // Mark as custom feature
530
+ tenantId: params.tenantId, // Always required for custom features
531
+ lastUpdated: new Date().toISOString()
532
+ }
533
+
534
+ if (params.policyArn) {
535
+ featureItem.policyArn = params.policyArn
536
+ }
537
+
538
+ if (params.policyName) {
539
+ featureItem.policyName = params.policyName
540
+ }
541
+
542
+ logger.info(`Creating tenant-isolated custom feature ${params.featureName} in module ${params.moduleName} for tenant ${params.tenantId}`)
543
+
544
+ // Use PutItem to create the feature with tenant-isolated key
545
+ const command = new PutItemCommand({
546
+ TableName: TABLE_NAME,
547
+ Item: marshall(featureItem, { removeUndefinedValues: true }),
548
+ ConditionExpression: 'attribute_not_exists(pk)', // Prevent overwriting existing features
549
+ })
550
+
551
+ await client.send(command)
552
+
553
+ logger.info(`Tenant-isolated custom feature ${params.featureName} created successfully for tenant ${params.tenantId}`)
554
+
555
+ // Return clean feature object (without DDB internals)
556
+ return {
557
+ moduleName: featureItem.moduleName,
558
+ featureName: featureItem.featureName,
559
+ featureId: featureItem.featureId,
560
+ description: featureItem.description,
561
+ version: featureItem.version,
562
+ endpoints: featureItem.endpoints,
563
+ customPolicies: featureItem.customPolicies,
564
+ customFeature: featureItem.customFeature,
565
+ tenantId: featureItem.tenantId,
566
+ endpointCount: featureItem.endpoints.length,
567
+ lastUpdated: featureItem.lastUpdated,
568
+ ...(featureItem.policyArn && { policyArn: featureItem.policyArn }),
569
+ ...(featureItem.policyName && { policyName: featureItem.policyName })
570
+ }
571
+ }, 'createCustomFeature')
572
+ }
573
+
574
+ /**
575
+ * Creates a new regular (non-custom) feature in the module registry
576
+ * @param {Object} featureData - The feature data
577
+ * @param {string} featureData.moduleName - The module name
578
+ * @param {string} featureData.featureName - The feature name
579
+ * @param {string} featureData.description - Feature description
580
+ * @param {string} featureData.version - Feature version (defaults to '1.0.0')
581
+ * @param {Array} featureData.endpoints - Array of endpoints
582
+ * @param {Array} featureData.customPolicies - Array of custom policies
583
+ * @param {string} featureData.policyArn - Optional policy ARN for the feature
584
+ * @param {string} featureData.policyName - Optional policy name for the feature
585
+ * @returns {Promise<Object>} Created feature object
586
+ */
587
+ export const createFeature = async (featureData) => {
588
+ const params = validateParams(featureData, ['moduleName', 'featureName', 'description'])
589
+
590
+ return withErrorHandling(async () => {
591
+ const logger = createModuleRegistryLogger('createFeature')
592
+
593
+ // Generate feature ID if not provided
594
+ const featureId = params.featureId || generateFeatureId()
595
+
596
+ // Validate feature ID
597
+ if (!validateFeatureId(featureId)) {
598
+ throw new Error(`Invalid feature ID: ${featureId} for feature: ${params.featureName}`)
599
+ }
600
+
601
+ // Prepare the feature item
602
+ const featureItem = {
603
+ pk: `MODULE#${params.moduleName}`,
604
+ sk: `FEATURE#${featureId}`,
605
+ gsi1pk: `MODULE#${params.moduleName}`,
606
+ gsi1sk: `FEATURE#${featureId}`,
607
+ itemType: 'feature',
608
+ moduleName: params.moduleName,
609
+ featureName: params.featureName,
610
+ featureId: featureId,
611
+ description: params.description,
612
+ version: params.version || '1.0.0',
613
+ endpoints: params.endpoints || [],
614
+ customPolicies: params.customPolicies || [],
615
+ customFeature: false, // Mark as regular feature
616
+ lastUpdated: new Date().toISOString()
617
+ }
618
+
619
+ // Add optional fields if provided
620
+ if (params.policyArn) {
621
+ featureItem.policyArn = params.policyArn
622
+ }
623
+
624
+ if (params.policyName) {
625
+ featureItem.policyName = params.policyName
626
+ }
627
+
628
+ logger.info(`Creating feature ${params.featureName} in module ${params.moduleName}`)
629
+
630
+ // Use PutItem to create the feature
631
+ const command = new PutItemCommand({
632
+ TableName: TABLE_NAME,
633
+ Item: marshall(featureItem, { removeUndefinedValues: true }),
634
+ ConditionExpression: 'attribute_not_exists(pk)', // Prevent overwriting existing features
635
+ })
636
+
637
+ await client.send(command)
638
+
639
+ logger.info(`Feature ${params.featureName} created successfully`)
640
+
641
+ // Return clean feature object (without DDB internals)
642
+ return {
643
+ moduleName: featureItem.moduleName,
644
+ featureName: featureItem.featureName,
645
+ featureId: featureItem.featureId,
646
+ description: featureItem.description,
647
+ version: featureItem.version,
648
+ endpoints: featureItem.endpoints,
649
+ customPolicies: featureItem.customPolicies,
650
+ customFeature: featureItem.customFeature,
651
+ endpointCount: featureItem.endpoints.length,
652
+ lastUpdated: featureItem.lastUpdated,
653
+ ...(featureItem.policyArn && { policyArn: featureItem.policyArn }),
654
+ ...(featureItem.policyName && { policyName: featureItem.policyName })
655
+ }
656
+ }, 'createFeature')
657
+ }
658
+
659
+ /**
660
+ * Updates a custom feature in the module registry
661
+ * @param {string} moduleName - The module name
662
+ * @param {string} featureId - The feature ID (featureId is KING!)
663
+ * @param {Object} updates - Fields to update
664
+ * @param {string} updates.description - Updated description
665
+ * @param {Array} updates.endpoints - Updated endpoints array
666
+ * @param {Array} updates.customPolicies - Updated custom policies
667
+ * @param {string} tenantId - Required tenant ID for tenant-isolated custom features
668
+ * @returns {Promise<Object>} Updated feature object
669
+ */
670
+ export const updateCustomFeature = async (moduleName, featureId, updates, tenantId) => {
671
+ const params = validateParams({ moduleName, featureId, tenantId }, ['moduleName', 'featureId', 'tenantId'])
672
+
673
+ return withErrorHandling(async () => {
674
+ const logger = createModuleRegistryLogger('updateCustomFeature')
675
+
676
+ const allowedUpdates = ['description', 'endpoints', 'customPolicies']
677
+ const filteredUpdates = {}
678
+
679
+ for (const [key, value] of Object.entries(updates)) {
680
+ if (allowedUpdates.includes(key)) {
681
+ filteredUpdates[key] = value
682
+ }
683
+ }
684
+
685
+ if (Object.keys(filteredUpdates).length === 0) {
686
+ throw new Error(`No valid fields to update. Allowed fields: ${allowedUpdates.join(', ')}`)
687
+ }
688
+
689
+ // Add lastUpdated timestamp
690
+ filteredUpdates.lastUpdated = new Date().toISOString()
691
+
692
+ logger.info(`Updating tenant-isolated custom feature ${params.featureId} in module ${params.moduleName} for tenant ${params.tenantId}`)
693
+
694
+ const expressionParts = []
695
+ const expressionAttributeNames = {}
696
+ const expressionAttributeValues = {}
697
+
698
+ for (const [key, value] of Object.entries(filteredUpdates)) {
699
+ expressionParts.push(`#${key} = :${key}`)
700
+ expressionAttributeNames[`#${key}`] = key
701
+ expressionAttributeValues[`:${key}`] = value
702
+ }
703
+
704
+ // Condition to ensure it exists, is a custom feature, and belongs to the tenant
705
+ const conditionExpression = 'attribute_exists(pk) AND customFeature = :customFeature AND tenantId = :tenantId'
706
+ expressionAttributeValues[':customFeature'] = true
707
+ expressionAttributeValues[':tenantId'] = params.tenantId
708
+
709
+ // Use tenant-isolated partition key
710
+ const tenantModulePk = `TENANT#${params.tenantId}#MODULE#${params.moduleName}`
711
+
712
+ const command = new UpdateItemCommand({
713
+ TableName: TABLE_NAME,
714
+ Key: marshall({
715
+ pk: tenantModulePk,
716
+ sk: `FEATURE#${params.featureId}`
717
+ }),
718
+ UpdateExpression: `SET ${expressionParts.join(', ')}`,
719
+ ExpressionAttributeNames: expressionAttributeNames,
720
+ ExpressionAttributeValues: marshall(expressionAttributeValues),
721
+ ConditionExpression: conditionExpression,
722
+ ReturnValues: 'ALL_NEW'
723
+ })
724
+
725
+ const { Attributes } = await client.send(command)
726
+
727
+ logger.info(`Tenant-isolated custom feature ${params.featureId} updated successfully for tenant ${params.tenantId}`)
728
+
729
+ // Return clean feature object (without DDB internals)
730
+ const updatedFeature = unmarshall(Attributes)
731
+ return {
732
+ moduleName: updatedFeature.moduleName,
733
+ featureName: updatedFeature.featureName,
734
+ description: updatedFeature.description,
735
+ version: updatedFeature.version,
736
+ endpoints: updatedFeature.endpoints,
737
+ customPolicies: updatedFeature.customPolicies,
738
+ customFeature: updatedFeature.customFeature,
739
+ tenantId: updatedFeature.tenantId,
740
+ endpointCount: updatedFeature.endpoints.length,
741
+ lastUpdated: updatedFeature.lastUpdated,
742
+ ...(updatedFeature.policyArn && { policyArn: updatedFeature.policyArn }),
743
+ ...(updatedFeature.policyName && { policyName: updatedFeature.policyName })
744
+ }
745
+ }, 'updateCustomFeature')
746
+ }
747
+
748
+ /**
749
+ * Deletes a custom feature from the module registry
750
+ * @param {string} moduleName - The module name
751
+ * @param {string} featureId - The feature ID (featureId is KING!)
752
+ * @param {string} tenantId - Required tenant ID for tenant-isolated custom features
753
+ * @returns {Promise<boolean>} True if deleted successfully
754
+ */
755
+ export const deleteCustomFeature = async (moduleName, featureId, tenantId) => {
756
+ const params = validateParams({ moduleName, featureId, tenantId }, ['moduleName', 'featureId', 'tenantId'])
757
+
758
+ return withErrorHandling(async () => {
759
+ const logger = createModuleRegistryLogger('deleteCustomFeature')
760
+
761
+ logger.info(`Deleting tenant-isolated custom feature ${params.featureId} from module ${params.moduleName} for tenant ${params.tenantId}`)
762
+
763
+ // Condition to ensure it exists, is a custom feature, and belongs to the tenant
764
+ const conditionExpression = 'attribute_exists(pk) AND customFeature = :customFeature AND tenantId = :tenantId'
765
+ const expressionAttributeValues = {
766
+ ':customFeature': true,
767
+ ':tenantId': params.tenantId
768
+ }
769
+
770
+ // Use tenant-isolated partition key
771
+ const tenantModulePk = `TENANT#${params.tenantId}#MODULE#${params.moduleName}`
772
+
773
+ const command = new DeleteItemCommand({
774
+ TableName: TABLE_NAME,
775
+ Key: marshall({
776
+ pk: tenantModulePk,
777
+ sk: `FEATURE#${params.featureId}`
778
+ }),
779
+ ConditionExpression: conditionExpression,
780
+ ExpressionAttributeValues: marshall(expressionAttributeValues)
781
+ })
782
+
783
+ await client.send(command)
784
+
785
+ logger.info(`Tenant-isolated custom feature ${params.featureId} deleted successfully for tenant ${params.tenantId}`)
786
+ return true
787
+ }, 'deleteCustomFeature')
788
+ }
789
+
790
+ /**
791
+ * Lists custom features for a specific tenant using efficient GSI query
792
+ * @param {string} tenantId - The tenant ID
793
+ * @returns {Promise<Array>} Array of custom features for the tenant
794
+ */
795
+ export const listCustomFeaturesByTenant = async (tenantId) => {
796
+ const params = validateParams({ tenantId }, ['tenantId'])
797
+
798
+ return withErrorHandling(async () => {
799
+ const logger = createModuleRegistryLogger('listCustomFeaturesByTenant')
800
+
801
+ logger.info(`Listing tenant-isolated custom features for tenant ${params.tenantId}`)
802
+
803
+ // Use GSI1 for efficient tenant-specific query
804
+ const command = new QueryCommand({
805
+ TableName: TABLE_NAME,
806
+ IndexName: GSI1_NAME,
807
+ KeyConditionExpression: 'gsi1pk = :tenantPk',
808
+ FilterExpression: 'itemType = :itemType AND customFeature = :customFeature',
809
+ ExpressionAttributeValues: {
810
+ ':tenantPk': { S: `TENANT#${params.tenantId}` },
811
+ ':itemType': { S: 'feature' },
812
+ ':customFeature': { BOOL: true }
813
+ }
814
+ })
815
+
816
+ const response = await client.send(command)
817
+
818
+ if (!response.Items) {
819
+ return []
820
+ }
821
+
822
+ // Transform to clean feature objects
823
+ const customFeatures = response.Items.map(item => {
824
+ const data = unmarshall(item)
825
+
826
+ return {
827
+ moduleName: data.moduleName,
828
+ featureName: data.featureName,
829
+ description: data.description,
830
+ version: data.version,
831
+ endpoints: data.endpoints || [],
832
+ customPolicies: data.customPolicies || [],
833
+ customFeature: data.customFeature,
834
+ tenantId: data.tenantId,
835
+ endpointCount: (data.endpoints || []).length,
836
+ lastUpdated: data.lastUpdated,
837
+ ...(data.policyArn && { policyArn: data.policyArn }),
838
+ ...(data.policyName && { policyName: data.policyName })
839
+ }
840
+ })
841
+
842
+ logger.info(`Found ${customFeatures.length} tenant-isolated custom features for tenant ${params.tenantId}`)
843
+ return customFeatures
844
+ }, 'listCustomFeaturesByTenant')
845
+ }
846
+
847
+ /**
848
+ * Gets detailed information about a specific tenant custom feature
849
+ * @param {string} moduleName - The module name
850
+ * @param {string} featureId - The feature ID (featureId is KING!)
851
+ * @param {string} tenantId - The tenant ID
852
+ * @returns {Promise<Object|null>} Tenant custom feature details or null if not found
853
+ */
854
+ export const getTenantCustomFeature = async (moduleName, featureId, tenantId) => {
855
+ const params = validateParams({ moduleName, featureId, tenantId }, ['moduleName', 'featureId', 'tenantId'])
856
+
857
+ return withErrorHandling(async () => {
858
+ const logger = createModuleRegistryLogger('getTenantCustomFeature')
859
+
860
+ logger.info(`Getting tenant-isolated custom feature ${params.featureId} from module ${params.moduleName} for tenant ${params.tenantId}`)
861
+
862
+ // Use tenant-isolated partition key
863
+ const tenantModulePk = `TENANT#${params.tenantId}#MODULE#${params.moduleName}`
864
+
865
+ const command = new QueryCommand({
866
+ TableName: TABLE_NAME,
867
+ KeyConditionExpression: 'pk = :pk AND sk = :sk',
868
+ ExpressionAttributeValues: {
869
+ ':pk': { S: tenantModulePk },
870
+ ':sk': { S: `FEATURE#${params.featureId}` }
871
+ }
872
+ })
873
+
874
+ const response = await client.send(command)
875
+
876
+ if (!response.Items || response.Items.length === 0) {
877
+ logger.info(`Tenant custom feature ${params.featureId} not found for tenant ${params.tenantId}`)
878
+ return null
879
+ }
880
+
881
+ // Transform DDB item to clean feature object
882
+ const data = unmarshall(response.Items[0])
883
+
884
+ return {
885
+ moduleName: data.moduleName,
886
+ featureName: data.featureName,
887
+ description: data.description,
888
+ version: data.version,
889
+ endpoints: data.endpoints || [],
890
+ customPolicies: data.customPolicies || [],
891
+ customFeature: data.customFeature,
892
+ tenantId: data.tenantId,
893
+ endpointCount: (data.endpoints || []).length,
894
+ lastUpdated: data.lastUpdated,
895
+ ...(data.policyArn && { policyArn: data.policyArn }),
896
+ ...(data.policyName && { policyName: data.policyName })
897
+ }
898
+ }, 'getTenantCustomFeature')
899
+ }
900
+
901
+ // =============================================================================
902
+ // ABAC (Attribute-Based Access Control) Functions
903
+ // =============================================================================
904
+
905
+ /**
906
+ * Converts an endpoint string to API Gateway ARN format
907
+ * @param {string} endpoint - Endpoint in format "METHOD - /path/pattern"
908
+ * @param {string} region - AWS region (defaults to *)
909
+ * @param {string} accountId - AWS account ID (defaults to *)
910
+ * @param {string} apiId - API Gateway ID (defaults to *)
911
+ * @returns {string} API Gateway ARN
912
+ */
913
+ const endpointToArn = (endpoint, region = '*', accountId = '*', apiId = '*') => {
914
+ // Parse endpoint format: "POST /admin/tenants" or "POST admin/tenants"
915
+ const trimmedEndpoint = endpoint.trim()
916
+ const spaceIndex = trimmedEndpoint.indexOf(' ')
917
+ if (spaceIndex === -1) {
918
+ throw new Error(`Invalid endpoint format: ${endpoint}. Expected "METHOD path"`)
919
+ }
920
+
921
+ const method = trimmedEndpoint.substring(0, spaceIndex).trim().toUpperCase()
922
+ let path = trimmedEndpoint.substring(spaceIndex + 1).trim()
923
+
924
+ // Normalize path to ensure it starts with /
925
+ if (!path.startsWith('/')) {
926
+ path = `/${path}`
927
+ }
928
+
929
+ // Convert path parameters from {param} to * for ARN matching
930
+ const arnPath = path.replace(/\{[^}]+\}/g, '*')
931
+
932
+ return `arn:aws:execute-api:${region}:${accountId}:${apiId}/*/${method}${arnPath}`
933
+ }
934
+
935
+ /**
936
+ * Creates an ABAC condition for feature access
937
+ * @param {string} moduleName - Module name (e.g., 'tenants')
938
+ * @param {string} featureName - Feature name (e.g., 'criar-tenant')
939
+ * @returns {Object} IAM condition object
940
+ */
941
+ const createAbacCondition = (moduleName, featureName) => {
942
+ return {
943
+ StringLike: {
944
+ [`aws:PrincipalTag/${moduleName}Features`]: `*${featureName}*`
945
+ }
946
+ }
947
+ }
948
+
949
+ /**
950
+ * Generates a single module-level managed policy with ABAC conditions
951
+ * @param {string} moduleName - The module name (e.g., 'tenants')
952
+ * @param {Object} options - Generation options
953
+ * @param {string} options.region - AWS region for ARN generation (defaults to *)
954
+ * @param {string} options.accountId - AWS account ID for ARN generation (defaults to *)
955
+ * @param {string} options.apiId - API Gateway ID for ARN generation (defaults to *)
956
+ * @returns {Promise<Object>} Complete IAM policy document with ABAC conditions
957
+ */
958
+ export const generateModuleAbacPolicy = async (moduleName, options = {}) => {
959
+ const params = validateParams({ moduleName }, ['moduleName'])
960
+ const { region = '*', accountId = '*', apiId = '*', selectedFeatures } = options
961
+
962
+ return withErrorHandling(async () => {
963
+ const logger = createModuleRegistryLogger('generateModuleAbacPolicy')
964
+
965
+ logger.info(`Generating ABAC policy for module ${params.moduleName}`)
966
+
967
+ // Use provided features if available, otherwise query the database
968
+ let features
969
+ if (selectedFeatures && Array.isArray(selectedFeatures)) {
970
+ // Load full feature details for each selected feature to get endpoints
971
+ logger.info(`Loading full details for ${selectedFeatures.length} selected features`)
972
+
973
+ features = await Promise.all(
974
+ selectedFeatures.map(async (sf) => {
975
+ const featureDetails = await getFeatureDetails(sf.moduleName, sf.featureId)
976
+ if (!featureDetails) {
977
+ throw new Error(`Feature '${sf.featureId}' not found in module '${sf.moduleName}'. Make sure the feature exists in the registry.`)
978
+ }
979
+
980
+ return {
981
+ featureName: featureDetails.featureName,
982
+ moduleName: featureDetails.moduleName || sf.moduleName,
983
+ endpoints: featureDetails.endpoints || [],
984
+ customPolicies: featureDetails.customPolicies || []
985
+ }
986
+ })
987
+ )
988
+
989
+ logger.info(`Loaded feature details for ${features.length} features`)
990
+ } else {
991
+ // Get all features for this module (both regular and custom) from database
992
+ features = await getModuleFeatures(params.moduleName)
993
+ logger.info(`Retrieved ${features?.length || 0} features from database`)
994
+ }
995
+
996
+ if (!features || features.length === 0) {
997
+ logger.warn(`No features found for module ${params.moduleName}`)
998
+ return {
999
+ Version: '2012-10-17',
1000
+ Statement: []
1001
+ }
1002
+ }
1003
+
1004
+ // Generate policy statements for each feature
1005
+ const statements = features.map(feature => {
1006
+ // Sanitize both module name and feature name for AWS IAM SID requirements (alphanumeric only)
1007
+ const moduleNameSafeId = params.moduleName.replace(/[^a-zA-Z0-9]/g, '')
1008
+ const featureNameSafeId = feature.featureName.replace(/[^a-zA-Z0-9]/g, '')
1009
+
1010
+ // Collect all actions (API Gateway + custom policies)
1011
+ const actions = ['execute-api:Invoke']
1012
+ const resources = []
1013
+
1014
+ // Add API Gateway resources from endpoints
1015
+ if (feature.endpoints && feature.endpoints.length > 0) {
1016
+ feature.endpoints.forEach(endpoint => {
1017
+ resources.push(endpointToArn(endpoint, region, accountId, apiId))
1018
+ })
1019
+ }
1020
+
1021
+ // Add custom policy actions and resources
1022
+ if (feature.customPolicies && feature.customPolicies.length > 0) {
1023
+ feature.customPolicies.forEach(policy => {
1024
+ if (policy.Action) {
1025
+ const policyActions = Array.isArray(policy.Action) ? policy.Action : [policy.Action]
1026
+ actions.push(...policyActions)
1027
+ }
1028
+ if (policy.Resource) {
1029
+ const policyResources = Array.isArray(policy.Resource) ? policy.Resource : [policy.Resource]
1030
+ resources.push(...policyResources)
1031
+ }
1032
+ })
1033
+ }
1034
+
1035
+ // Remove duplicates
1036
+ const uniqueActions = [...new Set(actions)]
1037
+ const uniqueResources = [...new Set(resources)]
1038
+
1039
+ // If no resources defined, default to all
1040
+ if (uniqueResources.length === 0) {
1041
+ uniqueResources.push('*')
1042
+ }
1043
+
1044
+ return {
1045
+ Sid: `${moduleNameSafeId}${featureNameSafeId}Access`,
1046
+ Effect: 'Allow',
1047
+ Action: uniqueActions,
1048
+ Resource: uniqueResources,
1049
+ Condition: createAbacCondition(params.moduleName, feature.featureName)
1050
+ }
1051
+ })
1052
+
1053
+ const policyDocument = {
1054
+ Version: '2012-10-17',
1055
+ Statement: statements
1056
+ }
1057
+
1058
+ logger.info(`Generated ABAC policy for module ${params.moduleName} with ${statements.length} feature statements`)
1059
+
1060
+ return policyDocument
1061
+ }, 'generateModuleAbacPolicy')
1062
+ }
1063
+
1064
+ /**
1065
+ * Generates ABAC policy documents for all modules
1066
+ * @param {Object} options - Generation options
1067
+ * @param {string} options.region - AWS region for ARN generation
1068
+ * @param {string} options.accountId - AWS account ID for ARN generation
1069
+ * @param {string} options.apiId - API Gateway ID for ARN generation
1070
+ * @returns {Promise<Object>} Object with module names as keys and policy documents as values
1071
+ */
1072
+ export const generateAllModuleAbacPolicies = async (options = {}) => {
1073
+ return withErrorHandling(async () => {
1074
+ const logger = createModuleRegistryLogger('generateAllModuleAbacPolicies')
1075
+
1076
+ logger.info('Generating ABAC policies for all modules')
1077
+
1078
+ // Get all modules
1079
+ const modules = await listAllModules()
1080
+
1081
+ if (!modules || modules.length === 0) {
1082
+ logger.warn('No modules found')
1083
+ return {}
1084
+ }
1085
+
1086
+ // Generate policy for each module
1087
+ const moduleAbacPolicies = {}
1088
+
1089
+ for (const module of modules) {
1090
+ logger.info(`Processing module: ${module.moduleName}`)
1091
+ moduleAbacPolicies[module.moduleName] = await generateModuleAbacPolicy(module.moduleName, options)
1092
+ }
1093
+
1094
+ logger.info(`Generated ABAC policies for ${Object.keys(moduleAbacPolicies).length} modules`)
1095
+
1096
+ return moduleAbacPolicies
1097
+ }, 'generateAllModuleAbacPolicies')
1098
+ }
1099
+
1100
+ /**
1101
+ * Creates resource tags for a role based on selected features
1102
+ * @param {Array} selectedFeatures - Array of feature objects with moduleName and featureId
1103
+ * @returns {Promise<Array>} Array of AWS resource tags for IAM role
1104
+ */
1105
+ export const generateAbacTags = async (selectedFeatures) => {
1106
+ const params = validateParams({ selectedFeatures }, ['selectedFeatures'])
1107
+
1108
+ return withErrorHandling(async () => {
1109
+ const logger = createModuleRegistryLogger('generateAbacTags')
1110
+
1111
+ if (!Array.isArray(params.selectedFeatures)) {
1112
+ throw new Error('selectedFeatures must be an array')
1113
+ }
1114
+
1115
+ logger.info(`Generating ABAC tags for ${params.selectedFeatures.length} selected features`)
1116
+
1117
+ // Group features by module
1118
+ const featuresByModule = {}
1119
+
1120
+ params.selectedFeatures.forEach(feature => {
1121
+ if (!feature.moduleName || !feature.featureId) {
1122
+ logger.warn('Skipping invalid feature - missing moduleName or featureId', feature)
1123
+ return
1124
+ }
1125
+
1126
+ if (!featuresByModule[feature.moduleName]) {
1127
+ featuresByModule[feature.moduleName] = []
1128
+ }
1129
+
1130
+ featuresByModule[feature.moduleName].push(feature.featureId)
1131
+ })
1132
+
1133
+ // Convert to AWS tags format - using short featureIds for IAM compliance
1134
+ const tags = Object.entries(featuresByModule).map(([moduleName, featureIds]) => ({
1135
+ Key: `${moduleName}Features`,
1136
+ Value: featureIds.join(':')
1137
+ }))
1138
+ logger.info(`ABAC tags: ${JSON.stringify(tags)}`)
1139
+ logger.info(`Generated ${tags.length} ABAC tags for modules: ${Object.keys(featuresByModule).join(', ')}`)
1140
+
1141
+ return tags
1142
+ }, 'generateAbacTags')
1143
+ }
1144
+
1145
+ /**
1146
+ * Gets required managed policy ARNs for a set of selected features
1147
+ * @param {Array} selectedFeatures - Array of feature objects with moduleName and featureName
1148
+ * @param {string} accountId - AWS account ID for ARN generation
1149
+ * @param {string} service - Service name (e.g., 'api')
1150
+ * @param {string} stage - Stage name (e.g., 'live')
1151
+ * @returns {Promise<Array>} Array of managed policy ARNs needed for the selected features
1152
+ */
1153
+ export const getRequiredModulePolicyArns = async (selectedFeatures, accountId, service, stage) => {
1154
+ const params = validateParams({ selectedFeatures, accountId, service, stage }, ['selectedFeatures', 'accountId', 'service', 'stage'])
1155
+
1156
+ return withErrorHandling(async () => {
1157
+ const logger = createModuleRegistryLogger('getRequiredModulePolicyArns')
1158
+
1159
+ if (!Array.isArray(params.selectedFeatures)) {
1160
+ throw new Error('selectedFeatures must be an array')
1161
+ }
1162
+
1163
+ logger.info(`Getting required module policy ARNs for ${params.selectedFeatures.length} selected features`)
1164
+
1165
+ // Get unique module names from selected features
1166
+ const moduleNames = [...new Set(params.selectedFeatures.map(f => f.moduleName).filter(Boolean))]
1167
+
1168
+ // Generate module-specific ABAC policy ARNs
1169
+ const policyArns = moduleNames.map(moduleName =>
1170
+ `arn:aws:iam::${params.accountId}:policy/${params.service}-${moduleName}-${params.stage}-abac`
1171
+ )
1172
+
1173
+ logger.info(`Required module-specific ABAC policy ARNs for modules [${moduleNames.join(', ')}]: ${JSON.stringify(policyArns)}`)
1174
+
1175
+ return policyArns
1176
+ }, 'getRequiredModulePolicyArns')
1177
+ }
1178
+