@izara_project/izara-core-generate-service-code 1.0.98 → 1.0.100

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.
Files changed (70) hide show
  1. package/package.json +1 -1
  2. package/src/compile/compileProject.js +16 -5
  3. package/src/compile/flowValidator.js +47 -8
  4. package/src/compile/loadSchemas.js +18 -7
  5. package/src/compile/schemaValidatorSetup.js +16 -0
  6. package/src/emit/application/coreFunction/emitCoreFunctionArtifacts.js +123 -109
  7. package/src/emit/application/coreFunction/templates/InitialSetupConfig.ejs +8 -4
  8. package/src/emit/application/coreFunction/templates/InitialSetupRunner.ejs +2 -2
  9. package/src/emit/application/crud/templates/CreateComplete_Main.ejs +2 -0
  10. package/src/emit/application/crud/templates/CreateEndpoint_Main.ejs +4 -4
  11. package/src/emit/application/crud/templates/CreateLogicalLib.ejs +74 -15
  12. package/src/emit/application/crud/templates/DeleteComplete_Main.ejs +2 -0
  13. package/src/emit/application/crud/templates/DeleteEndpoint_Main.ejs +4 -4
  14. package/src/emit/application/crud/templates/FlowEndpoint_HdrWbs.ejs +7 -5
  15. package/src/emit/application/crud/templates/UpdateComplete_Main.ejs +2 -0
  16. package/src/emit/application/crud/templates/UpdateEndpoint_Main.ejs +4 -4
  17. package/src/emit/application/custom/customFlowPaths.js +13 -16
  18. package/src/emit/application/custom/customFlowS3.js +277 -66
  19. package/src/emit/application/custom/customFlows.js +131 -0
  20. package/src/emit/application/custom/emitCustomFlowApplicationArtifacts.js +3 -1
  21. package/src/emit/application/custom/templates/FlowEndpoint_Main.ejs +2 -2
  22. package/src/emit/application/custom/templates/s3/ConfirmAfterUpload_HdrSqs.ejs +110 -0
  23. package/src/emit/application/custom/templates/s3/ConfirmAfterUpload_Main.ejs +89 -0
  24. package/src/emit/application/custom/templates/s3/ConfirmComplete_HdrSqs.ejs +110 -0
  25. package/src/emit/application/custom/templates/s3/ConfirmComplete_Main.ejs +89 -0
  26. package/src/emit/application/custom/templates/s3/CreateObject_HdrWbs.ejs +2 -1
  27. package/src/emit/application/custom/templates/s3/CreateObject_Main.ejs +4 -0
  28. package/src/emit/application/custom/templates/s3/CreatePreSignUrl_HdrSqs.ejs +42 -0
  29. package/src/emit/application/custom/templates/s3/CreatePreSignUrl_Main.ejs +23 -0
  30. package/src/emit/application/custom/templates/s3/ProcessS3File_Main.ejs +135 -27
  31. package/src/emit/application/custom/templates/s3/Start_Main.ejs +197 -0
  32. package/src/emit/application/rbac/emitDynamicRbacFlowSchemas.js +8 -1
  33. package/src/emit/application/rbac/emitRbacApplicationArtifacts.js +107 -25
  34. package/src/emit/application/rbac/templates/ReservedDynamicUsage_Main.ejs +29 -9
  35. package/src/emit/application/rbac/templates/ReservedUsageTtlExpired_HdrDss.ejs +176 -0
  36. package/src/emit/application/rbac/templates/UserRbacFlow_Main.ejs +4 -3
  37. package/src/emit/application/system/templates/Register_HdrWbs.ejs +2 -1
  38. package/src/emit/infrastructure/functions/functionYamlArtifact.js +31 -8
  39. package/src/emit/infrastructure/functions/templates/DssFunction_Yaml.ejs +21 -0
  40. package/src/emit/infrastructure/functions/templates/S3Function_Yaml.ejs +2 -3
  41. package/src/emit/infrastructure/resources/iamResourceArtifacts.js +67 -5
  42. package/src/emit/infrastructure/resources/resourceState.js +15 -5
  43. package/src/emit/infrastructure/resources/s3ResourceArtifacts.js +19 -3
  44. package/src/emit/infrastructure/resources/templates/GeneratedLambdaRoleServiceConfig.ejs +5 -3
  45. package/src/emit/infrastructure/resources/templates/S3Bucket_Yaml.ejs +1 -1
  46. package/src/emit/infrastructure/resources/templates/SystemDynamoDB_Yaml.ejs +2 -2
  47. package/src/emit/schemas/attributeTree/emitAttributeTreeSchemas.js +62 -1
  48. package/src/emit/schemas/error/emitErrorSchemas.js +120 -10
  49. package/src/emit/schemas/error/templates/errorConsts.ejs +12 -0
  50. package/src/generate.js +32 -34
  51. package/src/output/mergeHooks.js +105 -5
  52. package/src/plan/domainRegistry.js +0 -10
  53. package/task.md +40 -96
  54. package/test/integration/graphDrivenFlowSchema.test.js +58 -0
  55. package/test/integration/publicApi.test.js +33 -24
  56. package/test/unit/application/createTargetRoleHandler.test.js +101 -9
  57. package/test/unit/application/crudApplicationArtifacts.test.js +4 -0
  58. package/test/unit/application/customFlowApplicationArtifacts.test.js +6 -6
  59. package/test/unit/application/customFlowS3.test.js +730 -0
  60. package/test/unit/infrastructure/functionYamlArtifact.test.js +6 -4
  61. package/test/unit/infrastructure/resourceArtifacts.test.js +103 -0
  62. package/test/unit/output/mergeHooks.test.js +137 -0
  63. package/test/unit/schemas/attributeTreeSchemas.test.js +32 -3
  64. package/test/unit/schemas/attributeTreeSingleService.test.js +175 -0
  65. package/test/unit/schemas/dynamicRbacFlowSchemas.test.js +8 -6
  66. package/test/unit/schemas/dynamicRelationshipFlowSchemas.test.js +51 -0
  67. package/test/unit/schemas/errorSchemas.test.js +84 -5
  68. package/test/unit/templates/hookTagsValidation.test.js +83 -0
  69. package/src/emit/schemas/attributeTree/emitAttributeTreeTargetValueStandard.js +0 -132
  70. package/test/unit/schemas/attributeTreeDirectives.test.js +0 -177
