@intx/hub-sessions 0.2.2 → 0.4.0

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 (87) hide show
  1. package/README.md +3 -5
  2. package/dist/agent-repo.d.ts +23 -7
  3. package/dist/agent-repo.js +19 -6
  4. package/dist/agent-state-kind.js +18 -63
  5. package/dist/asset-service.d.ts +1 -20
  6. package/dist/asset-service.js +19 -97
  7. package/dist/committed-source-tree.d.ts +10 -0
  8. package/dist/committed-source-tree.js +35 -0
  9. package/dist/credential-push.d.ts +52 -7
  10. package/dist/credential-push.js +178 -22
  11. package/dist/event-collector-registry.d.ts +3 -2
  12. package/dist/event-collector-registry.js +42 -13
  13. package/dist/event-collector.d.ts +12 -2
  14. package/dist/event-collector.js +45 -4
  15. package/dist/hub-session-lookups.d.ts +125 -7
  16. package/dist/hub-session-lookups.js +541 -86
  17. package/dist/hub-session-orchestrator.d.ts +2 -3
  18. package/dist/hub-session-orchestrator.js +22 -56
  19. package/dist/index.d.ts +19 -9
  20. package/dist/index.js +16 -7
  21. package/dist/reconciliation-scheduler.d.ts +14 -0
  22. package/dist/reconciliation-scheduler.js +55 -0
  23. package/dist/repo-store/index.d.ts +2 -1
  24. package/dist/repo-store/index.js +1 -0
  25. package/dist/repo-store/store.d.ts +1 -1
  26. package/dist/repo-store/store.js +138 -1
  27. package/dist/repo-store/subscribe-kind.d.ts +6 -3
  28. package/dist/repo-store/subscribe-kind.js +42 -77
  29. package/dist/repo-store/types.d.ts +94 -6
  30. package/dist/repo-store/user-principal-gate.d.ts +26 -0
  31. package/dist/repo-store/user-principal-gate.js +78 -0
  32. package/dist/session-service.d.ts +252 -126
  33. package/dist/session-service.js +851 -624
  34. package/dist/sidecar-allocation/capability-policy.d.ts +27 -0
  35. package/dist/sidecar-allocation/capability-policy.js +124 -0
  36. package/dist/sidecar-allocation/contracts.d.ts +101 -0
  37. package/dist/sidecar-allocation/contracts.js +26 -0
  38. package/dist/sidecar-allocation/index.d.ts +5 -0
  39. package/dist/sidecar-allocation/index.js +4 -0
  40. package/dist/sidecar-allocation/operation.d.ts +10 -0
  41. package/dist/sidecar-allocation/operation.js +54 -0
  42. package/dist/sidecar-allocation/plugin-registry.d.ts +24 -0
  43. package/dist/sidecar-allocation/plugin-registry.js +61 -0
  44. package/dist/sidecar-allocation/reconciler.d.ts +54 -0
  45. package/dist/sidecar-allocation/reconciler.js +825 -0
  46. package/dist/skill-kind.js +12 -62
  47. package/dist/substrate.d.ts +3 -3
  48. package/dist/substrate.js +1 -1
  49. package/dist/workflow-allocation-service.d.ts +64 -0
  50. package/dist/workflow-allocation-service.js +554 -0
  51. package/dist/workflow-closure-resolution.d.ts +106 -0
  52. package/dist/workflow-closure-resolution.js +123 -0
  53. package/dist/workflow-definition-ensure.d.ts +24 -0
  54. package/dist/workflow-definition-ensure.js +75 -0
  55. package/dist/workflow-dispatch-service.d.ts +42 -0
  56. package/dist/workflow-dispatch-service.js +209 -0
  57. package/dist/workflow-dispatch-settlement.d.ts +29 -0
  58. package/dist/workflow-dispatch-settlement.js +140 -0
  59. package/dist/workflow-kind.d.ts +29 -1
  60. package/dist/workflow-kind.js +143 -139
  61. package/dist/workflow-probe-gate.d.ts +286 -0
  62. package/dist/workflow-probe-gate.js +382 -0
  63. package/dist/workflow-run-kind.d.ts +239 -32
  64. package/dist/workflow-run-kind.js +874 -188
  65. package/dist/workflow-run-reader.d.ts +1 -1
  66. package/dist/workflow-run-reader.js +3 -7
  67. package/dist/workflow-run-restore.d.ts +16 -0
  68. package/dist/workflow-run-restore.js +30 -0
  69. package/dist/workflow-source-closure.d.ts +35 -0
  70. package/dist/workflow-source-closure.js +342 -0
  71. package/dist/workflow-source-pins.d.ts +8 -0
  72. package/dist/workflow-source-pins.js +14 -0
  73. package/dist/ws/index.d.ts +3 -3
  74. package/dist/ws/index.js +2 -2
  75. package/dist/ws/pending-tracker.d.ts +93 -0
  76. package/dist/ws/pending-tracker.js +132 -0
  77. package/dist/ws/sidecar-events.d.ts +139 -37
  78. package/dist/ws/sidecar-events.js +2 -2
  79. package/dist/ws/sidecar-handler.d.ts +218 -60
  80. package/dist/ws/sidecar-handler.js +1713 -732
  81. package/dist/ws/sidecar-handler.test-helpers.d.ts +38 -0
  82. package/dist/ws/sidecar-handler.test-helpers.js +95 -0
  83. package/dist/ws/sidecar-token-authenticator.d.ts +3 -1
  84. package/dist/ws/sidecar-token-authenticator.js +78 -7
  85. package/package.json +14 -13
  86. package/dist/available-skills-stanza.d.ts +0 -21
  87. package/dist/available-skills-stanza.js +0 -32
