@bevel-software/platform-core-backend 0.21.0 → 0.22.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/core/create-core-server.d.ts.map +1 -1
- package/dist/core/create-core-server.js +32 -2
- package/dist/core/create-core-server.js.map +1 -1
- package/dist/core/create-core-services.d.ts +13 -1
- package/dist/core/create-core-services.d.ts.map +1 -1
- package/dist/core/create-core-services.js +51 -5
- package/dist/core/create-core-services.js.map +1 -1
- package/dist/modules/access/access-requests.contract.d.ts +75 -0
- package/dist/modules/access/access-requests.contract.d.ts.map +1 -0
- package/dist/modules/access/access-requests.contract.js +20 -0
- package/dist/modules/access/access-requests.contract.js.map +1 -0
- package/dist/modules/access/access-requests.routes.d.ts +35 -0
- package/dist/modules/access/access-requests.routes.d.ts.map +1 -0
- package/dist/modules/access/access-requests.routes.js +237 -0
- package/dist/modules/access/access-requests.routes.js.map +1 -0
- package/dist/modules/access/access-requests.service.d.ts +123 -0
- package/dist/modules/access/access-requests.service.d.ts.map +1 -0
- package/dist/modules/access/access-requests.service.js +337 -0
- package/dist/modules/access/access-requests.service.js.map +1 -0
- package/dist/modules/access-model/access-grammar.d.ts +12 -0
- package/dist/modules/access-model/access-grammar.d.ts.map +1 -1
- package/dist/modules/access-model/access-grammar.js +24 -0
- package/dist/modules/access-model/access-grammar.js.map +1 -1
- package/dist/modules/auth/auth.service.d.ts +26 -1
- package/dist/modules/auth/auth.service.d.ts.map +1 -1
- package/dist/modules/auth/auth.service.js +11 -2
- package/dist/modules/auth/auth.service.js.map +1 -1
- package/dist/modules/github-app/github-app.client.d.ts +101 -0
- package/dist/modules/github-app/github-app.client.d.ts.map +1 -0
- package/dist/modules/github-app/github-app.client.js +222 -0
- package/dist/modules/github-app/github-app.client.js.map +1 -0
- package/dist/modules/github-app/github-app.connection.d.ts +102 -0
- package/dist/modules/github-app/github-app.connection.d.ts.map +1 -0
- package/dist/modules/github-app/github-app.connection.js +185 -0
- package/dist/modules/github-app/github-app.connection.js.map +1 -0
- package/dist/modules/github-app/github-app.routes.d.ts +59 -0
- package/dist/modules/github-app/github-app.routes.d.ts.map +1 -0
- package/dist/modules/github-app/github-app.routes.js +323 -0
- package/dist/modules/github-app/github-app.routes.js.map +1 -0
- package/dist/modules/github-app/index.d.ts +4 -0
- package/dist/modules/github-app/index.d.ts.map +1 -0
- package/dist/modules/github-app/index.js +4 -0
- package/dist/modules/github-app/index.js.map +1 -0
- package/dist/modules/kb-fs/remote-url.d.ts +22 -0
- package/dist/modules/kb-fs/remote-url.d.ts.map +1 -0
- package/dist/modules/kb-fs/remote-url.js +35 -0
- package/dist/modules/kb-fs/remote-url.js.map +1 -0
- package/dist/modules/plugins/join-proposals.d.ts +17 -5
- package/dist/modules/plugins/join-proposals.d.ts.map +1 -1
- package/dist/modules/plugins/join-proposals.js +76 -22
- package/dist/modules/plugins/join-proposals.js.map +1 -1
- package/dist/modules/plugins/join-requests.service.d.ts +111 -18
- package/dist/modules/plugins/join-requests.service.d.ts.map +1 -1
- package/dist/modules/plugins/join-requests.service.js +173 -29
- package/dist/modules/plugins/join-requests.service.js.map +1 -1
- package/dist/modules/plugins/plugins.routes.d.ts.map +1 -1
- package/dist/modules/plugins/plugins.routes.js +3 -2
- package/dist/modules/plugins/plugins.routes.js.map +1 -1
- package/dist/modules/settings/deployment-settings.service.d.ts +22 -1
- package/dist/modules/settings/deployment-settings.service.d.ts.map +1 -1
- package/dist/modules/settings/deployment-settings.service.js +93 -4
- package/dist/modules/settings/deployment-settings.service.js.map +1 -1
- package/dist/modules/settings/managed-repository.d.ts +43 -0
- package/dist/modules/settings/managed-repository.d.ts.map +1 -0
- package/dist/modules/settings/managed-repository.js +60 -0
- package/dist/modules/settings/managed-repository.js.map +1 -0
- package/dist/modules/settings/repository-source.d.ts +128 -0
- package/dist/modules/settings/repository-source.d.ts.map +1 -0
- package/dist/modules/settings/repository-source.js +150 -0
- package/dist/modules/settings/repository-source.js.map +1 -0
- package/dist/modules/settings/setup.routes.d.ts +27 -4
- package/dist/modules/settings/setup.routes.d.ts.map +1 -1
- package/dist/modules/settings/setup.routes.js +187 -13
- package/dist/modules/settings/setup.routes.js.map +1 -1
- package/dist/modules/skills/skill-access-requests.routes.d.ts +6 -8
- package/dist/modules/skills/skill-access-requests.routes.d.ts.map +1 -1
- package/dist/modules/skills/skill-access-requests.routes.js +24 -63
- package/dist/modules/skills/skill-access-requests.routes.js.map +1 -1
- package/dist/modules/tool-helpers/tool-context.d.ts.map +1 -1
- package/dist/modules/tool-helpers/tool-context.js +15 -0
- package/dist/modules/tool-helpers/tool-context.js.map +1 -1
- package/dist/modules/workflow/agent-tools/workflow.tools.d.ts.map +1 -1
- package/dist/modules/workflow/agent-tools/workflow.tools.js +38 -2
- package/dist/modules/workflow/agent-tools/workflow.tools.js.map +1 -1
- package/dist/modules/workflow/git/git.service.d.ts +3 -1
- package/dist/modules/workflow/git/git.service.d.ts.map +1 -1
- package/dist/modules/workflow/git/git.service.js +17 -4
- package/dist/modules/workflow/git/git.service.js.map +1 -1
- package/dist/modules/workflow/git/node-git-runner.d.ts.map +1 -1
- package/dist/modules/workflow/git/node-git-runner.js +5 -0
- package/dist/modules/workflow/git/node-git-runner.js.map +1 -1
- package/dist/modules/workflow/git/pull-request.service.d.ts +45 -0
- package/dist/modules/workflow/git/pull-request.service.d.ts.map +1 -1
- package/dist/modules/workflow/git/pull-request.service.js +99 -0
- package/dist/modules/workflow/git/pull-request.service.js.map +1 -1
- package/dist/modules/workflow/workflow.routes.d.ts.map +1 -1
- package/dist/modules/workflow/workflow.routes.js +3 -1
- package/dist/modules/workflow/workflow.routes.js.map +1 -1
- package/dist/modules/workflow/workflow.service.d.ts +34 -7
- package/dist/modules/workflow/workflow.service.d.ts.map +1 -1
- package/dist/modules/workflow/workflow.service.js +148 -48
- package/dist/modules/workflow/workflow.service.js.map +1 -1
- package/dist/modules/workspace/startup/kb-startup-runner.d.ts +66 -0
- package/dist/modules/workspace/startup/kb-startup-runner.d.ts.map +1 -1
- package/dist/modules/workspace/startup/kb-startup-runner.js +148 -0
- package/dist/modules/workspace/startup/kb-startup-runner.js.map +1 -1
- package/dist/modules/workspace/workspace.service.d.ts.map +1 -1
- package/dist/modules/workspace/workspace.service.js +17 -1
- package/dist/modules/workspace/workspace.service.js.map +1 -1
- package/dist/modules/workspace/workspace.tools.d.ts.map +1 -1
- package/dist/modules/workspace/workspace.tools.js +28 -2
- package/dist/modules/workspace/workspace.tools.js.map +1 -1
- package/dist/shared/domain-errors.d.ts +39 -0
- package/dist/shared/domain-errors.d.ts.map +1 -1
- package/dist/shared/domain-errors.js +55 -0
- package/dist/shared/domain-errors.js.map +1 -1
- package/dist/shared/git.contract.d.ts +10 -0
- package/dist/shared/git.contract.d.ts.map +1 -1
- package/dist/shared/git.contract.js.map +1 -1
- package/package.json +3 -3
- package/src/core/create-core-server.ts +37 -1
- package/src/core/create-core-services.ts +65 -8
- package/src/modules/access/__tests__/access-requests.recut.test.ts +134 -0
- package/src/modules/access/__tests__/access-requests.routes.test.ts +610 -0
- package/src/modules/access/access-requests.contract.ts +93 -0
- package/src/modules/access/access-requests.routes.ts +286 -0
- package/src/modules/access/access-requests.service.ts +420 -0
- package/src/modules/access-model/access-grammar.ts +22 -0
- package/src/modules/auth/__tests__/auth.service.test.ts +38 -0
- package/src/modules/auth/auth.service.ts +28 -1
- package/src/modules/github-app/__tests__/github-app.test.ts +848 -0
- package/src/modules/github-app/github-app.client.ts +268 -0
- package/src/modules/github-app/github-app.connection.ts +204 -0
- package/src/modules/github-app/github-app.routes.ts +359 -0
- package/src/modules/github-app/index.ts +19 -0
- package/src/modules/kb-fs/__tests__/remote-url.test.ts +38 -0
- package/src/modules/kb-fs/remote-url.ts +35 -0
- package/src/modules/plugins/__tests__/join-proposals.test.ts +100 -19
- package/src/modules/plugins/__tests__/join-requests.service.test.ts +27 -11
- package/src/modules/plugins/__tests__/join-requests.settlement.test.ts +371 -0
- package/src/modules/plugins/__tests__/plugins.routes.test.ts +1 -1
- package/src/modules/plugins/join-proposals.ts +87 -19
- package/src/modules/plugins/join-requests.service.ts +199 -39
- package/src/modules/plugins/plugins.routes.ts +3 -2
- package/src/modules/settings/__tests__/repository-source.test.ts +191 -0
- package/src/modules/settings/__tests__/setup.routes.git-mode.test.ts +353 -0
- package/src/modules/settings/__tests__/setup.routes.github-app.test.ts +282 -0
- package/src/modules/settings/__tests__/setup.routes.managed-phase.test.ts +233 -0
- package/src/modules/settings/deployment-settings.service.ts +109 -3
- package/src/modules/settings/managed-repository.ts +68 -0
- package/src/modules/settings/repository-source.ts +197 -0
- package/src/modules/settings/setup.routes.ts +214 -10
- package/src/modules/skills/__tests__/skill-access-requests.routes.test.ts +5 -1
- package/src/modules/skills/skill-access-requests.routes.ts +25 -75
- package/src/modules/tool-helpers/tool-context.ts +15 -0
- package/src/modules/workflow/__tests__/apply-failure.test.ts +9 -1
- package/src/modules/workflow/__tests__/workflow.service.facade.test.ts +159 -8
- package/src/modules/workflow/__tests__/workflow.service.update-from-target.test.ts +146 -40
- package/src/modules/workflow/agent-tools/__tests__/workflow.tools.test.ts +141 -1
- package/src/modules/workflow/agent-tools/workflow.tools.ts +49 -3
- package/src/modules/workflow/git/__tests__/git.service.pull.test.ts +121 -0
- package/src/modules/workflow/git/__tests__/pull-request.service.getPrDetail.test.ts +145 -2
- package/src/modules/workflow/git/__tests__/pull-request.service.viewer-can-delete.test.ts +166 -0
- package/src/modules/workflow/git/git.service.ts +25 -4
- package/src/modules/workflow/git/node-git-runner.ts +6 -0
- package/src/modules/workflow/git/pull-request.service.ts +119 -0
- package/src/modules/workflow/workflow.routes.ts +3 -1
- package/src/modules/workflow/workflow.service.ts +170 -52
- package/src/modules/workspace/__tests__/workspace.service.unknown-branch.test.ts +43 -0
- package/src/modules/workspace/__tests__/workspace.tools.branch-errors.test.ts +233 -7
- package/src/modules/workspace/__tests__/workspace.tools.test.ts +35 -3
- package/src/modules/workspace/startup/__tests__/kb-startup-runner.test.ts +211 -0
- package/src/modules/workspace/startup/kb-startup-runner.ts +159 -0
- package/src/modules/workspace/workspace.service.ts +17 -1
- package/src/modules/workspace/workspace.tools.ts +35 -0
- package/src/shared/domain-errors.ts +58 -0
- package/src/shared/git.contract.ts +10 -0
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import type { AuthUser, ChangeRequest } from '@bevel-software/platform-shared';
|
|
2
|
+
import type { Verb } from '../access-model/access-grammar.js';
|
|
3
|
+
import { accessMdPathForFolder, type TargetKind } from './access-mutation.service.js';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* The ITEM a request is about — a folder, or a file that carries its own
|
|
7
|
+
* rules. Everything downstream (the branch name, the file spliced, the
|
|
8
|
+
* permission checked) is derived from this pair, so a request on a file can
|
|
9
|
+
* never reach beyond that file.
|
|
10
|
+
*/
|
|
11
|
+
export interface AccessRequestTarget {
|
|
12
|
+
/** Repo-relative path of the item itself — NOT of its rules file. */
|
|
13
|
+
path: string;
|
|
14
|
+
kind: TargetKind;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** A folder target, spelled once. */
|
|
18
|
+
export function folderTarget(repoRelFolder: string): AccessRequestTarget {
|
|
19
|
+
return { path: repoRelFolder, kind: 'folder' };
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* The file whose rules a request on this item edits: a folder's `access.md`,
|
|
24
|
+
* or the file itself (its own frontmatter). The one place the two cases part,
|
|
25
|
+
* so a file request writes nothing a sibling can see.
|
|
26
|
+
*/
|
|
27
|
+
export function rulesFileFor(target: AccessRequestTarget): string {
|
|
28
|
+
return target.kind === 'folder' ? accessMdPathForFolder(target.path) : target.path;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** The levels the Manage access dialog can ask for. `owner` covers `write`. */
|
|
32
|
+
export type RequestLevel = Extract<Verb, 'write' | 'owner'>;
|
|
33
|
+
export const REQUEST_LEVELS: readonly RequestLevel[] = ['write', 'owner'];
|
|
34
|
+
export function isRequestLevel(value: unknown): value is RequestLevel {
|
|
35
|
+
return typeof value === 'string' && (REQUEST_LEVELS as readonly string[]).includes(value);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** The optional note a requester may attach, in characters. */
|
|
39
|
+
export const REQUEST_NOTE_MAX = 500;
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* One grant a request's branch still proposes. Structurally the plugin
|
|
43
|
+
* module's `JoinProposal` — stated here so the access module can consume the
|
|
44
|
+
* request lifecycle without importing the module that implements it (the
|
|
45
|
+
* dependency runs library → access, and this keeps it that way).
|
|
46
|
+
*/
|
|
47
|
+
export interface AccessProposal {
|
|
48
|
+
verb: Verb;
|
|
49
|
+
id: string;
|
|
50
|
+
principal:
|
|
51
|
+
| { kind: 'user'; email: string; displayName: string }
|
|
52
|
+
| { kind: 'role'; role: string };
|
|
53
|
+
label: string;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** One open request, as the editors' surfaces render it. */
|
|
57
|
+
export interface AccessRequestSummary {
|
|
58
|
+
number: number;
|
|
59
|
+
branch: string;
|
|
60
|
+
requesterName: string;
|
|
61
|
+
createdAt: string;
|
|
62
|
+
proposals: AccessProposal[];
|
|
63
|
+
/** The requester's note, as plain text, when they gave one. */
|
|
64
|
+
note?: string;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* The request lifecycle, as the access module needs it: what a branch still
|
|
69
|
+
* proposes, the open requests for an item, and the settle-if-nothing-is-left
|
|
70
|
+
* check. Implemented by `JoinRequestsService`.
|
|
71
|
+
*
|
|
72
|
+
* `key` is what `joinBranchFor` was given when the branch was named — a
|
|
73
|
+
* plugin's name for a Subscribe request, and the ITEM'S OWN PATH for every
|
|
74
|
+
* request that comes from the Manage access dialog. Keying a skill folder's
|
|
75
|
+
* request on its path is what makes the skill page's "Request write access"
|
|
76
|
+
* and a Can edit request on that same folder one request rather than two.
|
|
77
|
+
*/
|
|
78
|
+
export interface IAccessRequestLifecycle {
|
|
79
|
+
/** Null ⇒ the branch could not be read; that is not "proposes nothing". */
|
|
80
|
+
proposalsOn(branch: string, target: AccessRequestTarget): Promise<AccessProposal[] | null>;
|
|
81
|
+
list(
|
|
82
|
+
key: string,
|
|
83
|
+
target: AccessRequestTarget,
|
|
84
|
+
crs: ChangeRequest[],
|
|
85
|
+
actor: AuthUser,
|
|
86
|
+
): Promise<AccessRequestSummary[]>;
|
|
87
|
+
reconcile(
|
|
88
|
+
key: string,
|
|
89
|
+
target: AccessRequestTarget,
|
|
90
|
+
cr: ChangeRequest,
|
|
91
|
+
actor: AuthUser,
|
|
92
|
+
): Promise<boolean>;
|
|
93
|
+
}
|
|
@@ -0,0 +1,286 @@
|
|
|
1
|
+
import express from 'express';
|
|
2
|
+
import type { AuthUser } from '@bevel-software/platform-shared';
|
|
3
|
+
import type { IWorkflowService } from '@bevel-software/platform-shared';
|
|
4
|
+
import { logger } from '../../shared/logging.js';
|
|
5
|
+
import type { KbContext } from '../../shared/kb-context.js';
|
|
6
|
+
import { branchForWorkspaceId } from '../../shared/workspace-id.js';
|
|
7
|
+
import type { AuthService } from '../auth/auth.service.js';
|
|
8
|
+
import type { WorkspaceService } from '../workspace/workspace.service.js';
|
|
9
|
+
import type { IAccessControl } from './access-control.interface.js';
|
|
10
|
+
import {
|
|
11
|
+
AccessMutationError,
|
|
12
|
+
AccessMutationService,
|
|
13
|
+
fileCarriesAccessRules,
|
|
14
|
+
governingFolderOf,
|
|
15
|
+
type TargetKind,
|
|
16
|
+
} from './access-mutation.service.js';
|
|
17
|
+
import { toHttpError as sharedToHttpError } from './admin-route-helpers.js';
|
|
18
|
+
import {
|
|
19
|
+
isRequestLevel,
|
|
20
|
+
rulesFileFor,
|
|
21
|
+
REQUEST_NOTE_MAX,
|
|
22
|
+
type AccessRequestTarget,
|
|
23
|
+
type IAccessRequestLifecycle,
|
|
24
|
+
} from './access-requests.contract.js';
|
|
25
|
+
import {
|
|
26
|
+
AccessRequestsService,
|
|
27
|
+
extractRequestNote,
|
|
28
|
+
itemNameOf,
|
|
29
|
+
} from './access-requests.service.js';
|
|
30
|
+
import '../auth/auth.middleware.js'; // Express Request.userId augmentation
|
|
31
|
+
|
|
32
|
+
const log = logger('access.request');
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Asking for Can edit or Owner on one item, and the editors' answer.
|
|
36
|
+
*
|
|
37
|
+
* POST /api/workspace/:id/access/request → { ok, number, state, level }
|
|
38
|
+
* GET /api/workspace/:id/access/request?path=&kind= → { state, level?, number? }
|
|
39
|
+
* GET /api/workspace/:id/access/requests?path=&kind= → { requests } (editors)
|
|
40
|
+
* POST /api/workspace/:id/access/requests/:n/reconcile → { closed }
|
|
41
|
+
*
|
|
42
|
+
* LIVE WORKSPACE ONLY. A draft branch's rules may differ from the ones the
|
|
43
|
+
* person is asking about, and a request opened against a draft would propose a
|
|
44
|
+
* grant into a branch nobody merges — so every route here refuses any
|
|
45
|
+
* workspace but the default one, rather than quietly retargeting.
|
|
46
|
+
*
|
|
47
|
+
* Fail-closed like the rest of the access family: an item the caller cannot
|
|
48
|
+
* read answers as unknown, and the editors' listing answers `[]` to everyone
|
|
49
|
+
* else rather than 403 — "am I an editor here" stays a question only the
|
|
50
|
+
* server answers.
|
|
51
|
+
*/
|
|
52
|
+
export function createAccessRequestRoutes(deps: {
|
|
53
|
+
accessControl: IAccessControl;
|
|
54
|
+
workspaceService: WorkspaceService;
|
|
55
|
+
authService: AuthService;
|
|
56
|
+
workflow: IWorkflowService;
|
|
57
|
+
lifecycle: IAccessRequestLifecycle;
|
|
58
|
+
kb: KbContext;
|
|
59
|
+
}): express.Router {
|
|
60
|
+
const { accessControl, workspaceService, authService, workflow, lifecycle, kb } = deps;
|
|
61
|
+
const { kbDirName } = kb;
|
|
62
|
+
const router = express.Router({ mergeParams: true });
|
|
63
|
+
const mutation = new AccessMutationService(workspaceService, accessControl, kbDirName);
|
|
64
|
+
const requests = new AccessRequestsService({ workflow, workspaceService, lifecycle, kb });
|
|
65
|
+
|
|
66
|
+
async function requireUser(
|
|
67
|
+
req: express.Request,
|
|
68
|
+
res: express.Response,
|
|
69
|
+
): Promise<AuthUser | null> {
|
|
70
|
+
if (!req.userId) {
|
|
71
|
+
res.status(401).json({ error: 'Unauthenticated' });
|
|
72
|
+
return null;
|
|
73
|
+
}
|
|
74
|
+
const user = await authService.getUserById(req.userId);
|
|
75
|
+
if (!user) {
|
|
76
|
+
res.status(401).json({ error: 'User not found' });
|
|
77
|
+
return null;
|
|
78
|
+
}
|
|
79
|
+
return user;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** Workspace-relative (what the dialog holds) → repo-relative (what the model speaks). */
|
|
83
|
+
function toRepoRelative(p: string): string {
|
|
84
|
+
const prefix = `${kbDirName}/`;
|
|
85
|
+
if (p === kbDirName) return '';
|
|
86
|
+
return p.startsWith(prefix) ? p.slice(prefix.length) : p;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Refuse a target that could escape the KB repo, or that is spelled in more
|
|
91
|
+
* than one way. Mirrors the grant route's guard — the branch name and the
|
|
92
|
+
* spliced path are both derived from this string, so two spellings of one
|
|
93
|
+
* item would be two different requests.
|
|
94
|
+
*/
|
|
95
|
+
function assertSafeTarget(repoRelTarget: string, kind: TargetKind): void {
|
|
96
|
+
if (kind === 'file' && !repoRelTarget) {
|
|
97
|
+
throw new AccessMutationError('file path is required');
|
|
98
|
+
}
|
|
99
|
+
if (
|
|
100
|
+
repoRelTarget.startsWith('/') ||
|
|
101
|
+
repoRelTarget.includes('\\') ||
|
|
102
|
+
repoRelTarget.includes('\0') ||
|
|
103
|
+
repoRelTarget.split('/').some((s) => s === '..' || s === '.')
|
|
104
|
+
) {
|
|
105
|
+
throw new AccessMutationError('path must stay inside the KB repo');
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* The (live workspace, readable item) pair every route here needs, or null
|
|
111
|
+
* after the refusal has been answered.
|
|
112
|
+
*
|
|
113
|
+
* The two refusals are deliberately different shapes: a non-default
|
|
114
|
+
* workspace is the CALLER asking the wrong question (400), while an item the
|
|
115
|
+
* caller cannot read answers exactly as one that does not exist (404) — a
|
|
116
|
+
* 403 would confirm the item is there.
|
|
117
|
+
*/
|
|
118
|
+
async function resolveTarget(
|
|
119
|
+
req: express.Request,
|
|
120
|
+
res: express.Response,
|
|
121
|
+
rawPath: unknown,
|
|
122
|
+
rawKind: unknown,
|
|
123
|
+
): Promise<{ user: AuthUser; target: AccessRequestTarget; wsId: string } | null> {
|
|
124
|
+
const user = await requireUser(req, res);
|
|
125
|
+
if (!user) return null;
|
|
126
|
+
const wsId = kb.defaultWorkspaceId();
|
|
127
|
+
if (branchForWorkspaceId(String(req.params.id)) !== kb.defaultBranch) {
|
|
128
|
+
res.status(400).json({
|
|
129
|
+
error: 'Access requests are only made on the live workspace.',
|
|
130
|
+
kind: 'not-live-workspace',
|
|
131
|
+
});
|
|
132
|
+
return null;
|
|
133
|
+
}
|
|
134
|
+
if (typeof rawPath !== 'string') {
|
|
135
|
+
res.status(400).json({ error: 'path is required', kind: 'access-error' });
|
|
136
|
+
return null;
|
|
137
|
+
}
|
|
138
|
+
const kind: TargetKind = rawKind === 'folder' ? 'folder' : 'file';
|
|
139
|
+
const target: AccessRequestTarget = { path: toRepoRelative(rawPath), kind };
|
|
140
|
+
try {
|
|
141
|
+
assertSafeTarget(target.path, kind);
|
|
142
|
+
} catch (err) {
|
|
143
|
+
const { status, body } = sharedToHttpError(err, 'access');
|
|
144
|
+
res.status(status).json(body);
|
|
145
|
+
return null;
|
|
146
|
+
}
|
|
147
|
+
if (!(await accessControl.canRead(wsId, user.email, target.path))) {
|
|
148
|
+
res.status(404).json({ error: 'Not found', kind: 'unknown-target' });
|
|
149
|
+
return null;
|
|
150
|
+
}
|
|
151
|
+
return { user, target, wsId };
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** Can the caller change this item's rules — the gate an Accept runs through. */
|
|
155
|
+
const isEditor = (wsId: string, email: string, target: AccessRequestTarget) =>
|
|
156
|
+
accessControl.canWrite(wsId, email, rulesFileFor(target));
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* A FILE that cannot hold rules of its own is governed by its folder, and
|
|
160
|
+
* there is nothing here to request. The dialog already points at the folder
|
|
161
|
+
* for these; the route says the same thing rather than splicing YAML into a
|
|
162
|
+
* PDF. Content counts as much as the name — bytes that are not text refuse
|
|
163
|
+
* too, exactly as the grant route refuses them.
|
|
164
|
+
*/
|
|
165
|
+
async function assertCanCarryRules(wsId: string, target: AccessRequestTarget): Promise<void> {
|
|
166
|
+
if (target.kind !== 'file') return;
|
|
167
|
+
if (fileCarriesAccessRules(target.path) && (await mutation.targetHoldsText(wsId, target.path))) {
|
|
168
|
+
return;
|
|
169
|
+
}
|
|
170
|
+
throw new AccessMutationError(
|
|
171
|
+
`Access for this file is managed on its folder. Ask there instead.`,
|
|
172
|
+
409,
|
|
173
|
+
{ kind: 'folder-governs', folder: governingFolderOf(target.path) },
|
|
174
|
+
);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
router.post('/workspace/:id/access/request', async (req, res) => {
|
|
178
|
+
const body = (req.body ?? {}) as Record<string, unknown>;
|
|
179
|
+
const ctx = await resolveTarget(req, res, body.path, body.kind);
|
|
180
|
+
if (!ctx) return;
|
|
181
|
+
const { user, target, wsId } = ctx;
|
|
182
|
+
try {
|
|
183
|
+
if (!isRequestLevel(body.level)) {
|
|
184
|
+
throw new AccessMutationError('level must be "write" or "owner"');
|
|
185
|
+
}
|
|
186
|
+
const note = typeof body.note === 'string' ? body.note : '';
|
|
187
|
+
if (typeof body.note !== 'undefined' && typeof body.note !== 'string') {
|
|
188
|
+
throw new AccessMutationError('note must be text');
|
|
189
|
+
}
|
|
190
|
+
if (note.length > REQUEST_NOTE_MAX) {
|
|
191
|
+
throw new AccessMutationError(`A note can be at most ${REQUEST_NOTE_MAX} characters.`);
|
|
192
|
+
}
|
|
193
|
+
await assertCanCarryRules(wsId, target);
|
|
194
|
+
if (await isEditor(wsId, user.email, target)) {
|
|
195
|
+
throw new AccessMutationError(
|
|
196
|
+
`You can already change who can use this ${target.kind}.`,
|
|
197
|
+
409,
|
|
198
|
+
{ kind: 'already-writable' },
|
|
199
|
+
);
|
|
200
|
+
}
|
|
201
|
+
const opened = await requests.open({
|
|
202
|
+
user,
|
|
203
|
+
target,
|
|
204
|
+
itemName: itemNameOf(target.path),
|
|
205
|
+
level: body.level,
|
|
206
|
+
note,
|
|
207
|
+
});
|
|
208
|
+
res.json({ ok: true, state: 'pending', number: opened.number, level: opened.level });
|
|
209
|
+
} catch (err) {
|
|
210
|
+
const { status, body: errBody } = sharedToHttpError(err, 'access');
|
|
211
|
+
if (status >= 500) log.error('failed to open an access request:', { err });
|
|
212
|
+
res.status(status).json(errBody);
|
|
213
|
+
}
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
router.get('/workspace/:id/access/request', async (req, res) => {
|
|
217
|
+
const ctx = await resolveTarget(req, res, req.query.path, req.query.kind);
|
|
218
|
+
if (!ctx) return;
|
|
219
|
+
try {
|
|
220
|
+
res.json(await requests.status(ctx.user, ctx.target));
|
|
221
|
+
} catch (err) {
|
|
222
|
+
log.error('failed to read an access request:', { err });
|
|
223
|
+
res.status(500).json({ error: 'Failed to read the request' });
|
|
224
|
+
}
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
router.get('/workspace/:id/access/requests', async (req, res) => {
|
|
228
|
+
const ctx = await resolveTarget(req, res, req.query.path, req.query.kind);
|
|
229
|
+
if (!ctx) return;
|
|
230
|
+
const { user, target, wsId } = ctx;
|
|
231
|
+
try {
|
|
232
|
+
if (!(await isEditor(wsId, user.email, target))) {
|
|
233
|
+
res.json({ requests: [] });
|
|
234
|
+
return;
|
|
235
|
+
}
|
|
236
|
+
const crs = await workflow.listChangeRequests();
|
|
237
|
+
const rows = await lifecycle.list(target.path, target, crs, user);
|
|
238
|
+
// The note lives in the request's description and nowhere else, so it
|
|
239
|
+
// is read back per request. Bounded by the open requests on ONE item —
|
|
240
|
+
// a handful at most — and a detail that cannot be fetched costs the
|
|
241
|
+
// note, never the line it belongs to.
|
|
242
|
+
const withNotes = await Promise.all(
|
|
243
|
+
rows.map(async (row) => {
|
|
244
|
+
const detail = await workflow
|
|
245
|
+
.getChangeRequestDetail(row.number, { patches: false })
|
|
246
|
+
.catch(() => null);
|
|
247
|
+
const note = extractRequestNote(detail?.body);
|
|
248
|
+
return note ? { ...row, note } : row;
|
|
249
|
+
}),
|
|
250
|
+
);
|
|
251
|
+
res.json({ requests: withNotes });
|
|
252
|
+
} catch (err) {
|
|
253
|
+
log.error('failed to list access requests:', { err });
|
|
254
|
+
res.status(500).json({ error: 'Failed to list access requests' });
|
|
255
|
+
}
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
router.post('/workspace/:id/access/requests/:number/reconcile', async (req, res) => {
|
|
259
|
+
const body = (req.body ?? {}) as Record<string, unknown>;
|
|
260
|
+
const ctx = await resolveTarget(req, res, body.path, body.kind);
|
|
261
|
+
if (!ctx) return;
|
|
262
|
+
const { user, target, wsId } = ctx;
|
|
263
|
+
try {
|
|
264
|
+
if (!(await isEditor(wsId, user.email, target))) {
|
|
265
|
+
res.status(404).json({ error: 'Not found' });
|
|
266
|
+
return;
|
|
267
|
+
}
|
|
268
|
+
const number = Number(req.params.number);
|
|
269
|
+
if (!Number.isSafeInteger(number) || number <= 0) {
|
|
270
|
+
res.status(400).json({ error: 'Invalid change request number' });
|
|
271
|
+
return;
|
|
272
|
+
}
|
|
273
|
+
const cr = await workflow.getChangeRequest(number);
|
|
274
|
+
if (!cr) {
|
|
275
|
+
res.status(404).json({ error: 'Not found' });
|
|
276
|
+
return;
|
|
277
|
+
}
|
|
278
|
+
res.json({ closed: await lifecycle.reconcile(target.path, target, cr, user) });
|
|
279
|
+
} catch (err) {
|
|
280
|
+
log.error('failed to reconcile an access request:', { err });
|
|
281
|
+
res.status(500).json({ error: 'Failed to reconcile the request' });
|
|
282
|
+
}
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
return router;
|
|
286
|
+
}
|