@@ -0,0 +1,197 @@
1
+ /*
2
+ Copyright (C) 2026 Sven Mason <http://izara.io>
3
+
4
+ This program is free software: you can redistribute it and/or modify
5
+ it under the terms of the GNU Affero General Public License as
6
+ published by the Free Software Foundation, either version 3 of the
7
+ License, or (at your option) any later version.
8
+
9
+ This program is distributed in the hope that it will be useful,
10
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
11
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12
+ GNU Affero General Public License for more details.
13
+
14
+ You should have received a copy of the GNU Affero General Public License
15
+ along with this program. If not, see <http://www.gnu.org/licenses/>.
16
+ */
17
+
18
+ import { v7 as uuidv7 } from 'uuid';
19
+ import { createPresignedPost } from '@aws-sdk/s3-presigned-post';
20
+ import { S3Client } from '@aws-sdk/client-s3';
21
+ import { consts } from '@izara_project/izara-middleware';
22
+ import dynamodbSharedLib from '@izara_project/izara-core-library-dynamodb';
23
+ import { webSocket } from '@izara_project/izara-core-library-external-request';
24
+ const { postToConnection } = webSocket;
25
+ import { utils } from '@izara_project/izara-core-library-service-schemas';
26
+ const { createFlowTypeConcat } = utils;
27
+
28
+ const client = new S3Client({ region: process.env.AWS_REGION });
29
+
30
+ //(<optionalRequired>)
31
+ //(</optionalRequired>)
32
+
33
+ /**
34
+ * Step 1: Start (Deferred-Node S3 Pipeline - ADR-016)
35
+ * Generates UUIDv7, computes S3 Presigned POST in-memory, writes ephemeral WebSocketTask,
36
+ * and returns upload parameters to client without writing entity records to the database.
37
+ *
38
+ * @param {Object} _izContext
39
+ * @param {Object} requestParams
40
+ * @param {Object} callingFlowConfig
41
+ * @returns {Promise<Object>}
42
+ */
43
+ export default async function <%- functionName %>(
44
+ _izContext,
45
+ requestParams,
46
+ callingFlowConfig = {}
47
+ //(<additionalParams>)
48
+ //(</additionalParams>)
49
+ ) {
50
+ try {
51
+ _izContext.logger.debug('<%- functionName %> started', {
52
+ requestParams,
53
+ callingFlowConfig
54
+ });
55
+
56
+ const connectionId = _izContext.correlationIds.get(consts.CONNECTION_ID);
57
+ const submittedByUserId = _izContext.correlationIds?.get(
58
+ consts.SUBMITTED_BY_USER_ID
59
+ );
60
+ if (!submittedByUserId) {
61
+ throw new Error('Submitted by user id is required');
62
+ }
63
+
64
+ //(<beforeCreateObjectHook>)
65
+ //(</beforeCreateObjectHook>)
66
+
67
+ <% if (isRbac) { -%>
68
+ const { checkRbacPermission } =
69
+ await import('@izara_project/izara-authorization');
70
+
71
+ //(<rbacParameterGroups_<%- flowTag %>>)
72
+ const parameterGroups = {};
73
+ //(</rbacParameterGroups_<%- flowTag %>>)
74
+
75
+ const resultPermissionCheck = await checkRbacPermission(
76
+ _izContext,
77
+ {
78
+ flowTag: requestParams.flowTag || '<%- flowTag %>',
79
+ serviceTag: process.env.iz_serviceTag
80
+ },
81
+ parameterGroups,
82
+ {
83
+ targetIdentifiers: requestParams.targetIdentifiers,
84
+ rbacObjType: {
85
+ serviceTag: '<%- parentServiceTag || rbacServiceTag %>',
86
+ objectType: '<%- parentObjectType || objectType %>'
87
+ },
88
+ sync: true
89
+ }
90
+ );
91
+
92
+ _izContext.logger.debug(
93
+ `[${process.env.iz_serviceTag}:<%- flowTag %>] checkRbacPermission result:`,
94
+ resultPermissionCheck
95
+ );
96
+
97
+ if (resultPermissionCheck && resultPermissionCheck.permission !== true) {
98
+ throw new Error('Permission denied');
99
+ }
100
+ <% } -%>
101
+
102
+ const <%- objectType %>Id = uuidv7();
103
+
104
+ <% if (typeof parentIdKeys !== 'undefined' && parentIdKeys && parentIdKeys.length > 0) { -%>
105
+ const targetParentId = requestParams.targetIdentifiers?.<%- parentIdKeys[0] %>;
106
+ <% for (const key of parentIdKeys) { -%>
107
+ const target<%- key.charAt(0).toUpperCase() + key.slice(1) %> = requestParams.targetIdentifiers?.<%- key %>;
108
+ if (!target<%- key.charAt(0).toUpperCase() + key.slice(1) %>) {
109
+ throw new Error('targetIdentifiers.<%- key %> is required');
110
+ }
111
+ <% } -%>
112
+ <% } -%>
113
+
114
+ let maxFileSizeMb = <%- maxFileSizeMb %>;
115
+ if (requestParams.file?.size) {
116
+ maxFileSizeMb = Math.ceil(requestParams.file.size / 1048576);
117
+ } else if (requestParams.file?.fileSize) {
118
+ maxFileSizeMb = requestParams.file.fileSize;
119
+ } else if (requestParams.fieldNames?.fileSize) {
120
+ maxFileSizeMb = requestParams.fieldNames.fileSize;
121
+ }
122
+
123
+ //(<createKeyForUploadFile>)
124
+ let importType = '<%- defaultImportType %>';
125
+ let uploadPath = '';
126
+ //(</createKeyForUploadFile>)
127
+ const Key = `<%- s3KeyPrefix %>${uploadPath}`;
128
+ const maximumSize = maxFileSizeMb * 1048576;
129
+
130
+ let createPreSignUrlParam = {
131
+ Bucket: '<%- bucketName %>',
132
+ Key: Key,
133
+ Conditions: [
134
+ ['content-length-range', 0, maximumSize],
135
+ ['eq', '$x-amz-meta-<%- objectType %>Id', <%- objectType %>Id],
136
+ <% if (typeof parentIdKeys !== 'undefined' && parentIdKeys && parentIdKeys.length > 0) { -%>
137
+ <% for (const key of parentIdKeys) { -%>
138
+ ['eq', '$x-amz-meta-<%- key %>', target<%- key.charAt(0).toUpperCase() + key.slice(1) %>],
139
+ <% } -%>
140
+ <% } -%>
141
+ ['starts-with', '$x-amz-meta-submittedByUserId', ''],
142
+ ['starts-with', '$Content-Type', '<%- contentTypePrefix %>']
143
+ //(<conditionCreateUrlLink>)
144
+ //(</conditionCreateUrlLink>)
145
+ ],
146
+ Fields: {
147
+ 'x-amz-meta-<%- objectType %>Id': <%- objectType %>Id,
148
+ <% if (typeof parentIdKeys !== 'undefined' && parentIdKeys && parentIdKeys.length > 0) { -%>
149
+ <% for (const key of parentIdKeys) { -%>
150
+ 'x-amz-meta-<%- key %>': target<%- key.charAt(0).toUpperCase() + key.slice(1) %>,
151
+ <% } -%>
152
+ <% } -%>
153
+ 'x-amz-meta-submittedByUserId': submittedByUserId,
154
+ //(<fieldCreateUrlLink>)
155
+ //(</fieldCreateUrlLink>)
156
+ },
157
+ Expires: <%- expires %>
158
+ };
159
+
160
+ //(<modifyPreSignUrlParam>)
161
+ //(</modifyPreSignUrlParam>)
162
+
163
+ const uploadUrl = await createPresignedPost(client, createPreSignUrlParam);
164
+
165
+ if (connectionId) {
166
+ await dynamodbSharedLib.putItem(
167
+ _izContext,
168
+ await dynamodbSharedLib.tableName(_izContext, 'WebSocketTask'),
169
+ {
170
+ taskKey: `${createFlowTypeConcat(_izContext, { flowTag: '<%- flowTag %>', serviceTag: process.env.iz_serviceTag })}${<%- objectType %>Id}`,
171
+ connectionId: connectionId,
172
+ expirationTime: Math.floor(Date.now() / 1000) + 7200
173
+ }
174
+ );
175
+
176
+ await postToConnection(
177
+ _izContext,
178
+ {
179
+ status: 'success',
180
+ data: {
181
+ uploadUrl: uploadUrl,
182
+ <%- objectType %>Id: <%- objectType %>Id
183
+ }
184
+ },
185
+ connectionId
186
+ );
187
+ }
188
+
189
+ return {
190
+ uploadUrl: uploadUrl,
191
+ <%- objectType %>Id: <%- objectType %>Id
192
+ };
193
+ } catch (err) {
194
+ _izContext.logger.error('Error in <%- functionName %>:', err);
195
+ throw err;
196
+ }
197
+ }
@@ -18,7 +18,10 @@ const RBAC_CORE_FLOW_DEFINITIONS = [
18
18
  ];