@@ -9,13 +9,41 @@ export type WorkflowSidecarPrincipal = {
9
9
  export type WorkflowPrincipal = WorkflowHubPrincipal | WorkflowSidecarPrincipal;
10
10
  export declare const WORKFLOW_JSON_PATH = "workflow.json";
11
11
  export declare const CAPABILITY_DECLARATIONS_JSON_PATH = "capability-declarations.json";
12
- export declare const WORKFLOW_GITIGNORE_PATH = ".gitignore";
12
+ export declare const PACKAGE_JSON_PATH = "package.json";
13
+ export declare const NODE_MODULES_PATH = "node_modules";
14
+ export declare const PNPM_WORKSPACE_PATH = "pnpm-workspace.yaml";
13
15
  export declare const workflowDefinitionEnvelopeSchema: import("arktype/internal/variants/object.ts").ObjectType<{
14
16
  id: string;
15
17
  triggers: unknown[];
16
18
  steps: Record<string, unknown>;
17
19
  stepOrder: string[];
18
20
  state?: Record<string, unknown>;
21
+ grantRequirements?: {
22
+ resource: string;
23
+ action: string;
24
+ source: "creator" | "invoker";
25
+ effect?: "allow" | "deny" | "ask";
26
+ conditions?: Record<string, unknown> | null;
27
+ }[];
28
+ credentialBindings?: {
29
+ package: string;
30
+ handle: string;
31
+ provider: string;
32
+ locator: "tenant";
33
+ name?: string;
34
+ }[];
35
+ sidecarPlacement?: {
36
+ capabilities?: {
37
+ capability: string;
38
+ effect: "require" | "block";
39
+ }[];
40
+ };
41
+ inboundMailPolicy?: {
42
+ untrustedFrom?: "reject" | "admit";
43
+ invalid?: "reject" | "admit";
44
+ missing?: "reject" | "admit";
45
+ unknown?: "reject" | "admit";
46
+ };
19
47
  }, {}>;
20
48
  export declare const workflowKindHandler: KindHandler;
21
49
  export declare const workflowAuthorize: AuthorizeFn;
@@ -1,18 +1,19 @@
1
1
  // KindHandler for the `workflow` asset kind.
2
2
  //
3
- // A workflow asset is a git repo that holds a single `WorkflowDefinition`
4
- // envelope plus its capability-walk output. The deploy tree shape is:
3
+ // A workflow asset is a codebase: a top-level `package.json` declaring an
4
+ // `interchange.workflow` entry module plus arbitrary source files. The sidecar
5
+ // materializes the codebase into a closure and evaluates the pinned entry to the
6
+ // definition. `validatePush` requires the `package.json`; a tree that lacks one
7
+ // is rejected. The legacy `workflow.json` envelope form is no longer accepted at
8
+ // the push boundary.
5
9
  //
6
- // - `workflow.json` the full `WorkflowDefinition` envelope. The
7
- // content is parsed and structurally validated at push time; deeper
8
- // primitive-shape and DAG validation belongs to the runtime layer
9
- // that instantiates the definition (`defineWorkflow`).
10
- // - `capability-declarations.json` the per-step capability-walk
11
- // output. Content shape is owned by the capability-walk module; the
12
- // substrate only verifies the file parses as a JSON object.
13
- // - `.gitignore` — supplied by the asset routes' genesis init body.
14
- //
15
- // Any top-level entry outside this set fails the push.
10
+ // Source files are unconstrained, but the push validates the manifest's shape
11
+ // and the entry-path's containment, and refuses an envelope-only
12
+ // `capability-declarations.json`, a committed `node_modules`, and an ambiguous
13
+ // tree that also carries an envelope-valid `workflow.json`, so one asset resolves
14
+ // to exactly one definition. The codebase shape accepts both a single package and
15
+ // a `workspaces` monorepo; for a monorepo the push validates only the root's
16
+ // well-formedness and leaves per-member validation to the resolver.
16
17
  //
17
18
  // Authz:
18
19
  // - hub principal: full access.
@@ -22,17 +23,16 @@
22
23
  // used by skill assets.
23
24
  import { type } from "arktype";
24
25
  import { getLogger } from "@intx/log";
25
- import { glob, repoActionToGrantVerb } from "@intx/hub-common";
26
- import { UserPrincipal, } from "./repo-store/index.js";
26
+ import { CredentialBinding, GrantRequirement, SidecarCapabilityPolicy, } from "@intx/types";
27
+ import { InboundMailPolicy } from "@intx/types/runtime";
28
+ import { PackageJSON, isContainedEntryPath } from "@intx/types/package-json";
29
+ import { authorizeUserPrincipal, } from "./repo-store/index.js";
27
30
  const logger = getLogger(["hub-sessions", "workflow-kind"]);
28
31
  export const WORKFLOW_JSON_PATH = "workflow.json";
29
32
  export const CAPABILITY_DECLARATIONS_JSON_PATH = "capability-declarations.json";
30
- export const WORKFLOW_GITIGNORE_PATH = ".gitignore";
31
- const ALLOWED_TOP_LEVEL = new Set([
32
- WORKFLOW_JSON_PATH,
33
- CAPABILITY_DECLARATIONS_JSON_PATH,
34
- WORKFLOW_GITIGNORE_PATH,
35
- ]);
33
+ export const PACKAGE_JSON_PATH = "package.json";
34
+ export const NODE_MODULES_PATH = "node_modules";
35
+ export const PNPM_WORKSPACE_PATH = "pnpm-workspace.yaml";
36
36
  /**
37
37
  * Structural arktype validator for the `workflow.json` envelope. The
38
38
  * substrate checks the cross-cutting shape of `WorkflowDefinition`
@@ -40,10 +40,10 @@ const ALLOWED_TOP_LEVEL = new Set([
40
40
  * `stepOrder`) but does not re-derive `defineWorkflow`'s DAG-level
41
41
  * validation here — primitive-level shape, default-input application,
42
42
  * and `after`-ref resolution belong to the runtime layer that hydrates
43
- * the definition. Push-time validation rejects the obvious wrongs
44
- * (missing top-level fields, wrong primitive types) so a tree that
45
- * could not possibly hydrate into a `WorkflowDefinition` never reaches
46
- * the deploy ref.
43
+ * the definition. The codebase push uses this validator to detect an
44
+ * ambiguous tree that also carries an envelope-valid `workflow.json`,
45
+ * and the hydrate-time definition loaders reuse it to validate a
46
+ * materialized definition before instantiation.
47
47
  */
48
48
  const StepsObject = type("Record<string, unknown>").narrow((value, ctx) => {
49
49
  if (Array.isArray(value)) {
@@ -63,20 +63,29 @@ export const workflowDefinitionEnvelopeSchema = type({
63
63
  steps: StepsObject,
64
64
  stepOrder: "string[]",
65
65
  "state?": StateObject,
66
+ // `grantRequirements` passes through the envelope whether or not it is
67
+ // declared here: arktype's `.onUndeclaredKey("ignore")` below is
68
+ // passthrough, not stripping (only `"delete"` strips), so the hydrate read
69
+ // sees the field either way. Declaring it here VALIDATES declared
70
+ // requirements at the deploy boundary — a malformed `source` is rejected
71
+ // rather than passed through unchecked — as defense in depth alongside the
72
+ // trigger route's own `GrantRequirements` re-validation. Compose the
73
+ // exported `GrantRequirement` arktype rather than restating its shape so the
74
+ // envelope and the definition stay in lockstep.
75
+ "grantRequirements?": GrantRequirement.array(),
76
+ // `credentialBindings` is validated here too -- same defense-in-depth
77
+ // rationale as grantRequirements above: a malformed binding (bad locator,
78
+ // authority, or handle) is rejected at the deploy boundary rather than
79
+ // passed through to launch-time resolution unchecked.
80
+ "credentialBindings?": CredentialBinding.array(),
81
+ "sidecarPlacement?": SidecarCapabilityPolicy,
82
+ // `inboundMailPolicy` is validated here too -- same defense-in-depth
83
+ // rationale as credentialBindings above: a malformed policy (an unknown
84
+ // outcome key or a value that is not reject/admit) is rejected at the deploy
85
+ // boundary rather than passed through to later admission resolution
86
+ // unchecked.
87
+ "inboundMailPolicy?": InboundMailPolicy,
66
88
  }).onUndeclaredKey("ignore");
67
- /**
68
- * Capability-declarations.json is held to "is a JSON object" at this
69
- * commit; the per-step structure is owned by the capability-walk
70
- * module that authors the file. `Record<string, unknown>` on its own
71
- * accepts arrays under arktype's structural-object semantics, so the
72
- * push validator pairs it with an array-rejection narrow.
73
- */
74
- const CapabilityDeclarationsObject = type("Record<string, unknown>").narrow((value, ctx) => {
75
- if (Array.isArray(value)) {
76
- return ctx.mustBe("a JSON object, not an array");
77
- }
78
- return true;
79
- });
80
89
  const SidecarPrincipal = type({
81
90
  kind: "'sidecar'",
82
91
  agentId: "string",
@@ -105,58 +114,99 @@ async function readJSONBlob(path, readBlob) {
105
114
  }
106
115
  return { ok: true, value: parsed };
107
116
  }
117
+ function isJSONObject(value) {
118
+ return typeof value === "object" && value !== null && !Array.isArray(value);
119
+ }
120
+ function rejectPush(repoId, ref, reason) {
121
+ logger.debug `workflow validatePush rejected ${repoId.kind}/${repoId.id} on ${ref}: ${reason}`;
122
+ return { ok: false, reason };
123
+ }
124
+ /**
125
+ * Validate the codebase shape: a top-level `package.json` declaring a contained
126
+ * `interchange.workflow` entry (single package), or a `workspaces` monorepo
127
+ * whose root well-formedness is checked and whose members are deferred to the
128
+ * resolver, plus arbitrary source files. Entered when the tree carries a
129
+ * `package.json`. Source files are unconstrained, but the push refuses the
130
+ * envelope-only `capability-declarations.json`, a committed `node_modules`, and
131
+ * an ambiguous tree that also carries an envelope-valid `workflow.json`, so one
132
+ * asset resolves to exactly one definition.
133
+ *
134
+ * The push validates STRUCTURE only. It never imports or evaluates the entry
135
+ * module -- that runs author code and is the sidecar's sandboxed job. The
136
+ * entry-path containment check is the string-level half of the loader's rule
137
+ * (`isContainedEntryPath`); the realpath-based symlink half runs at load time
138
+ * against the materialized directory, which the hub does not have here.
139
+ */
140
+ async function validateWorkflowCodebasePush(repoId, ref, topLevelTreePaths, readBlob) {
141
+ if (topLevelTreePaths.includes(CAPABILITY_DECLARATIONS_JSON_PATH)) {
142
+ return rejectPush(repoId, ref, `${CAPABILITY_DECLARATIONS_JSON_PATH} is an envelope-only artifact and cannot appear in a codebase workflow asset`);
143
+ }
144
+ if (topLevelTreePaths.includes(NODE_MODULES_PATH)) {
145
+ return rejectPush(repoId, ref, `a committed top-level ${NODE_MODULES_PATH} directory is not allowed; the sidecar materializes dependencies from the resolved closure`);
146
+ }
147
+ // A `workflow.json` that also parses as a valid envelope makes the asset
148
+ // advertise two definitions; reject that. A `workflow.json` present but not a
149
+ // valid envelope is an ordinary source file and is allowed.
150
+ if (topLevelTreePaths.includes(WORKFLOW_JSON_PATH)) {
151
+ const envelopeOutcome = await readJSONBlob(WORKFLOW_JSON_PATH, readBlob);
152
+ if (envelopeOutcome.ok &&
153
+ !(workflowDefinitionEnvelopeSchema(envelopeOutcome.value) instanceof
154
+ type.errors)) {
155
+ return rejectPush(repoId, ref, `tree carries both ${PACKAGE_JSON_PATH} and an envelope-valid ${WORKFLOW_JSON_PATH}; a workflow asset must be a codebase or an envelope, not both`);
156
+ }
157
+ }
158
+ const pkgOutcome = await readJSONBlob(PACKAGE_JSON_PATH, readBlob);
159
+ if (!pkgOutcome.ok) {
160
+ return rejectPush(repoId, ref, pkgOutcome.reason);
161
+ }
162
+ // `PackageJSON` does not declare `workspaces`, so it is read off the raw
163
+ // parsed value. A monorepo is a distinct codebase shape: the workflow lives
164
+ // in one member, selected at resolve time by `packageName`, so this gate does
165
+ // NOT descend into members or require a root `interchange.workflow`. It
166
+ // validates ROOT well-formedness only -- `workspaces` is an array of glob
167
+ // strings -- and defers per-member validation to the resolver, which re-reads
168
+ // every member and fails loud there (one enumeration owner, not two).
169
+ if (isJSONObject(pkgOutcome.value) && "workspaces" in pkgOutcome.value) {
170
+ const workspaces = pkgOutcome.value["workspaces"];
171
+ if (!Array.isArray(workspaces) ||
172
+ !workspaces.every((w) => typeof w === "string")) {
173
+ return rejectPush(repoId, ref, `${PACKAGE_JSON_PATH} "workspaces" must be an array of glob strings; the object form ({ packages, catalog, catalogs }) is not supported`);
174
+ }
175
+ return { ok: true };
176
+ }
177
+ // A pnpm monorepo declares its members in `pnpm-workspace.yaml`, not the
178
+ // package.json `workspaces` field, so a pnpm root has no `workspaces` and
179
+ // would fall through to the single-package check below. Reject that layout at
180
+ // the boundary with a clear message rather than letting it fail obscurely at
181
+ // resolve time (full pnpm support is tracked in INTR-461).
182
+ if (topLevelTreePaths.includes(PNPM_WORKSPACE_PATH)) {
183
+ return rejectPush(repoId, ref, `tree declares a ${PNPM_WORKSPACE_PATH}; the pnpm workspace layout is not supported -- declare members via a package.json "workspaces" array`);
184
+ }
185
+ const pkg = PackageJSON(pkgOutcome.value);
186
+ if (pkg instanceof type.errors) {
187
+ return rejectPush(repoId, ref, `${PACKAGE_JSON_PATH} failed validation: ${pkg.summary}`);
188
+ }
189
+ const entry = pkg.interchange?.workflow;
190
+ if (entry === undefined || entry === "") {
191
+ return rejectPush(repoId, ref, `${PACKAGE_JSON_PATH} must declare a non-empty "interchange.workflow" entry`);
192
+ }
193
+ if (!isContainedEntryPath(entry)) {
194
+ return rejectPush(repoId, ref, `"interchange.workflow" entry ${JSON.stringify(entry)} must be a package-relative path that does not escape the package`);
195
+ }
196
+ return { ok: true };
197
+ }
108
198
  export const workflowKindHandler = {
109
199
  kind: "workflow",
110
200
  directoryPrefix: "assets/workflow",
111
201
  async validatePush({ repoId, ref, topLevelTreePaths, readBlob, }) {
112
- for (const entry of topLevelTreePaths) {
113
- if (!ALLOWED_TOP_LEVEL.has(entry)) {
114
- return {
115
- ok: false,
116
- reason: `unexpected top-level entry ${JSON.stringify(entry)}; allowed: "${WORKFLOW_JSON_PATH}", "${CAPABILITY_DECLARATIONS_JSON_PATH}", "${WORKFLOW_GITIGNORE_PATH}"`,
117
- };
118
- }
119
- }
120
- // A workflow asset without `workflow.json` is structurally
121
- // incoherent: there is nothing for the deploy orchestrator to
122
- // hydrate. Reject so the push surfaces the missing envelope at
123
- // the boundary rather than at hydrate time.
124
- if (!topLevelTreePaths.includes(WORKFLOW_JSON_PATH)) {
125
- return {
126
- ok: false,
127
- reason: `tree is missing required ${WORKFLOW_JSON_PATH}`,
128
- };
129
- }
130
- const workflowOutcome = await readJSONBlob(WORKFLOW_JSON_PATH, readBlob);
131
- if (!workflowOutcome.ok) {
132
- logger.debug `workflow validatePush rejected ${repoId.kind}/${repoId.id} on ${ref}: ${workflowOutcome.reason}`;
133
- return { ok: false, reason: workflowOutcome.reason };
134
- }
135
- const validated = workflowDefinitionEnvelopeSchema(workflowOutcome.value);
136
- if (validated instanceof type.errors) {
137
- const reason = `${WORKFLOW_JSON_PATH} failed validation: ${validated.summary}`;
138
- logger.debug `workflow validatePush rejected ${repoId.kind}/${repoId.id} on ${ref}: ${reason}`;
139
- return { ok: false, reason };
202
+ if (topLevelTreePaths.includes(PACKAGE_JSON_PATH)) {
203
+ return validateWorkflowCodebasePush(repoId, ref, topLevelTreePaths, readBlob);
140
204
  }
141
- if (topLevelTreePaths.includes(CAPABILITY_DECLARATIONS_JSON_PATH)) {
142
- const capOutcome = await readJSONBlob(CAPABILITY_DECLARATIONS_JSON_PATH, readBlob);
143
- if (!capOutcome.ok) {
144
- logger.debug `workflow validatePush rejected ${repoId.kind}/${repoId.id} on ${ref}: ${capOutcome.reason}`;
145
- return { ok: false, reason: capOutcome.reason };
146
- }
147
- const capValidated = CapabilityDeclarationsObject(capOutcome.value);
148
- if (capValidated instanceof type.errors) {
149
- const reason = `${CAPABILITY_DECLARATIONS_JSON_PATH} must be a JSON object: ${capValidated.summary}`;
150
- logger.debug `workflow validatePush rejected ${repoId.kind}/${repoId.id} on ${ref}: ${reason}`;
151
- return { ok: false, reason };
152
- }
153
- }
154
- return { ok: true };
205
+ return rejectPush(repoId, ref, `a workflow asset must be a codebase declaring a ${PACKAGE_JSON_PATH} with an "interchange.workflow" entry; the ${WORKFLOW_JSON_PATH} envelope form is no longer supported`);
155
206
  },
156
207
  onRefUpdated() {
157
- // No cached index today. Consumers read the workflow.json and
158
- // capability-declarations.json through the substrate's blob-read
159
- // API at session time.
208
+ // No cached index today. Consumers read the asset's tree through the
209
+ // substrate's blob-read API at session time.
160
210
  },
161
211
  };
162
212
  export const workflowAuthorize = (principal, repoId, ref, action) => {
@@ -198,64 +248,18 @@ export const workflowAuthorize = (principal, repoId, ref, action) => {
198
248
  }
199
249
  }
200
250
  if (principal.kind === "user") {
201
- // The route layer has already pre-resolved the grant verdict and
202
- // attached it as `authz`. The substrate does NOT re-query the
203
- // grant store here; it (a) checks the bearer-token's claims
204
- // bound the requested (ref, action) and have not expired, and
205
- // (b) sanity-checks that the pre-resolved verdict targets this
206
- // exact resource and grant verb. Both gates must pass before the
207
- // verdict's `effect` is honoured.
208
- const parsed = UserPrincipal(principal);
209
- if (parsed instanceof type.errors) {
210
- return {
211
- allowed: false,
212
- reason: `user principal is malformed: ${parsed.summary}`,
213
- };
214
- }
215
- if (!parsed.tokenClaims.actions.includes(action)) {
216
- return {
217
- allowed: false,
218
- reason: `token does not grant action ${action}`,
219
- };
220
- }
221
- // `ref === "*"` is the substrate's sentinel for the bulk read
222
- // performed by `listRefs`. Per-ref filtering is the advertise-refs
223
- // layer's responsibility, so the bulk read is gated on action and
224
- // expiry alone.
225
- if (ref !== "*" && !glob.match(parsed.tokenClaims.refPattern, ref)) {
226
- return {
227
- allowed: false,
228
- reason: `token refPattern ${parsed.tokenClaims.refPattern} does not match ${ref}`,
229
- };
230
- }
231
- if (Date.now() >= parsed.tokenClaims.expiresAt) {
232
- return {
233
- allowed: false,
234
- reason: `token expired at ${parsed.tokenClaims.expiresAt}`,
235
- };
236
- }
237
- const expectedResource = `asset:${repoId.id}`;
238
- if (parsed.authz.resource !== expectedResource) {
239
- return {
240
- allowed: false,
241
- reason: `authz verdict resource ${parsed.authz.resource} does not match ${expectedResource}`,
242
- };
243
- }
244
- const expectedGrantVerb = repoActionToGrantVerb(action);
245
- if (parsed.authz.grantVerb !== expectedGrantVerb) {
246
- return {
247
- allowed: false,
248
- reason: `authz verdict grantVerb ${parsed.authz.grantVerb} does not match ${expectedGrantVerb}`,
249
- };
250
- }
251
- if (parsed.authz.effect === "allow") {
252
- return { allowed: true };
253
- }
254
- return {
255
- allowed: false,
256
- reason: `authz verdict denied for ${expectedResource} ${expectedGrantVerb}`,
257
- };
251
+ return authorizeUserPrincipal({
252
+ principal,
253
+ repoId,
254
+ ref,
255
+ action,
256
+ resourcePrefix: "asset",
257
+ });
258
258
  }
259
+ // Fail closed on any kind not handled above. The tenant-level
260
+ // `workflow` principal kind (`@intx/types` principalKinds) is a
261
+ // grant owner, not a git/asset bearer, and never carries a workflow
262
+ // repo push here -- so it is intentionally left denied.
259
263
  return {
260
264
  allowed: false,
261
265
  reason: `unknown principal kind: ${principal.kind}`,
@@ -0,0 +1,286 @@
1
+ import type { DBExecutor } from "@intx/db";
2
+ import type { ApprovalItem, GrantRequirement, GrantWalkSnapshot } from "@intx/types";
3
+ import type { PackumentFetcher, RegistryConfig } from "@intx/tool-packaging";
4
+ import type { WorkflowProjectionDefinition } from "@intx/types/sidecar";
5
+ import type { ToolPackageManifest } from "@intx/types/tool-packages";
6
+ import type { WorkflowDefinitionAssetSource, WorkflowDefinitionRegistrySource } from "@intx/types/workflow-sources";
7
+ import { type ApprovalSet } from "@intx/workflow-deploy";
8
+ import { type ResolveAssetAttachmentFn } from "./workflow-closure-resolution.js";
9
+ import type { SourceTreeReads } from "./workflow-source-closure.js";
10
+ import type { SendProbeArgs, WorkflowProbeResult } from "./ws/sidecar-handler.js";
11
+ type WorkflowProbeRouter = {
12
+ sendProbe(args: SendProbeArgs): Promise<WorkflowProbeResult>;
13
+ };
14
+ /**
15
+ * The frozen record an approval writes: the definition's asset selector, the
16
+ * approved wire hash (the freeze anchor), the approved surface, and the
17
+ * grant-walk snapshot the run path materializes grants from. The approved
18
+ * surface is a deterministic projection of the content the hash addresses and
19
+ * rides the deploy hand-off in memory; the snapshot is persisted onto the
20
+ * version row so a run derives its grants from the frozen walk without
21
+ * re-reading and re-walking the workflow's `workflow.json`.
22
+ *
23
+ * `approvedGrants` carries both kinds of approved item: the walk's grant-shape
24
+ * strings and the definition's declared grant requirements. The requirements
25
+ * belong in the same record because the run path mints real grant rows from
26
+ * them, so an account of the approval that listed only the walk strings would
27
+ * under-report the authority the definition will actually carry. It is the flat
28
+ * `ApprovalItem` list rather than the gate's partitioned `ApprovalSet` because
29
+ * this record is the input to persistence, and the persisted form is flat.
30
+ */
31
+ export type FrozenApproval = {
32
+ readonly assetId: string;
33
+ readonly approvedWireHash: string;
34
+ readonly approvedGrants: readonly ApprovalItem[];
35
+ readonly grantSnapshot: GrantWalkSnapshot;
36
+ };
37
+ /**
38
+ * Persists a frozen approval and returns the definition it was recorded
39
+ * against. Bound to a `DBExecutor` in production via
40
+ * `createDbFrozenApprovalWriter`; a test double records the call.
41
+ */
42
+ export type PersistFrozenApprovalFn = (approval: FrozenApproval) => Promise<{
43
+ definitionId: string;
44
+ }>;
45
+ /**
46
+ * The outcome of gating and freezing a probe result. `ok: true` is the frozen
47
+ * approval the deploy hand-off consumes. The `ok: false` arms name the five
48
+ * fail-closed paths: a shipped hash that does not match the hub recompute
49
+ * (tamper-evidence), advisory grants the operator did not approve, declared
50
+ * grant requirements the operator did not approve, a trigger type the runtime
51
+ * does not implement, and an executable step the grant walk left no record for.
52
+ */
53
+ export type ProbeGateResult = {
54
+ readonly ok: true;
55
+ readonly definitionId: string;
56
+ readonly approvedWireHash: string;
57
+ readonly approvedSurface: ApprovalSet;
58
+ /**
59
+ * The inert wire projection the freeze hashed. Rides the ok-arm so the
60
+ * deploy hand-off carries the exact content the frozen hash addresses,
61
+ * never a re-projection of a registry that may have moved since approval.
62
+ */
63
+ readonly projection: WorkflowProjectionDefinition;
64
+ } | {
65
+ readonly ok: false;
66
+ readonly reason: "wire_hash_mismatch";
67
+ readonly shippedWireHash: string;
68
+ readonly recomputedWireHash: string;
69
+ } | {
70
+ readonly ok: false;
71
+ readonly reason: "grants_not_approved";
72
+ readonly unapprovedGrants: readonly string[];
73
+ } | {
74
+ readonly ok: false;
75
+ readonly reason: "grant_requirements_not_approved";
76
+ /**
77
+ * The declared requirements the operator's approval does not cover, in
78
+ * the order the probe declared them. Named in full so the operator can
79
+ * see exactly which authority the definition asked to delegate.
80
+ */
81
+ readonly unapprovedGrantRequirements: readonly GrantRequirement[];
82
+ } | {
83
+ readonly ok: false;
84
+ readonly reason: "unimplemented_trigger";
85
+ /** The distinct reserved-but-unimplemented trigger types the projection declared. */
86
+ readonly unimplementedTriggerTypes: readonly string[];
87
+ } | {
88
+ readonly ok: false;
89
+ readonly reason: "steps_without_grant_record";
90
+ /** Every executable step the grant-walk snapshot accounts for nothing at. */
91
+ readonly stepsWithoutGrantRecord: readonly StepWithoutGrantRecord[];
92
+ /**
93
+ * One sentence naming every miss. A deploy that trips this is a defect in
94
+ * the deploy path rather than in the author's workflow, so the sentence
95
+ * carries what whoever maintains that path needs: the step, the position,
96
+ * and the record that was absent.
97
+ */
98
+ readonly message: string;
99
+ };
100
+ /**
101
+ * One step the deployment can execute that the probe's grant-walk snapshot
102
+ * carries no record for.
103
+ */
104
+ export type StepWithoutGrantRecord = {
105
+ /** The executable step id with no approved grants behind it. */
106
+ readonly stepId: string;
107
+ /**
108
+ * The chain of step ids the executable walk reached `stepId` through,
109
+ * outermost first and `stepId` itself last. Two nested bodies may
110
+ * legitimately carry the same step id, so the chain -- not the id alone --
111
+ * is what names the position in the closure.
112
+ */
113
+ readonly reachedThrough: readonly string[];
114
+ /**
115
+ * The top-level step whose snapshot record was supposed to account for
116
+ * `stepId`. The capability walk folds every nested body's grants into the
117
+ * record of the top-level step that carries the body, so this names the
118
+ * `perStep` key the absent record would have had.
119
+ */
120
+ readonly recordStepId: string;
121
+ };
122
+ /**
123
+ * Build the production persistence step of the freeze. Records identity through
124
+ * the selector-keyed ensure helper (a definition keyed by `(assetId,
125
+ * wireHash)`) and writes the approved wire hash and the grant-walk snapshot onto
126
+ * that definition's version row in one transaction. The grant SET is not written
127
+ * to a version-row column -- the approved wire hash already pins the content the
128
+ * grants project from -- so it travels with the returned frozen approval; the
129
+ * snapshot is written because the run path reads it back to materialize grants.
130
+ */
131
+ export declare function createDbFrozenApprovalWriter(db: DBExecutor): PersistFrozenApprovalFn;
132
+ /**
133
+ * Approve exactly the grant surface the probe reports, without a pre-walked
134
+ * operator `ApprovalSet` to gate against. Under this mode the gate skips the
135
+ * per-grant and per-requirement membership checks and freezes exactly what the
136
+ * probe advertised.
137
+ *
138
+ * This is the code-sourced analogue of the live-authored self-approve: the hub
139
+ * has no live definition to pre-walk, so the probe's advertised grants ARE the
140
+ * declared surface. It does NOT relax tamper-evidence -- the wire-hash check
141
+ * still runs and can still fail closed.
142
+ */
143
+ export type ApproveProbedGrants = {
144
+ readonly kind: "approve-probed";
145
+ };
146
+ /**
147
+ * How the gate turns the probe's advertised surface into an approved one.
148
+ * Either an explicit operator `ApprovalSet` -- every advertised grant and every
149
+ * declared grant requirement must appear in it or the gate fails closed -- or
150
+ * `approve-probed`, which approves exactly the surface the probe reported.
151
+ */
152
+ export type ProbeApprovalPolicy = ApprovalSet | ApproveProbedGrants;
153
+ export type GateAndFreezeArgs = {
154
+ /** The `workflow`-kind asset the frozen definition projects over. */
155
+ readonly assetId: string;
156
+ /** The sidecar's inert probe answer: projection, advisory grants, shipped hash. */
157
+ readonly probeResult: WorkflowProbeResult;
158
+ /**
159
+ * The approval policy. An `ApprovalSet` gates the advisory set and the
160
+ * declared grant requirements against the operator-approved items;
161
+ * `approve-probed` approves exactly the surface the probe reported.
162
+ */
163
+ readonly approvals: ProbeApprovalPolicy;
164
+ /** Persistence step for the freeze; `createDbFrozenApprovalWriter` in production. */
165
+ readonly persist: PersistFrozenApprovalFn;
166
+ };
167
+ /**
168
+ * Gate a probe result and, on approval, freeze it. Operates purely over the
169
+ * inert projection and grant set -- no author code runs here and the capability
170
+ * walk is never re-run.
171
+ *
172
+ * Fails closed on the three security-load-bearing checks before it writes
173
+ * anything: the recomputed wire hash must match the hash the sidecar shipped
174
+ * (tamper-evidence), every advisory grant must be operator-approved, and every
175
+ * declared grant requirement must be operator-approved. It also refuses a
176
+ * projection whose triggers include a reserved-but-unimplemented type -- not a
177
+ * security check, but the layer a pinned closure cannot carry a stale copy of,
178
+ * so it is where a workflow that could only sit inert is caught -- and one
179
+ * whose executable closure reaches a step the grant walk left no record for --
180
+ * the layer holding both halves of the probe answer at once, and a presence
181
+ * check on those records rather than a check that any record's contents are
182
+ * sufficient.
183
+ * Only then does it freeze the recomputed hash onto the version row and return
184
+ * the approved grant set.
185
+ */
186
+ export declare function gateAndFreezeProbeResult(args: GateAndFreezeArgs): Promise<ProbeGateResult>;
187
+ type InstallAndApproveCommonArgs = {
188
+ /** The `interchange.workflow` entry-module path the sidecar evaluates to project the definition. */
189
+ readonly entry: string;
190
+ /** The `workflow`-kind asset the frozen definition projects over. */
191
+ readonly assetId: string;
192
+ /**
193
+ * The approval policy threaded to the gate: an operator `ApprovalSet` to gate
194
+ * the advisory set against, or `approve-probed` to approve exactly the
195
+ * surface the probe reports.
196
+ */
197
+ readonly approvals: ProbeApprovalPolicy;
198
+ /** The sidecar router carrying the probe transport. */
199
+ readonly router: WorkflowProbeRouter;
200
+ /** Executor the freeze writes through. */
201
+ readonly db: DBExecutor;
202
+ /** Optional durable handoff invoked before the approval gate writes. */
203
+ readonly onProbeResult?: (result: WorkflowProbeResult) => Promise<void>;
204
+ };
205
+ /** Install a definition published to an npm registry. */
206
+ export type InstallAndApproveRegistryArgs = InstallAndApproveCommonArgs & {
207
+ readonly source: WorkflowDefinitionRegistrySource;
208
+ /** A `name@range` spec for the workflow definition package. */
209
+ readonly pin: string;
210
+ /** URL and credentials for the registry `source` names. */
211
+ readonly registryConfig: RegistryConfig;
212
+ /** Test seam for packument fetches, threaded to closure resolution. Omitted in production. */
213
+ readonly fetchPackument?: PackumentFetcher;
214
+ };
215
+ /**
216
+ * Install a definition published as a tarball inside a hub `package-registry`
217
+ * asset. The caller mints the asset-read closures (`readBlob`/`listBlobs`) and
218
+ * `resolveAttachment`; this glue never imports the asset service, so hub-service
219
+ * ownership stays at the caller.
220
+ */
221
+ export type InstallAndApproveAssetTarballArgs = InstallAndApproveCommonArgs & {
222
+ readonly source: WorkflowDefinitionAssetSource;
223
+ /** A `name@range` spec for the workflow definition package. */
224
+ readonly pin: string;
225
+ /** Reads a blob at `path` from the asset the definition is sourced from. */
226
+ readonly readBlob: (path: string) => Promise<Uint8Array>;
227
+ /** Lists the blob names directly under `dir` in that asset. */
228
+ readonly listBlobs: (dir: string) => Promise<string[]>;
229
+ /** Resolves each asset the closure references to the pack the probe delivers. */
230
+ readonly resolveAttachment: ResolveAssetAttachmentFn;
231
+ };
232
+ /**
233
+ * Install a definition whose package lives as a git subtree of a hub asset at a
234
+ * pinned commit. The caller binds a `SourceTreeReads` to that commit and
235
+ * supplies the npm `registryConfig` for the external deps; there is no
236
+ * `name@range` pin (the member is selected from `source.package.packageName`).
237
+ * `resolveAttachment` delivers the same git pack the tarball arm does, so the
238
+ * sidecar checks the subtree out of it.
239
+ */
240
+ export type InstallAndApproveAssetSourceArgs = InstallAndApproveCommonArgs & {
241
+ readonly source: WorkflowDefinitionAssetSource;
242
+ /** Git-tree reads pinned to `source.package.commitSha`. */
243
+ readonly reads: SourceTreeReads;
244
+ /**
245
+ * The registry name external deps are stamped with in the frozen closure.
246
+ * Must be a name the sidecar's registry map is keyed by (its npm registry).
247
+ */
248
+ readonly registryName: string;
249
+ /** URL and credentials for the npm registry external deps resolve against. */
250
+ readonly registryConfig: RegistryConfig;
251
+ /** Test seam for packument fetches, threaded to closure resolution. Omitted in production. */
252
+ readonly fetchPackument?: PackumentFetcher;
253
+ /** Resolves each asset the closure references to the pack the probe delivers. */
254
+ readonly resolveAttachment: ResolveAssetAttachmentFn;
255
+ };
256
+ export type InstallAndApproveArgs = InstallAndApproveRegistryArgs | InstallAndApproveAssetTarballArgs | InstallAndApproveAssetSourceArgs;
257
+ /**
258
+ * The frozen hand-off `installAndApproveWorkflowDefinition` produces. It carries
259
+ * the gate outcome plus the two values the source-ref deploy frame needs and
260
+ * must NOT recompute at deploy: the inert `projection` the freeze hashed and the
261
+ * frozen dependency `closure` the pin resolved to. Re-resolving either at deploy
262
+ * would reintroduce the non-determinism the freeze eliminates -- a registry that
263
+ * moved between approve and deploy would pin different bytes and project
264
+ * differently, failing the child re-verify -- so both ride from approve verbatim.
265
+ */
266
+ export type InstallAndApproveResult = {
267
+ readonly approval: ProbeGateResult;
268
+ readonly projection: WorkflowProjectionDefinition;
269
+ readonly closure: ToolPackageManifest;
270
+ };
271
+ /**
272
+ * The install/approve orchestration entrypoint the end-to-end flow drives:
273
+ * resolve the frozen closure, probe the sidecar, then gate and freeze the
274
+ * result. This is production glue, not test-only wiring.
275
+ *
276
+ * The operator-approval decision is an input (`approvals`): the caller supplies
277
+ * either the `ApprovalSet` the operator approved, which the gate holds the
278
+ * advisory set to, or `approve-probed` to approve exactly the surface the probe
279
+ * reports.
280
+ *
281
+ * Returns the gate outcome alongside the inert projection and the frozen
282
+ * closure so the deploy hand-off consumes them verbatim rather than re-probing
283
+ * or re-resolving.
284
+ */
285
+ export declare function installAndApproveWorkflowDefinition(args: InstallAndApproveArgs): Promise<InstallAndApproveResult>;
286
+ export {};