@intx/hub-sessions 0.1.2 → 0.3.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 (113) hide show
  1. package/LICENSE +176 -0
  2. package/README.md +82 -1
  3. package/dist/agent-repo.d.ts +93 -0
  4. package/dist/agent-repo.js +109 -0
  5. package/dist/agent-state-kind.d.ts +12 -0
  6. package/dist/agent-state-kind.js +189 -0
  7. package/dist/asset-service.d.ts +104 -0
  8. package/dist/asset-service.js +267 -0
  9. package/dist/committed-source-tree.d.ts +10 -0
  10. package/dist/committed-source-tree.js +35 -0
  11. package/dist/credential-push.d.ts +33 -0
  12. package/dist/credential-push.js +109 -0
  13. package/dist/event-collector-registry.d.ts +20 -0
  14. package/dist/event-collector-registry.js +115 -0
  15. package/dist/event-collector.d.ts +39 -0
  16. package/dist/event-collector.js +365 -0
  17. package/dist/hub-session-lookups.d.ts +135 -0
  18. package/dist/hub-session-lookups.js +663 -0
  19. package/dist/hub-session-orchestrator.d.ts +25 -0
  20. package/dist/hub-session-orchestrator.js +87 -0
  21. package/dist/index.d.ts +27 -0
  22. package/dist/index.js +24 -0
  23. package/dist/package-registry-kind.d.ts +70 -0
  24. package/dist/package-registry-kind.js +260 -0
  25. package/dist/repo-store/index.d.ts +4 -0
  26. package/dist/repo-store/index.js +3 -0
  27. package/dist/repo-store/store.d.ts +41 -0
  28. package/dist/repo-store/store.js +1829 -0
  29. package/dist/repo-store/subscribe-kind.d.ts +56 -0
  30. package/dist/repo-store/subscribe-kind.js +144 -0
  31. package/dist/repo-store/types.d.ts +571 -0
  32. package/dist/repo-store/types.js +42 -0
  33. package/dist/session-service.d.ts +416 -0
  34. package/dist/session-service.js +1191 -0
  35. package/dist/sidecar-allocation/contracts.d.ts +78 -0
  36. package/dist/sidecar-allocation/contracts.js +21 -0
  37. package/dist/sidecar-allocation/index.d.ts +4 -0
  38. package/dist/sidecar-allocation/index.js +3 -0
  39. package/dist/sidecar-allocation/placement-policy.d.ts +11 -0
  40. package/dist/sidecar-allocation/placement-policy.js +21 -0
  41. package/dist/sidecar-allocation/plugin-registry.d.ts +11 -0
  42. package/dist/sidecar-allocation/plugin-registry.js +37 -0
  43. package/dist/sidecar-allocation/reconciler.d.ts +42 -0
  44. package/dist/sidecar-allocation/reconciler.js +431 -0
  45. package/dist/skill-kind.d.ts +41 -0
  46. package/dist/skill-kind.js +292 -0
  47. package/dist/substrate.d.ts +8 -0
  48. package/dist/substrate.js +21 -0
  49. package/dist/workflow-allocation-service.d.ts +58 -0
  50. package/dist/workflow-allocation-service.js +239 -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 +40 -0
  56. package/dist/workflow-dispatch-service.js +146 -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 +37 -0
  60. package/dist/workflow-kind.js +310 -0
  61. package/dist/workflow-probe-gate.d.ts +214 -0
  62. package/dist/workflow-probe-gate.js +207 -0
  63. package/dist/workflow-run-event-log.d.ts +21 -0
  64. package/dist/workflow-run-event-log.js +51 -0
  65. package/dist/workflow-run-kind.d.ts +440 -0
  66. package/dist/workflow-run-kind.js +2916 -0
  67. package/dist/workflow-run-reader.d.ts +47 -0
  68. package/dist/workflow-run-reader.js +153 -0
  69. package/dist/workflow-run-restore.d.ts +15 -0
  70. package/dist/workflow-run-restore.js +26 -0
  71. package/dist/workflow-source-closure.d.ts +35 -0
  72. package/dist/workflow-source-closure.js +342 -0
  73. package/dist/ws/index.d.ts +3 -0
  74. package/dist/ws/index.js +3 -0
  75. package/dist/ws/sidecar-events.d.ts +222 -0
  76. package/dist/ws/sidecar-events.js +72 -0
  77. package/dist/ws/sidecar-handler.d.ts +305 -0
  78. package/dist/ws/sidecar-handler.js +2537 -0
  79. package/dist/ws/sidecar-token-authenticator.d.ts +17 -0
  80. package/dist/ws/sidecar-token-authenticator.js +81 -0
  81. package/package.json +35 -12
  82. package/src/agent-repo.test.ts +0 -310
  83. package/src/agent-repo.ts +0 -165
  84. package/src/agent-state-kind.test.ts +0 -247
  85. package/src/agent-state-kind.ts +0 -204
  86. package/src/asset-service.test.ts +0 -540
  87. package/src/asset-service.ts +0 -378
  88. package/src/available-skills-stanza.test.ts +0 -87
  89. package/src/available-skills-stanza.ts +0 -47
  90. package/src/credential-push.ts +0 -65
  91. package/src/event-collector-registry.test.ts +0 -73
  92. package/src/event-collector-registry.ts +0 -171
  93. package/src/event-collector.test.ts +0 -1387
  94. package/src/event-collector.ts +0 -424
  95. package/src/hub-session-lookups.ts +0 -206
  96. package/src/hub-session-orchestrator.test.ts +0 -510
  97. package/src/hub-session-orchestrator.ts +0 -213
  98. package/src/index.ts +0 -78
  99. package/src/repo-store/index.ts +0 -15
  100. package/src/repo-store/store.test.ts +0 -1169
  101. package/src/repo-store/store.ts +0 -428
  102. package/src/repo-store/types.ts +0 -253
  103. package/src/session-service.test.ts +0 -895
  104. package/src/session-service.ts +0 -464
  105. package/src/skill-kind.test.ts +0 -599
  106. package/src/skill-kind.ts +0 -350
  107. package/src/ws/index.ts +0 -18
  108. package/src/ws/sidecar-events.test.ts +0 -96
  109. package/src/ws/sidecar-events.ts +0 -231
  110. package/src/ws/sidecar-handler.test.ts +0 -2217
  111. package/src/ws/sidecar-handler.ts +0 -1574
  112. package/tsconfig.json +0 -4
  113. package/tsconfig.tsbuildinfo +0 -1