19
19
 
20
20
  const LIMIT_FLOW_DEFINITIONS = [
21
- { flowTag: 'ReservedDynamicUsage', event: ['ownTopic'] },
21
+ {
22
+ flowTag: 'ReservedDynamicUsage',
23
+ event: ['lambdaSyncInv', 'lambdaSyncApi']
24
+ },
22
25
  {
23
26
  flowTag: 'ConfirmUsage',
24
27
  event: ['ownTopic', 'lambdaSyncInv', 'lambdaSyncApi']
@@ -26,6 +29,10 @@ const LIMIT_FLOW_DEFINITIONS = [
26
29
  {
27
30
  flowTag: 'CancelUsage',
28
31
  event: ['ownTopic', 'lambdaSyncInv', 'lambdaSyncApi']
32
+ },
33
+ {
34
+ flowTag: 'ReservedUsageTtlExpired',
35
+ event: ['dss']
29
36
  }
30
37
  ];
31
38
 
@@ -3,6 +3,7 @@ import { readFile } from 'node:fs/promises';
3
3
 
4
4
  import {
5
5
  renderApiFunctionYaml,
6
+ renderDssFunctionYaml,
6
7
  renderInvFunctionYaml,
7
8
  renderSqsFunctionYaml,
8
9
  renderWbsFunctionYaml
@@ -53,7 +54,10 @@ export async function emitRbacApplicationArtifacts(objects) {
53
54
 
54
55
  const limitHandlers = await Promise.all(
55
56
  hasSelfRbac
56
- ? ACCOUNT_LIMIT_HANDLERS.map(createAccountLimitHandler)
57
+ ? [
58
+ ...ACCOUNT_LIMIT_HANDLERS.map(createAccountLimitHandler),
59
+ createReservedUsageTtlExpiredHandler()
60
+ ]
57
61
  : []
58
62
  );
59
63
 
@@ -170,12 +174,22 @@ const STATIC_RBAC_APPLICATION_PATHS = [
170
174
  ];
171
175
 
172
176
  const STATIC_LIMIT_APPLICATION_PATHS = [
173
- ...ACCOUNT_LIMIT_HANDLERS.flatMap((flowTag) => [
174
- `app/src/generatedCode/FlowLimit/${flowTag}/source/${flowTag}_Main.js`,
175
- `app/src/generatedCode/FlowLimit/${flowTag}/source/${flowTag}_HdrSqs.js`,
176
- `app/src/generatedCode/FlowLimit/${flowTag}/source/${flowTag}_WbsMain.js`,
177
- `app/src/generatedCode/FlowLimit/${flowTag}/source/${flowTag}_HdrWbs.js`
178
- ]),
177
+ ...ACCOUNT_LIMIT_HANDLERS.flatMap((flowTag) => {
178
+ const paths = [
179
+ `app/src/generatedCode/FlowLimit/${flowTag}/source/${flowTag}_Main.js`,
180
+ `app/src/generatedCode/FlowLimit/${flowTag}/source/${flowTag}_HdrInv.js`,
181
+ `app/src/generatedCode/FlowLimit/${flowTag}/source/${flowTag}_HdrApi.js`
182
+ ];
183
+ if (flowTag !== 'ReservedDynamicUsage') {
184
+ paths.splice(
185
+ 1,
186
+ 0,
187
+ `app/src/generatedCode/FlowLimit/${flowTag}/source/${flowTag}_HdrSqs.js`
188
+ );
189
+ }
190
+ return paths;
191
+ }),
192
+ 'app/src/generatedCode/FlowLimit/ReservedUsageTtlExpired/source/ReservedUsageTtlExpired_HdrDss.js',
179
193
  FLOW_LIMITS_YAML_PATH
180
194
  ];
181
195
 
@@ -205,34 +219,101 @@ export function rbacApplicationManagedPaths(objects = []) {
205
219
  async function createAccountLimitHandler(flowTag) {
206
220
  const sourcePath = `app/src/generatedCode/FlowLimit/${flowTag}/source`;
207
221
  const handlerPath = `src/generatedCode/FlowLimit/${flowTag}/source`;
208
- const webSocket = await createWebSocketEntry(
222
+ const shared = {
209
223
  flowTag,
210
- sourcePath,
211
- handlerPath
224
+ flowStepName: '',
225
+ mainFileName: `${flowTag}_Main`,
226
+ functionName: flowTag
227
+ };
228
+ const artifacts = [
229
+ {
230
+ path: `${sourcePath}/${flowTag}_Main.js`,
231
+ content: await renderRbacTemplate(`${flowTag}_Main.ejs`, { flowTag })
232
+ }
233
+ ];
234
+ const functions = [];
235
+ if (flowTag !== 'ReservedDynamicUsage') {
236
+ artifacts.push({
237
+ path: `${sourcePath}/${flowTag}_HdrSqs.js`,
238
+ content: await renderRbacTemplate('ListRbac_HdrSqs.ejs', { flowTag })
239
+ });
240
+ functions.push({
241
+ type: 'sqs',
242
+ functionName: `${flowTag}HdrSqs`,
243
+ handlerPath: `${handlerPath}/${flowTag}_HdrSqs.main`,
244
+ roleName: 'FlowRbacRole',
245
+ queueName: `${flowTag}HdrSqs`,
246
+ flowTag: flowTag,
247
+ flowStepName: ''
248
+ });
249
+ }
250
+ artifacts.push(
251
+ {
252
+ path: `${sourcePath}/${flowTag}_HdrInv.js`,
253
+ content: await renderSharedTemplate('FlowHandler_HdrInv.ejs', shared)
254
+ },
255
+ {
256
+ path: `${sourcePath}/${flowTag}_HdrApi.js`,
257
+ content: await renderSharedTemplate('FlowHandler_HdrApi.ejs', shared)
258
+ }
212
259
  );
260
+ functions.push(
261
+ {
262
+ type: 'inv',
263
+ functionName: `${flowTag}HdrInv`,
264
+ handlerPath: `${handlerPath}/${flowTag}_HdrInv.main`,
265
+ roleName: 'FlowRbacRole',
266
+ flowTag: flowTag,
267
+ flowStepName: ''
268
+ },
269
+ {
270
+ type: 'api',
271
+ functionName: `${flowTag}HdrApi`,
272
+ handlerPath: `${handlerPath}/${flowTag}_HdrApi.main`,
273
+ roleName: 'FlowRbacRole',
274
+ flowTag: flowTag,
275
+ flowStepName: ''
276
+ }
277
+ );
278
+ return {
279
+ artifacts,
280
+ functions
281
+ };
282
+ }
283
+
284
+ async function createReservedUsageTtlExpiredHandler() {
285
+ const sourcePath =
286
+ 'app/src/generatedCode/FlowLimit/ReservedUsageTtlExpired/source';
287
+ const handlerPath =
288
+ 'src/generatedCode/FlowLimit/ReservedUsageTtlExpired/source';
213
289
  return {
214
290
  artifacts: [
215
291
  {
216
- path: `${sourcePath}/${flowTag}_Main.js`,
217
- content: await renderRbacTemplate(`${flowTag}_Main.ejs`, { flowTag })
218
- },
219
- {
220
- path: `${sourcePath}/${flowTag}_HdrSqs.js`,
221
- content: await renderRbacTemplate('ListRbac_HdrSqs.ejs', { flowTag })
222
- },
223
- ...webSocket.artifacts
292
+ path: `${sourcePath}/ReservedUsageTtlExpired_HdrDss.js`,
293
+ content: await renderRbacTemplate('ReservedUsageTtlExpired_HdrDss.ejs')
294
+ }
224
295
  ],
225
296
  functions: [
226
297
  {
227
- type: 'sqs',
228
- functionName: `${flowTag}HdrSqs`,
229
- handlerPath: `${handlerPath}/${flowTag}_HdrSqs.main`,
298
+ type: 'dss',
299
+ functionName: 'ReservedUsageTtlExpiredHdrDss',
300
+ handlerPath: `${handlerPath}/ReservedUsageTtlExpired_HdrDss.main`,
230
301
  roleName: 'FlowRbacRole',
231
- queueName: `${flowTag}HdrSqs`,
232
- flowTag: flowTag,
302
+ tableName: 'RunningTotalReservedUsage',
303
+ batchSize: 100,
304
+ startingPosition: 'TRIM_HORIZON',
305
+ filterPatterns: [
306
+ {
307
+ eventName: ['REMOVE'],
308
+ userIdentity: {
309
+ type: ['Service'],
310
+ principalId: ['dynamodb.amazonaws.com']
311
+ }
312
+ }
313
+ ],
314
+ flowTag: 'ReservedUsageTtlExpired',
233
315
  flowStepName: ''
234
- },
235
- ...webSocket.functions
316
+ }
236
317
  ]
237
318
  };
238
319
  }
@@ -523,6 +604,7 @@ function renderFunctionYaml(descriptor) {
523
604
  if (descriptor.type === 'api') return renderApiFunctionYaml(descriptor);
524
605
  if (descriptor.type === 'inv') return renderInvFunctionYaml(descriptor);
525
606
  if (descriptor.type === 'wbs') return renderWbsFunctionYaml(descriptor);
607
+ if (descriptor.type === 'dss') return renderDssFunctionYaml(descriptor);
526
608
  return renderSqsFunctionYaml(descriptor);
527
609
  }
528
610
 
@@ -161,7 +161,7 @@ export default async function ReservedDynamicUsage(_izContext, requestParams) {
161
161
  let passStatus = false;
162
162
  if (currentTotal + value <= maxLimit) {
163
163
  passStatus = true;
164
- const ttlTimestamp = Math.floor(Date.now() / 1000) + 3600;
164
+ const expirationTime = Math.floor(Date.now() / 1000) + 3600;
165
165
 
166
166
  await dynamodbSharedLib.putItem(
167
167
  _izContext,
@@ -169,9 +169,11 @@ export default async function ReservedDynamicUsage(_izContext, requestParams) {
169
169
  {
170
170
  runningTotalReservedId: `${identifiersConcat}_${flowTypeConcat}`,
171
171
  usedTimestampId: uniqueIdentify,
172
+ identifiersConcat,
173
+ flowTypeConcat,
172
174
  usageCount: value,
173
175
  runningTotalUsageStatus: 'reserved',
174
- ttlTimestamp
176
+ expirationTime
175
177
  }
176
178
  );
177
179
 
@@ -194,13 +196,31 @@ export default async function ReservedDynamicUsage(_izContext, requestParams) {
194
196
  identifiersConcat
195
197
  };
196
198
 
197
- await sns.publishMessage(_izContext, {
198
- message: { returnResult },
199
- TopicArn: snsSharedLib.snsTopicArnByFlowSchema(
200
- _izContext,
201
- 'ReservedDynamicUsage_Out'
202
- )
203
- });
199
+ const eventSourceTag =
200
+ _izContext.izEventSource?.eventSourceTag ||
201
+ requestParams?.izEventSource?.eventSourceTag;
202
+
203
+ let isSync = false;
204
+ if (
205
+ eventSourceTag === 'DirectInvoke' ||
206
+ eventSourceTag === 'ApiGateway' ||
207
+ eventSourceTag === 'ApiGatewayV2' ||
208
+ requestParams?.hasOwnProperty('__context__')
209
+ ) {
210
+ isSync = true;
211
+ }
212
+
213
+ if (!isSync) {
214
+ await sns.publishMessage(_izContext, {
215
+ message: { returnResult },
216
+ TopicArn: snsSharedLib.snsTopicArnByFlowSchema(
217
+ _izContext,
218
+ 'ReservedDynamicUsage_Out'
219
+ )
220
+ });
221
+ }
222
+
223
+ return returnResult;
204
224
  } catch (error) {
205
225
  _izContext.logger.error(`[${process.env.iz_serviceTag}:ReservedDynamicUsage] error:`, error);
206
226
  throw error;
@@ -0,0 +1,176 @@
1
+ /*
2
+ Copyright (C) 2020 Sven Mason <http://izara.io>
3
+
4
+ This program is free software: you can redistribute it and/or modify
5
+ it under the terms of the GNU Affero General Public License as
6
+ published by the Free Software Foundation, either version 3 of the
7
+ License, or (at your option) any later version.
8
+
9
+ This program is distributed in the hope that it will be useful,
10
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
11
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12
+ GNU Affero General Public License for more details.
13
+
14
+ You should have received a copy of the GNU Affero General Public License
15
+ along with this program. If not, see <http://www.gnu.org/licenses/>.
16
+ */
17
+
18
+ import { middlewareHandler as middleware } from '@izara_project/izara-middleware';
19
+ import Logger from '@izara_project/izara-core-library-logger';
20
+ import dynamodbSharedLib from '@izara_project/izara-core-library-dynamodb';
21
+
22
+ /**
23
+ * DynamoDB Stream handler for TTL expired records in RunningTotalReservedUsage.
24
+ * Automatically refunds abandoned reservations with atomic decrement.
25
+ */
26
+ export const main = middleware.wrap(async (event, context) => {
27
+ try {
28
+ if (!event || !Array.isArray(event.Records)) {
29
+ Logger.debug('No records in DynamoDB stream event');
30
+ return;
31
+ }
32
+
33
+ let _izContext = event._izContext;
34
+ if (!_izContext) {
35
+ _izContext = {
36
+ logger: Logger,
37
+ correlationIds: new Map()
38
+ };
39
+ }
40
+
41
+ for (const record of event.Records) {
42
+ let recordContext = record._izContext;
43
+ if (!recordContext) {
44
+ recordContext = _izContext;
45
+ }
46
+ let recordLogger = recordContext.logger;
47
+ if (!recordLogger) {
48
+ recordLogger = Logger;
49
+ }
50
+
51
+ const isTtlDelete =
52
+ record.eventName === 'REMOVE' &&
53
+ record.userIdentity &&
54
+ record.userIdentity.type === 'Service' &&
55
+ record.userIdentity.principalId === 'dynamodb.amazonaws.com';
56
+
57
+ if (!isTtlDelete) {
58
+ continue;
59
+ }
60
+
61
+ const oldImage = record.dynamodb && record.dynamodb.OldImage;
62
+ if (!oldImage) {
63
+ continue;
64
+ }
65
+
66
+ let oldStatus = null;
67
+ if (oldImage.runningTotalUsageStatus && oldImage.runningTotalUsageStatus.S) {
68
+ oldStatus = oldImage.runningTotalUsageStatus.S;
69
+ }
70
+
71
+ if (oldStatus !== 'reserved') {
72
+ continue;
73
+ }
74
+
75
+ let identifiersConcat = null;
76
+ if (oldImage.identifiersConcat && oldImage.identifiersConcat.S) {
77
+ identifiersConcat = oldImage.identifiersConcat.S;
78
+ }
79
+ let flowTypeConcat = null;
80
+ if (oldImage.flowTypeConcat && oldImage.flowTypeConcat.S) {
81
+ flowTypeConcat = oldImage.flowTypeConcat.S;
82
+ }
83
+
84
+ if ((!identifiersConcat || !flowTypeConcat) && oldImage.runningTotalReservedId && oldImage.runningTotalReservedId.S) {
85
+ const reservedId = oldImage.runningTotalReservedId.S;
86
+ const lastUnderscoreIndex = reservedId.lastIndexOf('_');
87
+ if (lastUnderscoreIndex > 0) {
88
+ if (!identifiersConcat) {
89
+ identifiersConcat = reservedId.substring(0, lastUnderscoreIndex);
90
+ }
91
+ if (!flowTypeConcat) {
92
+ flowTypeConcat = reservedId.substring(lastUnderscoreIndex + 1);
93
+ }
94
+ }
95
+ }
96
+
97
+ let usageCount = 1;
98
+ if (oldImage.usageCount && oldImage.usageCount.N) {
99
+ usageCount = Number(oldImage.usageCount.N);
100
+ }
101
+
102
+ if (identifiersConcat && flowTypeConcat && usageCount > 0) {
103
+ const usageTable = dynamodbSharedLib.tableName(
104
+ recordContext,
105
+ 'RunningTotalUsage'
106
+ );
107
+
108
+ const usageRecord = await dynamodbSharedLib.getItem(
109
+ recordContext,
110
+ usageTable,
111
+ {
112
+ identifiersConcat: identifiersConcat,
113
+ flowTypeConcat: flowTypeConcat
114
+ }
115
+ );
116
+
117
+ if (usageRecord) {
118
+ let currentTotal = 0;
119
+ if (typeof usageRecord.totalCount === 'number') {
120
+ currentTotal = usageRecord.totalCount;
121
+ }
122
+ const newTotal = Math.max(0, currentTotal - usageCount);
123
+ const updateUuid = recordContext.uniqueRequestId || `${Date.now()}`;
124
+
125
+ const queryElementsWhenUpdate = {
126
+ additionalAttributes: { currentUuid: usageRecord.updateUuid },
127
+ logicalElements: [
128
+ {
129
+ type: 'logical',
130
+ comparison: 'equals',
131
+ lhsAttribute: 'updateUuid',
132
+ rhsReference: 'currentUuid'
133
+ }
134
+ ],
135
+ returnValues: 'ALL_NEW'
136
+ };
137
+
138
+ const updateAttributes = [
139
+ { attributeName: 'totalCount', action: 'set', value: newTotal },
140
+ { attributeName: 'updateUuid', action: 'set', value: updateUuid }
141
+ ];
142
+
143
+ await dynamodbSharedLib.updateItem(
144
+ recordContext,
145
+ usageTable,
146
+ {
147
+ identifiersConcat: identifiersConcat,
148
+ flowTypeConcat: flowTypeConcat
149
+ },
150
+ updateAttributes,
151
+ queryElementsWhenUpdate,
152
+ {
153
+ complexAttributes: true,
154
+ errorOnConditionalExpNotPass: false
155
+ }
156
+ ).catch(() => null);
157
+
158
+ recordLogger.info({
159
+ message: '[ReservedUsageTtlExpired] Auto-refunded expired reservation',
160
+ identifiersConcat: identifiersConcat,
161
+ flowTypeConcat: flowTypeConcat,
162
+ usageCount: usageCount,
163
+ previousTotal: currentTotal,
164
+ newTotal: newTotal
165
+ });
166
+ }
167
+ }
168
+ }
169
+ } catch (err) {
170
+ Logger.error({
171
+ message: 'Unhandled error in ReservedUsageTtlExpired_HdrDss',
172
+ error: err
173
+ });
174
+ throw err;
175
+ }
176
+ });
@@ -55,7 +55,8 @@ async function _getL2Cache(_izContext, submittedByUserId, cacheSubKey) {
55
55
  userId: submittedByUserId,
56
56
  cacheKey: cacheSubKey
57
57
  });
58
- if (result && result.ttlTimestamp && result.ttlTimestamp > Math.floor(Date.now() / 1000)) {
58
+ const expTime = result && (result.expirationTime || result.ttlTimestamp);
59
+ if (expTime && expTime > Math.floor(Date.now() / 1000)) {
59
60
  return {
60
61
  returnValue: {
61
62
  permission: Boolean(result.permission),
@@ -75,14 +76,14 @@ async function _getL2Cache(_izContext, submittedByUserId, cacheSubKey) {
75
76
  async function _setL2Cache(_izContext, submittedByUserId, cacheSubKey, value) {
76
77
  try {
77
78
  const tableName = dynamodbSharedLib.tableName(_izContext, 'RbacPermissionCaches');
78
- const ttlTimestamp = Math.floor(Date.now() / 1000) + L2_TTL_SECONDS;
79
+ const expirationTime = Math.floor(Date.now() / 1000) + L2_TTL_SECONDS;
79
80
  await dynamodbSharedLib.putItem(_izContext, tableName, {
80
81
  userId: submittedByUserId,
81
82
  cacheKey: cacheSubKey,
82
83
  permission: value.returnValue ? value.returnValue.permission : value.permission,
83
84
  roleIdKey: value.returnValue ? value.returnValue.roleIdKey : value.roleIdKey,
84
85
  rbacType: 'target',
85
- ttlTimestamp
86
+ expirationTime
86
87
  });
87
88
  } catch (error) {
88
89
  // Safely ignore L2 write errors if table is not configured
@@ -27,7 +27,8 @@ export const main = middleware.wrap(async (event, context) => {
27
27
  await dynamodbSharedLib.tableName(event._izContext, 'WebSocketTask'),
28
28
  {
29
29
  taskKey: correlationId,
30
- connectionId: connectionId
30
+ connectionId: connectionId,
31
+ expirationTime: Math.floor(Date.now() / 1000) + 7200
31
32
  }
32
33
  );
33
34