@xyne/workflow-sdk 3.2.15 → 3.2.17

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 (97) hide show
  1. package/dist/agents/agent-step.d.ts.map +1 -1
  2. package/dist/agents/agent-step.js +3 -1
  3. package/dist/agents/agent-step.js.map +1 -1
  4. package/dist/authz/attributes.d.ts +41 -0
  5. package/dist/authz/attributes.d.ts.map +1 -0
  6. package/dist/authz/attributes.js +2 -0
  7. package/dist/authz/attributes.js.map +1 -0
  8. package/dist/authz/default.d.ts +31 -0
  9. package/dist/authz/default.d.ts.map +1 -0
  10. package/dist/authz/default.js +47 -0
  11. package/dist/authz/default.js.map +1 -0
  12. package/dist/authz/id-scope.d.ts +27 -0
  13. package/dist/authz/id-scope.d.ts.map +1 -0
  14. package/dist/authz/id-scope.js +24 -0
  15. package/dist/authz/id-scope.js.map +1 -0
  16. package/dist/authz/principal-field.d.ts +23 -0
  17. package/dist/authz/principal-field.d.ts.map +1 -0
  18. package/dist/authz/principal-field.js +34 -0
  19. package/dist/authz/principal-field.js.map +1 -0
  20. package/dist/authz/types.d.ts +129 -0
  21. package/dist/authz/types.d.ts.map +1 -0
  22. package/dist/authz/types.js +74 -0
  23. package/dist/authz/types.js.map +1 -0
  24. package/dist/client/types.d.ts +26 -1
  25. package/dist/client/types.d.ts.map +1 -1
  26. package/dist/client/workflow-client.d.ts.map +1 -1
  27. package/dist/client/workflow-client.js +21 -2
  28. package/dist/client/workflow-client.js.map +1 -1
  29. package/dist/engine/workflow-executor.d.ts +1 -1
  30. package/dist/engine/workflow-executor.d.ts.map +1 -1
  31. package/dist/engine/workflow-executor.js +13 -2
  32. package/dist/engine/workflow-executor.js.map +1 -1
  33. package/dist/index.d.ts +7 -1
  34. package/dist/index.d.ts.map +1 -1
  35. package/dist/index.js +3 -0
  36. package/dist/index.js.map +1 -1
  37. package/dist/persistence/in-memory-adapter.d.ts +40 -20
  38. package/dist/persistence/in-memory-adapter.d.ts.map +1 -1
  39. package/dist/persistence/in-memory-adapter.js +108 -38
  40. package/dist/persistence/in-memory-adapter.js.map +1 -1
  41. package/dist/persistence/types.d.ts +121 -20
  42. package/dist/persistence/types.d.ts.map +1 -1
  43. package/dist/router/types.d.ts +11 -8
  44. package/dist/router/types.d.ts.map +1 -1
  45. package/dist/router/workflow-router.d.ts +2 -2
  46. package/dist/router/workflow-router.d.ts.map +1 -1
  47. package/dist/router/workflow-router.js +179 -62
  48. package/dist/router/workflow-router.js.map +1 -1
  49. package/dist/runtime/types.d.ts +22 -2
  50. package/dist/runtime/types.d.ts.map +1 -1
  51. package/dist/runtime/workflow-runtime.d.ts +137 -43
  52. package/dist/runtime/workflow-runtime.d.ts.map +1 -1
  53. package/dist/runtime/workflow-runtime.js +288 -98
  54. package/dist/runtime/workflow-runtime.js.map +1 -1
  55. package/dist/steps/base-step.d.ts +19 -2
  56. package/dist/steps/base-step.d.ts.map +1 -1
  57. package/dist/steps/base-step.js.map +1 -1
  58. package/dist/storage/types.d.ts +14 -4
  59. package/dist/storage/types.d.ts.map +1 -1
  60. package/dist/testing/authz-conformance.d.ts +90 -0
  61. package/dist/testing/authz-conformance.d.ts.map +1 -0
  62. package/dist/testing/authz-conformance.js +250 -0
  63. package/dist/testing/authz-conformance.js.map +1 -0
  64. package/dist/testing/index.d.ts +7 -0
  65. package/dist/testing/index.d.ts.map +1 -1
  66. package/dist/testing/index.js +6 -0
  67. package/dist/testing/index.js.map +1 -1
  68. package/dist/testing/mock-context.d.ts +4 -0
  69. package/dist/testing/mock-context.d.ts.map +1 -1
  70. package/dist/testing/mock-context.js +4 -1
  71. package/dist/testing/mock-context.js.map +1 -1
  72. package/dist/testing/mock-step-context.d.ts.map +1 -1
  73. package/dist/testing/mock-step-context.js +1 -0
  74. package/dist/testing/mock-step-context.js.map +1 -1
  75. package/dist/types/context.d.ts +22 -3
  76. package/dist/types/context.d.ts.map +1 -1
  77. package/package.json +9 -3
  78. package/dist/common/principal.d.ts +0 -45
  79. package/dist/common/principal.d.ts.map +0 -1
  80. package/dist/common/principal.js +0 -9
  81. package/dist/common/principal.js.map +0 -1
  82. package/dist/steps/builtin/transform.step.d.ts +0 -247
  83. package/dist/steps/builtin/transform.step.d.ts.map +0 -1
  84. package/dist/steps/builtin/transform.step.js +0 -135
  85. package/dist/steps/builtin/transform.step.js.map +0 -1
  86. package/dist/types/attachment.d.ts +0 -23
  87. package/dist/types/attachment.d.ts.map +0 -1
  88. package/dist/types/attachment.js +0 -2
  89. package/dist/types/attachment.js.map +0 -1
  90. package/dist/types/resume-payload.d.ts +0 -34
  91. package/dist/types/resume-payload.d.ts.map +0 -1
  92. package/dist/types/resume-payload.js +0 -12
  93. package/dist/types/resume-payload.js.map +0 -1
  94. package/dist/util/executable-check.d.ts +0 -42
  95. package/dist/util/executable-check.d.ts.map +0 -1
  96. package/dist/util/executable-check.js +0 -115
  97. package/dist/util/executable-check.js.map +0 -1
