@stackwright-services/compiler 0.0.1

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/index.mjs ADDED
@@ -0,0 +1,1945 @@
1
+ // src/validate.ts
2
+ import {
3
+ scanDataSources as defaultScanDataSources
4
+ } from "@stackwright-services/runtime";
5
+ import {
6
+ FlowDefinition,
7
+ WorkflowDefinition
8
+ } from "@stackwright-services/types";
9
+ import { parse as parseYaml } from "yaml";
10
+ function detectDefinitionType(obj) {
11
+ if (typeof obj !== "object" || obj === null) return "unknown";
12
+ const record = obj;
13
+ if ("steps" in record && "trigger" in record) return "flow";
14
+ if ("states" in record && "initial" in record) return "workflow";
15
+ return "unknown";
16
+ }
17
+ function validate(yamlContent, registry, options) {
18
+ let parsed;
19
+ try {
20
+ parsed = parseYaml(yamlContent);
21
+ } catch (err) {
22
+ return {
23
+ success: false,
24
+ errors: [{ code: "YAML_PARSE_ERROR", message: `Invalid YAML: ${err.message}` }],
25
+ warnings: []
26
+ };
27
+ }
28
+ if (parsed === null || parsed === void 0) {
29
+ return {
30
+ success: false,
31
+ errors: [{ code: "EMPTY_DOCUMENT", message: "YAML document is empty" }],
32
+ warnings: []
33
+ };
34
+ }
35
+ const defType = detectDefinitionType(parsed);
36
+ if (defType === "flow") {
37
+ return validateFlow(parsed, registry, options);
38
+ } else if (defType === "workflow") {
39
+ return validateWorkflow(parsed, registry);
40
+ } else {
41
+ return {
42
+ success: false,
43
+ errors: [
44
+ {
45
+ code: "UNKNOWN_DEFINITION_TYPE",
46
+ message: "Could not detect definition type. Expected a flow (with trigger + steps) or workflow (with states + initial)."
47
+ }
48
+ ],
49
+ warnings: []
50
+ };
51
+ }
52
+ }
53
+ function validateFlow(parsed, registry, options) {
54
+ const result = FlowDefinition.safeParse(parsed);
55
+ if (!result.success) {
56
+ const errors2 = result.error.issues.map((issue) => ({
57
+ code: "SCHEMA_VALIDATION_ERROR",
58
+ message: issue.message,
59
+ path: issue.path.join(".")
60
+ }));
61
+ return { success: false, errors: errors2, warnings: [] };
62
+ }
63
+ const flow = result.data;
64
+ const errors = [];
65
+ const warnings = [];
66
+ if (registry) {
67
+ for (const step of flow.steps) {
68
+ if (!registry.has(step.use)) {
69
+ errors.push({
70
+ code: "UNKNOWN_CAPABILITY",
71
+ message: `Step "${step.name}" references unknown capability "${step.use}"`,
72
+ path: `steps.${step.name}.use`
73
+ });
74
+ }
75
+ }
76
+ }
77
+ const stepNames = flow.steps.map((s) => s.name);
78
+ const seen = /* @__PURE__ */ new Set();
79
+ for (const name of stepNames) {
80
+ if (seen.has(name)) {
81
+ warnings.push({
82
+ code: "DUPLICATE_STEP_NAME",
83
+ message: `Duplicate step name "${name}" -- step names should be unique for observability`,
84
+ path: `steps.${name}`
85
+ });
86
+ }
87
+ seen.add(name);
88
+ }
89
+ const sourceRefs = [];
90
+ for (const step of flow.steps) {
91
+ const sourceVal = step.with?.["source"];
92
+ if (typeof sourceVal === "string" && (sourceVal.startsWith("openapi:") || sourceVal.startsWith("pulse:"))) {
93
+ sourceRefs.push({ ref: sourceVal, stepName: step.name });
94
+ }
95
+ }
96
+ if (sourceRefs.length > 0) {
97
+ const doScan = options?.scanDataSources ?? defaultScanDataSources;
98
+ const scanResult = doScan();
99
+ for (const { ref, stepName } of sourceRefs) {
100
+ const sourceId = ref.slice(ref.indexOf(":") + 1);
101
+ if (scanResult.detectedProviders.length === 0) {
102
+ warnings.push({
103
+ code: "SOURCE_UNVERIFIABLE",
104
+ message: `source '${ref}' cannot be verified: no DataSourceProvider is installed`,
105
+ path: `steps.${stepName}.with.source`
106
+ });
107
+ } else if (!scanResult.sources.some((s) => s.id === sourceId)) {
108
+ warnings.push({
109
+ code: "SOURCE_NOT_REGISTERED",
110
+ message: `source '${ref}' is not registered by any installed provider`,
111
+ path: `steps.${stepName}.with.source`
112
+ });
113
+ }
114
+ }
115
+ }
116
+ return {
117
+ success: errors.length === 0,
118
+ data: flow,
119
+ errors,
120
+ warnings
121
+ };
122
+ }
123
+ function validateWorkflow(parsed, registry) {
124
+ const result = WorkflowDefinition.safeParse(parsed);
125
+ if (!result.success) {
126
+ const errors2 = result.error.issues.map((issue) => ({
127
+ code: "SCHEMA_VALIDATION_ERROR",
128
+ message: issue.message,
129
+ path: issue.path.join(".")
130
+ }));
131
+ return { success: false, errors: errors2, warnings: [] };
132
+ }
133
+ const workflow = result.data;
134
+ const errors = [];
135
+ const warnings = [];
136
+ const stateNames = Object.keys(workflow.states);
137
+ if (!stateNames.includes(workflow.initial)) {
138
+ errors.push({
139
+ code: "INITIAL_STATE_NOT_FOUND",
140
+ message: `Initial state "${workflow.initial}" does not exist in states map`,
141
+ path: "initial"
142
+ });
143
+ }
144
+ for (const [stateName, state] of Object.entries(workflow.states)) {
145
+ if (state.transitions) {
146
+ for (const transition of state.transitions) {
147
+ if (!stateNames.includes(transition.to)) {
148
+ errors.push({
149
+ code: "TRANSITION_TARGET_NOT_FOUND",
150
+ message: `State "${stateName}" has transition to unknown state "${transition.to}"`,
151
+ path: `states.${stateName}.transitions`
152
+ });
153
+ }
154
+ }
155
+ }
156
+ if (registry && state.on_enter) {
157
+ if (!registry.has(state.on_enter.use)) {
158
+ errors.push({
159
+ code: "UNKNOWN_CAPABILITY",
160
+ message: `State "${stateName}" on_enter references unknown capability "${state.on_enter.use}"`,
161
+ path: `states.${stateName}.on_enter.use`
162
+ });
163
+ }
164
+ }
165
+ }
166
+ const terminalStates = stateNames.filter((name) => workflow.states[name]?.type === "terminal");
167
+ if (terminalStates.length === 0 && stateNames.length > 0) {
168
+ errors.push({
169
+ code: "NO_TERMINAL_STATE",
170
+ message: "Workflow has no terminal state -- workflows must have at least one terminal state"
171
+ });
172
+ }
173
+ for (const [stateName, state] of Object.entries(workflow.states)) {
174
+ if (state.type !== "terminal" && (!state.transitions || state.transitions.length === 0)) {
175
+ errors.push({
176
+ code: "DEADLOCK_STATE",
177
+ message: `State "${stateName}" is not terminal but has no transitions \u2014 workflow will deadlock here`,
178
+ path: `states.${stateName}`
179
+ });
180
+ }
181
+ }
182
+ if (stateNames.includes(workflow.initial)) {
183
+ const reachable = /* @__PURE__ */ new Set();
184
+ const queue = [workflow.initial];
185
+ while (queue.length > 0) {
186
+ const current = queue.shift();
187
+ if (reachable.has(current)) continue;
188
+ reachable.add(current);
189
+ const state = workflow.states[current];
190
+ if (state?.transitions) {
191
+ for (const transition of state.transitions) {
192
+ if (!reachable.has(transition.to)) {
193
+ queue.push(transition.to);
194
+ }
195
+ }
196
+ }
197
+ }
198
+ for (const stateName of stateNames) {
199
+ if (!reachable.has(stateName)) {
200
+ warnings.push({
201
+ code: "UNREACHABLE_STATE",
202
+ message: `State "${stateName}" is not reachable from initial state "${workflow.initial}"`,
203
+ path: `states.${stateName}`
204
+ });
205
+ }
206
+ }
207
+ }
208
+ if (terminalStates.length > 0) {
209
+ const canReachTerminal = new Set(terminalStates);
210
+ const reverseQueue = [...terminalStates];
211
+ const reverseAdj = /* @__PURE__ */ new Map();
212
+ for (const name of stateNames) {
213
+ reverseAdj.set(name, []);
214
+ }
215
+ for (const [stateName, state] of Object.entries(workflow.states)) {
216
+ if (state.transitions) {
217
+ for (const transition of state.transitions) {
218
+ reverseAdj.get(transition.to)?.push(stateName);
219
+ }
220
+ }
221
+ }
222
+ while (reverseQueue.length > 0) {
223
+ const current = reverseQueue.shift();
224
+ for (const predecessor of reverseAdj.get(current) ?? []) {
225
+ if (!canReachTerminal.has(predecessor)) {
226
+ canReachTerminal.add(predecessor);
227
+ reverseQueue.push(predecessor);
228
+ }
229
+ }
230
+ }
231
+ for (const stateName of stateNames) {
232
+ if (workflow.states[stateName]?.type !== "terminal" && !canReachTerminal.has(stateName)) {
233
+ warnings.push({
234
+ code: "NO_PATH_TO_TERMINAL",
235
+ message: `State "${stateName}" has no path to any terminal state`,
236
+ path: `states.${stateName}`
237
+ });
238
+ }
239
+ }
240
+ }
241
+ if (workflow.signals) {
242
+ const declaredSignals = new Set(workflow.signals.map((s) => s.name));
243
+ const usedSignals = /* @__PURE__ */ new Set();
244
+ for (const [stateName, state] of Object.entries(workflow.states)) {
245
+ if (state.transitions) {
246
+ for (const transition of state.transitions) {
247
+ usedSignals.add(transition.on);
248
+ if (!declaredSignals.has(transition.on)) {
249
+ warnings.push({
250
+ code: "UNKNOWN_SIGNAL",
251
+ message: `Transition in state "${stateName}" uses signal "${transition.on}" which is not declared in workflow signals`,
252
+ path: `states.${stateName}.transitions`
253
+ });
254
+ }
255
+ }
256
+ }
257
+ }
258
+ for (const signal of workflow.signals) {
259
+ if (!usedSignals.has(signal.name)) {
260
+ warnings.push({
261
+ code: "UNUSED_SIGNAL",
262
+ message: `Signal "${signal.name}" is declared but never used by any transition`,
263
+ path: "signals"
264
+ });
265
+ }
266
+ }
267
+ }
268
+ for (const [stateName, state] of Object.entries(workflow.states)) {
269
+ if (state.transitions) {
270
+ const unguardedSignals = /* @__PURE__ */ new Set();
271
+ for (const transition of state.transitions) {
272
+ if (!transition.when) {
273
+ if (unguardedSignals.has(transition.on)) {
274
+ warnings.push({
275
+ code: "AMBIGUOUS_TRANSITION",
276
+ message: `State "${stateName}" has multiple unguarded transitions for signal "${transition.on}" \u2014 only the first will match`,
277
+ path: `states.${stateName}.transitions`
278
+ });
279
+ }
280
+ unguardedSignals.add(transition.on);
281
+ }
282
+ }
283
+ }
284
+ }
285
+ if (registry && workflow.timeouts?.on_timeout) {
286
+ if (!registry.has(workflow.timeouts.on_timeout.use)) {
287
+ errors.push({
288
+ code: "UNKNOWN_CAPABILITY",
289
+ message: `Timeout on_timeout references unknown capability "${workflow.timeouts.on_timeout.use}"`,
290
+ path: "timeouts.on_timeout.use"
291
+ });
292
+ }
293
+ }
294
+ return {
295
+ success: errors.length === 0,
296
+ data: workflow,
297
+ errors,
298
+ warnings
299
+ };
300
+ }
301
+
302
+ // src/permissions.ts
303
+ function resolvePermissionVariables(permission, stepParams) {
304
+ if (!stepParams) return permission;
305
+ let { resource } = permission;
306
+ const variablePattern = /\$([a-zA-Z_][a-zA-Z0-9_]*)/g;
307
+ resource = resource.replace(variablePattern, (_match, varName) => {
308
+ const value = stepParams[varName];
309
+ if (typeof value === "string") return value;
310
+ if (typeof value === "number" || typeof value === "boolean") return String(value);
311
+ return _match;
312
+ });
313
+ return { ...permission, resource };
314
+ }
315
+ function deriveFlowPermissions(flow, registry) {
316
+ const sources = [];
317
+ for (const step of flow.steps) {
318
+ const capability = registry.lookup(step.use);
319
+ if (!capability) continue;
320
+ if (capability.definition.kind === "effect") {
321
+ for (const perm of capability.definition.requiredPermissions) {
322
+ const resolved = resolvePermissionVariables(perm, step.with);
323
+ sources.push({
324
+ permission: resolved,
325
+ capabilityName: step.use,
326
+ location: `steps.${step.name}`
327
+ });
328
+ }
329
+ }
330
+ }
331
+ return {
332
+ permissions: deduplicatePermissions(sources.map((s) => s.permission)),
333
+ sources
334
+ };
335
+ }
336
+ function deriveWorkflowPermissions(workflow, registry) {
337
+ const sources = [];
338
+ for (const [stateName, state] of Object.entries(workflow.states)) {
339
+ if (state.on_enter) {
340
+ const capability = registry.lookup(state.on_enter.use);
341
+ if (!capability) continue;
342
+ if (capability.definition.kind === "effect") {
343
+ for (const perm of capability.definition.requiredPermissions) {
344
+ sources.push({
345
+ permission: perm,
346
+ capabilityName: state.on_enter.use,
347
+ location: `states.${stateName}.on_enter`
348
+ });
349
+ }
350
+ }
351
+ }
352
+ }
353
+ if (workflow.timeouts?.on_timeout) {
354
+ const capability = registry.lookup(workflow.timeouts.on_timeout.use);
355
+ if (capability && capability.definition.kind === "effect") {
356
+ for (const perm of capability.definition.requiredPermissions) {
357
+ sources.push({
358
+ permission: perm,
359
+ capabilityName: workflow.timeouts.on_timeout.use,
360
+ location: "timeouts.on_timeout"
361
+ });
362
+ }
363
+ }
364
+ }
365
+ return {
366
+ permissions: deduplicatePermissions(sources.map((s) => s.permission)),
367
+ sources
368
+ };
369
+ }
370
+ function deduplicatePermissions(permissions) {
371
+ const seen = /* @__PURE__ */ new Set();
372
+ const unique = [];
373
+ for (const perm of permissions) {
374
+ const key = `${perm.resource}:${perm.action}:${perm.scope ?? ""}`;
375
+ if (!seen.has(key)) {
376
+ seen.add(key);
377
+ unique.push(perm);
378
+ }
379
+ }
380
+ return unique;
381
+ }
382
+ function toIamPolicy(derived) {
383
+ if (derived.permissions.length === 0) {
384
+ return {
385
+ Version: "2012-10-17",
386
+ Statement: []
387
+ };
388
+ }
389
+ return {
390
+ Version: "2012-10-17",
391
+ Statement: derived.permissions.map((perm) => ({
392
+ Effect: "Allow",
393
+ Action: `stackwright:${perm.action}`,
394
+ Resource: perm.resource,
395
+ ...perm.scope ? { Condition: { StringEquals: { "stackwright:scope": perm.scope } } } : {}
396
+ }))
397
+ };
398
+ }
399
+ function toStepFunctionsIamPolicy(workflow, derived) {
400
+ const statements = [];
401
+ const statesWithActions = Object.entries(workflow.states).filter(([, state]) => state.on_enter !== void 0).map(([name]) => name);
402
+ if (statesWithActions.length > 0) {
403
+ statements.push({
404
+ Sid: "InvokeStateLambdas",
405
+ Effect: "Allow",
406
+ Action: ["lambda:InvokeFunction"],
407
+ Resource: statesWithActions.map(
408
+ (name) => `arn:aws:lambda:*:*:function:${workflow.id}-${name}`
409
+ )
410
+ });
411
+ }
412
+ if (derived.permissions.length > 0) {
413
+ statements.push(
414
+ ...derived.permissions.map((perm) => ({
415
+ Effect: "Allow",
416
+ Action: `stackwright:${perm.action}`,
417
+ Resource: perm.resource,
418
+ ...perm.scope ? { Condition: { StringEquals: { "stackwright:scope": perm.scope } } } : {}
419
+ }))
420
+ );
421
+ }
422
+ if (workflow.timeouts?.pending_after || workflow.timeouts?.absolute) {
423
+ statements.push({
424
+ Sid: "TimeoutScheduling",
425
+ Effect: "Allow",
426
+ Action: ["events:PutRule", "events:PutTargets", "events:DeleteRule", "events:RemoveTargets"],
427
+ Resource: `arn:aws:events:*:*:rule/${workflow.id}-timeout-*`
428
+ });
429
+ }
430
+ statements.push({
431
+ Sid: "ExecutionLogging",
432
+ Effect: "Allow",
433
+ Action: ["logs:CreateLogGroup", "logs:CreateLogStream", "logs:PutLogEvents"],
434
+ Resource: `arn:aws:logs:*:*:log-group:/aws/stepfunctions/${workflow.id}:*`
435
+ });
436
+ return {
437
+ Version: "2012-10-17",
438
+ Statement: statements
439
+ };
440
+ }
441
+ function toRbacRules(derived) {
442
+ if (derived.permissions.length === 0) return [];
443
+ const grouped = /* @__PURE__ */ new Map();
444
+ for (const perm of derived.permissions) {
445
+ const verbs = grouped.get(perm.resource) ?? [];
446
+ if (!verbs.includes(perm.action)) {
447
+ verbs.push(perm.action);
448
+ }
449
+ grouped.set(perm.resource, verbs);
450
+ }
451
+ return Array.from(grouped.entries()).map(([resource, verbs]) => ({
452
+ apiGroups: ["stackwright.io"],
453
+ resources: [resource],
454
+ verbs
455
+ }));
456
+ }
457
+
458
+ // src/observability.ts
459
+ function generateFlowInstrumentation(flow) {
460
+ return flow.steps.map((step) => ({
461
+ stepName: step.name,
462
+ capabilityName: step.use,
463
+ flowId: flow.id,
464
+ metrics: {
465
+ latency: "flow.step.latency",
466
+ invocations: "flow.step.invocations",
467
+ errors: "flow.step.errors"
468
+ }
469
+ }));
470
+ }
471
+ function sanitizeIdentifier(name) {
472
+ return name.replace(/[^a-zA-Z0-9]/g, "_");
473
+ }
474
+ function generateInstrumentedStepCode(instrumentation) {
475
+ const { stepName, capabilityName, flowId, metrics } = instrumentation;
476
+ const fnName = sanitizeIdentifier(stepName);
477
+ return [
478
+ `async function executeStep_${fnName}(`,
479
+ " input: unknown,",
480
+ " context: CapabilityContext,",
481
+ " provider: ObservabilityProvider,",
482
+ " auditProvider: AuditProvider,",
483
+ " handler: CapabilityHandler,",
484
+ "): Promise<unknown> {",
485
+ ` const span = provider.startSpan('step.${stepName}', {`,
486
+ ` 'capability.name': '${capabilityName}',`,
487
+ ` 'flow.id': '${flowId}',`,
488
+ ` 'step.name': '${stepName}',`,
489
+ " });",
490
+ " const startTime = Date.now();",
491
+ "",
492
+ " try {",
493
+ " const result = await handler(input, context);",
494
+ ` provider.recordMetric('${metrics.invocations}', 1, { step: '${stepName}', capability: '${capabilityName}', status: 'success' });`,
495
+ " auditProvider.record({",
496
+ " type: 'capability_invocation',",
497
+ " traceId: context.traceId,",
498
+ " timestamp: new Date().toISOString(),",
499
+ ` flowId: '${flowId}',`,
500
+ ` stepName: '${stepName}',`,
501
+ ` capabilityName: '${capabilityName}',`,
502
+ " inputHash: auditHash(input),",
503
+ " outputHash: auditHash(result),",
504
+ " durationMs: Date.now() - startTime,",
505
+ " status: 'success',",
506
+ " });",
507
+ " return result;",
508
+ " } catch (error) {",
509
+ " span.setError(error instanceof Error ? error : new Error(String(error)));",
510
+ ` provider.recordMetric('${metrics.errors}', 1, { step: '${stepName}', capability: '${capabilityName}' });`,
511
+ " auditProvider.record({",
512
+ " type: 'capability_invocation',",
513
+ " traceId: context.traceId,",
514
+ " timestamp: new Date().toISOString(),",
515
+ ` flowId: '${flowId}',`,
516
+ ` stepName: '${stepName}',`,
517
+ ` capabilityName: '${capabilityName}',`,
518
+ " inputHash: auditHash(input),",
519
+ " outputHash: '',",
520
+ " durationMs: Date.now() - startTime,",
521
+ " status: 'error',",
522
+ " error: error instanceof Error ? error.message : String(error),",
523
+ " });",
524
+ " throw error;",
525
+ " } finally {",
526
+ " const duration = Date.now() - startTime;",
527
+ ` provider.recordMetric('${metrics.latency}', duration, { step: '${stepName}', capability: '${capabilityName}' });`,
528
+ " span.end();",
529
+ " }",
530
+ "}"
531
+ ].join("\n");
532
+ }
533
+ function generateInstrumentedFlowCode(flow) {
534
+ const instrumentations = generateFlowInstrumentation(flow);
535
+ const stepFunctions = instrumentations.map(generateInstrumentedStepCode);
536
+ const stepCalls = instrumentations.map(
537
+ (inst) => ` stepResult = await executeStep_${sanitizeIdentifier(inst.stepName)}(stepResult, context, provider, auditProvider, registry.get('${inst.capabilityName}').handler);`
538
+ ).join("\n");
539
+ const header = [
540
+ "// Auto-generated by @stackwright-services/compiler",
541
+ `// Flow: ${flow.id}`,
542
+ `// Description: ${flow.description}`,
543
+ "//",
544
+ "// DO NOT EDIT -- regenerate from the source YAML.",
545
+ "",
546
+ "import type { CapabilityContext, CapabilityHandler } from '@stackwright-services/capabilities';",
547
+ "import type { ObservabilityProvider } from '@stackwright-services/compiler';",
548
+ "import type { AuditProvider, AuditEvent } from '@stackwright-services/runtime';",
549
+ "import { auditHash } from '@stackwright-services/runtime';",
550
+ ""
551
+ ].join("\n");
552
+ const executeFlow = [
553
+ "export async function executeFlow(",
554
+ " input: unknown,",
555
+ " context: CapabilityContext,",
556
+ " provider: ObservabilityProvider,",
557
+ " auditProvider: AuditProvider,",
558
+ " registry: { get(name: string): { handler: CapabilityHandler } },",
559
+ "): Promise<unknown> {",
560
+ " auditProvider.record({",
561
+ " type: 'flow_start',",
562
+ " traceId: context.traceId,",
563
+ " timestamp: new Date().toISOString(),",
564
+ ` flowId: '${flow.id}',`,
565
+ " stepName: '',",
566
+ " capabilityName: '',",
567
+ " inputHash: auditHash(input),",
568
+ " outputHash: '',",
569
+ " durationMs: 0,",
570
+ " status: 'success',",
571
+ " });",
572
+ ` const flowSpan = provider.startSpan('flow.${flow.id}', {`,
573
+ ` 'flow.id': '${flow.id}',`,
574
+ ` 'flow.steps': ${flow.steps.length},`,
575
+ " });",
576
+ " const flowStart = Date.now();",
577
+ "",
578
+ " try {",
579
+ " let stepResult: unknown = input;",
580
+ stepCalls,
581
+ " auditProvider.record({",
582
+ " type: 'flow_end',",
583
+ " traceId: context.traceId,",
584
+ " timestamp: new Date().toISOString(),",
585
+ ` flowId: '${flow.id}',`,
586
+ " stepName: '',",
587
+ " capabilityName: '',",
588
+ " inputHash: auditHash(input),",
589
+ " outputHash: auditHash(stepResult),",
590
+ " durationMs: Date.now() - flowStart,",
591
+ " status: 'success',",
592
+ " });",
593
+ ` provider.recordMetric('flow.invocations', 1, { flow: '${flow.id}', status: 'success' });`,
594
+ " return stepResult;",
595
+ " } catch (error) {",
596
+ " flowSpan.setError(error instanceof Error ? error : new Error(String(error)));",
597
+ ` provider.recordMetric('flow.errors', 1, { flow: '${flow.id}' });`,
598
+ " auditProvider.record({",
599
+ " type: 'flow_end',",
600
+ " traceId: context.traceId,",
601
+ " timestamp: new Date().toISOString(),",
602
+ ` flowId: '${flow.id}',`,
603
+ " stepName: '',",
604
+ " capabilityName: '',",
605
+ " inputHash: auditHash(input),",
606
+ " outputHash: '',",
607
+ " durationMs: Date.now() - flowStart,",
608
+ " status: 'error',",
609
+ " error: error instanceof Error ? error.message : String(error),",
610
+ " });",
611
+ " throw error;",
612
+ " } finally {",
613
+ ` provider.recordMetric('flow.latency', Date.now() - flowStart, { flow: '${flow.id}' });`,
614
+ " flowSpan.end();",
615
+ " }",
616
+ "}",
617
+ ""
618
+ ].join("\n");
619
+ return header + stepFunctions.join("\n\n") + "\n\n" + executeFlow;
620
+ }
621
+
622
+ // src/workflow-observability.ts
623
+ function generateWorkflowInstrumentation(workflow) {
624
+ return Object.entries(workflow.states).filter(([, state]) => !!state.on_enter).map(([stateName, state]) => ({
625
+ stateName,
626
+ // filter above guarantees on_enter exists; fallback silences linter.
627
+ capabilityName: state.on_enter?.use ?? "unknown",
628
+ workflowId: workflow.id,
629
+ isTerminal: state.type === "terminal",
630
+ metrics: {
631
+ invocations: "workflow.state.invocations",
632
+ errors: "workflow.state.errors",
633
+ latency: "workflow.state.latency",
634
+ transitions: "workflow.state.transitions"
635
+ }
636
+ }));
637
+ }
638
+ function generateInstrumentedStateHandlerCode(instrumentation, capabilityParams) {
639
+ const { stateName, capabilityName, workflowId, metrics } = instrumentation;
640
+ const paramsJson = JSON.stringify(capabilityParams);
641
+ const header = [
642
+ "// Generated by @stackwright-services/compiler \u2014 DO NOT EDIT",
643
+ `// Workflow: ${workflowId}, State: ${stateName}`,
644
+ `// Capability: ${capabilityName}`,
645
+ "//",
646
+ "// DO NOT EDIT \u2014 regenerate from the source YAML.",
647
+ "",
648
+ "import type { Context } from 'aws-lambda';",
649
+ "import type { ObservabilityProvider } from '@stackwright-services/compiler';",
650
+ "import type { AuditProvider } from '@stackwright-services/runtime';",
651
+ "import { auditHash, noopAuditProvider } from '@stackwright-services/runtime';",
652
+ "",
653
+ "// Noop observability \u2014 replace with real implementation in production bootstrap.",
654
+ "const noopProvider: ObservabilityProvider = {",
655
+ " startSpan: () => ({ setAttribute: () => {}, setError: () => {}, end: () => {} }),",
656
+ " recordMetric: () => {},",
657
+ "};",
658
+ ""
659
+ ].join("\n");
660
+ const handlerLines = [
661
+ "export async function handler(event: unknown, _context: Context): Promise<unknown> {",
662
+ " // TODO: Replace noop providers with real implementations via DI bootstrap.",
663
+ " const provider: ObservabilityProvider = noopProvider;",
664
+ " const auditProvider: AuditProvider = noopAuditProvider;",
665
+ ` // Capability params: ${paramsJson}`,
666
+ "",
667
+ ` const span = provider.startSpan('workflow.state.${stateName}', {`,
668
+ ` 'workflow.id': '${workflowId}',`,
669
+ ` 'state.name': '${stateName}',`,
670
+ ` 'capability.name': '${capabilityName}',`,
671
+ " });",
672
+ " const startTime = Date.now();",
673
+ "",
674
+ " // Transition count \u2014 which signal drove entry to this state.",
675
+ " const incomingSignal = (event as Record<string, unknown>)?.['signal'] as string | undefined;",
676
+ ` provider.recordMetric('${metrics.transitions}', 1, {`,
677
+ ` workflow: '${workflowId}',`,
678
+ ` state: '${stateName}',`,
679
+ " signal: incomingSignal ?? 'initial',",
680
+ " });",
681
+ "",
682
+ " try {",
683
+ ` // TODO: Wire capability invocation for '${capabilityName}'.`,
684
+ " const result = {",
685
+ " ...(event as Record<string, unknown>),",
686
+ ` __state: '${stateName}',`,
687
+ ` __capability: '${capabilityName}',`,
688
+ " __timestamp: new Date().toISOString(),",
689
+ " };",
690
+ "",
691
+ ` provider.recordMetric('${metrics.invocations}', 1, {`,
692
+ ` workflow: '${workflowId}',`,
693
+ ` state: '${stateName}',`,
694
+ ` capability: '${capabilityName}',`,
695
+ " status: 'success',",
696
+ " });",
697
+ " auditProvider.record({",
698
+ " type: 'capability_invocation',",
699
+ " traceId: _context.awsRequestId,",
700
+ " timestamp: new Date().toISOString(),",
701
+ ` flowId: '${workflowId}',`,
702
+ ` stepName: '${stateName}',`,
703
+ ` capabilityName: '${capabilityName}',`,
704
+ " inputHash: auditHash(event),",
705
+ " outputHash: auditHash(result),",
706
+ " durationMs: Date.now() - startTime,",
707
+ " status: 'success',",
708
+ " });",
709
+ " return result;",
710
+ " } catch (error) {",
711
+ " span.setError(error instanceof Error ? error : new Error(String(error)));",
712
+ ` provider.recordMetric('${metrics.errors}', 1, {`,
713
+ ` workflow: '${workflowId}',`,
714
+ ` state: '${stateName}',`,
715
+ " });",
716
+ " auditProvider.record({",
717
+ " type: 'capability_invocation',",
718
+ " traceId: _context.awsRequestId,",
719
+ " timestamp: new Date().toISOString(),",
720
+ ` flowId: '${workflowId}',`,
721
+ ` stepName: '${stateName}',`,
722
+ ` capabilityName: '${capabilityName}',`,
723
+ " inputHash: auditHash(event),",
724
+ " outputHash: '',",
725
+ " durationMs: Date.now() - startTime,",
726
+ " status: 'error',",
727
+ " error: error instanceof Error ? error.message : String(error),",
728
+ " });",
729
+ " throw error;",
730
+ " } finally {",
731
+ " const duration = Date.now() - startTime;",
732
+ ` provider.recordMetric('${metrics.latency}', duration, {`,
733
+ ` workflow: '${workflowId}',`,
734
+ ` state: '${stateName}',`,
735
+ " });",
736
+ " span.end();",
737
+ " }",
738
+ "}",
739
+ ""
740
+ ].join("\n");
741
+ return header + handlerLines;
742
+ }
743
+
744
+ // src/targets/lambda.ts
745
+ function compileLambdaTarget(flow, permissions, instrumentedCode) {
746
+ const files = {};
747
+ files["handler.ts"] = generateHandlerTs(flow);
748
+ files["flow.ts"] = instrumentedCode;
749
+ files["Dockerfile"] = generateDockerfile(flow);
750
+ files["iam-policy.json"] = generateIamPolicy(flow.trigger, permissions);
751
+ files["package.json"] = generatePackageJson(flow);
752
+ const triggerFiles = generateTriggerFiles(flow);
753
+ for (const [path, content] of Object.entries(triggerFiles)) {
754
+ files[path] = content;
755
+ }
756
+ return { files };
757
+ }
758
+ function generateHandlerTs(flow) {
759
+ const trigger = flow.trigger;
760
+ let routeSetup;
761
+ if (trigger.type === "http") {
762
+ const method = trigger.method.toLowerCase();
763
+ const inputExpr = trigger.method === "GET" ? "c.req.query()" : "await c.req.json()";
764
+ routeSetup = `
765
+ app.${method}('${trigger.path}', async (c) => {
766
+ const input = ${inputExpr};
767
+ const context = createContext(c);
768
+ const provider = getObservabilityProvider();
769
+
770
+ try {
771
+ const result = await executeFlow(input, context, provider, registry);
772
+ return c.json(result);
773
+ } catch (error) {
774
+ context.logger.error('Flow execution failed', { error: String(error) });
775
+ return c.json({ error: 'Internal server error' }, 500);
776
+ }
777
+ });`;
778
+ } else {
779
+ routeSetup = `
780
+ // Non-HTTP trigger: ${trigger.type}
781
+ // This handler expects to be invoked by the trigger adapter
782
+ app.post('/_invoke', async (c) => {
783
+ const input = await c.req.json();
784
+ const context = createContext(c);
785
+ const provider = getObservabilityProvider();
786
+
787
+ try {
788
+ const result = await executeFlow(input, context, provider, registry);
789
+ return c.json(result);
790
+ } catch (error) {
791
+ context.logger.error('Flow execution failed', { error: String(error) });
792
+ return c.json({ error: 'Internal server error' }, 500);
793
+ }
794
+ });`;
795
+ }
796
+ return `// Auto-generated Lambda handler for flow: ${flow.id}
797
+ // DO NOT EDIT -- regenerate from source YAML.
798
+
799
+ import { Hono } from 'hono';
800
+ import { executeFlow } from './flow';
801
+ import { CapabilityRegistry, registerAllCapabilities } from '@stackwright-services/capabilities';
802
+ import type { CapabilityContext } from '@stackwright-services/capabilities';
803
+
804
+ // Initialize registry with all built-in capabilities
805
+ const registry = new CapabilityRegistry();
806
+ registerAllCapabilities(registry);
807
+
808
+ const app = new Hono();
809
+
810
+ // Health check
811
+ app.get('/health', (c) => c.json({ status: 'ok', flow: '${flow.id}' }));
812
+ ${routeSetup}
813
+
814
+ function createContext(c: any): CapabilityContext {
815
+ return {
816
+ traceId: c.req.header('x-trace-id') ?? crypto.randomUUID(),
817
+ logger: {
818
+ debug: (msg: string, data?: Record<string, unknown>) => console.debug(JSON.stringify({ level: 'debug', msg, ...data })),
819
+ info: (msg: string, data?: Record<string, unknown>) => console.info(JSON.stringify({ level: 'info', msg, ...data })),
820
+ warn: (msg: string, data?: Record<string, unknown>) => console.warn(JSON.stringify({ level: 'warn', msg, ...data })),
821
+ error: (msg: string, data?: Record<string, unknown>) => console.error(JSON.stringify({ level: 'error', msg, ...data })),
822
+ },
823
+ };
824
+ }
825
+
826
+ function getObservabilityProvider() {
827
+ // No-op provider by default -- swap with OTel provider when configured
828
+ return {
829
+ startSpan: () => ({
830
+ setAttribute: () => {},
831
+ setError: () => {},
832
+ end: () => {},
833
+ }),
834
+ recordMetric: () => {},
835
+ };
836
+ }
837
+
838
+ export default app;
839
+ `;
840
+ }
841
+ function generateDockerfile(flow) {
842
+ return `# Auto-generated Dockerfile for flow: ${flow.id}
843
+ # Uses AWS Lambda Web Adapter for Hono compatibility
844
+ # DO NOT EDIT -- regenerate from source YAML.
845
+
846
+ FROM public.ecr.aws/docker/library/node:22-slim AS builder
847
+
848
+ WORKDIR /app
849
+ COPY package.json ./
850
+ RUN npm install --production=false
851
+ COPY . .
852
+ RUN npm run build
853
+
854
+ FROM public.ecr.aws/docker/library/node:22-slim AS runner
855
+
856
+ # Install AWS Lambda Web Adapter
857
+ COPY --from=public.ecr.aws/awsguru/aws-lambda-web-adapter:0.8.4 /lambda-adapter /opt/extensions/lambda-adapter
858
+
859
+ WORKDIR /app
860
+ COPY --from=builder /app/dist ./dist
861
+ COPY --from=builder /app/node_modules ./node_modules
862
+ COPY --from=builder /app/package.json ./
863
+
864
+ ENV PORT=8080
865
+ ENV AWS_LWA_PORT=8080
866
+ ENV NODE_ENV=production
867
+
868
+ CMD ["node", "dist/handler.js"]
869
+ `;
870
+ }
871
+ function generatePackageJson(flow) {
872
+ return JSON.stringify(
873
+ {
874
+ name: `${flow.id}-lambda`,
875
+ version: "0.0.1",
876
+ private: true,
877
+ scripts: {
878
+ build: "tsc",
879
+ start: "node dist/handler.js"
880
+ },
881
+ dependencies: {
882
+ hono: "^4",
883
+ "@stackwright-services/capabilities": "workspace:*"
884
+ },
885
+ devDependencies: {
886
+ typescript: "^6"
887
+ }
888
+ },
889
+ null,
890
+ 2
891
+ ) + "\n";
892
+ }
893
+ function getTriggerPermissions(trigger) {
894
+ switch (trigger.type) {
895
+ case "event":
896
+ return [
897
+ {
898
+ Effect: "Allow",
899
+ Action: ["sns:Subscribe", "sns:Unsubscribe"],
900
+ Resource: "${TOPIC_ARN}"
901
+ }
902
+ ];
903
+ case "schedule":
904
+ return [
905
+ {
906
+ Effect: "Allow",
907
+ Action: ["events:PutRule", "events:PutTargets", "events:DeleteRule"],
908
+ Resource: "*"
909
+ }
910
+ ];
911
+ case "queue":
912
+ return [
913
+ {
914
+ Effect: "Allow",
915
+ Action: ["sqs:ReceiveMessage", "sqs:DeleteMessage", "sqs:GetQueueAttributes"],
916
+ Resource: "${QUEUE_ARN}"
917
+ }
918
+ ];
919
+ case "http":
920
+ default:
921
+ return [];
922
+ }
923
+ }
924
+ function generateIamPolicy(trigger, permissions) {
925
+ const base = toIamPolicy(permissions);
926
+ const triggerStatements = getTriggerPermissions(trigger);
927
+ base.Statement = [...base.Statement, ...triggerStatements];
928
+ return JSON.stringify(base, null, 2) + "\n";
929
+ }
930
+ function generateTriggerFiles(flow) {
931
+ const trigger = flow.trigger;
932
+ switch (trigger.type) {
933
+ case "event": {
934
+ const source = trigger.source.replace(/^bus:/, "");
935
+ return {
936
+ "sns-subscription.json": JSON.stringify(
937
+ {
938
+ _comment: `Auto-generated SNS subscription for flow: ${flow.id}. Bind TOPIC_ARN and FUNCTION_ARN at deploy time.`,
939
+ TopicArn: "${TOPIC_ARN}",
940
+ Protocol: "lambda",
941
+ Endpoint: "${FUNCTION_ARN}",
942
+ Attributes: {
943
+ RawMessageDelivery: "true"
944
+ },
945
+ source
946
+ },
947
+ null,
948
+ 2
949
+ ) + "\n"
950
+ };
951
+ }
952
+ case "schedule":
953
+ return {
954
+ "eventbridge-rule.json": JSON.stringify(
955
+ {
956
+ _comment: `Auto-generated EventBridge rule for flow: ${flow.id}. Bind FUNCTION_ARN at deploy time.`,
957
+ Name: `${flow.id}-schedule`,
958
+ ScheduleExpression: `cron(${trigger.cron})`,
959
+ State: "ENABLED",
960
+ Targets: [
961
+ {
962
+ Id: `${flow.id}-target`,
963
+ Arn: "${FUNCTION_ARN}"
964
+ }
965
+ ]
966
+ },
967
+ null,
968
+ 2
969
+ ) + "\n"
970
+ };
971
+ case "queue":
972
+ return {
973
+ "sqs-event-source-mapping.json": JSON.stringify(
974
+ {
975
+ _comment: `Auto-generated SQS event source mapping for flow: ${flow.id}. Bind QUEUE_ARN and FUNCTION_ARN at deploy time.`,
976
+ EventSourceArn: "${QUEUE_ARN}",
977
+ FunctionName: "${FUNCTION_ARN}",
978
+ BatchSize: 10,
979
+ Enabled: true,
980
+ queueName: trigger.name
981
+ },
982
+ null,
983
+ 2
984
+ ) + "\n"
985
+ };
986
+ case "http":
987
+ default:
988
+ return {};
989
+ }
990
+ }
991
+
992
+ // src/targets/helm.ts
993
+ function compileHelmTarget(flow, permissions) {
994
+ const files = {};
995
+ const chartName = sanitizeChartName(flow.id);
996
+ const trigger = flow.trigger;
997
+ files["Chart.yaml"] = generateChartYaml(chartName, flow);
998
+ files["values.yaml"] = generateValuesYaml(chartName, flow);
999
+ files["templates/service.yaml"] = generateServiceYaml(chartName);
1000
+ files["templates/serviceaccount.yaml"] = generateServiceAccountYaml(chartName);
1001
+ files["templates/_helpers.tpl"] = generateHelpersTpl(chartName);
1002
+ if (trigger.type === "schedule") {
1003
+ files["templates/cronjob.yaml"] = generateCronJobYaml(chartName, trigger.cron);
1004
+ } else {
1005
+ files["templates/deployment.yaml"] = generateDeploymentYaml(chartName, trigger);
1006
+ if (trigger.type === "http") {
1007
+ files["templates/ingress.yaml"] = generateIngressYaml(chartName);
1008
+ }
1009
+ }
1010
+ if (trigger.type === "event") {
1011
+ files["templates/bus-subscription.yaml"] = generateBusConfigYaml(chartName, trigger.source);
1012
+ }
1013
+ if (trigger.type === "queue") {
1014
+ files["templates/queue-config.yaml"] = generateQueueConfigYaml(chartName, trigger.name);
1015
+ }
1016
+ if (permissions.permissions.length > 0) {
1017
+ files["templates/rbac.yaml"] = generateRbacYaml(chartName, permissions);
1018
+ }
1019
+ return { files };
1020
+ }
1021
+ function sanitizeChartName(name) {
1022
+ return name.replace(/[^a-z0-9-]/g, "-").replace(/-+/g, "-").replace(/^-|-$/g, "");
1023
+ }
1024
+ function generateChartYaml(chartName, flow) {
1025
+ return `# Auto-generated Helm chart for flow: ${flow.id}
1026
+ # Defense Unicorns compatible
1027
+ # DO NOT EDIT -- regenerate from source YAML.
1028
+ apiVersion: v2
1029
+ name: ${chartName}
1030
+ description: ${flow.description}
1031
+ type: application
1032
+ version: 0.1.0
1033
+ appVersion: "0.0.1"
1034
+ `;
1035
+ }
1036
+ function generateValuesYaml(chartName, flow) {
1037
+ const trigger = flow.trigger;
1038
+ let triggerValues = "";
1039
+ if (trigger.type === "event") {
1040
+ const topic = trigger.source.replace(/^bus:/, "").toUpperCase().replace(/-/g, "_");
1041
+ triggerValues = `
1042
+ # Event trigger configuration
1043
+ trigger:
1044
+ type: "event"
1045
+ busTopicArn: "\${BUS_TOPIC_${topic}_ARN}"
1046
+ `;
1047
+ } else if (trigger.type === "schedule") {
1048
+ triggerValues = `
1049
+ # Schedule trigger configuration
1050
+ trigger:
1051
+ type: "schedule"
1052
+ schedule: "${trigger.cron}"
1053
+ `;
1054
+ } else if (trigger.type === "queue") {
1055
+ const queueKey = trigger.name.toUpperCase().replace(/-/g, "_");
1056
+ triggerValues = `
1057
+ # Queue trigger configuration
1058
+ trigger:
1059
+ type: "queue"
1060
+ queueUrl: "\${BUS_QUEUE_${queueKey}_URL}"
1061
+ `;
1062
+ }
1063
+ const ingressSection = trigger.type === "http" ? `
1064
+ ingress:
1065
+ enabled: false
1066
+ className: ""
1067
+ annotations: {}
1068
+ hosts:
1069
+ - host: ${chartName}.local
1070
+ paths:
1071
+ - path: /
1072
+ pathType: ImplementationSpecific
1073
+ tls: []
1074
+ ` : "";
1075
+ return `# Default values for ${chartName}
1076
+ # DO NOT EDIT -- regenerate from source YAML.
1077
+
1078
+ replicaCount: 1
1079
+
1080
+ image:
1081
+ repository: ""
1082
+ tag: "latest"
1083
+ pullPolicy: IfNotPresent
1084
+
1085
+ imagePullSecrets: []
1086
+
1087
+ serviceAccount:
1088
+ create: true
1089
+ name: ""
1090
+ annotations: {}
1091
+
1092
+ service:
1093
+ type: ClusterIP
1094
+ port: 8080
1095
+ ${ingressSection}
1096
+ resources:
1097
+ limits:
1098
+ cpu: 500m
1099
+ memory: 256Mi
1100
+ requests:
1101
+ cpu: 100m
1102
+ memory: 128Mi
1103
+
1104
+ env: []
1105
+
1106
+ nodeSelector: {}
1107
+ tolerations: []
1108
+ affinity: {}
1109
+
1110
+ # Flow metadata
1111
+ flow:
1112
+ id: "${flow.id}"
1113
+ description: "${flow.description}"
1114
+ ${triggerValues}`;
1115
+ }
1116
+ function generateDeploymentYaml(chartName, trigger) {
1117
+ let annotations = "";
1118
+ if (trigger.type === "event") {
1119
+ annotations = ` annotations:
1120
+ stackwright.io/trigger-type: event
1121
+ stackwright.io/bus-topic: "${trigger.source.replace(/^bus:/, "")}"
1122
+ `;
1123
+ } else if (trigger.type === "queue") {
1124
+ annotations = ` annotations:
1125
+ stackwright.io/trigger-type: queue
1126
+ stackwright.io/queue-name: "${trigger.name}"
1127
+ `;
1128
+ }
1129
+ return `# Auto-generated Deployment for ${chartName}
1130
+ apiVersion: apps/v1
1131
+ kind: Deployment
1132
+ metadata:
1133
+ name: {{ include "${chartName}.fullname" . }}
1134
+ labels:
1135
+ {{- include "${chartName}.labels" . | nindent 4 }}
1136
+ ${annotations}spec:
1137
+ replicas: {{ .Values.replicaCount }}
1138
+ selector:
1139
+ matchLabels:
1140
+ {{- include "${chartName}.selectorLabels" . | nindent 6 }}
1141
+ template:
1142
+ metadata:
1143
+ labels:
1144
+ {{- include "${chartName}.selectorLabels" . | nindent 8 }}
1145
+ spec:
1146
+ {{- with .Values.imagePullSecrets }}
1147
+ imagePullSecrets:
1148
+ {{- toYaml . | nindent 8 }}
1149
+ {{- end }}
1150
+ serviceAccountName: {{ include "${chartName}.serviceAccountName" . }}
1151
+ containers:
1152
+ - name: {{ .Chart.Name }}
1153
+ image: "{{ .Values.image.repository }}:{{ .Values.image.tag }}"
1154
+ imagePullPolicy: {{ .Values.image.pullPolicy }}
1155
+ ports:
1156
+ - name: http
1157
+ containerPort: 8080
1158
+ protocol: TCP
1159
+ livenessProbe:
1160
+ httpGet:
1161
+ path: /health
1162
+ port: http
1163
+ initialDelaySeconds: 5
1164
+ periodSeconds: 10
1165
+ readinessProbe:
1166
+ httpGet:
1167
+ path: /health
1168
+ port: http
1169
+ initialDelaySeconds: 5
1170
+ periodSeconds: 10
1171
+ resources:
1172
+ {{- toYaml .Values.resources | nindent 12 }}
1173
+ env:
1174
+ - name: PORT
1175
+ value: "8080"
1176
+ - name: NODE_ENV
1177
+ value: "production"
1178
+ - name: FLOW_ID
1179
+ value: {{ .Values.flow.id | quote }}
1180
+ {{- with .Values.env }}
1181
+ {{- toYaml . | nindent 12 }}
1182
+ {{- end }}
1183
+ {{- with .Values.nodeSelector }}
1184
+ nodeSelector:
1185
+ {{- toYaml . | nindent 8 }}
1186
+ {{- end }}
1187
+ {{- with .Values.tolerations }}
1188
+ tolerations:
1189
+ {{- toYaml . | nindent 8 }}
1190
+ {{- end }}
1191
+ {{- with .Values.affinity }}
1192
+ affinity:
1193
+ {{- toYaml . | nindent 8 }}
1194
+ {{- end }}
1195
+ `;
1196
+ }
1197
+ function generateServiceYaml(chartName) {
1198
+ return `# Auto-generated Service for ${chartName}
1199
+ apiVersion: v1
1200
+ kind: Service
1201
+ metadata:
1202
+ name: {{ include "${chartName}.fullname" . }}
1203
+ labels:
1204
+ {{- include "${chartName}.labels" . | nindent 4 }}
1205
+ spec:
1206
+ type: {{ .Values.service.type }}
1207
+ ports:
1208
+ - port: {{ .Values.service.port }}
1209
+ targetPort: http
1210
+ protocol: TCP
1211
+ name: http
1212
+ selector:
1213
+ {{- include "${chartName}.selectorLabels" . | nindent 4 }}
1214
+ `;
1215
+ }
1216
+ function generateIngressYaml(chartName) {
1217
+ return `# Auto-generated Ingress for ${chartName}
1218
+ {{- if .Values.ingress.enabled }}
1219
+ apiVersion: networking.k8s.io/v1
1220
+ kind: Ingress
1221
+ metadata:
1222
+ name: {{ include "${chartName}.fullname" . }}
1223
+ labels:
1224
+ {{- include "${chartName}.labels" . | nindent 4 }}
1225
+ {{- with .Values.ingress.annotations }}
1226
+ annotations:
1227
+ {{- toYaml . | nindent 4 }}
1228
+ {{- end }}
1229
+ spec:
1230
+ {{- if .Values.ingress.className }}
1231
+ ingressClassName: {{ .Values.ingress.className }}
1232
+ {{- end }}
1233
+ {{- if .Values.ingress.tls }}
1234
+ tls:
1235
+ {{- range .Values.ingress.tls }}
1236
+ - hosts:
1237
+ {{- range .hosts }}
1238
+ - {{ . | quote }}
1239
+ {{- end }}
1240
+ secretName: {{ .secretName }}
1241
+ {{- end }}
1242
+ {{- end }}
1243
+ rules:
1244
+ {{- range .Values.ingress.hosts }}
1245
+ - host: {{ .host | quote }}
1246
+ http:
1247
+ paths:
1248
+ {{- range .paths }}
1249
+ - path: {{ .path }}
1250
+ pathType: {{ .pathType }}
1251
+ backend:
1252
+ service:
1253
+ name: {{ include "${chartName}.fullname" $ }}
1254
+ port:
1255
+ name: http
1256
+ {{- end }}
1257
+ {{- end }}
1258
+ {{- end }}
1259
+ `;
1260
+ }
1261
+ function generateServiceAccountYaml(chartName) {
1262
+ return `# Auto-generated ServiceAccount for ${chartName}
1263
+ {{- if .Values.serviceAccount.create }}
1264
+ apiVersion: v1
1265
+ kind: ServiceAccount
1266
+ metadata:
1267
+ name: {{ include "${chartName}.serviceAccountName" . }}
1268
+ labels:
1269
+ {{- include "${chartName}.labels" . | nindent 4 }}
1270
+ {{- with .Values.serviceAccount.annotations }}
1271
+ annotations:
1272
+ {{- toYaml . | nindent 4 }}
1273
+ {{- end }}
1274
+ {{- end }}
1275
+ `;
1276
+ }
1277
+ function generateHelpersTpl(chartName) {
1278
+ return `{{/*
1279
+ Auto-generated helpers for ${chartName}
1280
+ */}}
1281
+
1282
+ {{- define "${chartName}.name" -}}
1283
+ {{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" }}
1284
+ {{- end }}
1285
+
1286
+ {{- define "${chartName}.fullname" -}}
1287
+ {{- if .Values.fullnameOverride }}
1288
+ {{- .Values.fullnameOverride | trunc 63 | trimSuffix "-" }}
1289
+ {{- else }}
1290
+ {{- $name := default .Chart.Name .Values.nameOverride }}
1291
+ {{- if contains $name .Release.Name }}
1292
+ {{- .Release.Name | trunc 63 | trimSuffix "-" }}
1293
+ {{- else }}
1294
+ {{- printf "%s-%s" .Release.Name $name | trunc 63 | trimSuffix "-" }}
1295
+ {{- end }}
1296
+ {{- end }}
1297
+ {{- end }}
1298
+
1299
+ {{- define "${chartName}.labels" -}}
1300
+ helm.sh/chart: {{ include "${chartName}.name" . }}-{{ .Chart.Version | replace "+" "_" }}
1301
+ {{ include "${chartName}.selectorLabels" . }}
1302
+ app.kubernetes.io/managed-by: {{ .Release.Service }}
1303
+ app.kubernetes.io/part-of: stackwright-services
1304
+ {{- end }}
1305
+
1306
+ {{- define "${chartName}.selectorLabels" -}}
1307
+ app.kubernetes.io/name: {{ include "${chartName}.name" . }}
1308
+ app.kubernetes.io/instance: {{ .Release.Name }}
1309
+ {{- end }}
1310
+
1311
+ {{- define "${chartName}.serviceAccountName" -}}
1312
+ {{- if .Values.serviceAccount.create }}
1313
+ {{- default (include "${chartName}.fullname" .) .Values.serviceAccount.name }}
1314
+ {{- else }}
1315
+ {{- default "default" .Values.serviceAccount.name }}
1316
+ {{- end }}
1317
+ {{- end }}
1318
+ `;
1319
+ }
1320
+ function generateCronJobYaml(chartName, cron) {
1321
+ return `# Auto-generated CronJob for ${chartName}
1322
+ # DO NOT EDIT -- regenerate from source YAML.
1323
+ apiVersion: batch/v1
1324
+ kind: CronJob
1325
+ metadata:
1326
+ name: {{ include "${chartName}.fullname" . }}
1327
+ labels:
1328
+ {{- include "${chartName}.labels" . | nindent 4 }}
1329
+ spec:
1330
+ schedule: "${cron}"
1331
+ concurrencyPolicy: Forbid
1332
+ successfulJobsHistoryLimit: 3
1333
+ failedJobsHistoryLimit: 3
1334
+ jobTemplate:
1335
+ spec:
1336
+ template:
1337
+ metadata:
1338
+ labels:
1339
+ {{- include "${chartName}.selectorLabels" . | nindent 12 }}
1340
+ spec:
1341
+ serviceAccountName: {{ include "${chartName}.serviceAccountName" . }}
1342
+ containers:
1343
+ - name: {{ .Chart.Name }}
1344
+ image: "{{ .Values.image.repository }}:{{ .Values.image.tag }}"
1345
+ imagePullPolicy: {{ .Values.image.pullPolicy }}
1346
+ env:
1347
+ - name: PORT
1348
+ value: "8080"
1349
+ - name: NODE_ENV
1350
+ value: "production"
1351
+ - name: TRIGGER_MODE
1352
+ value: "schedule"
1353
+ - name: FLOW_ID
1354
+ value: {{ .Values.flow.id | quote }}
1355
+ {{- with .Values.env }}
1356
+ {{- toYaml . | nindent 16 }}
1357
+ {{- end }}
1358
+ resources:
1359
+ {{- toYaml .Values.resources | nindent 16 }}
1360
+ restartPolicy: OnFailure
1361
+ `;
1362
+ }
1363
+ function generateBusConfigYaml(chartName, source) {
1364
+ const topic = source.replace(/^bus:/, "");
1365
+ return `# Auto-generated bus subscription config for ${chartName}
1366
+ # The concrete subscription mechanism depends on the cluster's message bus operator.
1367
+ # DO NOT EDIT -- regenerate from source YAML.
1368
+ apiVersion: v1
1369
+ kind: ConfigMap
1370
+ metadata:
1371
+ name: {{ include "${chartName}.fullname" . }}-bus-config
1372
+ labels:
1373
+ {{- include "${chartName}.labels" . | nindent 4 }}
1374
+ data:
1375
+ trigger-type: "event"
1376
+ bus-topic: "${topic}"
1377
+ source: "${source}"
1378
+ `;
1379
+ }
1380
+ function generateQueueConfigYaml(chartName, queueName) {
1381
+ return `# Auto-generated queue config for ${chartName}
1382
+ # The concrete queue binding depends on the cluster's message bus operator.
1383
+ # DO NOT EDIT -- regenerate from source YAML.
1384
+ apiVersion: v1
1385
+ kind: ConfigMap
1386
+ metadata:
1387
+ name: {{ include "${chartName}.fullname" . }}-queue-config
1388
+ labels:
1389
+ {{- include "${chartName}.labels" . | nindent 4 }}
1390
+ data:
1391
+ trigger-type: "queue"
1392
+ queue-name: "${queueName}"
1393
+ `;
1394
+ }
1395
+ function generateRbacYaml(chartName, permissions) {
1396
+ const rules = toRbacRules(permissions);
1397
+ const rulesYaml = rules.map(
1398
+ (r) => ` - apiGroups: [${r.apiGroups.map((g) => `"${g}"`).join(", ")}]
1399
+ resources: [${r.resources.map((res) => `"${res}"`).join(", ")}]
1400
+ verbs: [${r.verbs.map((v) => `"${v}"`).join(", ")}]`
1401
+ ).join("\n");
1402
+ return `# Auto-generated RBAC for ${chartName}
1403
+ # Derived from least-privilege permission analysis
1404
+ apiVersion: rbac.authorization.k8s.io/v1
1405
+ kind: Role
1406
+ metadata:
1407
+ name: {{ include "${chartName}.fullname" . }}
1408
+ labels:
1409
+ {{- include "${chartName}.labels" . | nindent 4 }}
1410
+ rules:
1411
+ ${rulesYaml}
1412
+ ---
1413
+ apiVersion: rbac.authorization.k8s.io/v1
1414
+ kind: RoleBinding
1415
+ metadata:
1416
+ name: {{ include "${chartName}.fullname" . }}
1417
+ labels:
1418
+ {{- include "${chartName}.labels" . | nindent 4 }}
1419
+ roleRef:
1420
+ apiGroup: rbac.authorization.k8s.io
1421
+ kind: Role
1422
+ name: {{ include "${chartName}.fullname" . }}
1423
+ subjects:
1424
+ - kind: ServiceAccount
1425
+ name: {{ include "${chartName}.serviceAccountName" . }}
1426
+ namespace: {{ .Release.Namespace }}
1427
+ `;
1428
+ }
1429
+
1430
+ // src/targets/stepfunctions.ts
1431
+ function compileStepFunctionsTarget(workflow, permissions) {
1432
+ const files = {};
1433
+ const asl = generateAslDefinition(workflow);
1434
+ files["asl-definition.json"] = JSON.stringify(asl, null, 2);
1435
+ files["iam-execution-role.json"] = JSON.stringify(
1436
+ toStepFunctionsIamPolicy(workflow, permissions),
1437
+ null,
1438
+ 2
1439
+ );
1440
+ for (const inst of generateWorkflowInstrumentation(workflow)) {
1441
+ const params = workflow.states[inst.stateName]?.on_enter?.with ?? {};
1442
+ files[`handlers/${inst.stateName}.ts`] = generateInstrumentedStateHandlerCode(inst, params);
1443
+ }
1444
+ files["package.json"] = generatePackageJson2(workflow);
1445
+ return { files };
1446
+ }
1447
+ function generateAslDefinition(workflow) {
1448
+ const states = {};
1449
+ for (const [name, state] of Object.entries(workflow.states)) {
1450
+ Object.assign(states, generateAslStates(name, state, workflow));
1451
+ }
1452
+ const definition = {
1453
+ Comment: `Stackwright workflow: ${workflow.description}`,
1454
+ StartAt: workflow.initial,
1455
+ States: states
1456
+ };
1457
+ if (workflow.timeouts?.absolute) {
1458
+ definition["TimeoutSeconds"] = iso8601ToSeconds(workflow.timeouts.absolute);
1459
+ }
1460
+ return definition;
1461
+ }
1462
+ function generateAslStates(name, state, workflow) {
1463
+ const isTerminal = state.type === "terminal";
1464
+ const hasOnEnter = !!state.on_enter;
1465
+ const transitions = state.transitions ?? [];
1466
+ if (isTerminal && hasOnEnter) {
1467
+ const succeedName = `${name}__succeed`;
1468
+ return {
1469
+ [name]: buildTaskState(workflow.id, name, succeedName),
1470
+ [succeedName]: { Type: "Succeed" }
1471
+ };
1472
+ }
1473
+ if (isTerminal) {
1474
+ return { [name]: { Type: "Succeed" } };
1475
+ }
1476
+ if (transitions.length === 0) {
1477
+ return hasOnEnter ? { [name]: buildTaskState(workflow.id, name) } : { [name]: { Type: "Pass", End: true } };
1478
+ }
1479
+ const first = transitions[0];
1480
+ if (transitions.length === 1 && first && !first.when) {
1481
+ return hasOnEnter ? { [name]: buildTaskState(workflow.id, name, first.to) } : { [name]: { Type: "Pass", Next: first.to } };
1482
+ }
1483
+ if (hasOnEnter) {
1484
+ const choiceName = `${name}__choice`;
1485
+ return {
1486
+ [name]: buildTaskState(workflow.id, name, choiceName),
1487
+ [choiceName]: buildChoiceState(transitions)
1488
+ };
1489
+ }
1490
+ return { [name]: buildChoiceState(transitions) };
1491
+ }
1492
+ function buildTaskState(workflowId, stateName, next) {
1493
+ const task = {
1494
+ Type: "Task",
1495
+ Resource: "arn:aws:states:::lambda:invoke",
1496
+ Parameters: {
1497
+ FunctionName: `${workflowId}-${stateName}`,
1498
+ "Payload.$": "$"
1499
+ }
1500
+ };
1501
+ if (next) {
1502
+ task["Next"] = next;
1503
+ } else {
1504
+ task["End"] = true;
1505
+ }
1506
+ return task;
1507
+ }
1508
+ function buildChoiceState(transitions) {
1509
+ const choices = [];
1510
+ let defaultTarget;
1511
+ const lastNoWhenIdx = findLastIndex(transitions, (t) => !t.when);
1512
+ for (const [i, t] of transitions.entries()) {
1513
+ if (i === lastNoWhenIdx) {
1514
+ defaultTarget = t.to;
1515
+ continue;
1516
+ }
1517
+ choices.push(transitionToChoiceRule(t));
1518
+ }
1519
+ const choiceState = {
1520
+ Type: "Choice",
1521
+ Choices: choices
1522
+ };
1523
+ if (defaultTarget) {
1524
+ choiceState["Default"] = defaultTarget;
1525
+ }
1526
+ return choiceState;
1527
+ }
1528
+ function transitionToChoiceRule(transition) {
1529
+ const signalCheck = { Variable: "$.signal", StringEquals: transition.on };
1530
+ if (!transition.when || transition.when.length === 0) {
1531
+ return { ...signalCheck, Next: transition.to };
1532
+ }
1533
+ return {
1534
+ And: [signalCheck, ...transition.when.map(predicateToChoiceRule)],
1535
+ Next: transition.to
1536
+ };
1537
+ }
1538
+ function predicateToChoiceRule(predicate) {
1539
+ const variable = `$.${predicate.field}`;
1540
+ switch (predicate.op) {
1541
+ case "equals":
1542
+ return buildEqualsRule(variable, predicate.value);
1543
+ case "not_equals":
1544
+ return { Not: buildEqualsRule(variable, predicate.value) };
1545
+ case "less_than":
1546
+ return { Variable: variable, NumericLessThan: predicate.value };
1547
+ case "greater_than":
1548
+ return { Variable: variable, NumericGreaterThan: predicate.value };
1549
+ case "less_than_or_equal":
1550
+ return { Variable: variable, NumericLessThanEquals: predicate.value };
1551
+ case "greater_than_or_equal":
1552
+ return { Variable: variable, NumericGreaterThanEquals: predicate.value };
1553
+ case "matches_prefix":
1554
+ return { Variable: variable, StringMatches: `${String(predicate.value)}*` };
1555
+ case "contains":
1556
+ return { Variable: variable, StringMatches: `*${String(predicate.value)}*` };
1557
+ case "in":
1558
+ return buildInRule(variable, predicate.value);
1559
+ case "not_in":
1560
+ return { Not: buildInRule(variable, predicate.value) };
1561
+ case "less_than_field":
1562
+ case "greater_than_field":
1563
+ case "equals_field":
1564
+ return { Variable: variable, StringEquals: `__UNSUPPORTED_FIELD_OP_${predicate.op}__` };
1565
+ }
1566
+ }
1567
+ function buildEqualsRule(variable, value) {
1568
+ if (typeof value === "string") return { Variable: variable, StringEquals: value };
1569
+ if (typeof value === "number") return { Variable: variable, NumericEquals: value };
1570
+ if (typeof value === "boolean") return { Variable: variable, BooleanEquals: value };
1571
+ return { Variable: variable, StringEquals: String(value) };
1572
+ }
1573
+ function buildInRule(variable, value) {
1574
+ if (!Array.isArray(value)) return buildEqualsRule(variable, value);
1575
+ return { Or: value.map((v) => buildEqualsRule(variable, v)) };
1576
+ }
1577
+ function iso8601ToSeconds(duration) {
1578
+ const match = duration.match(
1579
+ /^P(?:(\d+)Y)?(?:(\d+)M)?(?:(\d+)W)?(?:(\d+)D)?(?:T(?:(\d+)H)?(?:(\d+)M)?(?:(\d+)S)?)?$/
1580
+ );
1581
+ if (!match) {
1582
+ throw new Error(`Invalid ISO 8601 duration: ${duration}`);
1583
+ }
1584
+ const [, years, months, weeks, days, hours, minutes, seconds] = match;
1585
+ let total = 0;
1586
+ if (years) total += parseInt(years, 10) * 365 * 86400;
1587
+ if (months) total += parseInt(months, 10) * 30 * 86400;
1588
+ if (weeks) total += parseInt(weeks, 10) * 7 * 86400;
1589
+ if (days) total += parseInt(days, 10) * 86400;
1590
+ if (hours) total += parseInt(hours, 10) * 3600;
1591
+ if (minutes) total += parseInt(minutes, 10) * 60;
1592
+ if (seconds) total += parseInt(seconds, 10);
1593
+ return total;
1594
+ }
1595
+ function generatePackageJson2(workflow) {
1596
+ return JSON.stringify(
1597
+ {
1598
+ name: `${workflow.id}-stepfunctions`,
1599
+ version: "0.0.1",
1600
+ private: true,
1601
+ scripts: {
1602
+ build: "tsc"
1603
+ },
1604
+ dependencies: {
1605
+ "@stackwright-services/capabilities": "workspace:*"
1606
+ },
1607
+ devDependencies: {
1608
+ "@types/aws-lambda": "^8",
1609
+ typescript: "^6"
1610
+ }
1611
+ },
1612
+ null,
1613
+ 2
1614
+ ) + "\n";
1615
+ }
1616
+ function findLastIndex(arr, predicate) {
1617
+ for (let i = arr.length - 1; i >= 0; i--) {
1618
+ if (predicate(arr[i])) return i;
1619
+ }
1620
+ return -1;
1621
+ }
1622
+
1623
+ // src/targets/openapi.ts
1624
+ function compileOpenApiTarget(flow, options = {}) {
1625
+ if (flow.trigger.type !== "http") {
1626
+ return { files: {} };
1627
+ }
1628
+ const trigger = flow.trigger;
1629
+ const version = options.version ?? "0.0.1";
1630
+ const spec = buildOpenApiSpec(flow, trigger, version, options);
1631
+ return {
1632
+ files: {
1633
+ "openapi.yaml": serializeYaml(spec)
1634
+ }
1635
+ };
1636
+ }
1637
+ function buildOpenApiSpec(flow, trigger, version, options) {
1638
+ const operationId = generateOperationId(flow.id, trigger.method);
1639
+ const spec = {
1640
+ openapi: "3.1.0",
1641
+ info: {
1642
+ title: flow.id,
1643
+ description: flow.description,
1644
+ version
1645
+ },
1646
+ paths: {
1647
+ [trigger.path]: {
1648
+ [trigger.method.toLowerCase()]: {
1649
+ operationId,
1650
+ summary: flow.description,
1651
+ ...trigger.method === "GET" ? { parameters: buildQueryParameters(trigger) } : { requestBody: buildRequestBody(trigger) },
1652
+ responses: buildResponses(trigger)
1653
+ }
1654
+ }
1655
+ }
1656
+ };
1657
+ if (options.serverUrl) {
1658
+ spec.servers = [{ url: options.serverUrl }];
1659
+ }
1660
+ if (options.buildKey) {
1661
+ spec.components = {
1662
+ ...spec.components,
1663
+ securitySchemes: {
1664
+ buildKey: {
1665
+ type: "apiKey",
1666
+ in: "header",
1667
+ name: "x-stackwright-build-key",
1668
+ description: "Build-coherence key. Auto-generated at compile time. Ensures frontend/backend version match."
1669
+ }
1670
+ }
1671
+ };
1672
+ spec.security = [{ buildKey: [] }];
1673
+ spec["x-stackwright-build-key"] = options.buildKey;
1674
+ }
1675
+ spec.components = {
1676
+ ...spec.components,
1677
+ schemas: {
1678
+ [trigger.input_schema]: {
1679
+ type: "object",
1680
+ description: `Input schema: ${trigger.input_schema} (resolve from Zod schema at build time)`
1681
+ },
1682
+ [trigger.output_schema]: {
1683
+ type: "object",
1684
+ description: `Output schema: ${trigger.output_schema} (resolve from Zod schema at build time)`
1685
+ }
1686
+ }
1687
+ };
1688
+ return spec;
1689
+ }
1690
+ function generateOperationId(flowId, method) {
1691
+ const camelCase = flowId.replace(/[^a-zA-Z0-9]+(.)/g, (_, ch) => ch.toUpperCase()).replace(/^[A-Z]/, (ch) => ch.toLowerCase());
1692
+ const prefix = method.toLowerCase();
1693
+ return `${prefix}${camelCase.charAt(0).toUpperCase()}${camelCase.slice(1)}`;
1694
+ }
1695
+ function buildQueryParameters(trigger) {
1696
+ return [
1697
+ {
1698
+ name: trigger.input_schema,
1699
+ in: "query",
1700
+ description: `Query parameters per ${trigger.input_schema} schema`,
1701
+ required: false,
1702
+ schema: { $ref: `#/components/schemas/${trigger.input_schema}` }
1703
+ }
1704
+ ];
1705
+ }
1706
+ function buildRequestBody(trigger) {
1707
+ return {
1708
+ required: true,
1709
+ content: {
1710
+ "application/json": {
1711
+ schema: { $ref: `#/components/schemas/${trigger.input_schema}` }
1712
+ }
1713
+ }
1714
+ };
1715
+ }
1716
+ function buildResponses(trigger) {
1717
+ return {
1718
+ "200": {
1719
+ description: "Successful response",
1720
+ content: {
1721
+ "application/json": {
1722
+ schema: { $ref: `#/components/schemas/${trigger.output_schema}` }
1723
+ }
1724
+ }
1725
+ },
1726
+ "400": {
1727
+ description: "Validation error",
1728
+ content: {
1729
+ "application/json": {
1730
+ schema: {
1731
+ type: "object",
1732
+ properties: {
1733
+ error: { type: "string" },
1734
+ code: { type: "string" },
1735
+ details: { type: "object" }
1736
+ }
1737
+ }
1738
+ }
1739
+ }
1740
+ },
1741
+ "500": {
1742
+ description: "Internal server error",
1743
+ content: {
1744
+ "application/json": {
1745
+ schema: {
1746
+ type: "object",
1747
+ properties: {
1748
+ error: { type: "string" },
1749
+ code: { type: "string" }
1750
+ }
1751
+ }
1752
+ }
1753
+ }
1754
+ }
1755
+ };
1756
+ }
1757
+ function serializeYaml(obj, indent = 0) {
1758
+ const pad = " ".repeat(indent);
1759
+ if (obj === null || obj === void 0) return "null";
1760
+ if (typeof obj === "string") {
1761
+ if (needsQuoting(obj)) {
1762
+ return `'${obj.replace(/'/g, "''")}'`;
1763
+ }
1764
+ return obj;
1765
+ }
1766
+ if (typeof obj === "number" || typeof obj === "boolean") return String(obj);
1767
+ if (Array.isArray(obj)) {
1768
+ if (obj.length === 0) return "[]";
1769
+ return obj.map((item) => {
1770
+ if (typeof item === "object" && item !== null) {
1771
+ const inner = serializeYaml(item, indent + 1);
1772
+ const lines = inner.split("\n");
1773
+ const firstLine = lines[0] ?? "";
1774
+ return `${pad}- ${firstLine.trimStart()}
1775
+ ${lines.slice(1).join("\n")}`.trimEnd();
1776
+ }
1777
+ return `${pad}- ${serializeYaml(item)}`;
1778
+ }).join("\n");
1779
+ }
1780
+ if (typeof obj === "object") {
1781
+ const entries = Object.entries(obj);
1782
+ if (entries.length === 0) return "{}";
1783
+ return entries.map(([key, val]) => {
1784
+ const safeKey = keyNeedsQuoting(key) ? `'${key}'` : key;
1785
+ if (typeof val === "object" && val !== null && !Array.isArray(val) && Object.keys(val).length > 0) {
1786
+ return `${pad}${safeKey}:
1787
+ ${serializeYaml(val, indent + 1)}`;
1788
+ }
1789
+ if (Array.isArray(val)) {
1790
+ if (val.length === 0) return `${pad}${safeKey}: []`;
1791
+ return `${pad}${safeKey}:
1792
+ ${serializeYaml(val, indent + 1)}`;
1793
+ }
1794
+ return `${pad}${safeKey}: ${serializeYaml(val)}`;
1795
+ }).join("\n");
1796
+ }
1797
+ return String(obj);
1798
+ }
1799
+ function needsQuoting(str) {
1800
+ return /[:{},&*#?|<>=!%@`\n[\]]/.test(str) || str === "" || str === "true" || str === "false";
1801
+ }
1802
+ function keyNeedsQuoting(key) {
1803
+ return /^\d+$/.test(key) || key === "true" || key === "false" || key === "null" || key === "";
1804
+ }
1805
+
1806
+ // src/auth-permissions.ts
1807
+ function deriveFlowAuthRequirements(flow) {
1808
+ void flow;
1809
+ return {
1810
+ requiresAuth: false,
1811
+ requiredRoles: [],
1812
+ sources: []
1813
+ };
1814
+ }
1815
+ function deriveWorkflowAuthRequirements(workflow) {
1816
+ const sources = [];
1817
+ for (const [stateName, state] of Object.entries(workflow.states)) {
1818
+ if (state.transitions) {
1819
+ for (const transition of state.transitions) {
1820
+ if (transition.guard_role) {
1821
+ sources.push({
1822
+ role: transition.guard_role,
1823
+ location: `states.${stateName}.transitions[on=${transition.on}].guard_role`
1824
+ });
1825
+ }
1826
+ }
1827
+ }
1828
+ }
1829
+ const uniqueRoles = [...new Set(sources.map((s) => s.role))];
1830
+ return {
1831
+ requiresAuth: sources.length > 0,
1832
+ requiredRoles: uniqueRoles,
1833
+ sources
1834
+ };
1835
+ }
1836
+ function toLambdaAuthorizerConfig(auth) {
1837
+ if (!auth.requiresAuth) return null;
1838
+ return {
1839
+ type: "REQUEST",
1840
+ identitySource: "method.request.header.Authorization",
1841
+ authorizerResultTtlInSeconds: 300,
1842
+ // The actual authorizer Lambda is provisioned separately —
1843
+ // this config tells API Gateway to require it
1844
+ requiredRoles: auth.requiredRoles
1845
+ };
1846
+ }
1847
+ function toHelmAuthAnnotations(auth) {
1848
+ if (!auth.requiresAuth) return {};
1849
+ return {
1850
+ "stackwright.io/auth-required": "true",
1851
+ "stackwright.io/auth-roles": auth.requiredRoles.join(","),
1852
+ "stackwright.io/auth-provider": "oidc"
1853
+ // default, overridable via values.yaml
1854
+ };
1855
+ }
1856
+
1857
+ // src/build-coherence.ts
1858
+ import { createHash } from "crypto";
1859
+ function deriveBuildCoherence(flow, options = {}) {
1860
+ const enabled = options.enabled ?? true;
1861
+ const buildTimestamp = options.timestamp ?? (/* @__PURE__ */ new Date()).toISOString();
1862
+ const contentHash = createHash("sha256").update(JSON.stringify(flow)).digest("hex").slice(0, 16);
1863
+ const buildKey = createHash("sha256").update(`${contentHash}:${buildTimestamp}`).digest("hex").slice(0, 32);
1864
+ return {
1865
+ enabled,
1866
+ buildKey,
1867
+ contentHash,
1868
+ buildTimestamp
1869
+ };
1870
+ }
1871
+ function toBuildKeyMiddleware(config) {
1872
+ if (!config.enabled) return "";
1873
+ return `
1874
+ // \u2500\u2500\u2500 Build Coherence \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
1875
+ // Auto-generated by the Stackwright Services compiler.
1876
+ // Ensures this backend matches the frontend that was compiled with it.
1877
+ // NOT authentication \u2014 that's AuthProvider's job.
1878
+ const STACKWRIGHT_BUILD_KEY = process.env.STACKWRIGHT_BUILD_KEY ?? '${config.buildKey}';
1879
+ const STACKWRIGHT_CONTENT_HASH = '${config.contentHash}';
1880
+
1881
+ app.use('*', async (c, next) => {
1882
+ // Skip health check
1883
+ if (c.req.path === '/health') return next();
1884
+
1885
+ const clientKey = c.req.header('x-stackwright-build-key');
1886
+ if (!clientKey) {
1887
+ return c.json({
1888
+ error: 'Missing build-coherence key',
1889
+ code: 'BUILD_KEY_MISSING',
1890
+ hint: 'Include x-stackwright-build-key header. This is auto-set by the generated client.',
1891
+ }, 403);
1892
+ }
1893
+ if (clientKey !== STACKWRIGHT_BUILD_KEY) {
1894
+ return c.json({
1895
+ error: 'Build mismatch \u2014 frontend and backend were compiled from different builds',
1896
+ code: 'BUILD_COHERENCE_FAILURE',
1897
+ expected: STACKWRIGHT_BUILD_KEY.slice(0, 8) + '\u2026',
1898
+ received: clientKey.slice(0, 8) + '\u2026',
1899
+ contentHash: STACKWRIGHT_CONTENT_HASH,
1900
+ }, 403);
1901
+ }
1902
+ await next();
1903
+ });
1904
+ `;
1905
+ }
1906
+ function toBuildKeyEnv(config) {
1907
+ if (!config.enabled) return {};
1908
+ return {
1909
+ STACKWRIGHT_BUILD_KEY: config.buildKey,
1910
+ STACKWRIGHT_CONTENT_HASH: config.contentHash,
1911
+ STACKWRIGHT_BUILD_TIMESTAMP: config.buildTimestamp
1912
+ };
1913
+ }
1914
+ export {
1915
+ compileHelmTarget,
1916
+ compileLambdaTarget,
1917
+ compileOpenApiTarget,
1918
+ compileStepFunctionsTarget,
1919
+ deriveBuildCoherence,
1920
+ deriveFlowAuthRequirements,
1921
+ deriveFlowPermissions,
1922
+ deriveWorkflowAuthRequirements,
1923
+ deriveWorkflowPermissions,
1924
+ detectDefinitionType,
1925
+ generateFlowInstrumentation,
1926
+ generateInstrumentedFlowCode,
1927
+ generateInstrumentedStateHandlerCode,
1928
+ generateInstrumentedStepCode,
1929
+ generateWorkflowInstrumentation,
1930
+ getTriggerPermissions,
1931
+ iso8601ToSeconds,
1932
+ sanitizeChartName,
1933
+ sanitizeIdentifier,
1934
+ toBuildKeyEnv,
1935
+ toBuildKeyMiddleware,
1936
+ toHelmAuthAnnotations,
1937
+ toIamPolicy,
1938
+ toLambdaAuthorizerConfig,
1939
+ toRbacRules,
1940
+ toStepFunctionsIamPolicy,
1941
+ validate,
1942
+ validateFlow,
1943
+ validateWorkflow
1944
+ };
1945
+ //# sourceMappingURL=index.mjs.map