@cat-factory/server 0.226.0 → 0.228.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.
- package/dist/agents/resolveRepoTarget.d.ts.map +1 -1
- package/dist/agents/resolveRepoTarget.js +20 -10
- package/dist/agents/resolveRepoTarget.js.map +1 -1
- package/dist/app.d.ts +29 -0
- package/dist/app.d.ts.map +1 -1
- package/dist/app.js +131 -88
- package/dist/app.js.map +1 -1
- package/dist/github/GitHubPrReportPublisher.d.ts +63 -11
- package/dist/github/GitHubPrReportPublisher.d.ts.map +1 -1
- package/dist/github/GitHubPrReportPublisher.js +137 -35
- package/dist/github/GitHubPrReportPublisher.js.map +1 -1
- package/dist/http/workspaceAccess.d.ts +65 -40
- package/dist/http/workspaceAccess.d.ts.map +1 -1
- package/dist/http/workspaceAccess.js +84 -44
- package/dist/http/workspaceAccess.js.map +1 -1
- package/dist/modules/agentPrompts/AgentPromptController.js +2 -2
- package/dist/modules/agentPrompts/AgentPromptController.js.map +1 -1
- package/dist/modules/agentSettings/WorkspaceAgentSettingsController.js +2 -2
- package/dist/modules/agentSettings/WorkspaceAgentSettingsController.js.map +1 -1
- package/dist/modules/bootstrap/BootstrapController.js +2 -2
- package/dist/modules/bootstrap/BootstrapController.js.map +1 -1
- package/dist/modules/capabilityCredentials/CapabilityCredentialsController.d.ts +2 -2
- package/dist/modules/capabilityCredentials/CapabilityCredentialsController.d.ts.map +1 -1
- package/dist/modules/capabilityCredentials/CapabilityCredentialsController.js +4 -11
- package/dist/modules/capabilityCredentials/CapabilityCredentialsController.js.map +1 -1
- package/dist/modules/consensus/ConsensusGroupController.js +2 -2
- package/dist/modules/consensus/ConsensusGroupController.js.map +1 -1
- package/dist/modules/documents/DocumentSourceController.d.ts +10 -0
- package/dist/modules/documents/DocumentSourceController.d.ts.map +1 -1
- package/dist/modules/documents/DocumentSourceController.js +37 -4
- package/dist/modules/documents/DocumentSourceController.js.map +1 -1
- package/dist/modules/environments/EnvironmentController.d.ts.map +1 -1
- package/dist/modules/environments/EnvironmentController.js +6 -2
- package/dist/modules/environments/EnvironmentController.js.map +1 -1
- package/dist/modules/foundationalServices/FoundationalServiceController.d.ts.map +1 -1
- package/dist/modules/foundationalServices/FoundationalServiceController.js +14 -13
- package/dist/modules/foundationalServices/FoundationalServiceController.js.map +1 -1
- package/dist/modules/fragmentLibrary/FragmentLibraryController.d.ts.map +1 -1
- package/dist/modules/fragmentLibrary/FragmentLibraryController.js +17 -11
- package/dist/modules/fragmentLibrary/FragmentLibraryController.js.map +1 -1
- package/dist/modules/github/GitHubController.js +3 -3
- package/dist/modules/github/GitHubController.js.map +1 -1
- package/dist/modules/gitlab/GitLabController.js +2 -2
- package/dist/modules/gitlab/GitLabController.js.map +1 -1
- package/dist/modules/incidentEnrichment/IncidentEnrichmentController.js +2 -2
- package/dist/modules/incidentEnrichment/IncidentEnrichmentController.js.map +1 -1
- package/dist/modules/merge/RiskPolicyController.js +2 -2
- package/dist/modules/merge/RiskPolicyController.js.map +1 -1
- package/dist/modules/modelPresets/ModelPresetController.js +2 -2
- package/dist/modules/modelPresets/ModelPresetController.js.map +1 -1
- package/dist/modules/notificationWebhook/NotificationWebhookController.js +2 -2
- package/dist/modules/notificationWebhook/NotificationWebhookController.js.map +1 -1
- package/dist/modules/packageRegistries/PackageRegistriesController.js +2 -2
- package/dist/modules/packageRegistries/PackageRegistriesController.js.map +1 -1
- package/dist/modules/preview/PreviewController.js +2 -2
- package/dist/modules/preview/PreviewController.js.map +1 -1
- package/dist/modules/providers/ApiKeyController.js +2 -2
- package/dist/modules/providers/ApiKeyController.js.map +1 -1
- package/dist/modules/providers/VendorCredentialController.js +2 -2
- package/dist/modules/providers/VendorCredentialController.js.map +1 -1
- package/dist/modules/publicApi/PublicApiKeyController.js +2 -2
- package/dist/modules/publicApi/PublicApiKeyController.js.map +1 -1
- package/dist/modules/publicApi/openapiDocument.generated.d.ts +1 -1
- package/dist/modules/publicApi/openapiDocument.generated.d.ts.map +1 -1
- package/dist/modules/publicApi/openapiDocument.generated.js +1 -1
- package/dist/modules/publicApi/openapiDocument.generated.js.map +1 -1
- package/dist/modules/recurring/TrackerSettingsController.js +2 -2
- package/dist/modules/recurring/TrackerSettingsController.js.map +1 -1
- package/dist/modules/releaseHealth/ReleaseHealthController.js +2 -2
- package/dist/modules/releaseHealth/ReleaseHealthController.js.map +1 -1
- package/dist/modules/runners/RunnerPoolController.js +2 -2
- package/dist/modules/runners/RunnerPoolController.js.map +1 -1
- package/dist/modules/sandbox/SandboxController.js +2 -2
- package/dist/modules/sandbox/SandboxController.js.map +1 -1
- package/dist/modules/settings/WorkspaceSettingsController.js +2 -2
- package/dist/modules/settings/WorkspaceSettingsController.js.map +1 -1
- package/dist/modules/sharedStack/SharedStackController.js +2 -2
- package/dist/modules/sharedStack/SharedStackController.js.map +1 -1
- package/dist/modules/slack/SlackController.js +2 -2
- package/dist/modules/slack/SlackController.js.map +1 -1
- package/dist/modules/tasks/TaskSourceController.js +2 -2
- package/dist/modules/tasks/TaskSourceController.js.map +1 -1
- package/dist/modules/testSecrets/TestSecretsController.js +2 -2
- package/dist/modules/testSecrets/TestSecretsController.js.map +1 -1
- package/dist/modules/toolServers/ToolServerController.d.ts +1 -1
- package/dist/modules/toolServers/ToolServerController.d.ts.map +1 -1
- package/dist/modules/toolServers/ToolServerController.js +3 -9
- package/dist/modules/toolServers/ToolServerController.js.map +1 -1
- package/dist/modules/tutorial/TutorialController.js +1 -1
- package/dist/modules/tutorial/TutorialController.js.map +1 -1
- package/dist/modules/validation/ValidationConfigController.d.ts.map +1 -1
- package/dist/modules/validation/ValidationConfigController.js +5 -2
- package/dist/modules/validation/ValidationConfigController.js.map +1 -1
- package/dist/modules/vcs/VcsConnectController.d.ts +9 -1
- package/dist/modules/vcs/VcsConnectController.d.ts.map +1 -1
- package/dist/modules/vcs/VcsConnectController.js +11 -3
- package/dist/modules/vcs/VcsConnectController.js.map +1 -1
- package/package.json +8 -8
|
@@ -1,8 +1,26 @@
|
|
|
1
|
-
import { spliceManagedSection } from '@cat-factory/kernel';
|
|
1
|
+
import { noopLogger, runBestEffort, spliceManagedSection } from '@cat-factory/kernel';
|
|
2
|
+
import { allPullRequests } from '@cat-factory/contracts';
|
|
2
3
|
import { githubRepoOrigin } from '../agents/containerAgentBody.js';
|
|
4
|
+
import { splitRepo } from './repoFullName.js';
|
|
3
5
|
/**
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
+
* The numeric connection handle the engine VCS client takes, recovered from a target's neutral
|
|
7
|
+
* {@link VcsConnectionRef}.
|
|
8
|
+
*
|
|
9
|
+
* Not kernel's `githubInstallationId`, which refuses a non-GitHub connection: this adapter serves
|
|
10
|
+
* a GitLab deployment too (through `vcsBackedGitHubClient`), and there the same numeric id is the
|
|
11
|
+
* workspace's `github_installations` row that holds its PAT. A non-numeric id is a wiring bug and
|
|
12
|
+
* throws, which the engine records as a failed publish rather than a run failure.
|
|
13
|
+
*/
|
|
14
|
+
function connectionId(connection) {
|
|
15
|
+
const id = Number(connection.connectionId);
|
|
16
|
+
if (!Number.isInteger(id)) {
|
|
17
|
+
throw new Error(`PR report target carries an unusable connection id "${connection.connectionId}".`);
|
|
18
|
+
}
|
|
19
|
+
return id;
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* Upserts the engine's verification report onto a block's pull requests as a marker-delimited
|
|
23
|
+
* section of each PR description (see `docs/initiatives/pr-verification-report.md`, decision
|
|
6
24
|
* D1: the markers ARE the section's identity, so the write is idempotent across re-runs,
|
|
7
25
|
* retries and replayed durable steps with no persisted state at all).
|
|
8
26
|
*
|
|
@@ -10,51 +28,135 @@ import { githubRepoOrigin } from '../agents/containerAgentBody.js';
|
|
|
10
28
|
* as it is right now, so a human who edited the description while the run was in flight keeps
|
|
11
29
|
* their edit. An unchanged body short-circuits before the remote write.
|
|
12
30
|
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
31
|
+
* On a MULTI-REPO run (service-connections phase 3) the task opens one PR per repo it changed,
|
|
32
|
+
* and every one of them is a target: a reviewer on a connected service's PR is looking at part
|
|
33
|
+
* of this change and is as entitled to the run's evidence as one on the own-service PR. The
|
|
34
|
+
* engine composes a distinct report per target — the own-service-only sections are withheld
|
|
35
|
+
* from a peer's copy rather than copied onto it.
|
|
36
|
+
*
|
|
37
|
+
* **All addressing happens in {@link resolveTargets}, once.** Each target it returns carries the
|
|
38
|
+
* connection and repo its write needs, so `publish` reads no repository and a run with N pull
|
|
39
|
+
* requests costs one resolution per settlement rather than N. That also removes the question
|
|
40
|
+
* `publish` would otherwise have to answer on every call ("is this still the block's PR?") on a
|
|
41
|
+
* hook that fires on every settled step.
|
|
16
42
|
*/
|
|
17
43
|
export class GitHubPrReportPublisher {
|
|
18
44
|
deps;
|
|
19
45
|
constructor(deps) {
|
|
20
46
|
this.deps = deps;
|
|
21
47
|
}
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
async resolve(workspaceId, blockId) {
|
|
48
|
+
/**
|
|
49
|
+
* Every pull request of the block's run, own-service first, each addressed for writing.
|
|
50
|
+
*
|
|
51
|
+
* ONE block read and ONE repo resolution for the whole set: the own-service target is
|
|
52
|
+
* resolved first (it is needed regardless, and lets the multi-repo resolver skip re-walking
|
|
53
|
+
* the block's ancestry), then a single `resolveRepoTargets` call covers every peer. Resolving
|
|
54
|
+
* per recorded peer PR would be the N+1 this codebase bans.
|
|
55
|
+
*/
|
|
56
|
+
async resolveTargets(workspaceId, blockId) {
|
|
32
57
|
const block = await this.deps.blockRepository.get(workspaceId, blockId);
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
58
|
+
if (!block)
|
|
59
|
+
return [];
|
|
60
|
+
// `allPullRequests` is the single source of truth for "every PR this task opened", own
|
|
61
|
+
// first then peers — the same enumeration the CI gate aggregates over. The own-service
|
|
62
|
+
// entry is the one carrying no `repo` (its repo is the task's own service).
|
|
63
|
+
const prs = allPullRequests(block);
|
|
64
|
+
if (prs.length === 0)
|
|
65
|
+
return [];
|
|
66
|
+
const targets = [];
|
|
67
|
+
// The own-service PR FIRST when it exists: the engine reads the peer reports' back-pointer
|
|
68
|
+
// off the head of this list rather than re-resolving it. Matched on an ABSENT `repo` rather
|
|
69
|
+
// than a falsy one, and only when the block records an own PR at all: a peer whose recorded
|
|
70
|
+
// repo is the empty string is unaddressable, not the own-service one.
|
|
71
|
+
const own = block.pullRequest ? prs.find((pr) => pr.repo === undefined) : undefined;
|
|
72
|
+
const ownRepo = own ? await this.deps.resolveRepoTarget(workspaceId, blockId) : null;
|
|
73
|
+
if (own?.ref.number != null && ownRepo) {
|
|
74
|
+
targets.push(this.describe(ownRepo, own.ref.number, { role: 'own', url: own.ref.url }));
|
|
75
|
+
}
|
|
76
|
+
// BEST-EFFORT, and deliberately so: the multi-repo resolver THROWS on a workspace with no
|
|
77
|
+
// installation or an involved frame that lost its repo linkage, and letting that propagate
|
|
78
|
+
// would mean one broken peer linkage costs the OWN-SERVICE report too — the one a reviewer is
|
|
79
|
+
// most likely reading. The same "a failure on one target does not cost the others" rule the
|
|
80
|
+
// engine applies when publishing, applied one step earlier where the targets are found.
|
|
81
|
+
const peers = await runBestEffort(this.deps.logger ?? noopLogger, 'pr-report peer target resolution', () => this.peerTargets(workspaceId, blockId, block, prs, ownRepo), { workspaceId, blockId });
|
|
82
|
+
targets.push(...(peers ?? []));
|
|
83
|
+
return targets;
|
|
38
84
|
}
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
85
|
+
/**
|
|
86
|
+
* The run's PEER pull requests, addressed through the multi-repo repo resolution.
|
|
87
|
+
*
|
|
88
|
+
* A recorded peer PR is only targetable when two things line up: it carries a `number` (the
|
|
89
|
+
* unit the description write addresses, absent on a ref that was only ever a URL) and its
|
|
90
|
+
* repo is in the resolved checkout set (which is what supplies the connection to write
|
|
91
|
+
* through, and is the platform's own answer about which repos this run touched). A peer that
|
|
92
|
+
* satisfies neither is skipped rather than guessed at — the repo name on a recorded peer PR
|
|
93
|
+
* is harness-reported, and writing a report onto a pull request we cannot confirm the identity
|
|
94
|
+
* of is the one failure mode worse than not writing it.
|
|
95
|
+
*/
|
|
96
|
+
async peerTargets(workspaceId, blockId, block, prs, ownRepo) {
|
|
97
|
+
// Every entry that names a repo: the complement of the own-service one above.
|
|
98
|
+
const peers = prs.filter((pr) => pr.repo !== undefined);
|
|
99
|
+
const resolveAll = this.deps.resolveRepoTargets;
|
|
100
|
+
if (!peers.length || !resolveAll)
|
|
101
|
+
return [];
|
|
102
|
+
// The frames to resolve: the task's declared involved services, plus any frame a recorded
|
|
103
|
+
// peer PR attributes itself to. The union rather than either alone, because the declared
|
|
104
|
+
// list is what the run fanned out over while the recorded PRs are what it actually opened,
|
|
105
|
+
// and a peer whose frame left the task's involved list still has an open PR owed a report.
|
|
106
|
+
const frameIds = [
|
|
107
|
+
...new Set([
|
|
108
|
+
...(block.involvedServiceIds ?? []),
|
|
109
|
+
...peers.map((p) => p.frameId).filter((id) => !!id),
|
|
110
|
+
]),
|
|
111
|
+
];
|
|
112
|
+
const resolved = await resolveAll(workspaceId, blockId, frameIds, ownRepo ?? undefined);
|
|
113
|
+
const byRepo = new Map(resolved.checkouts.map((c) => [`${c.target.owner}/${c.target.name}`, c.target]));
|
|
114
|
+
const out = [];
|
|
115
|
+
for (const peer of peers) {
|
|
116
|
+
const number = peer.ref.number;
|
|
117
|
+
const repo = peer.repo ? byRepo.get(peer.repo) : undefined;
|
|
118
|
+
if (number == null || !repo)
|
|
119
|
+
continue;
|
|
120
|
+
out.push(this.describe(repo, number, { role: 'peer', frameId: peer.frameId, url: peer.ref.url }));
|
|
121
|
+
}
|
|
122
|
+
return out;
|
|
123
|
+
}
|
|
124
|
+
/**
|
|
125
|
+
* A resolved repo + PR number as a self-describing target: which PR, in which repo, on which
|
|
126
|
+
* provider, through which connection.
|
|
127
|
+
*
|
|
128
|
+
* The connection names the DEPLOYMENT'S provider, not this class's name: a GitLab deployment
|
|
129
|
+
* reaches this adapter through `vcsBackedGitHubClient`, and a target that claimed `github`
|
|
130
|
+
* would mis-route the day a native GitLab publisher selects its adapter from it. Both providers
|
|
131
|
+
* ride the same numeric connection id, because a GitLab workspace's PAT connect reuses the
|
|
132
|
+
* `github_installations` row this id comes from.
|
|
133
|
+
*/
|
|
134
|
+
describe(repo, prNumber, scope) {
|
|
135
|
+
const origin = (this.deps.resolveRepoOrigin ?? githubRepoOrigin)(repo);
|
|
136
|
+
return {
|
|
137
|
+
prNumber,
|
|
138
|
+
repo: `${repo.owner}/${repo.name}`,
|
|
139
|
+
provider: origin.provider,
|
|
140
|
+
connection: { provider: origin.provider, connectionId: String(repo.installationId) },
|
|
141
|
+
role: scope.role,
|
|
142
|
+
frameId: scope.frameId ?? null,
|
|
143
|
+
url: scope.url ?? null,
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
async publish(_workspaceId, target, section) {
|
|
147
|
+
// No resolution here at all: the target names its own pull request, repo and connection,
|
|
148
|
+
// and the caller composed `section` for exactly that. See the class doc.
|
|
149
|
+
const installationId = connectionId(target.connection);
|
|
150
|
+
const [owner, name] = splitRepo(target.repo);
|
|
151
|
+
const ref = { owner, repo: name };
|
|
152
|
+
const number = target.prNumber;
|
|
51
153
|
const gh = this.deps.githubClient;
|
|
52
|
-
const current = await gh.getPullRequestBody(
|
|
154
|
+
const current = await gh.getPullRequestBody(installationId, ref, number);
|
|
53
155
|
const next = spliceManagedSection(current, section);
|
|
54
156
|
if (next === (current ?? '')) {
|
|
55
157
|
return { published: false, skipped: 'unchanged', prNumber: number };
|
|
56
158
|
}
|
|
57
|
-
await gh.updatePullRequest(
|
|
159
|
+
await gh.updatePullRequest(installationId, ref, number, { body: next });
|
|
58
160
|
return { published: true, prNumber: number };
|
|
59
161
|
}
|
|
60
162
|
}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"GitHubPrReportPublisher.js","sourceRoot":"","sources":["../../src/github/GitHubPrReportPublisher.ts"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"GitHubPrReportPublisher.js","sourceRoot":"","sources":["../../src/github/GitHubPrReportPublisher.ts"],"names":[],"mappings":"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"}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { type BlockEditActor } from '@cat-factory/contracts';
|
|
2
2
|
import { type WorkspaceAccess, type WorkspacePermission } from '@cat-factory/kernel';
|
|
3
|
-
import type { Context,
|
|
3
|
+
import type { Context, Hono } from 'hono';
|
|
4
4
|
import type { AppEnv, ServerContainer } from './env.js';
|
|
5
5
|
/**
|
|
6
6
|
* Resolve a signed-in user's effective access to one board, THROUGH the `workspaceAccess` cache.
|
|
@@ -40,66 +40,91 @@ export declare function requirePermission<E extends AppEnv>(c: Context<E>, permi
|
|
|
40
40
|
*/
|
|
41
41
|
export declare function blockEditActor<E extends AppEnv>(c: Context<E>): BlockEditActor;
|
|
42
42
|
/**
|
|
43
|
-
*
|
|
44
|
-
*
|
|
45
|
-
*
|
|
46
|
-
*
|
|
47
|
-
*
|
|
48
|
-
*
|
|
43
|
+
* The controller-level admin-tier gate (workspace-rbac, slice 6): every WRITE served under one of
|
|
44
|
+
* `prefixes` requires `permission`, with ZERO per-handler code. Reads (GET/HEAD) pass straight
|
|
45
|
+
* through, so the controller stays viewer-readable (`workspace.read`, satisfied by the gate
|
|
46
|
+
* resolution) while its mutations are admin-only. Use
|
|
47
|
+
* {@link mountWorkspacePermissionIncludingReads} for the rare controller whose READ is the
|
|
48
|
+
* sensitive half.
|
|
49
49
|
*
|
|
50
|
-
* This is the method-shaped counterpart to the gate's viewer floor: the floor rejects viewer
|
|
51
|
-
*
|
|
52
|
-
*
|
|
53
|
-
*
|
|
54
|
-
* drift a shadow route-map would suffer can't happen. It runs BEFORE the handler, so an
|
|
50
|
+
* This is the method-shaped counterpart to the gate's viewer floor: the floor rejects viewer writes
|
|
51
|
+
* wholesale (non-GET ⇒ ≥ member) in ONE place; this rejects member writes on the admin groups in
|
|
52
|
+
* ONE place per group. Because it is co-located with the controller's own mount (not a central
|
|
53
|
+
* path→permission table), a new route under a gated prefix inherits the correct gate automatically:
|
|
54
|
+
* the drift a shadow route-map would suffer can't happen. It runs BEFORE the handler, so an
|
|
55
55
|
* unauthorized caller is refused even when the underlying integration is unwired (a member never
|
|
56
|
-
* learns whether Slack/GitHub/etc. is configured)
|
|
56
|
+
* learns whether Slack/GitHub/etc. is configured), and before body validation, so a malformed
|
|
57
|
+
* payload cannot turn a refusal into a 422 that answers the same question.
|
|
57
58
|
*
|
|
58
59
|
* `OPTIONS` (CORS preflight) is never gated. Where a controller mixes gated and ungated writes
|
|
59
|
-
* under one
|
|
60
|
+
* under one prefix (e.g. workspace create vs rename), call {@link requirePermission} per-handler
|
|
60
61
|
* instead of mounting this.
|
|
61
62
|
*
|
|
62
|
-
*
|
|
63
|
-
* re-registers each of `sub`'s entries under
|
|
64
|
-
* `ALL /workspaces/:workspaceId/*` on the shared app
|
|
65
|
-
* mounted on the same prefix. Hono
|
|
66
|
-
*
|
|
67
|
-
*
|
|
68
|
-
*
|
|
69
|
-
*
|
|
70
|
-
*
|
|
71
|
-
*
|
|
63
|
+
* **`prefixes` are the controller's OWN top-level paths, and taking them is the whole point of this
|
|
64
|
+
* helper.** `app.route('/workspaces/:workspaceId', sub)` re-registers each of `sub`'s entries under
|
|
65
|
+
* that prefix, so a `sub.use('*', …)` becomes `ALL /workspaces/:workspaceId/*` on the shared app and
|
|
66
|
+
* matches routes OTHER controllers mounted on the same prefix. Hono runs a matching middleware for
|
|
67
|
+
* every route registered after it, which made the blast radius depend on the order in `app.ts`: the
|
|
68
|
+
* admin document and task-source controllers are registered ahead of the human-gate controllers, so
|
|
69
|
+
* their `'*'` mounts refused a plain member's review decisions, requirement answers and initiative
|
|
70
|
+
* writes with `integrations.manage`. Nobody noticed because an account admin resolves as a workspace
|
|
71
|
+
* admin, and dogfooding happens as one. Passing prefixes cannot reach a sibling whatever the order,
|
|
72
|
+
* and it is why no middleware factory is exported from this module: a `'*'` mount is now
|
|
73
|
+
* unrepresentable rather than merely discouraged. `permissionMounts.test.ts` pins the invariant
|
|
74
|
+
* (a member is refused exactly the writes their own controller gates).
|
|
75
|
+
*
|
|
76
|
+
* Each prefix is mounted twice, bare and `/*`, because Hono's `*` does not match the bare prefix.
|
|
77
|
+
* A prefix may carry params (`/services/:blockId/test-secrets`), which is how a controller whose
|
|
78
|
+
* routes hang off a shared first segment gates its own without claiming the segment.
|
|
72
79
|
*/
|
|
73
|
-
export declare function
|
|
80
|
+
export declare function mountWorkspacePermission<E extends AppEnv>(app: Hono<E>, permission: WorkspacePermission, prefixes: readonly string[]): void;
|
|
74
81
|
/**
|
|
75
82
|
* The same controller-level gate, applied to READS as well.
|
|
76
83
|
*
|
|
77
|
-
* {@link
|
|
84
|
+
* {@link mountWorkspacePermission} lets GET/HEAD through by design: on almost every admin
|
|
78
85
|
* controller the permission guards MUTATION and the configuration itself is viewer-readable. A few
|
|
79
|
-
* controllers invert that, because their READ is the sensitive half
|
|
86
|
+
* controllers invert that, because their READ is the sensitive half. The capability-credential
|
|
80
87
|
* checklist and the tool-server inventory both project the credential KEY NAMES this deployment's
|
|
81
88
|
* capabilities want, which is exactly what the workspace snapshot withholds from a viewer ("no
|
|
82
89
|
* business learning which environment variables the deployment sets"). Moving the value into a
|
|
83
90
|
* sealed row did not change that judgement, and neither did adding a second surface over the same
|
|
84
|
-
* registry.
|
|
91
|
+
* registry. `vcsConnectController` is the third and the plainest: it serves ONE read and no write,
|
|
92
|
+
* so the writes-only mount left it enforcing nothing at all.
|
|
85
93
|
*
|
|
86
94
|
* A separate function rather than an option, so the choice is legible at the mount and a controller
|
|
87
95
|
* cannot acquire it by a default changing underneath it. `OPTIONS` still passes: a CORS preflight
|
|
88
96
|
* carries no credentials to judge, and refusing it breaks the browser before the real request that
|
|
89
97
|
* this gate is here to refuse.
|
|
90
98
|
*
|
|
91
|
-
* The failure this exists to prevent is not hypothetical
|
|
92
|
-
* gated (in the controller, in the SPA's tab gate, in the store's 403
|
|
93
|
-
* every member's GET through, because "gated on `secrets.manage`"
|
|
94
|
-
* and covered only its writes.
|
|
99
|
+
* The failure this exists to prevent is not hypothetical, and all three call sites hit it: each
|
|
100
|
+
* documented the read as gated (in the controller, in the SPA's tab gate, in the store's 403
|
|
101
|
+
* handling) while the mount let every member's GET through, because "gated on `secrets.manage`"
|
|
102
|
+
* reads as covering the controller and covered only its writes. `permissionMounts.test.ts` now
|
|
103
|
+
* asserts the READS of an `IncludingReads` controller are covered too, so the gap cannot reopen
|
|
104
|
+
* by a prefix going missing.
|
|
105
|
+
*/
|
|
106
|
+
export declare function mountWorkspacePermissionIncludingReads<E extends AppEnv>(app: Hono<E>, permission: WorkspacePermission, prefixes: readonly string[]): void;
|
|
107
|
+
/** What one mounted gate enforces: the permission, and whether it covers READS as well. */
|
|
108
|
+
export interface MountedGate {
|
|
109
|
+
permission: WorkspacePermission;
|
|
110
|
+
/** True for {@link mountWorkspacePermissionIncludingReads}, false for the writes-only mount. */
|
|
111
|
+
gatesReads: boolean;
|
|
112
|
+
}
|
|
113
|
+
/**
|
|
114
|
+
* What a mounted gate enforces, or `undefined` for any other middleware.
|
|
115
|
+
*
|
|
116
|
+
* Read off a controller's route table by `permissionMounts.test.ts`, which asserts that a member is
|
|
117
|
+
* refused exactly the writes their own controller gates AND that a gated controller leaves none of
|
|
118
|
+
* its own routes uncovered. It exists because a controller's middleware is not necessarily a
|
|
119
|
+
* permission gate: `executionController` and `notificationController` both mount their own `ALL`
|
|
120
|
+
* middleware, and treating every middleware entry as a gate made the derived expectation wrong for
|
|
121
|
+
* them. Tagging the handler is what lets the invariant be READ from what the mount actually did
|
|
122
|
+
* instead of from a second list somebody has to keep in step.
|
|
95
123
|
*
|
|
96
|
-
*
|
|
97
|
-
*
|
|
98
|
-
*
|
|
99
|
-
*
|
|
100
|
-
* pattern then refuses every member-readable GET registered after it — `GET /github/repos` was the
|
|
101
|
-
* one that caught it. Two patterns are needed, `/thing` and `/thing/*`: Hono's `*` does not match
|
|
102
|
-
* the bare prefix.
|
|
124
|
+
* Read it off a STANDALONE-built controller, not the composed app: `app.route()` passes a sub-app's
|
|
125
|
+
* handlers through untouched only while the sub-app has no error handler of its own, and wraps them
|
|
126
|
+
* (losing this tag) the moment one calls `app.onError`. No controller does today, and the invariant
|
|
127
|
+
* does not depend on none ever doing.
|
|
103
128
|
*/
|
|
104
|
-
export declare function
|
|
129
|
+
export declare function permissionGateOf(handler: unknown): MountedGate | undefined;
|
|
105
130
|
//# sourceMappingURL=workspaceAccess.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"workspaceAccess.d.ts","sourceRoot":"","sources":["../../src/http/workspaceAccess.ts"],"names":[],"mappings":"AAAA,OAAO,EAA6B,KAAK,cAAc,EAAE,MAAM,wBAAwB,CAAA;AACvF,OAAO,EAGL,KAAK,eAAe,EACpB,KAAK,mBAAmB,EACzB,MAAM,qBAAqB,CAAA;AAC5B,OAAO,KAAK,EAAE,OAAO,EAAE,
|
|
1
|
+
{"version":3,"file":"workspaceAccess.d.ts","sourceRoot":"","sources":["../../src/http/workspaceAccess.ts"],"names":[],"mappings":"AAAA,OAAO,EAA6B,KAAK,cAAc,EAAE,MAAM,wBAAwB,CAAA;AACvF,OAAO,EAGL,KAAK,eAAe,EACpB,KAAK,mBAAmB,EACzB,MAAM,qBAAqB,CAAA;AAC5B,OAAO,KAAK,EAAE,OAAO,EAAE,IAAI,EAAqB,MAAM,MAAM,CAAA;AAC5D,OAAO,KAAK,EAAE,MAAM,EAAE,eAAe,EAAE,MAAM,UAAU,CAAA;AAiBvD;;;;;;GAMG;AACH,wBAAsB,mBAAmB,CACvC,SAAS,EAAE,eAAe,EAC1B,WAAW,EAAE,MAAM,EACnB,MAAM,EAAE,MAAM,GACb,OAAO,CAAC,eAAe,GAAG,IAAI,CAAC,CAKjC;AA6BD;;;;;;;;;;;;;;GAcG;AACH,wBAAgB,iBAAiB,CAAC,CAAC,SAAS,MAAM,EAChD,CAAC,EAAE,OAAO,CAAC,CAAC,CAAC,EACb,UAAU,EAAE,mBAAmB,GAC9B,IAAI,CASN;AAED;;;;;;;;;;;GAWG;AACH,wBAAgB,cAAc,CAAC,CAAC,SAAS,MAAM,EAAE,CAAC,EAAE,OAAO,CAAC,CAAC,CAAC,GAAG,cAAc,CAI9E;AAED;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAqCG;AACH,wBAAgB,wBAAwB,CAAC,CAAC,SAAS,MAAM,EACvD,GAAG,EAAE,IAAI,CAAC,CAAC,CAAC,EACZ,UAAU,EAAE,mBAAmB,EAC/B,QAAQ,EAAE,SAAS,MAAM,EAAE,GAC1B,IAAI,CAQN;AAED;;;;;;;;;;;;;;;;;;;;;;;;GAwBG;AACH,wBAAgB,sCAAsC,CAAC,CAAC,SAAS,MAAM,EACrE,GAAG,EAAE,IAAI,CAAC,CAAC,CAAC,EACZ,UAAU,EAAE,mBAAmB,EAC/B,QAAQ,EAAE,SAAS,MAAM,EAAE,GAC1B,IAAI,CAKN;AAID,2FAA2F;AAC3F,MAAM,WAAW,WAAW;IAC1B,UAAU,EAAE,mBAAmB,CAAA;IAC/B,gGAAgG;IAChG,UAAU,EAAE,OAAO,CAAA;CACpB;AAED;;;;;;;;;;;;;;;GAeG;AACH,wBAAgB,gBAAgB,CAAC,OAAO,EAAE,OAAO,GAAG,WAAW,GAAG,SAAS,CAI1E"}
|
|
@@ -93,80 +93,120 @@ export function blockEditActor(c) {
|
|
|
93
93
|
return { role: access.role, managesPolicy: access.permissions.has('settings.manage') };
|
|
94
94
|
}
|
|
95
95
|
/**
|
|
96
|
-
*
|
|
97
|
-
*
|
|
98
|
-
*
|
|
99
|
-
*
|
|
100
|
-
*
|
|
101
|
-
*
|
|
96
|
+
* The controller-level admin-tier gate (workspace-rbac, slice 6): every WRITE served under one of
|
|
97
|
+
* `prefixes` requires `permission`, with ZERO per-handler code. Reads (GET/HEAD) pass straight
|
|
98
|
+
* through, so the controller stays viewer-readable (`workspace.read`, satisfied by the gate
|
|
99
|
+
* resolution) while its mutations are admin-only. Use
|
|
100
|
+
* {@link mountWorkspacePermissionIncludingReads} for the rare controller whose READ is the
|
|
101
|
+
* sensitive half.
|
|
102
102
|
*
|
|
103
|
-
* This is the method-shaped counterpart to the gate's viewer floor: the floor rejects viewer
|
|
104
|
-
*
|
|
105
|
-
*
|
|
106
|
-
*
|
|
107
|
-
* drift a shadow route-map would suffer can't happen. It runs BEFORE the handler, so an
|
|
103
|
+
* This is the method-shaped counterpart to the gate's viewer floor: the floor rejects viewer writes
|
|
104
|
+
* wholesale (non-GET ⇒ ≥ member) in ONE place; this rejects member writes on the admin groups in
|
|
105
|
+
* ONE place per group. Because it is co-located with the controller's own mount (not a central
|
|
106
|
+
* path→permission table), a new route under a gated prefix inherits the correct gate automatically:
|
|
107
|
+
* the drift a shadow route-map would suffer can't happen. It runs BEFORE the handler, so an
|
|
108
108
|
* unauthorized caller is refused even when the underlying integration is unwired (a member never
|
|
109
|
-
* learns whether Slack/GitHub/etc. is configured)
|
|
109
|
+
* learns whether Slack/GitHub/etc. is configured), and before body validation, so a malformed
|
|
110
|
+
* payload cannot turn a refusal into a 422 that answers the same question.
|
|
110
111
|
*
|
|
111
112
|
* `OPTIONS` (CORS preflight) is never gated. Where a controller mixes gated and ungated writes
|
|
112
|
-
* under one
|
|
113
|
+
* under one prefix (e.g. workspace create vs rename), call {@link requirePermission} per-handler
|
|
113
114
|
* instead of mounting this.
|
|
114
115
|
*
|
|
115
|
-
*
|
|
116
|
-
* re-registers each of `sub`'s entries under
|
|
117
|
-
* `ALL /workspaces/:workspaceId/*` on the shared app
|
|
118
|
-
* mounted on the same prefix. Hono
|
|
119
|
-
*
|
|
120
|
-
*
|
|
121
|
-
*
|
|
122
|
-
*
|
|
123
|
-
*
|
|
124
|
-
*
|
|
116
|
+
* **`prefixes` are the controller's OWN top-level paths, and taking them is the whole point of this
|
|
117
|
+
* helper.** `app.route('/workspaces/:workspaceId', sub)` re-registers each of `sub`'s entries under
|
|
118
|
+
* that prefix, so a `sub.use('*', …)` becomes `ALL /workspaces/:workspaceId/*` on the shared app and
|
|
119
|
+
* matches routes OTHER controllers mounted on the same prefix. Hono runs a matching middleware for
|
|
120
|
+
* every route registered after it, which made the blast radius depend on the order in `app.ts`: the
|
|
121
|
+
* admin document and task-source controllers are registered ahead of the human-gate controllers, so
|
|
122
|
+
* their `'*'` mounts refused a plain member's review decisions, requirement answers and initiative
|
|
123
|
+
* writes with `integrations.manage`. Nobody noticed because an account admin resolves as a workspace
|
|
124
|
+
* admin, and dogfooding happens as one. Passing prefixes cannot reach a sibling whatever the order,
|
|
125
|
+
* and it is why no middleware factory is exported from this module: a `'*'` mount is now
|
|
126
|
+
* unrepresentable rather than merely discouraged. `permissionMounts.test.ts` pins the invariant
|
|
127
|
+
* (a member is refused exactly the writes their own controller gates).
|
|
128
|
+
*
|
|
129
|
+
* Each prefix is mounted twice, bare and `/*`, because Hono's `*` does not match the bare prefix.
|
|
130
|
+
* A prefix may carry params (`/services/:blockId/test-secrets`), which is how a controller whose
|
|
131
|
+
* routes hang off a shared first segment gates its own without claiming the segment.
|
|
125
132
|
*/
|
|
126
|
-
export function
|
|
127
|
-
|
|
133
|
+
export function mountWorkspacePermission(app, permission, prefixes) {
|
|
134
|
+
mountGate(app, prefixes, { permission, gatesReads: false }, (c, next) => {
|
|
128
135
|
const method = c.req.method;
|
|
129
136
|
if (method !== 'GET' && method !== 'HEAD' && method !== 'OPTIONS') {
|
|
130
137
|
requirePermission(c, permission);
|
|
131
138
|
}
|
|
132
139
|
return next();
|
|
133
|
-
};
|
|
140
|
+
});
|
|
134
141
|
}
|
|
135
142
|
/**
|
|
136
143
|
* The same controller-level gate, applied to READS as well.
|
|
137
144
|
*
|
|
138
|
-
* {@link
|
|
145
|
+
* {@link mountWorkspacePermission} lets GET/HEAD through by design: on almost every admin
|
|
139
146
|
* controller the permission guards MUTATION and the configuration itself is viewer-readable. A few
|
|
140
|
-
* controllers invert that, because their READ is the sensitive half
|
|
147
|
+
* controllers invert that, because their READ is the sensitive half. The capability-credential
|
|
141
148
|
* checklist and the tool-server inventory both project the credential KEY NAMES this deployment's
|
|
142
149
|
* capabilities want, which is exactly what the workspace snapshot withholds from a viewer ("no
|
|
143
150
|
* business learning which environment variables the deployment sets"). Moving the value into a
|
|
144
151
|
* sealed row did not change that judgement, and neither did adding a second surface over the same
|
|
145
|
-
* registry.
|
|
152
|
+
* registry. `vcsConnectController` is the third and the plainest: it serves ONE read and no write,
|
|
153
|
+
* so the writes-only mount left it enforcing nothing at all.
|
|
146
154
|
*
|
|
147
155
|
* A separate function rather than an option, so the choice is legible at the mount and a controller
|
|
148
156
|
* cannot acquire it by a default changing underneath it. `OPTIONS` still passes: a CORS preflight
|
|
149
157
|
* carries no credentials to judge, and refusing it breaks the browser before the real request that
|
|
150
158
|
* this gate is here to refuse.
|
|
151
159
|
*
|
|
152
|
-
* The failure this exists to prevent is not hypothetical
|
|
153
|
-
* gated (in the controller, in the SPA's tab gate, in the store's 403
|
|
154
|
-
* every member's GET through, because "gated on `secrets.manage`"
|
|
155
|
-
* and covered only its writes.
|
|
156
|
-
*
|
|
157
|
-
*
|
|
158
|
-
* {@link requireWorkspacePermission}: a `'*'` mount lands on `/workspaces/:workspaceId/*` and can
|
|
159
|
-
* refuse a SIBLING controller's routes. That is survivable while only writes are gated (the
|
|
160
|
-
* siblings it can reach are admin-tier anyway); gating READS makes it fatal, because the same
|
|
161
|
-
* pattern then refuses every member-readable GET registered after it — `GET /github/repos` was the
|
|
162
|
-
* one that caught it. Two patterns are needed, `/thing` and `/thing/*`: Hono's `*` does not match
|
|
163
|
-
* the bare prefix.
|
|
160
|
+
* The failure this exists to prevent is not hypothetical, and all three call sites hit it: each
|
|
161
|
+
* documented the read as gated (in the controller, in the SPA's tab gate, in the store's 403
|
|
162
|
+
* handling) while the mount let every member's GET through, because "gated on `secrets.manage`"
|
|
163
|
+
* reads as covering the controller and covered only its writes. `permissionMounts.test.ts` now
|
|
164
|
+
* asserts the READS of an `IncludingReads` controller are covered too, so the gap cannot reopen
|
|
165
|
+
* by a prefix going missing.
|
|
164
166
|
*/
|
|
165
|
-
export function
|
|
166
|
-
|
|
167
|
+
export function mountWorkspacePermissionIncludingReads(app, permission, prefixes) {
|
|
168
|
+
mountGate(app, prefixes, { permission, gatesReads: true }, (c, next) => {
|
|
167
169
|
if (c.req.method !== 'OPTIONS')
|
|
168
170
|
requirePermission(c, permission);
|
|
169
171
|
return next();
|
|
170
|
-
};
|
|
172
|
+
});
|
|
173
|
+
}
|
|
174
|
+
const MOUNTED_GATE = Symbol('workspacePermissionGate');
|
|
175
|
+
/**
|
|
176
|
+
* What a mounted gate enforces, or `undefined` for any other middleware.
|
|
177
|
+
*
|
|
178
|
+
* Read off a controller's route table by `permissionMounts.test.ts`, which asserts that a member is
|
|
179
|
+
* refused exactly the writes their own controller gates AND that a gated controller leaves none of
|
|
180
|
+
* its own routes uncovered. It exists because a controller's middleware is not necessarily a
|
|
181
|
+
* permission gate: `executionController` and `notificationController` both mount their own `ALL`
|
|
182
|
+
* middleware, and treating every middleware entry as a gate made the derived expectation wrong for
|
|
183
|
+
* them. Tagging the handler is what lets the invariant be READ from what the mount actually did
|
|
184
|
+
* instead of from a second list somebody has to keep in step.
|
|
185
|
+
*
|
|
186
|
+
* Read it off a STANDALONE-built controller, not the composed app: `app.route()` passes a sub-app's
|
|
187
|
+
* handlers through untouched only while the sub-app has no error handler of its own, and wraps them
|
|
188
|
+
* (losing this tag) the moment one calls `app.onError`. No controller does today, and the invariant
|
|
189
|
+
* does not depend on none ever doing.
|
|
190
|
+
*/
|
|
191
|
+
export function permissionGateOf(handler) {
|
|
192
|
+
if (typeof handler !== 'function')
|
|
193
|
+
return undefined;
|
|
194
|
+
const tagged = handler[MOUNTED_GATE];
|
|
195
|
+
return typeof tagged === 'object' && tagged !== null ? tagged : undefined;
|
|
196
|
+
}
|
|
197
|
+
/** Mount one gate on each prefix, bare and `/*` (Hono's `*` does not match the bare prefix). */
|
|
198
|
+
function mountGate(app, prefixes, mounted, gate) {
|
|
199
|
+
if (prefixes.length === 0) {
|
|
200
|
+
// A gated controller with no prefix would gate nothing while reading as gated, which is the
|
|
201
|
+
// silent hole this whole helper exists to close. A prefix that matches none of the controller's
|
|
202
|
+
// routes is the same hole one step later, so `permissionMounts.test.ts` refuses that too: this
|
|
203
|
+
// throw can only catch the spelling of it that names no path at all.
|
|
204
|
+
throw new Error('mountWorkspacePermission: at least one path prefix is required');
|
|
205
|
+
}
|
|
206
|
+
Object.defineProperty(gate, MOUNTED_GATE, { value: mounted });
|
|
207
|
+
for (const prefix of prefixes) {
|
|
208
|
+
app.use(prefix, gate);
|
|
209
|
+
app.use(`${prefix}/*`, gate);
|
|
210
|
+
}
|
|
171
211
|
}
|
|
172
212
|
//# sourceMappingURL=workspaceAccess.js.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"workspaceAccess.js","sourceRoot":"","sources":["../../src/http/workspaceAccess.ts"],"names":[],"mappings":"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
|
|
1
|
+
{"version":3,"file":"workspaceAccess.js","sourceRoot":"","sources":["../../src/http/workspaceAccess.ts"],"names":[],"mappings":"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"}
|
|
@@ -2,7 +2,7 @@ import { getAgentPromptContract, listAgentPromptsContract, promoteAgentPromptCon
|
|
|
2
2
|
import { promptIdForKind, promptVersionLabel } from '@cat-factory/agents';
|
|
3
3
|
import { buildHonoRoute } from '@toad-contracts/hono';
|
|
4
4
|
import { Hono } from 'hono';
|
|
5
|
-
import {
|
|
5
|
+
import { mountWorkspacePermission } from '../../http/workspaceAccess.js';
|
|
6
6
|
import { param } from '../../http/params.js';
|
|
7
7
|
import { requireCapability } from '../../http/guards.js';
|
|
8
8
|
import { NotFoundError, ValidationError } from '@cat-factory/kernel';
|
|
@@ -56,7 +56,7 @@ function detailFor(c, agentKind, revisions) {
|
|
|
56
56
|
*/
|
|
57
57
|
export function agentPromptController() {
|
|
58
58
|
const app = new Hono();
|
|
59
|
-
app
|
|
59
|
+
mountWorkspacePermission(app, 'settings.manage', ['/agent-prompts']);
|
|
60
60
|
buildHonoRoute(app, listAgentPromptsContract, async (c) => {
|
|
61
61
|
const prompts = requireAgentPrompts(c);
|
|
62
62
|
return c.json(await prompts.service.listSummaries(param(c, 'workspaceId')), 200);
|