@@ -13,6 +13,7 @@
13
13
  */
14
14
  import { ConfigValidator } from '../engine/config-validator.js';
15
15
  import { validateTriggerPayload } from '../common/validate-payload.js';
16
+ import { AccessDeniedError, ResourceNotFoundError } from '../authz/default.js';
16
17
  import { EventTrigger } from '../triggers/event-trigger.js';
17
18
  import { WebhookTrigger } from '../triggers/webhook-trigger.js';
18
19
  import { CronTrigger } from '../triggers/cron-trigger.js';
@@ -61,6 +62,8 @@ function findStepConfigById(config, id) {
61
62
  // ─── WorkflowRuntime ───
62
63
  export class WorkflowRuntime {
63
64
  persistence;
65
+ /** Authorization port — host-supplied, always. See {@link RuntimeOptions.authorizer}. */
66
+ authorizer;
64
67
  queue;
65
68
  scheduler;
66
69
  services;
@@ -83,6 +86,10 @@ export class WorkflowRuntime {
83
86
  aiBuilder;
84
87
  constructor(opts) {
85
88
  this.persistence = opts.persistence;
89
+ // No fallback and no cast: the authorizer and the adapter must agree on
90
+ // `TFilter`, and the only way to guarantee that is to make the host supply
91
+ // both. See {@link RuntimeOptions.authorizer}.
92
+ this.authorizer = opts.authorizer;
86
93
  this.queue = opts.queue;
87
94
  this.scheduler = opts.scheduler;
88
95
  this.services = opts.services;
@@ -99,6 +106,86 @@ export class WorkflowRuntime {
99
106
  this.defaultCronTimezone = config.defaultCronTimezone ?? 'Asia/Kolkata';
100
107
  this.aiBuilder = config.aiBuilder;
101
108
  }
109
+ // ─── Authorization primitives ───
110
+ //
111
+ // Every caller-facing operation goes through exactly one of these. There is no
112
+ // bypass: system-initiated work lives on `this.system` and never reaches here.
113
+ /**
114
+ * Guard an action on a ref the caller already has. Throws
115
+ * {@link AccessDeniedError} if denied; returns the full action set so callers
116
+ * can reuse it as the resource's `permittedActions` for the UI.
117
+ */
118
+ async require(ctx, action, ref) {
119
+ const perms = await this.authorizer.permissions(ctx, ref);
120
+ if (!perms.includes(action))
121
+ throw new AccessDeniedError(action, ref);
122
+ return perms;
123
+ }
124
+ /**
125
+ * Fetch a workflow AND decide on it, atomically. This is the ONLY way a
126
+ * caller-facing op obtains a workflow — so "forgot to authorize" is not
127
+ * expressible, rather than merely discouraged.
128
+ */
129
+ async authorizedWorkflow(ctx, id, action) {
130
+ const record = await this.persistence.getWorkflow(id);
131
+ if (!record)
132
+ throw new ResourceNotFoundError('Workflow', id);
133
+ const permittedActions = await this.require(ctx, action, this.workflowRef(record));
134
+ return { record, permittedActions };
135
+ }
136
+ /** Fetch a folder AND decide on it, atomically. See {@link authorizedWorkflow}. */
137
+ async authorizedFolder(ctx, id, action) {
138
+ const record = await this.persistence.getFolder(id);
139
+ if (!record)
140
+ throw new ResourceNotFoundError('Folder', id);
141
+ const permittedActions = await this.require(ctx, action, this.folderRef(record));
142
+ return { record, permittedActions };
143
+ }
144
+ /** The caller's list predicate for `type`, handed to the adapter verbatim. */
145
+ filterFor(ctx, type) {
146
+ return this.authorizer.visibleFilter(ctx, type);
147
+ }
148
+ /**
149
+ * The container a new resource would land in: the named folder, or `root`
150
+ * when there is no parent. Create is guarded against this — the container is
151
+ * the only thing that exists to ask about before the resource does.
152
+ */
153
+ async containerRef(parentId) {
154
+ if (!parentId)
155
+ return { type: 'root' };
156
+ const folder = await this.persistence.getFolder(parentId);
157
+ if (!folder)
158
+ throw new ResourceNotFoundError('Folder', parentId);
159
+ return this.folderRef(folder);
160
+ }
161
+ /** Build a workflow ref — the record already carries typed `attributes`. */
162
+ workflowRef(wf) {
163
+ return { type: 'workflow', id: wf.id, record: wf };
164
+ }
165
+ /** Build a folder ref from the stored record — carries the folder's typed `attributes`. */
166
+ folderRef(folder) {
167
+ return { type: 'folder', id: folder.id, record: folder };
168
+ }
169
+ executionRef(exec) {
170
+ return { type: 'execution', id: exec.id, record: exec };
171
+ }
172
+ credentialRef(cred) {
173
+ return { type: 'credential', id: cred.id, record: cred };
174
+ }
175
+ /**
176
+ * Populate each list row's `permittedActions` in one authorizer round-trip
177
+ * (via `permissionsBatch`, falling back to per-item) so the UI gates inline
178
+ * row actions synchronously — no N+1 fetch, no optimistic buttons.
179
+ */
180
+ async project(ctx, records, toRef) {
181
+ if (records.length === 0)
182
+ return records;
183
+ const refs = records.map(toRef);
184
+ const perms = this.authorizer.permissionsBatch
185
+ ? await this.authorizer.permissionsBatch(ctx, refs)
186
+ : await Promise.all(refs.map((ref) => this.authorizer.permissions(ctx, ref)));
187
+ return records.map((r, i) => ({ ...r, permittedActions: perms[i] }));
188
+ }
102
189
  // ─── Validation ───
103
190
  /**
104
191
  * Validate a workflow config before saving.
@@ -119,11 +206,18 @@ export class WorkflowRuntime {
119
206
  * - CronTrigger: registers with the scheduler
120
207
  * - EventTrigger / ManualTrigger: no activation needed (status change only)
121
208
  */
122
- async activateWorkflow(workflowId) {
123
- const workflow = await this.persistence.getWorkflow(workflowId);
124
- if (!workflow) {
125
- throw new Error(`Workflow ${workflowId} not found`);
126
- }
209
+ async activateWorkflow(ctx, workflowId) {
210
+ // Guard up front, before any external side-effects (webhook/scheduler).
211
+ const { record } = await this.authorizedWorkflow(ctx, workflowId, 'workflow:activate');
212
+ return this.activateInternal(record);
213
+ }
214
+ /**
215
+ * The activation work, already authorized. Used by the guarded
216
+ * {@link activateWorkflow} and by `createWorkflow` (whose create was itself
217
+ * authorized against the target folder).
218
+ */
219
+ async activateInternal(workflow) {
220
+ const workflowId = workflow.id;
127
221
  const config = parseWorkflowConfig(workflow.config);
128
222
  if (!this.triggers.has(config.trigger.type)) {
129
223
  throw new Error(`Trigger type "${config.trigger.type}" is not registered`);
@@ -152,11 +246,12 @@ export class WorkflowRuntime {
152
246
  /**
153
247
  * Deactivate a workflow's trigger.
154
248
  */
155
- async deactivateWorkflow(workflowId) {
249
+ async deactivateWorkflow(ctx, workflowId) {
156
250
  const workflow = await this.persistence.getWorkflow(workflowId);
157
251
  if (!workflow) {
158
252
  throw new Error(`Workflow ${workflowId} not found`);
159
253
  }
254
+ await this.require(ctx, 'workflow:activate', this.workflowRef(workflow));
160
255
  const config = parseWorkflowConfig(workflow.config);
161
256
  if (!this.triggers.has(config.trigger.type))
162
257
  return;
@@ -332,7 +427,9 @@ export class WorkflowRuntime {
332
427
  const data = body && typeof body === 'object' && !Array.isArray(body)
333
428
  ? body
334
429
  : { body };
335
- await this.resume(executionId, {
430
+ // System path: the unguessable secret in the URL is the authorization —
431
+ // there is no caller to ask the authorizer about.
432
+ await this.resumeInternal(executionId, {
336
433
  data: { action: 'approve', data },
337
434
  });
338
435
  return { status: 202, body: { received: true, executionId } };
@@ -349,11 +446,12 @@ export class WorkflowRuntime {
349
446
  *
350
447
  * Returns the execution ID.
351
448
  */
352
- async triggerManual(workflowId, payload) {
449
+ async triggerManual(ctx, workflowId, payload) {
353
450
  const workflow = await this.persistence.getWorkflow(workflowId);
354
451
  if (!workflow) {
355
452
  throw new Error(`Workflow ${workflowId} not found`);
356
453
  }
454
+ await this.require(ctx, 'workflow:run', this.workflowRef(workflow));
357
455
  const config = parseWorkflowConfig(workflow.config);
358
456
  const triggerCfg = (config.trigger.config ?? {});
359
457
  const inputSchema = triggerCfg['inputSchema'];
@@ -368,11 +466,21 @@ export class WorkflowRuntime {
368
466
  return this.createAndEnqueue(workflow, triggerPayload);
369
467
  }
370
468
  /**
371
- * Resume a paused execution.
372
- *
373
- * Stores the resume payload and re-enqueues the execution.
469
+ * Resume a paused execution. Guards `execution:approve`, then does the work.
374
470
  */
375
- async resume(executionId, input) {
471
+ async resume(ctx, executionId, input) {
472
+ const exec = await this.persistence.getExecution(executionId);
473
+ if (!exec)
474
+ throw new ResourceNotFoundError('Execution', executionId);
475
+ await this.require(ctx, 'execution:approve', this.executionRef(exec));
476
+ return this.resumeInternal(executionId, input);
477
+ }
478
+ /**
479
+ * The resume work, already authorized. Private so the only ways in are the
480
+ * guarded {@link resume} or an explicit system entry point (a wait-callback,
481
+ * whose secret-in-path IS its authorization).
482
+ */
483
+ async resumeInternal(executionId, input) {
376
484
  const exec = await this.persistence.getExecution(executionId);
377
485
  if (!exec) {
378
486
  throw new Error(`Execution ${executionId} not found`);
@@ -447,27 +555,36 @@ export class WorkflowRuntime {
447
555
  *
448
556
  * Returns the new execution ID.
449
557
  */
450
- async rerunFromStep(sourceExecutionId, fromStepId, options) {
558
+ async rerunFromStep(ctx, sourceExecutionId, fromStepId, options) {
559
+ const source = await this.persistence.getExecution(sourceExecutionId);
560
+ if (!source)
561
+ throw new Error(`Execution ${sourceExecutionId} not found`);
562
+ await this.require(ctx, 'execution:rerun', this.executionRef(source));
451
563
  const newExecutionId = await this.executor.rerunFromStep(sourceExecutionId, fromStepId, options);
452
564
  await this.queue.enqueue({ executionId: newExecutionId });
453
565
  return newExecutionId;
454
566
  }
455
567
  // ─── CRUD (used by router) ───
456
568
  /**
457
- * List all workflows visible to the given auth metadata.
569
+ * List the workflows visible to `ctx`. The host's filter is pushed *into*
570
+ * the adapter's query — the SDK never enumerates ids to post-filter.
458
571
  */
459
- async listWorkflows(metadata, filter) {
460
- return this.persistence.listWorkflows(metadata, filter);
572
+ async listWorkflows(ctx, page) {
573
+ const filter = await this.filterFor(ctx, 'workflow');
574
+ const records = await this.persistence.listWorkflows(filter, page);
575
+ return this.project(ctx, records, (wf) => this.workflowRef(wf));
461
576
  }
462
- /** Workflow counts for the scope — total + per-folder, for the folder rail. */
463
- async workflowCounts(metadata) {
464
- return this.persistence.countWorkflowsByFolder(metadata);
577
+ /** Workflow counts for the visible set — total + per-folder, for the folder rail. */
578
+ async workflowCounts(ctx) {
579
+ return this.persistence.countWorkflowsByFolder(await this.filterFor(ctx, 'workflow'));
465
580
  }
466
581
  /**
467
- * Get a single workflow by ID.
582
+ * Get a single workflow by ID. Guards `workflow:read` and attaches the
583
+ * caller's `permittedActions` for the UI.
468
584
  */
469
- async getWorkflow(workflowId) {
470
- return this.persistence.getWorkflow(workflowId);
585
+ async getWorkflow(ctx, workflowId) {
586
+ const { record, permittedActions } = await this.authorizedWorkflow(ctx, workflowId, 'workflow:read');
587
+ return { ...record, permittedActions };
471
588
  }
472
589
  /**
473
590
  * Create a new workflow definition.
@@ -475,13 +592,12 @@ export class WorkflowRuntime {
475
592
  * Validates the config, persists it, and optionally activates the trigger.
476
593
  * Returns the created WorkflowRecord.
477
594
  */
478
- async createWorkflow(input) {
595
+ async createWorkflow(ctx, input) {
596
+ // Create is an action on the *container*: the folder is the thing that
597
+ // exists, so this same decision is what the UI projects onto its "New
598
+ // workflow" button. Also proves the folder exists — no separate lookup.
599
+ await this.authorizedFolder(ctx, input.folderId, 'workflow:create');
479
600
  const validation = this.validateConfig(input.config);
480
- // Every workflow must live in an existing folder.
481
- const folders = await this.persistence.listFolders(input.metadata ?? {});
482
- if (!folders.some((f) => f.id === input.folderId)) {
483
- throw new Error(`Folder ${input.folderId} not found`);
484
- }
485
601
  const metadata = { ...input.metadata, name: input.name };
486
602
  const id = await this.persistence.createWorkflow({
487
603
  status: 'ACTIVE',
@@ -490,13 +606,15 @@ export class WorkflowRuntime {
490
606
  eventType: input.config.trigger.type,
491
607
  summary: input.summary ?? null,
492
608
  folderId: input.folderId,
609
+ attributes: input.attributes,
493
610
  });
494
611
  const record = await this.persistence.getWorkflow(id);
495
612
  if (!record)
496
613
  throw new Error('Failed to read created workflow');
497
614
  if (validation.valid) {
498
615
  try {
499
- await this.activateWorkflow(id);
616
+ // Internal post-create activation — already authorized by the create above.
617
+ await this.activateInternal(record);
500
618
  }
501
619
  catch {
502
620
  // Non-fatal — workflow is created but trigger may need manual activation
@@ -511,20 +629,16 @@ export class WorkflowRuntime {
511
629
  * Accepts partial updates: config, metadata, or both.
512
630
  * If config changes, validates it and updates the trigger type.
513
631
  */
514
- async updateWorkflow(workflowId, input) {
515
- const existing = await this.persistence.getWorkflow(workflowId);
516
- if (!existing) {
517
- throw new Error(`Workflow ${workflowId} not found`);
518
- }
632
+ async updateWorkflow(ctx, workflowId, input) {
633
+ const { record: existing } = await this.authorizedWorkflow(ctx, workflowId, 'workflow:update');
519
634
  const update = {};
520
635
  if (input.summary !== undefined)
521
636
  update.summary = input.summary;
522
- // Move to another folder — must exist in scope.
523
- if (input.folderId !== undefined) {
524
- const folders = await this.persistence.listFolders(input.metadata ?? {});
525
- if (!folders.some((f) => f.id === input.folderId)) {
526
- throw new Error(`Folder ${input.folderId} not found`);
527
- }
637
+ // Move to another folder — needs `workflow:create` on the destination, the
638
+ // same decision that gates creating one there. `authorizedFolder` also
639
+ // proves the folder exists.
640
+ if (input.folderId !== undefined && input.folderId !== existing.folderId) {
641
+ await this.authorizedFolder(ctx, input.folderId, 'workflow:create');
528
642
  update.folderId = input.folderId;
529
643
  }
530
644
  // Update config if provided.
@@ -552,12 +666,35 @@ export class WorkflowRuntime {
552
666
  return updated;
553
667
  }
554
668
  // ─── Folders ───
555
- /** List folders visible to the given scope metadata. */
556
- async listFolders(metadata) {
557
- return this.persistence.listFolders(metadata);
669
+ /** List folders the caller may see. */
670
+ async listFolders(ctx) {
671
+ const records = await this.persistence.listFolders(await this.filterFor(ctx, 'folder'));
672
+ return this.project(ctx, records, (f) => this.folderRef(f));
673
+ }
674
+ /**
675
+ * Get a single folder by id. Guards `folder:read` and attaches the caller's
676
+ * `permittedActions` — which include the container actions
677
+ * (`workflow:create` / `folder:create`), so the UI gates this folder's "New…"
678
+ * menu off the same decision the runtime will enforce.
679
+ */
680
+ async getFolder(ctx, folderId) {
681
+ const { record, permittedActions } = await this.authorizedFolder(ctx, folderId, 'folder:read');
682
+ return { ...record, permittedActions };
683
+ }
684
+ /**
685
+ * What may the caller do at the top level — create a root folder, manage
686
+ * credentials? Container actions on a resource that has no instance, so the
687
+ * client can gate its top-level "New…" buttons without inventing a resource.
688
+ */
689
+ async capabilities(ctx) {
690
+ return this.authorizer.permissions(ctx, { type: 'root' });
558
691
  }
559
692
  /** Create a folder for organizing workflows. */
560
- async createFolder(input) {
693
+ async createFolder(ctx, input) {
694
+ // Create is an action on the container: a nested folder is gated by its
695
+ // parent, a top-level folder by `root`. Both are things the client can ask
696
+ // about before rendering "New folder".
697
+ await this.require(ctx, 'folder:create', await this.containerRef(input.parentId));
561
698
  const name = input.name.trim();
562
699
  if (!name)
563
700
  throw new Error('Folder name is required');
@@ -565,21 +702,25 @@ export class WorkflowRuntime {
565
702
  name,
566
703
  metadata: JSON.stringify(input.metadata ?? {}),
567
704
  parentId: input.parentId ?? null,
705
+ attributes: input.attributes,
568
706
  });
569
- const folders = await this.persistence.listFolders(input.metadata ?? {});
570
- const created = folders.find((f) => f.id === id);
707
+ const created = await this.persistence.getFolder(id);
571
708
  if (!created)
572
709
  throw new Error('Failed to read created folder');
573
710
  return created;
574
711
  }
575
- /** Rename or reparent a folder. */
576
- async moveBatch(authMetadata, input) {
712
+ /** Reorganize folders and workflows in one batch (drag-and-drop). */
713
+ async moveBatch(ctx, input) {
714
+ // Each item is authorized individually by the op it delegates to, which
715
+ // guards both ends of the move: `*:update` on the thing moved and
716
+ // `*:create` on the destination container.
577
717
  await Promise.all([
578
- ...input.folders.map((f) => this.updateFolder(f.id, { parentId: f.parentId })),
579
- ...input.workflows.map((w) => this.updateWorkflow(w.id, { folderId: w.folderId, metadata: authMetadata })),
718
+ ...input.folders.map((f) => this.updateFolder(ctx, f.id, { parentId: f.parentId })),
719
+ ...input.workflows.map((w) => this.updateWorkflow(ctx, w.id, { folderId: w.folderId })),
580
720
  ]);
581
721
  }
582
- async updateFolder(id, input) {
722
+ async updateFolder(ctx, id, input) {
723
+ await this.authorizedFolder(ctx, id, 'folder:update');
583
724
  if (input.name !== undefined && !input.name.trim()) {
584
725
  throw new Error('Folder name cannot be empty');
585
726
  }
@@ -588,10 +729,14 @@ export class WorkflowRuntime {
588
729
  data.name = input.name.trim();
589
730
  if ('parentId' in input) {
590
731
  const newParent = input.parentId ?? null;
732
+ // Reparenting adds a child to the destination, so it needs the same
733
+ // permission as creating one there — otherwise `folder:create` gates the
734
+ // "New folder" button and a drag-and-drop walks straight around it.
735
+ await this.require(ctx, 'folder:create', await this.containerRef(newParent));
591
736
  // Moving a folder into itself or any of its own descendants creates a
592
737
  // cycle — the folder would disappear from all reachable tree paths.
593
738
  if (newParent !== null) {
594
- const allFolders = await this.persistence.listFolders({});
739
+ const allFolders = await this.persistence.listAllFolders();
595
740
  const descendants = new Set([id]);
596
741
  const queue = [id];
597
742
  while (queue.length > 0) {
@@ -615,9 +760,12 @@ export class WorkflowRuntime {
615
760
  * Delete a folder. Blocked while it still holds workflows — every workflow
616
761
  * must belong to a folder, so callers must move/remove its members first.
617
762
  */
618
- async deleteFolder(id, metadata) {
619
- const members = await this.persistence.listWorkflows(metadata, { folderId: id, limit: 1 });
620
- if (members.length > 0) {
763
+ async deleteFolder(ctx, id) {
764
+ await this.authorizedFolder(ctx, id, 'folder:delete');
765
+ // Unfiltered on purpose: emptiness is a property of the folder, not of what
766
+ // this caller can see. Counting only visible workflows would let someone
767
+ // delete a folder out from under workflows they have no access to.
768
+ if ((await this.persistence.countWorkflowsInFolder(id)) > 0) {
621
769
  throw new Error('Folder not empty');
622
770
  }
623
771
  await this.persistence.deleteFolder(id);
@@ -625,48 +773,61 @@ export class WorkflowRuntime {
625
773
  /**
626
774
  * Top N recurring error messages across failed executions in a time window.
627
775
  */
628
- async getTopErrors(params) {
776
+ async getTopErrors(ctx, params) {
629
777
  if (typeof this.persistence.getTopErrors !== 'function')
630
778
  return [];
631
- return this.persistence.getTopErrors(params);
779
+ // Derived from executions, so it carries the same visibility as them.
780
+ return this.persistence.getTopErrors(await this.filterFor(ctx, 'workflow'), params);
632
781
  }
633
782
  /**
634
783
  * Full analytics summary computed server-side — replaces fetching all executions on the client.
635
784
  */
636
- async getAnalyticsSummary(params) {
637
- if (typeof this.persistence.getAnalyticsSummary !== 'function') {
638
- return {
639
- totalRuns: 0,
640
- successRate: 0,
641
- byStatus: {},
642
- runsByDay: [],
643
- byWorkflow: [],
644
- recentFailures: [],
645
- activeWorkflows: 0,
646
- totalWorkflows: 0,
647
- };
648
- }
649
- return this.persistence.getAnalyticsSummary(params);
785
+ async getAnalyticsSummary(ctx, params) {
786
+ const empty = {
787
+ totalRuns: 0,
788
+ successRate: 0,
789
+ byStatus: {},
790
+ runsByDay: [],
791
+ byWorkflow: [],
792
+ recentFailures: [],
793
+ activeWorkflows: 0,
794
+ totalWorkflows: 0,
795
+ };
796
+ if (typeof this.persistence.getAnalyticsSummary !== 'function')
797
+ return empty;
798
+ // Analytics is derived from workflows — scope it to the caller's visible
799
+ // workflows via the authorizer, exactly like listExecutions.
800
+ return this.persistence.getAnalyticsSummary(await this.filterFor(ctx, 'workflow'), params);
650
801
  }
651
802
  /**
652
803
  * Per-step progress events (agent thinking / tool calls / logs) for the run
653
804
  * view's activity timeline. Delegates to persistence.
654
805
  */
655
- async getStepEvents(executionId, stepName, after) {
806
+ async getStepEvents(ctx, executionId, stepName, after) {
807
+ const exec = await this.persistence.getExecution(executionId);
808
+ if (exec)
809
+ await this.require(ctx, 'execution:read', this.executionRef(exec));
656
810
  return this.persistence.getStepEvents(executionId, stepName, after);
657
811
  }
658
812
  /**
659
- * List executions with optional filters and cursor pagination.
813
+ * List executions with optional filters and cursor pagination. Scoped to the
814
+ * caller's visible workflows (an execution is visible iff its workflow is).
660
815
  */
661
- async listExecutions(params) {
662
- return this.persistence.listExecutions(params);
816
+ async listExecutions(ctx, params) {
817
+ // Executions inherit their workflow's visibility — the adapter joins the
818
+ // workflow filter rather than us enumerating workflow ids first.
819
+ return this.persistence.listExecutions(await this.filterFor(ctx, 'workflow'), params);
663
820
  }
664
821
  /**
665
822
  * Lightweight list of executions waiting for approval — only the fields
666
823
  * needed to render the approval queue without N individual fetches.
667
824
  */
668
- async listPendingApprovals(limit) {
669
- const { items } = await this.persistence.listExecutions({ status: 'EXTERNAL_WAIT', ...(limit !== undefined ? { limit } : {}) });
825
+ async listPendingApprovals(ctx, limit) {
826
+ // Visible-workflow scoped for now: any caller who can see the workflow sees
827
+ // its pending gates. Narrowing this to the step's assignee is P3, and lands
828
+ // as `visibleFilter(ctx, 'approval')` — this stays the workflow filter until
829
+ // approval steps actually carry an assignee.
830
+ const { items } = await this.persistence.listExecutions(await this.filterFor(ctx, 'workflow'), { status: 'EXTERNAL_WAIT', ...(limit !== undefined ? { limit } : {}) });
670
831
  const results = await Promise.all(items.map(async (exec) => {
671
832
  const steps = await this.persistence.getStepRows(exec.id);
672
833
  // Prefer the resumable leaf gate (non-control); fall back to any wait.
@@ -717,13 +878,14 @@ export class WorkflowRuntime {
717
878
  /**
718
879
  * Get execution detail including step records.
719
880
  */
720
- async getExecutionDetail(executionId) {
881
+ async getExecutionDetail(ctx, executionId) {
721
882
  const exec = await this.persistence.getExecution(executionId);
722
883
  if (!exec)
723
884
  return null;
885
+ const perms = await this.require(ctx, 'execution:read', this.executionRef(exec));
724
886
  const stepRows = await this.persistence.getStepRows(executionId);
725
887
  // Try to attach the workflow config snapshot
726
- const result = { execution: exec, steps: stepRows };
888
+ const result = { execution: exec, steps: stepRows, permittedActions: perms };
727
889
  const workflow = await this.persistence.getWorkflow(exec.workflowId);
728
890
  if (workflow) {
729
891
  result.summary = workflow.summary;
@@ -746,9 +908,9 @@ export class WorkflowRuntime {
746
908
  try {
747
909
  const state = await this.persistence.getExecutionState(executionId);
748
910
  if (state?.context) {
749
- const ctx = JSON.parse(state.context);
750
- if (ctx.trigger) {
751
- result.triggerData = ctx.trigger;
911
+ const wfCtx = JSON.parse(state.context);
912
+ if (wfCtx.trigger) {
913
+ result.triggerData = wfCtx.trigger;
752
914
  }
753
915
  }
754
916
  }
@@ -760,11 +922,12 @@ export class WorkflowRuntime {
760
922
  /**
761
923
  * Cancel a running or paused execution.
762
924
  */
763
- async cancelExecution(executionId) {
925
+ async cancelExecution(ctx, executionId) {
764
926
  const exec = await this.persistence.getExecution(executionId);
765
927
  if (!exec) {
766
928
  throw new Error(`Execution ${executionId} not found`);
767
929
  }
930
+ await this.require(ctx, 'execution:cancel', this.executionRef(exec));
768
931
  if (exec.status === 'COMPLETED' || exec.status === 'FAILED' || exec.status === 'CANCELLED') {
769
932
  throw new Error(`Execution ${executionId} is already terminal (status: ${exec.status})`);
770
933
  }
@@ -773,23 +936,43 @@ export class WorkflowRuntime {
773
936
  this.log.info(`execution ${executionId} cancelled`);
774
937
  }
775
938
  /**
776
- * List credentials visible to the given auth metadata.
939
+ * List credentials the caller may see — filtered by the authorizer, redacted
940
+ * (never secret data).
777
941
  */
778
- async listCredentials(metadata) {
779
- return this.persistence.listCredentials(metadata);
942
+ async listCredentials(ctx) {
943
+ const creds = await this.persistence.listCredentials(await this.filterFor(ctx, 'credential'));
944
+ return creds.map((c) => ({ id: c.id, name: c.name, type: c.type }));
780
945
  }
781
- /**
782
- * Store a credential.
783
- */
784
- async createCredential(metadata, name, credential) {
785
- await this.persistence.storeCredential(metadata, name, credential);
786
- return { name, type: credential.type };
946
+ /** Fetch a single credential's metadata by id (guarded `credential:read`; no secret). */
947
+ async getCredential(ctx, id) {
948
+ const cred = await this.persistence.getCredential(id);
949
+ if (!cred)
950
+ return null;
951
+ const perms = await this.require(ctx, 'credential:read', this.credentialRef(cred));
952
+ return { ...cred, permittedActions: perms };
787
953
  }
788
954
  /**
789
- * Delete a credential by name.
955
+ * Create a credential. Tenant/ownership ride typed `attributes` (via
956
+ * {@link Register}), stamped and stored exactly like a workflow's.
790
957
  */
791
- async deleteCredential(metadata, name) {
792
- await this.persistence.deleteCredential(metadata, name);
958
+ async createCredential(ctx, input) {
959
+ // Credentials have no folder — `root` is their container.
960
+ await this.require(ctx, 'credential:manage', { type: 'root' });
961
+ const id = await this.persistence.createCredential({
962
+ name: input.name,
963
+ type: input.type,
964
+ data: input.data,
965
+ attributes: input.attributes,
966
+ });
967
+ return { id, name: input.name, type: input.type };
968
+ }
969
+ /** Delete a credential by id — guarded by `credential:manage` on the stored row. */
970
+ async deleteCredential(ctx, id) {
971
+ const cred = await this.persistence.getCredential(id);
972
+ if (!cred)
973
+ throw new Error(`Credential ${id} not found`);
974
+ await this.require(ctx, 'credential:manage', this.credentialRef(cred));
975
+ await this.persistence.deleteCredential(id);
793
976
  }
794
977
  // ─── Job Processing ───
795
978
  /**
@@ -915,6 +1098,10 @@ export class WorkflowRuntime {
915
1098
  const initialContext = {
916
1099
  workflow: {
917
1100
  id: workflow.id,
1101
+ // The execution's tenant, carried by the resource. The executor
1102
+ // re-hydrates this from the record on every run, so this is a seed
1103
+ // rather than the authority.
1104
+ attributes: workflow.attributes,
918
1105
  metadata,
919
1106
  },
920
1107
  trigger: triggerPayload,
@@ -930,11 +1117,14 @@ export class WorkflowRuntime {
930
1117
  return execId;
931
1118
  }
932
1119
  async resolveCredentialsForTrigger(workflow) {
933
- const metadata = JSON.parse(workflow.metadata ?? '{}');
934
- const creds = await this.persistence.listCredentials(metadata);
1120
+ // System path: no caller exists at execution time, so the scope is carried
1121
+ // by the resource — the workflow's own typed `attributes` say which tenant's
1122
+ // credentials it may resolve. Not an ACL decision; there is nobody to ask about.
1123
+ const scope = workflow.attributes;
1124
+ const creds = await this.persistence.listTenantCredentials(scope);
935
1125
  const resolved = {};
936
1126
  for (const cred of creds) {
937
- const full = await this.persistence.resolveCredential(metadata, cred.name);
1127
+ const full = await this.persistence.resolveCredential(scope, cred.name);
938
1128
  if (full) {
939
1129
  resolved[cred.name] = full;
940
1130
  }