@@ -0,0 +1,140 @@
1
+ import { type } from "arktype";
2
+ import { splitCombinedEventLog, WORKFLOW_RUN_EVENTS_FILE, } from "./workflow-run-event-log.js";
3
+ import { WORKFLOW_RUN_ADDRESSES_PREFIX, WORKFLOW_RUN_CONSUMED_DIR, WORKFLOW_RUN_EVENTS_DIR, WORKFLOW_RUN_RUNS_PREFIX, } from "./workflow-run-kind.js";
4
+ const ConsumedEnvelope = type({
5
+ messageId: "string",
6
+ address: "string",
7
+ "rejection?": {
8
+ code: "string > 0",
9
+ message: "string > 0",
10
+ },
11
+ "+": "ignore",
12
+ });
13
+ const SignalReceived = type({
14
+ type: "'SignalReceived'",
15
+ signalId: "string",
16
+ "+": "ignore",
17
+ });
18
+ const RunStarted = type({
19
+ type: "'RunStarted'",
20
+ "consumedMessageId?": "string",
21
+ "+": "ignore",
22
+ });
23
+ function projectAcceptedDispatch(raw, path) {
24
+ let decoded;
25
+ try {
26
+ decoded = JSON.parse(raw);
27
+ }
28
+ catch (cause) {
29
+ throw new Error(`workflow_dispatch_event_invalid_json: ${path}`, {
30
+ cause,
31
+ });
32
+ }
33
+ if (typeof decoded !== "object" || decoded === null || !("type" in decoded)) {
34
+ return null;
35
+ }
36
+ if (decoded.type === "SignalReceived") {
37
+ const signal = SignalReceived(decoded);
38
+ if (signal instanceof type.errors) {
39
+ throw new Error(`workflow_dispatch_signal_invalid: ${path}: ${signal.summary}`);
40
+ }
41
+ return { kind: "signal", messageId: signal.signalId };
42
+ }
43
+ if (decoded.type === "RunStarted") {
44
+ const started = RunStarted(decoded);
45
+ if (started instanceof type.errors) {
46
+ throw new Error(`workflow_dispatch_run_started_invalid: ${path}: ${started.summary}`);
47
+ }
48
+ return started.consumedMessageId === undefined
49
+ ? null
50
+ : { kind: "mail", messageId: started.consumedMessageId };
51
+ }
52
+ return null;
53
+ }
54
+ /** Enumerate dispatches durably accepted by one live or sealed run log. */
55
+ export async function listAcceptedWorkflowDispatches(reads, runId, targetMessageIds) {
56
+ if (targetMessageIds?.size === 0)
57
+ return [];
58
+ const accepted = [];
59
+ const remaining = targetMessageIds === undefined ? undefined : new Set(targetMessageIds);
60
+ const accept = (dispatch) => {
61
+ if (dispatch === null)
62
+ return;
63
+ if (remaining !== undefined && !remaining.delete(dispatch.messageId))
64
+ return;
65
+ accepted.push({ runId, ...dispatch });
66
+ };
67
+ const runPath = `${WORKFLOW_RUN_RUNS_PREFIX}/${runId}`;
68
+ const children = await reads.listDir(runPath);
69
+ const combined = children.find((entry) => entry.type === "blob" && entry.name === WORKFLOW_RUN_EVENTS_FILE);
70
+ if (combined !== undefined) {
71
+ const raw = new TextDecoder().decode(await reads.readBlobByOid(combined.oid));
72
+ const lines = splitCombinedEventLog(raw);
73
+ for (let index = lines.length - 1; index >= 0; index -= 1) {
74
+ const line = lines[index];
75
+ if (line === undefined)
76
+ continue;
77
+ accept(projectAcceptedDispatch(line, `${runPath}/${WORKFLOW_RUN_EVENTS_FILE}:${String(index + 1)}`));
78
+ if (remaining?.size === 0)
79
+ break;
80
+ }
81
+ return accepted.reverse();
82
+ }
83
+ const eventsPath = `${runPath}/${WORKFLOW_RUN_EVENTS_DIR}`;
84
+ const eventEntries = (await reads.listDir(eventsPath))
85
+ .filter((entry) => entry.type === "blob" && entry.name.endsWith(".json"))
86
+ .sort((left, right) => right.name.localeCompare(left.name, undefined, {
87
+ numeric: true,
88
+ }));
89
+ for (const eventEntry of eventEntries) {
90
+ const raw = new TextDecoder().decode(await reads.readBlobByOid(eventEntry.oid));
91
+ accept(projectAcceptedDispatch(raw, `${eventsPath}/${eventEntry.name}`));
92
+ if (remaining?.size === 0)
93
+ break;
94
+ }
95
+ return accepted.reverse();
96
+ }
97
+ /** Enumerate durable SignalReceived ids from one live or sealed run log. */
98
+ export async function listReceivedWorkflowSignals(reads, runId) {
99
+ return (await listAcceptedWorkflowDispatches(reads, runId))
100
+ .filter((dispatch) => dispatch.kind === "signal")
101
+ .map((dispatch) => ({ runId, signalId: dispatch.messageId }));
102
+ }
103
+ /**
104
+ * Enumerate the workflow-run claim-check's retained consumed index. The kind
105
+ * validator guarantees this tree shape before the ref advances; the runtime
106
+ * validator here keeps the Git-to-database projection fail-closed if that
107
+ * invariant is ever violated.
108
+ */
109
+ export async function listConsumedWorkflowDispatches(reads) {
110
+ const consumed = [];
111
+ for (const addressEntry of await reads.listDir(WORKFLOW_RUN_ADDRESSES_PREFIX)) {
112
+ if (addressEntry.type !== "tree")
113
+ continue;
114
+ const consumedPath = `${WORKFLOW_RUN_ADDRESSES_PREFIX}/${addressEntry.name}/${WORKFLOW_RUN_CONSUMED_DIR}`;
115
+ for (const entry of await reads.listDir(consumedPath)) {
116
+ if (entry.type !== "blob" || !entry.name.endsWith(".json"))
117
+ continue;
118
+ const raw = await reads.readBlobByOid(entry.oid);
119
+ let decoded;
120
+ try {
121
+ decoded = JSON.parse(new TextDecoder().decode(raw));
122
+ }
123
+ catch (cause) {
124
+ throw new Error(`workflow_dispatch_consumed_invalid_json: ${consumedPath}/${entry.name}`, { cause });
125
+ }
126
+ const envelope = ConsumedEnvelope(decoded);
127
+ if (envelope instanceof type.errors) {
128
+ throw new Error(`workflow_dispatch_consumed_invalid: ${consumedPath}/${entry.name}: ${envelope.summary}`);
129
+ }
130
+ consumed.push({
131
+ messageId: envelope.messageId,
132
+ address: envelope.address,
133
+ ...(envelope.rejection !== undefined
134
+ ? { rejection: envelope.rejection }
135
+ : {}),
136
+ });
137
+ }
138
+ }
139
+ return consumed;
140
+ }
@@ -0,0 +1,37 @@
1
+ import { type AuthorizeFn, type KindHandler } from "./repo-store/index.js";
2
+ export type WorkflowHubPrincipal = {
3
+ readonly kind: "hub";
4
+ };
5
+ export type WorkflowSidecarPrincipal = {
6
+ readonly kind: "sidecar";
7
+ readonly agentId: string;
8
+ };
9
+ export type WorkflowPrincipal = WorkflowHubPrincipal | WorkflowSidecarPrincipal;
10
+ export declare const WORKFLOW_JSON_PATH = "workflow.json";
11
+ export declare const CAPABILITY_DECLARATIONS_JSON_PATH = "capability-declarations.json";
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";
15
+ export declare const workflowDefinitionEnvelopeSchema: import("arktype/internal/variants/object.ts").ObjectType<{
16
+ id: string;
17
+ triggers: unknown[];
18
+ steps: Record<string, unknown>;
19
+ stepOrder: string[];
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
+ }, {}>;
36
+ export declare const workflowKindHandler: KindHandler;
37
+ export declare const workflowAuthorize: AuthorizeFn;
@@ -0,0 +1,310 @@
1
+ // KindHandler for the `workflow` asset kind.
2
+ //
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.
9
+ //
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.
17
+ //
18
+ // Authz:
19
+ // - hub principal: full access.
20
+ // - sidecar principal: read-only (createPack, resolveRef).
21
+ // - user principal: gated by bearer-token claims and the route
22
+ // layer's pre-resolved authz verdict, mirroring the convention
23
+ // used by skill assets.
24
+ import { type } from "arktype";
25
+ import { getLogger } from "@intx/log";
26
+ import { CredentialBinding, GrantRequirement } from "@intx/types";
27
+ import { PackageJSON, isContainedEntryPath } from "@intx/types/package-json";
28
+ import { glob, repoActionToGrantVerb } from "@intx/hub-common";
29
+ import { UserPrincipal, } from "./repo-store/index.js";
30
+ const logger = getLogger(["hub-sessions", "workflow-kind"]);
31
+ export const WORKFLOW_JSON_PATH = "workflow.json";
32
+ export const CAPABILITY_DECLARATIONS_JSON_PATH = "capability-declarations.json";
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
+ /**
37
+ * Structural arktype validator for the `workflow.json` envelope. The
38
+ * substrate checks the cross-cutting shape of `WorkflowDefinition`
39
+ * (presence and primitive type of `id`, `triggers`, `steps`,
40
+ * `stepOrder`) but does not re-derive `defineWorkflow`'s DAG-level
41
+ * validation here — primitive-level shape, default-input application,
42
+ * and `after`-ref resolution belong to the runtime layer that hydrates
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
+ */
48
+ const StepsObject = type("Record<string, unknown>").narrow((value, ctx) => {
49
+ if (Array.isArray(value)) {
50
+ return ctx.mustBe("a JSON object, not an array");
51
+ }
52
+ return true;
53
+ });
54
+ const StateObject = type("Record<string, unknown>").narrow((value, ctx) => {
55
+ if (Array.isArray(value)) {
56
+ return ctx.mustBe("a JSON object, not an array");
57
+ }
58
+ return true;
59
+ });
60
+ export const workflowDefinitionEnvelopeSchema = type({
61
+ id: "string > 0",
62
+ triggers: "unknown[]",
63
+ steps: StepsObject,
64
+ stepOrder: "string[]",
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
+ }).onUndeclaredKey("ignore");
82
+ const SidecarPrincipal = type({
83
+ kind: "'sidecar'",
84
+ agentId: "string",
85
+ });
86
+ async function readJSONBlob(path, readBlob) {
87
+ let raw;
88
+ try {
89
+ raw = await readBlob(path);
90
+ }
91
+ catch (cause) {
92
+ return {
93
+ ok: false,
94
+ reason: `${path} could not be read from the tree: ${cause instanceof Error ? cause.message : String(cause)}`,
95
+ };
96
+ }
97
+ const text = new TextDecoder().decode(raw);
98
+ let parsed;
99
+ try {
100
+ parsed = JSON.parse(text);
101
+ }
102
+ catch (cause) {
103
+ return {
104
+ ok: false,
105
+ reason: `${path} is not valid JSON: ${cause instanceof Error ? cause.message : String(cause)}`,
106
+ };
107
+ }
108
+ return { ok: true, value: parsed };
109
+ }
110
+ function isJSONObject(value) {
111
+ return typeof value === "object" && value !== null && !Array.isArray(value);
112
+ }
113
+ function rejectPush(repoId, ref, reason) {
114
+ logger.debug `workflow validatePush rejected ${repoId.kind}/${repoId.id} on ${ref}: ${reason}`;
115
+ return { ok: false, reason };
116
+ }
117
+ /**
118
+ * Validate the codebase shape: a top-level `package.json` declaring a contained
119
+ * `interchange.workflow` entry (single package), or a `workspaces` monorepo
120
+ * whose root well-formedness is checked and whose members are deferred to the
121
+ * resolver, plus arbitrary source files. Entered when the tree carries a
122
+ * `package.json`. Source files are unconstrained, but the push refuses the
123
+ * envelope-only `capability-declarations.json`, a committed `node_modules`, and
124
+ * an ambiguous tree that also carries an envelope-valid `workflow.json`, so one
125
+ * asset resolves to exactly one definition.
126
+ *
127
+ * The push validates STRUCTURE only. It never imports or evaluates the entry
128
+ * module -- that runs author code and is the sidecar's sandboxed job. The
129
+ * entry-path containment check is the string-level half of the loader's rule
130
+ * (`isContainedEntryPath`); the realpath-based symlink half runs at load time
131
+ * against the materialized directory, which the hub does not have here.
132
+ */
133
+ async function validateWorkflowCodebasePush(repoId, ref, topLevelTreePaths, readBlob) {
134
+ if (topLevelTreePaths.includes(CAPABILITY_DECLARATIONS_JSON_PATH)) {
135
+ return rejectPush(repoId, ref, `${CAPABILITY_DECLARATIONS_JSON_PATH} is an envelope-only artifact and cannot appear in a codebase workflow asset`);
136
+ }
137
+ if (topLevelTreePaths.includes(NODE_MODULES_PATH)) {
138
+ return rejectPush(repoId, ref, `a committed top-level ${NODE_MODULES_PATH} directory is not allowed; the sidecar materializes dependencies from the resolved closure`);
139
+ }
140
+ // A `workflow.json` that also parses as a valid envelope makes the asset
141
+ // advertise two definitions; reject that. A `workflow.json` present but not a
142
+ // valid envelope is an ordinary source file and is allowed.
143
+ if (topLevelTreePaths.includes(WORKFLOW_JSON_PATH)) {
144
+ const envelopeOutcome = await readJSONBlob(WORKFLOW_JSON_PATH, readBlob);
145
+ if (envelopeOutcome.ok &&
146
+ !(workflowDefinitionEnvelopeSchema(envelopeOutcome.value) instanceof
147
+ type.errors)) {
148
+ 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`);
149
+ }
150
+ }
151
+ const pkgOutcome = await readJSONBlob(PACKAGE_JSON_PATH, readBlob);
152
+ if (!pkgOutcome.ok) {
153
+ return rejectPush(repoId, ref, pkgOutcome.reason);
154
+ }
155
+ // `PackageJSON` does not declare `workspaces`, so it is read off the raw
156
+ // parsed value. A monorepo is a distinct codebase shape: the workflow lives
157
+ // in one member, selected at resolve time by `packageName`, so this gate does
158
+ // NOT descend into members or require a root `interchange.workflow`. It
159
+ // validates ROOT well-formedness only -- `workspaces` is an array of glob
160
+ // strings -- and defers per-member validation to the resolver, which re-reads
161
+ // every member and fails loud there (one enumeration owner, not two).
162
+ if (isJSONObject(pkgOutcome.value) && "workspaces" in pkgOutcome.value) {
163
+ const workspaces = pkgOutcome.value["workspaces"];
164
+ if (!Array.isArray(workspaces) ||
165
+ !workspaces.every((w) => typeof w === "string")) {
166
+ return rejectPush(repoId, ref, `${PACKAGE_JSON_PATH} "workspaces" must be an array of glob strings; the object form ({ packages, catalog, catalogs }) is not supported`);
167
+ }
168
+ return { ok: true };
169
+ }
170
+ // A pnpm monorepo declares its members in `pnpm-workspace.yaml`, not the
171
+ // package.json `workspaces` field, so a pnpm root has no `workspaces` and
172
+ // would fall through to the single-package check below. Reject that layout at
173
+ // the boundary with a clear message rather than letting it fail obscurely at
174
+ // resolve time (full pnpm support is tracked in INTR-461).
175
+ if (topLevelTreePaths.includes(PNPM_WORKSPACE_PATH)) {
176
+ 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`);
177
+ }
178
+ const pkg = PackageJSON(pkgOutcome.value);
179
+ if (pkg instanceof type.errors) {
180
+ return rejectPush(repoId, ref, `${PACKAGE_JSON_PATH} failed validation: ${pkg.summary}`);
181
+ }
182
+ const entry = pkg.interchange?.workflow;
183
+ if (entry === undefined || entry === "") {
184
+ return rejectPush(repoId, ref, `${PACKAGE_JSON_PATH} must declare a non-empty "interchange.workflow" entry`);
185
+ }
186
+ if (!isContainedEntryPath(entry)) {
187
+ return rejectPush(repoId, ref, `"interchange.workflow" entry ${JSON.stringify(entry)} must be a package-relative path that does not escape the package`);
188
+ }
189
+ return { ok: true };
190
+ }
191
+ export const workflowKindHandler = {
192
+ kind: "workflow",
193
+ directoryPrefix: "assets/workflow",
194
+ async validatePush({ repoId, ref, topLevelTreePaths, readBlob, }) {
195
+ if (topLevelTreePaths.includes(PACKAGE_JSON_PATH)) {
196
+ return validateWorkflowCodebasePush(repoId, ref, topLevelTreePaths, readBlob);
197
+ }
198
+ 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`);
199
+ },
200
+ onRefUpdated() {
201
+ // No cached index today. Consumers read the asset's tree through the
202
+ // substrate's blob-read API at session time.
203
+ },
204
+ };
205
+ export const workflowAuthorize = (principal, repoId, ref, action) => {
206
+ if (repoId.kind !== "workflow") {
207
+ return {
208
+ allowed: false,
209
+ reason: `workflow authorize received non-workflow repo ${repoId.kind}/${repoId.id}`,
210
+ };
211
+ }
212
+ if (principal.kind === "hub") {
213
+ return { allowed: true };
214
+ }
215
+ if (principal.kind === "sidecar") {
216
+ const parsed = SidecarPrincipal(principal);
217
+ if (parsed instanceof type.errors) {
218
+ return {
219
+ allowed: false,
220
+ reason: `sidecar principal is malformed: ${parsed.summary}`,
221
+ };
222
+ }
223
+ switch (action) {
224
+ case "createPack":
225
+ case "resolveRef":
226
+ return { allowed: true };
227
+ case "init":
228
+ case "writeTree":
229
+ case "receivePack":
230
+ return {
231
+ allowed: false,
232
+ reason: `sidecars may only read workflow assets, not ${action}`,
233
+ };
234
+ default: {
235
+ const _exhaustive = action;
236
+ return {
237
+ allowed: false,
238
+ reason: `unhandled action: ${String(_exhaustive)}`,
239
+ };
240
+ }
241
+ }
242
+ }
243
+ if (principal.kind === "user") {
244
+ // The route layer has already pre-resolved the grant verdict and
245
+ // attached it as `authz`. The substrate does NOT re-query the
246
+ // grant store here; it (a) checks the bearer-token's claims
247
+ // bound the requested (ref, action) and have not expired, and
248
+ // (b) sanity-checks that the pre-resolved verdict targets this
249
+ // exact resource and grant verb. Both gates must pass before the
250
+ // verdict's `effect` is honoured.
251
+ const parsed = UserPrincipal(principal);
252
+ if (parsed instanceof type.errors) {
253
+ return {
254
+ allowed: false,
255
+ reason: `user principal is malformed: ${parsed.summary}`,
256
+ };
257
+ }
258
+ if (!parsed.tokenClaims.actions.includes(action)) {
259
+ return {
260
+ allowed: false,
261
+ reason: `token does not grant action ${action}`,
262
+ };
263
+ }
264
+ // `ref === "*"` is the substrate's sentinel for the bulk read
265
+ // performed by `listRefs`. Per-ref filtering is the advertise-refs
266
+ // layer's responsibility, so the bulk read is gated on action and
267
+ // expiry alone.
268
+ if (ref !== "*" && !glob.match(parsed.tokenClaims.refPattern, ref)) {
269
+ return {
270
+ allowed: false,
271
+ reason: `token refPattern ${parsed.tokenClaims.refPattern} does not match ${ref}`,
272
+ };
273
+ }
274
+ if (Date.now() >= parsed.tokenClaims.expiresAt) {
275
+ return {
276
+ allowed: false,
277
+ reason: `token expired at ${parsed.tokenClaims.expiresAt}`,
278
+ };
279
+ }
280
+ const expectedResource = `asset:${repoId.id}`;
281
+ if (parsed.authz.resource !== expectedResource) {
282
+ return {
283
+ allowed: false,
284
+ reason: `authz verdict resource ${parsed.authz.resource} does not match ${expectedResource}`,
285
+ };
286
+ }
287
+ const expectedGrantVerb = repoActionToGrantVerb(action);
288
+ if (parsed.authz.grantVerb !== expectedGrantVerb) {
289
+ return {
290
+ allowed: false,
291
+ reason: `authz verdict grantVerb ${parsed.authz.grantVerb} does not match ${expectedGrantVerb}`,
292
+ };
293
+ }
294
+ if (parsed.authz.effect === "allow") {
295
+ return { allowed: true };
296
+ }
297
+ return {
298
+ allowed: false,
299
+ reason: `authz verdict denied for ${expectedResource} ${expectedGrantVerb}`,
300
+ };
301
+ }
302
+ // Fail closed on any kind not handled above. The tenant-level
303
+ // `workflow` principal kind (`@intx/types` principalKinds) is a
304
+ // grant owner, not a git/asset bearer, and never carries a workflow
305
+ // repo push here -- so it is intentionally left denied.
306
+ return {
307
+ allowed: false,
308
+ reason: `unknown principal kind: ${principal.kind}`,
309
+ };
310
+ };