canopycms 0.0.68-int.109 → 0.0.68-int.111
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/README.md +2 -2
- package/dist/ai/resolve-branch.js +9 -2
- package/dist/api/admin-branch-health.d.ts +24 -1
- package/dist/api/admin-branch-health.js +43 -7
- package/dist/api/admin.d.ts +15 -9
- package/dist/api/admin.js +2 -1
- package/dist/api/branch-merge.js +1 -1
- package/dist/api/branch-review.js +1 -1
- package/dist/api/branch-status.js +10 -2
- package/dist/api/branch.js +4 -3
- package/dist/api/client.d.ts +16 -2
- package/dist/api/client.js +16 -2
- package/dist/api/comments.d.ts +4 -3
- package/dist/api/comments.js +71 -7
- package/dist/api/content.js +9 -7
- package/dist/api/entries-constants.d.ts +8 -3
- package/dist/api/entries-constants.js +8 -3
- package/dist/api/entries.js +2 -4
- package/dist/api/github-sync.d.ts +2 -0
- package/dist/api/github-sync.js +3 -1
- package/dist/api/groups.js +1 -0
- package/dist/api/guards.js +4 -0
- package/dist/api/permissions.d.ts +19 -0
- package/dist/api/permissions.js +48 -2
- package/dist/api/route-builder.js +12 -1
- package/dist/api/settings-helpers.d.ts +5 -1
- package/dist/api/settings-helpers.js +10 -2
- package/dist/api/types.d.ts +5 -1
- package/dist/api/users-constants.d.ts +6 -0
- package/dist/api/users-constants.js +6 -0
- package/dist/auth/caching-auth-plugin.d.ts +2 -0
- package/dist/auth/caching-auth-plugin.js +10 -0
- package/dist/auth/plugin.d.ts +9 -0
- package/dist/auth/user-metadata-lookup.d.ts +15 -0
- package/dist/auth/user-metadata-lookup.js +78 -0
- package/dist/authorization/protected-branch.js +2 -1
- package/dist/branch-health.d.ts +28 -7
- package/dist/branch-health.js +39 -14
- package/dist/branch-metadata-file.d.ts +6 -0
- package/dist/branch-metadata-file.js +2 -0
- package/dist/branch-metadata.d.ts +28 -1
- package/dist/branch-metadata.js +71 -2
- package/dist/branch-workspace.js +6 -8
- package/dist/cli/cli.js +362 -186
- package/dist/cli/generate-ai-content.js +264 -140
- package/dist/cli/init.js +11 -10
- package/dist/cli/project-detect.d.ts +2 -0
- package/dist/cli/project-detect.js +2 -0
- package/dist/cli/template-files/Dockerfile.cms.template +13 -5
- package/dist/cli/template-files/cms-stack.ts.template +3 -2
- package/dist/comment-store.d.ts +13 -0
- package/dist/comment-store.js +42 -8
- package/dist/config/schemas/config.js +2 -2
- package/dist/config/schemas/field.js +2 -0
- package/dist/config/types.d.ts +14 -3
- package/dist/content-id-index.js +2 -2
- package/dist/content-reader.js +2 -4
- package/dist/content-store.d.ts +5 -1
- package/dist/content-store.js +22 -16
- package/dist/context.js +3 -1
- package/dist/editor/CanopyEditor.js +1 -1
- package/dist/editor/EditorAuthGate.d.ts +7 -1
- package/dist/editor/EditorAuthGate.js +32 -5
- package/dist/editor/admin/SystemHealthPanel.js +78 -6
- package/dist/editor/admin/useSystemHealth.d.ts +18 -0
- package/dist/editor/admin/useSystemHealth.js +70 -1
- package/dist/editor/context/ApiClientContext.d.ts +6 -1
- package/dist/editor/context/ApiClientContext.js +17 -7
- package/dist/editor/fields/BlockField.js +2 -1
- package/dist/editor/fields/ObjectField.js +2 -1
- package/dist/editor/hooks/useDraftManager.js +23 -8
- package/dist/editor/hooks/useEntryManager.d.ts +5 -1
- package/dist/editor/hooks/useEntryManager.js +5 -3
- package/dist/editor/hooks/useGroupManager.js +3 -13
- package/dist/editor/hooks/useUserMetadata.d.ts +1 -5
- package/dist/editor/hooks/useUserMetadata.js +18 -49
- package/dist/editor/hooks/user-metadata-batcher.d.ts +7 -0
- package/dist/editor/hooks/user-metadata-batcher.js +26 -0
- package/dist/git-manager.d.ts +36 -6
- package/dist/git-manager.js +66 -18
- package/dist/github-service.js +2 -1
- package/dist/http/handler.js +41 -3
- package/dist/operating-mode/editor-mode-check.d.ts +15 -0
- package/dist/operating-mode/editor-mode-check.js +24 -0
- package/dist/operating-mode/mode-env.d.ts +13 -14
- package/dist/operating-mode/mode-env.js +13 -14
- package/dist/services.d.ts +16 -2
- package/dist/services.js +94 -23
- package/dist/submission-attribution.d.ts +8 -0
- package/dist/submission-attribution.js +19 -0
- package/dist/task-queue/worker-status.d.ts +5 -5
- package/dist/task-queue/worker-status.js +7 -6
- package/dist/types.d.ts +11 -2
- package/dist/utils/base-branch.d.ts +13 -0
- package/dist/utils/base-branch.js +23 -0
- package/dist/utils/content-write-lock.js +4 -3
- package/dist/utils/git.d.ts +13 -1
- package/dist/utils/git.js +31 -2
- package/dist/utils/occ-json-write.js +8 -10
- package/dist/utils/provisioning-lock.d.ts +11 -0
- package/dist/utils/provisioning-lock.js +34 -29
- package/dist/validation/field-traversal.d.ts +2 -0
- package/dist/validation/field-traversal.js +2 -1
- package/dist/validation/markdown-safety.js +22 -1
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/dist/worker/cms-worker.d.ts +47 -17
- package/dist/worker/cms-worker.js +175 -39
- package/dist/worker/git-sync.d.ts +1 -1
- package/dist/worker/git-sync.js +44 -47
- package/dist/worker/github-mirror.d.ts +90 -0
- package/dist/worker/github-mirror.js +297 -0
- package/dist/worker/history-rewrite.d.ts +3 -4
- package/dist/worker/history-rewrite.js +11 -16
- package/dist/worker/rebase.js +14 -17
- package/dist/worker/remote-git-maintenance.js +2 -1
- package/dist/worker/shared-repo-git.d.ts +77 -0
- package/dist/worker/shared-repo-git.js +422 -0
- package/dist/worker/sparse-cone.js +7 -3
- package/dist/worker/task-runner.d.ts +1 -1
- package/dist/worker/task-runner.js +78 -99
- package/dist/worker/worker-context.d.ts +6 -0
- package/package.json +4 -1
package/README.md
CHANGED
|
@@ -47,7 +47,7 @@ you misunderstood, which is why it is worth resolving the ref rather than guessi
|
|
|
47
47
|
|
|
48
48
|
- Workspaces resolve per mode: `prod` uses `$CANOPYCMS_WORKSPACE_ROOT/content-branches` (default: `/mnt/efs/content-branches`), `dev` uses `.canopy-dev/content-branches/<branch>`.
|
|
49
49
|
- For `prod` mode, you must set `defaultRemoteUrl`. For `dev`, `defaultRemoteUrl` is **optional** - if omitted, a local remote is auto-created at `.canopy-dev/remote.git`.
|
|
50
|
-
- Optionally configure `defaultRemoteName` (default: `origin`) and `defaultBaseBranch` (
|
|
50
|
+
- Optionally configure `defaultRemoteName` (default: `origin`) and `defaultBaseBranch` (unset: the checked-out git branch in dev, the repository's default branch in prod).
|
|
51
51
|
- Git author identity is required for `prod` mode: set `gitBotAuthorName` and `gitBotAuthorEmail` so bot commits can be created reliably.
|
|
52
52
|
- Branch names are sanitized and traversal is blocked before creating directories.
|
|
53
53
|
- Metadata lives at `<workspace>/.canopy-meta/branch.json`; the registry lives at `<branchesRoot>/branches.json` and records the workspaceRoot for each branch.
|
|
@@ -515,7 +515,7 @@ export default defineCanopyConfig({
|
|
|
515
515
|
**How it works:**
|
|
516
516
|
|
|
517
517
|
1. When you create your first branch, CanopyCMS automatically creates a bare git repository at `.canopy-dev/remote.git`
|
|
518
|
-
2. Your current `baseBranch` (
|
|
518
|
+
2. Your current `baseBranch` (unset: the checked-out branch) is pushed to this local remote
|
|
519
519
|
3. Branch workspaces are cloned from this local remote into `.canopy-dev/content-branches/<branch-name>/`
|
|
520
520
|
4. All git operations (push, fetch, etc.) work against the local remote
|
|
521
521
|
|
|
@@ -4,6 +4,8 @@
|
|
|
4
4
|
*/
|
|
5
5
|
import { loadOrCreateBranchContext } from '../branch-workspace.js';
|
|
6
6
|
import { readsFromCheckout } from '../build-mode.js';
|
|
7
|
+
import { GitManager } from '../git-manager.js';
|
|
8
|
+
import { baseBranchOf } from '../utils/base-branch.js';
|
|
7
9
|
import { detectHeadBranch } from '../utils/git.js';
|
|
8
10
|
/**
|
|
9
11
|
* Resolve the branch root directory for reading content.
|
|
@@ -15,6 +17,11 @@ export async function resolveBranchRoot(config) {
|
|
|
15
17
|
if (readsFromCheckout(config)) {
|
|
16
18
|
return process.cwd();
|
|
17
19
|
}
|
|
20
|
+
// Prod reads an unset base branch from the remote's HEAD, as services does; provisioning
|
|
21
|
+
// needs it even when the active branch is configured.
|
|
22
|
+
const resolved = config.mode === 'prod' && !config.defaultBaseBranch
|
|
23
|
+
? { ...config, defaultBaseBranch: await GitManager.detectBaseBranch(config, 'throw') }
|
|
24
|
+
: config;
|
|
18
25
|
let activeBranch;
|
|
19
26
|
if (config.defaultActiveBranch) {
|
|
20
27
|
activeBranch = config.defaultActiveBranch;
|
|
@@ -23,10 +30,10 @@ export async function resolveBranchRoot(config) {
|
|
|
23
30
|
activeBranch = await detectHeadBranch(process.cwd(), config.defaultBaseBranch ?? 'main');
|
|
24
31
|
}
|
|
25
32
|
else {
|
|
26
|
-
activeBranch =
|
|
33
|
+
activeBranch = baseBranchOf(resolved);
|
|
27
34
|
}
|
|
28
35
|
const context = await loadOrCreateBranchContext({
|
|
29
|
-
config,
|
|
36
|
+
config: resolved,
|
|
30
37
|
branchName: activeBranch,
|
|
31
38
|
mode: config.mode,
|
|
32
39
|
createdBy: 'canopycms-ai',
|
|
@@ -13,9 +13,26 @@ import { z } from 'zod';
|
|
|
13
13
|
import type { BranchAccessControl, BranchMetadata, BranchStatus } from '../types.js';
|
|
14
14
|
import { type BranchHealthEntry } from '../branch-health.js';
|
|
15
15
|
import type { ApiResponse } from './types.js';
|
|
16
|
+
/**
|
|
17
|
+
* Wall-clock budget the opt-in duplicate-ID scan shares across every healthy
|
|
18
|
+
* branch: well inside the CMS Lambda's default 60 s timeout
|
|
19
|
+
* (`DEFAULT_CMS_LAMBDA_TIMEOUT` in canopycms-cdk), leaving the rest of the
|
|
20
|
+
* request its headroom.
|
|
21
|
+
* @internal Exported for tests.
|
|
22
|
+
*/
|
|
23
|
+
export declare const DUPLICATE_ID_SCAN_BUDGET_MS = 20000;
|
|
16
24
|
export interface BranchHealthData {
|
|
17
25
|
entries: BranchHealthEntry[];
|
|
18
26
|
generatedAt: string;
|
|
27
|
+
/**
|
|
28
|
+
* Present only when the request asked for the duplicate-ID scan. `truncated`
|
|
29
|
+
* is true when the budget ran out before every healthy branch was scanned;
|
|
30
|
+
* those branches report `duplicateIdScan.state === 'unknown'`.
|
|
31
|
+
*/
|
|
32
|
+
duplicateIdScan?: {
|
|
33
|
+
budgetMs: number;
|
|
34
|
+
truncated: boolean;
|
|
35
|
+
};
|
|
19
36
|
}
|
|
20
37
|
/** Response type for GET /admin/branch-health */
|
|
21
38
|
export type BranchHealthResponse = ApiResponse<BranchHealthData>;
|
|
@@ -65,7 +82,13 @@ export type RepairContentDuplicatesResponse = ApiResponse<RepairContentDuplicate
|
|
|
65
82
|
* router/generate-client import.
|
|
66
83
|
*/
|
|
67
84
|
export declare const ADMIN_BRANCH_HEALTH_ROUTES: {
|
|
68
|
-
readonly branchHealth: import("./route-builder.js").RouteDefinition<
|
|
85
|
+
readonly branchHealth: import("./route-builder.js").RouteDefinition<z.ZodObject<{
|
|
86
|
+
duplicates: z.ZodOptional<z.ZodLiteral<"1">>;
|
|
87
|
+
}, "strip", z.ZodTypeAny, {
|
|
88
|
+
duplicates?: "1" | undefined;
|
|
89
|
+
}, {
|
|
90
|
+
duplicates?: "1" | undefined;
|
|
91
|
+
}>, undefined, BranchHealthResponse>;
|
|
69
92
|
readonly purgeBranchDir: import("./route-builder.js").RouteDefinition<z.ZodObject<{
|
|
70
93
|
dirName: z.ZodEffects<z.ZodEffects<z.ZodString, string, string>, string, string>;
|
|
71
94
|
}, "strip", z.ZodTypeAny, {
|
|
@@ -27,8 +27,17 @@ import { branchProvisioningLockName, tryAcquireProvisioningLock } from '../utils
|
|
|
27
27
|
import { withContentWriteLock, ContentWriteLockBusyError, DEFAULT_CONTENT_WRITE_LOCK_WAIT_MS, } from '../utils/content-write-lock.js';
|
|
28
28
|
import { getErrorMessage, isNodeError, isNotFoundError } from '../utils/error.js';
|
|
29
29
|
import { defineEndpoint } from './route-builder.js';
|
|
30
|
+
import { baseBranchOf } from '../utils/base-branch.js';
|
|
30
31
|
/** [H1] A fresh (< 5 min old) init lock blocks purge -- provisioning may be running. */
|
|
31
32
|
const PROVISIONING_LOCK_FRESH_MS = 5 * 60_000;
|
|
33
|
+
/**
|
|
34
|
+
* Wall-clock budget the opt-in duplicate-ID scan shares across every healthy
|
|
35
|
+
* branch: well inside the CMS Lambda's default 60 s timeout
|
|
36
|
+
* (`DEFAULT_CMS_LAMBDA_TIMEOUT` in canopycms-cdk), leaving the rest of the
|
|
37
|
+
* request its headroom.
|
|
38
|
+
* @internal Exported for tests.
|
|
39
|
+
*/
|
|
40
|
+
export const DUPLICATE_ID_SCAN_BUDGET_MS = 20_000;
|
|
32
41
|
// Mirrors deleteTaskHandler's fileName pattern in admin.ts: conservative
|
|
33
42
|
// charset plus explicit traversal/dot-prefix refinements (the regex alone
|
|
34
43
|
// technically excludes '/' already, but the refinements keep intent explicit
|
|
@@ -39,6 +48,11 @@ const dirNameSchema = z
|
|
|
39
48
|
.refine((v) => !v.includes('..'), { message: 'dirName must not contain ..' })
|
|
40
49
|
.refine((v) => !v.startsWith('.'), { message: 'dirName must not be dot-prefixed' });
|
|
41
50
|
const branchDirParamsSchema = z.object({ dirName: dirNameSchema });
|
|
51
|
+
/**
|
|
52
|
+
* `duplicates=1` opts in to the duplicate-ID scan, a full content-tree readdir
|
|
53
|
+
* per healthy branch. The panel's 30 s poll leaves it off.
|
|
54
|
+
*/
|
|
55
|
+
const branchHealthParamsSchema = z.object({ duplicates: z.literal('1').optional() });
|
|
42
56
|
/**
|
|
43
57
|
* Re-resolve dirName under baseRoot and enforce containment (belt-and-
|
|
44
58
|
* suspenders on top of the zod regex, matching deleteTaskHandler's pattern
|
|
@@ -60,19 +74,31 @@ class RepairPreconditionError extends Error {
|
|
|
60
74
|
this.status = status;
|
|
61
75
|
}
|
|
62
76
|
}
|
|
63
|
-
const getBranchHealthHandler = async (_gc, ctx, _req) => {
|
|
77
|
+
const getBranchHealthHandler = async (_gc, ctx, _req, params) => {
|
|
64
78
|
// Same derivation services.ts uses to construct the BranchRegistry --
|
|
65
79
|
// admin handlers must agree with it or the scan silently looks at the
|
|
66
80
|
// wrong directory.
|
|
67
81
|
const baseRoot = getDefaultBranchBase(ctx.services.config.mode);
|
|
68
|
-
const baseBranchName = ctx.services.config
|
|
82
|
+
const baseBranchName = baseBranchOf(ctx.services.config);
|
|
69
83
|
const contentRootName = ctx.services.config.contentRoot || 'content';
|
|
84
|
+
const scanDuplicates = params.duplicates === '1';
|
|
70
85
|
try {
|
|
71
|
-
const entries = await scanBranchHealth(baseRoot, {
|
|
86
|
+
const entries = await scanBranchHealth(baseRoot, {
|
|
87
|
+
baseBranchName,
|
|
88
|
+
contentRootName,
|
|
89
|
+
...(scanDuplicates ? { duplicateIdScan: { budgetMs: DUPLICATE_ID_SCAN_BUDGET_MS } } : {}),
|
|
90
|
+
});
|
|
91
|
+
const truncated = entries.some((e) => e.duplicateIdScan?.state === 'unknown' && e.duplicateIdScan.reason === 'out-of-time');
|
|
72
92
|
return {
|
|
73
93
|
ok: true,
|
|
74
94
|
status: 200,
|
|
75
|
-
data: {
|
|
95
|
+
data: {
|
|
96
|
+
entries,
|
|
97
|
+
generatedAt: new Date().toISOString(),
|
|
98
|
+
...(scanDuplicates
|
|
99
|
+
? { duplicateIdScan: { budgetMs: DUPLICATE_ID_SCAN_BUDGET_MS, truncated } }
|
|
100
|
+
: {}),
|
|
101
|
+
},
|
|
76
102
|
};
|
|
77
103
|
}
|
|
78
104
|
catch (err) {
|
|
@@ -102,7 +128,7 @@ const getBranchHealthHandler = async (_gc, ctx, _req) => {
|
|
|
102
128
|
*/
|
|
103
129
|
const purgeBranchDirHandler = async (_gc, ctx, _req, params) => {
|
|
104
130
|
const baseRoot = getDefaultBranchBase(ctx.services.config.mode);
|
|
105
|
-
const baseBranchName = ctx.services.config
|
|
131
|
+
const baseBranchName = baseBranchOf(ctx.services.config);
|
|
106
132
|
const sanitizedBaseBranchName = sanitizeBranchName(baseBranchName);
|
|
107
133
|
if (params.dirName === sanitizedBaseBranchName) {
|
|
108
134
|
return { ok: false, status: 400, error: 'The base branch directory can never be purged' };
|
|
@@ -412,7 +438,7 @@ async function checkStillCorrupt(dirPath) {
|
|
|
412
438
|
* rebuild) and invalidates any ContentStore already registered in THIS
|
|
413
439
|
* process for the same root.
|
|
414
440
|
*/
|
|
415
|
-
const repairContentDuplicatesHandler = async (_gc, ctx,
|
|
441
|
+
const repairContentDuplicatesHandler = async (_gc, ctx, req, params) => {
|
|
416
442
|
const baseRoot = getDefaultBranchBase(ctx.services.config.mode);
|
|
417
443
|
const dirPath = resolveDirWithinBase(baseRoot, params.dirName);
|
|
418
444
|
if (!dirPath) {
|
|
@@ -485,15 +511,25 @@ const repairContentDuplicatesHandler = async (_gc, ctx, _req, params) => {
|
|
|
485
511
|
: getErrorMessage(err),
|
|
486
512
|
};
|
|
487
513
|
}
|
|
514
|
+
finally {
|
|
515
|
+
// Archived files leave the branch at its next submit, a partial repair's included, so the
|
|
516
|
+
// admin is one of its editors. No `writableBranch` guard can say so: this route names a
|
|
517
|
+
// directory, not a branch.
|
|
518
|
+
if (resolved.length > 0) {
|
|
519
|
+
await ctx.services.recordBranchEditor({ branchRoot: dirPath, baseRoot }, req.user);
|
|
520
|
+
}
|
|
521
|
+
}
|
|
488
522
|
};
|
|
489
523
|
/**
|
|
490
|
-
* Branch directory health scan (healthy/corrupt-metadata/orphan)
|
|
524
|
+
* Branch directory health scan (healthy/corrupt-metadata/orphan); `duplicates=1`
|
|
525
|
+
* adds the bounded duplicate-ID scan
|
|
491
526
|
*/
|
|
492
527
|
const getBranchHealth = defineEndpoint({
|
|
493
528
|
namespace: 'admin',
|
|
494
529
|
name: 'branchHealth',
|
|
495
530
|
method: 'GET',
|
|
496
531
|
path: '/admin/branch-health',
|
|
532
|
+
params: branchHealthParamsSchema,
|
|
497
533
|
responseType: 'BranchHealthResponse',
|
|
498
534
|
response: {},
|
|
499
535
|
defaultMockData: { entries: [], generatedAt: '2024-01-01T00:00:00.000Z' },
|
package/dist/api/admin.d.ts
CHANGED
|
@@ -78,10 +78,10 @@ declare const listAdminTasksParamsSchema: z.ZodObject<{
|
|
|
78
78
|
status: z.ZodEnum<["pending", "processing", "completed", "failed", "corrupt"]>;
|
|
79
79
|
limit: z.ZodOptional<z.ZodNumber>;
|
|
80
80
|
}, "strip", z.ZodTypeAny, {
|
|
81
|
-
status: "failed" | "
|
|
81
|
+
status: "failed" | "pending" | "corrupt" | "processing" | "completed";
|
|
82
82
|
limit?: number | undefined;
|
|
83
83
|
}, {
|
|
84
|
-
status: "failed" | "
|
|
84
|
+
status: "failed" | "pending" | "corrupt" | "processing" | "completed";
|
|
85
85
|
limit?: number | undefined;
|
|
86
86
|
}>;
|
|
87
87
|
export type ListAdminTasksParams = z.infer<typeof listAdminTasksParamsSchema>;
|
|
@@ -89,10 +89,10 @@ declare const deleteTaskParamsSchema: z.ZodObject<{
|
|
|
89
89
|
status: z.ZodEnum<["pending", "failed", "corrupt"]>;
|
|
90
90
|
fileName: z.ZodEffects<z.ZodString, string, string>;
|
|
91
91
|
}, "strip", z.ZodTypeAny, {
|
|
92
|
-
status: "failed" | "
|
|
92
|
+
status: "failed" | "pending" | "corrupt";
|
|
93
93
|
fileName: string;
|
|
94
94
|
}, {
|
|
95
|
-
status: "failed" | "
|
|
95
|
+
status: "failed" | "pending" | "corrupt";
|
|
96
96
|
fileName: string;
|
|
97
97
|
}>;
|
|
98
98
|
export type DeleteTaskParams = z.infer<typeof deleteTaskParamsSchema>;
|
|
@@ -105,7 +105,13 @@ export type DeleteTaskParams = z.infer<typeof deleteTaskParamsSchema>;
|
|
|
105
105
|
* `ADMIN_ROUTES` import.
|
|
106
106
|
*/
|
|
107
107
|
export declare const ADMIN_ROUTES: {
|
|
108
|
-
readonly branchHealth: import("./route-builder.js").RouteDefinition<
|
|
108
|
+
readonly branchHealth: import("./route-builder.js").RouteDefinition<z.ZodObject<{
|
|
109
|
+
duplicates: z.ZodOptional<z.ZodLiteral<"1">>;
|
|
110
|
+
}, "strip", z.ZodTypeAny, {
|
|
111
|
+
duplicates?: "1" | undefined;
|
|
112
|
+
}, {
|
|
113
|
+
duplicates?: "1" | undefined;
|
|
114
|
+
}>, undefined, import("./admin-branch-health.js").BranchHealthResponse>;
|
|
109
115
|
readonly purgeBranchDir: import("./route-builder.js").RouteDefinition<z.ZodObject<{
|
|
110
116
|
dirName: z.ZodEffects<z.ZodEffects<z.ZodString, string, string>, string, string>;
|
|
111
117
|
}, "strip", z.ZodTypeAny, {
|
|
@@ -132,10 +138,10 @@ export declare const ADMIN_ROUTES: {
|
|
|
132
138
|
status: z.ZodEnum<["pending", "processing", "completed", "failed", "corrupt"]>;
|
|
133
139
|
limit: z.ZodOptional<z.ZodNumber>;
|
|
134
140
|
}, "strip", z.ZodTypeAny, {
|
|
135
|
-
status: "failed" | "
|
|
141
|
+
status: "failed" | "pending" | "corrupt" | "processing" | "completed";
|
|
136
142
|
limit?: number | undefined;
|
|
137
143
|
}, {
|
|
138
|
-
status: "failed" | "
|
|
144
|
+
status: "failed" | "pending" | "corrupt" | "processing" | "completed";
|
|
139
145
|
limit?: number | undefined;
|
|
140
146
|
}>, undefined, AdminTasksResponse>;
|
|
141
147
|
readonly retryTask: import("./route-builder.js").RouteDefinition<z.ZodObject<{
|
|
@@ -149,10 +155,10 @@ export declare const ADMIN_ROUTES: {
|
|
|
149
155
|
status: z.ZodEnum<["pending", "failed", "corrupt"]>;
|
|
150
156
|
fileName: z.ZodEffects<z.ZodString, string, string>;
|
|
151
157
|
}, "strip", z.ZodTypeAny, {
|
|
152
|
-
status: "failed" | "
|
|
158
|
+
status: "failed" | "pending" | "corrupt";
|
|
153
159
|
fileName: string;
|
|
154
160
|
}, {
|
|
155
|
-
status: "failed" | "
|
|
161
|
+
status: "failed" | "pending" | "corrupt";
|
|
156
162
|
fileName: string;
|
|
157
163
|
}>, undefined, AdminDeleteTaskResponse>;
|
|
158
164
|
};
|
package/dist/api/admin.js
CHANGED
|
@@ -16,6 +16,7 @@ import { getErrorMessage, isNotFoundError, redactCredentials } from '../utils/er
|
|
|
16
16
|
import { getBuildIdentity } from '../build-identity.js';
|
|
17
17
|
import { loadSharp } from '../assets/sharp-loader.js';
|
|
18
18
|
import { ADMIN_BRANCH_HEALTH_ROUTES } from './admin-branch-health.js';
|
|
19
|
+
import { baseBranchOf } from '../utils/base-branch.js';
|
|
19
20
|
/**
|
|
20
21
|
* 60_000 = DEFAULT_LOCK_STALE_MS in worker/cms-worker.ts, hardcoded rather
|
|
21
22
|
* than threaded through config: an adopter who overrides `lockStaleMs` skews
|
|
@@ -91,7 +92,7 @@ async function readSettingsWorkspaceError(ctx) {
|
|
|
91
92
|
async function readBaseSchemaIssues(ctx) {
|
|
92
93
|
try {
|
|
93
94
|
const { config, branchSchemaCache, entrySchemaRegistry } = ctx.services;
|
|
94
|
-
const base = await ctx.getBranchContext(config
|
|
95
|
+
const base = await ctx.getBranchContext(baseBranchOf(config));
|
|
95
96
|
if (!base)
|
|
96
97
|
return [];
|
|
97
98
|
const { issues } = await branchSchemaCache.getSchema(base.branchRoot, entrySchemaRegistry, config.contentRoot || 'content');
|
package/dist/api/branch-merge.js
CHANGED
|
@@ -54,7 +54,7 @@ const markAsMergedHandler = async (gc, ctx, _req, params) => {
|
|
|
54
54
|
// console.error(`CanopyCMS: Failed to delete remote branch ${branchName}:`, err)
|
|
55
55
|
// }
|
|
56
56
|
// }
|
|
57
|
-
// Comments.json is already in the branch workspace at .
|
|
57
|
+
// Comments.json is already in the branch workspace at .canopy-meta/comments.json
|
|
58
58
|
// It will be preserved with the archived branch - no action needed
|
|
59
59
|
return {
|
|
60
60
|
ok: true,
|
|
@@ -17,7 +17,7 @@ const requestChangesHandler = async (gc, ctx, _req, _params) => {
|
|
|
17
17
|
const updated = await meta.save({
|
|
18
18
|
branch: { name: branchContext.branch.name, status: 'editing' },
|
|
19
19
|
});
|
|
20
|
-
// TODO: Optionally record comment in .
|
|
20
|
+
// TODO: Optionally record comment in .canopy-meta/comments.json when comment system is implemented
|
|
21
21
|
return {
|
|
22
22
|
ok: true,
|
|
23
23
|
status: 200,
|
|
@@ -12,6 +12,7 @@ import { isNonFastForwardRejection } from '../utils/git.js';
|
|
|
12
12
|
import { ContentWriteLockBusyError } from '../utils/content-write-lock.js';
|
|
13
13
|
import { submissionEditorFromUser } from '../submission-attribution.js';
|
|
14
14
|
import { NothingToSubmitError } from '../services.js';
|
|
15
|
+
import { baseBranchOf } from '../utils/base-branch.js';
|
|
15
16
|
const getBranchStatusHandler = async (gc, _ctx, _req, _params) => {
|
|
16
17
|
const { branchContext } = gc;
|
|
17
18
|
return { ok: true, status: 200, data: { branch: branchContext.branch } };
|
|
@@ -53,10 +54,16 @@ const submitBranchForMergeHandler = async (gc, ctx, req, _params) => {
|
|
|
53
54
|
}
|
|
54
55
|
// Commit and push changes
|
|
55
56
|
const submitter = submissionEditorFromUser(req.user);
|
|
57
|
+
const { authPlugin } = ctx;
|
|
56
58
|
let changedPaths;
|
|
59
|
+
let editors;
|
|
57
60
|
try {
|
|
58
61
|
;
|
|
59
|
-
({ changedPaths } = await ctx.services.submitBranch({
|
|
62
|
+
({ changedPaths, editors } = await ctx.services.submitBranch({
|
|
63
|
+
context: branchContext,
|
|
64
|
+
submitter,
|
|
65
|
+
lookupEditor: authPlugin ? (id) => authPlugin.getUserMetadata(id) : undefined,
|
|
66
|
+
}));
|
|
60
67
|
}
|
|
61
68
|
catch (err) {
|
|
62
69
|
if (err instanceof NothingToSubmitError) {
|
|
@@ -113,6 +120,7 @@ const submitBranchForMergeHandler = async (gc, ctx, req, _params) => {
|
|
|
113
120
|
const submittedAt = new Date().toISOString();
|
|
114
121
|
const prResult = await syncSubmitPr(ctx, branchContext, {
|
|
115
122
|
submitter,
|
|
123
|
+
editors,
|
|
116
124
|
changedPaths,
|
|
117
125
|
submittedAt,
|
|
118
126
|
});
|
|
@@ -123,7 +131,7 @@ const submitBranchForMergeHandler = async (gc, ctx, req, _params) => {
|
|
|
123
131
|
// stays editable; the stamp records that it is now on GitHub, for delete.
|
|
124
132
|
if (prResult.nothingToSubmit) {
|
|
125
133
|
await meta.save({ branch: { name: branchContext.branch.name, ...pushed } });
|
|
126
|
-
const base = branchContext.branch.baseBranch ?? ctx.services.config
|
|
134
|
+
const base = branchContext.branch.baseBranch ?? baseBranchOf(ctx.services.config);
|
|
127
135
|
return {
|
|
128
136
|
ok: false,
|
|
129
137
|
status: 400,
|
package/dist/api/branch.js
CHANGED
|
@@ -16,6 +16,7 @@ import { sanitizeBranchName, RESERVED_SETTINGS_BRANCH_PREFIX, RESERVED_ROUTE_BRA
|
|
|
16
16
|
import { GitManager } from '../git-manager.js';
|
|
17
17
|
import { syncDeleteRemoteBranch } from './github-sync.js';
|
|
18
18
|
import { branchNameSchema, branchParamSchema } from './validators.js';
|
|
19
|
+
import { baseBranchOf } from '../utils/base-branch.js';
|
|
19
20
|
const log = createDebugLogger({ prefix: 'BranchAPI' });
|
|
20
21
|
const createBranchBodySchema = z.object({
|
|
21
22
|
branch: branchNameSchema,
|
|
@@ -300,7 +301,7 @@ export const createBranchHandler = async (ctx, req, body) => {
|
|
|
300
301
|
// Load path permissions from the base branch's JSON file (the resolved
|
|
301
302
|
// fork point — baked into config at service creation; dev-mode git HEAD
|
|
302
303
|
// when not explicitly configured)
|
|
303
|
-
const baseBranch = ctx.services.config
|
|
304
|
+
const baseBranch = baseBranchOf(ctx.services.config);
|
|
304
305
|
const baseBranchContext = await ctx.getBranchContext(baseBranch);
|
|
305
306
|
let pathPermissions = [];
|
|
306
307
|
if (baseBranchContext) {
|
|
@@ -377,7 +378,7 @@ export const listBranchesHandler = async (ctx, req) => {
|
|
|
377
378
|
// but registry branch names are filesystem-sanitized ('claude-foo') — the
|
|
378
379
|
// editor matches defaultBranch against registry names, so return the form
|
|
379
380
|
// that can actually be found there.
|
|
380
|
-
const defaultBranch = sanitizeBranchName(ctx.services.config.defaultActiveBranch ?? ctx.services.config
|
|
381
|
+
const defaultBranch = sanitizeBranchName(ctx.services.config.defaultActiveBranch ?? baseBranchOf(ctx.services.config));
|
|
381
382
|
// Admins and Reviewers see all branches
|
|
382
383
|
if (isPrivileged(req.user.groups)) {
|
|
383
384
|
return {
|
|
@@ -551,7 +552,7 @@ export const deleteBranchHandler = async (ctx, req, params) => {
|
|
|
551
552
|
const sanitizedDeleted = sanitizeBranchName(branchContext.branch.name);
|
|
552
553
|
// Defense-in-depth only -- isProtected above already rejects the base
|
|
553
554
|
// branch, and settings branches never resolve through getBranchContext.
|
|
554
|
-
const sanitizedBase = sanitizeBranchName(branchContext.branch.baseBranch ?? ctx.services.config
|
|
555
|
+
const sanitizedBase = sanitizeBranchName(branchContext.branch.baseBranch ?? baseBranchOf(ctx.services.config));
|
|
555
556
|
const deletableHead = sanitizedDeleted !== sanitizedBase &&
|
|
556
557
|
!sanitizedDeleted.startsWith(RESERVED_SETTINGS_BRANCH_PREFIX);
|
|
557
558
|
if (deletableHead &&
|
package/dist/api/client.d.ts
CHANGED
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
* AUTO-GENERATED by scripts/generate-client.ts; do not edit this file manually
|
|
6
6
|
* (changes will be overwritten), edit the generator's templates instead.
|
|
7
7
|
*/
|
|
8
|
+
import type { OperatingMode } from '../operating-mode/types.js';
|
|
8
9
|
import type { BranchDeleteResponse, BranchListItemResponse, BranchListResponse, BranchResponse, CreateBranchBody, UpdateBranchAccessBody } from './branch.js';
|
|
9
10
|
import type { BranchMergeResponse } from './branch-status.js';
|
|
10
11
|
import type { AddCommentBody, AddCommentResponse, CommentsResponse, ResolveCommentResponse } from './comments.js';
|
|
@@ -13,7 +14,7 @@ import type { ReferenceOptionsResponse } from './reference-options.js';
|
|
|
13
14
|
import type { ResolveReferencesBody, ResolveReferencesResponse } from './resolve-references.js';
|
|
14
15
|
import type { DeleteEntryResponse, EntriesResponse } from './entries.js';
|
|
15
16
|
import type { AssetDeleteResponse, AssetsListResponse, FinalizeAssetBody, FinalizeAssetResponse, PresignAssetBody, PresignAssetResponse } from './assets.js';
|
|
16
|
-
import type { GetUserMetadataResponse, ListGroupsResponse, PermissionsResponse, SearchUsersResponse, UpdatePermissionsBody } from './permissions.js';
|
|
17
|
+
import type { BatchGetUserMetadataBody, BatchGetUserMetadataResponse, GetUserMetadataResponse, ListGroupsResponse, PermissionsResponse, SearchUsersResponse, UpdatePermissionsBody } from './permissions.js';
|
|
17
18
|
import type { ExternalGroupsResponse, InternalGroupsResponse, UpdateInternalGroupsBody, UpdateInternalGroupsResponse } from './groups.js';
|
|
18
19
|
import type { UserInfoResponse } from './user.js';
|
|
19
20
|
import type { AddEntryTypeApiResponse, CreateCollectionApiResponse, DeleteCollectionApiResponse, GetCollectionApiResponse, GetSchemaApiResponse, InvalidateSchemaCacheApiResponse, RemoveEntryTypeApiResponse, UpdateCollectionApiResponse, UpdateEntryTypeApiResponse, UpdateOrderApiResponse, UpdateOrderBody } from './schema.js';
|
|
@@ -37,6 +38,15 @@ export interface ApiClientOptions {
|
|
|
37
38
|
* notification, not a retry; the editor's auth gate uses it to show sign-in.
|
|
38
39
|
*/
|
|
39
40
|
onUnauthorized?: () => void;
|
|
41
|
+
/**
|
|
42
|
+
* The mode this bundle was built for, sent on every request so the server refuses a bundle built
|
|
43
|
+
* for the other mode (operating-mode/editor-mode-check.ts). Set by the editor from
|
|
44
|
+
* `config.mode`; a client without it is not checked.
|
|
45
|
+
* @internal
|
|
46
|
+
*/
|
|
47
|
+
editorMode?: OperatingMode;
|
|
48
|
+
/** Called whenever the server refuses `editorMode` (code `EDITOR_MODE_MISMATCH`). @internal */
|
|
49
|
+
onEditorModeMismatch?: () => void;
|
|
40
50
|
}
|
|
41
51
|
/**
|
|
42
52
|
* Typed client for the CanopyCMS API: one method per endpoint, grouped by namespace, each
|
|
@@ -53,6 +63,8 @@ export declare class CanopyApiClient {
|
|
|
53
63
|
private fetchFn;
|
|
54
64
|
private trailingSlash;
|
|
55
65
|
private onUnauthorized;
|
|
66
|
+
private editorMode;
|
|
67
|
+
private onEditorModeMismatch;
|
|
56
68
|
readonly branches: {
|
|
57
69
|
/** GET /branches */
|
|
58
70
|
list: () => Promise<BranchListResponse>;
|
|
@@ -130,6 +142,8 @@ export declare class CanopyApiClient {
|
|
|
130
142
|
listGroups: () => Promise<ListGroupsResponse>;
|
|
131
143
|
/** GET /users/:userId */
|
|
132
144
|
getUserMetadata: (params: Record<string, string>) => Promise<GetUserMetadataResponse>;
|
|
145
|
+
/** POST /users/batch */
|
|
146
|
+
batchGetUserMetadata: (body: BatchGetUserMetadataBody) => Promise<BatchGetUserMetadataResponse>;
|
|
133
147
|
};
|
|
134
148
|
readonly groups: {
|
|
135
149
|
/** GET /groups/internal */
|
|
@@ -167,7 +181,7 @@ export declare class CanopyApiClient {
|
|
|
167
181
|
};
|
|
168
182
|
readonly admin: {
|
|
169
183
|
/** GET /admin/branch-health */
|
|
170
|
-
branchHealth: () => Promise<BranchHealthResponse>;
|
|
184
|
+
branchHealth: (params: Record<string, string>) => Promise<BranchHealthResponse>;
|
|
171
185
|
/** POST /admin/branch-dirs/:dirName/purge */
|
|
172
186
|
purgeBranchDir: (params: Record<string, string>) => Promise<PurgeBranchDirResponse>;
|
|
173
187
|
/** POST /admin/branch-dirs/:dirName/repair-metadata */
|
package/dist/api/client.js
CHANGED
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
*/
|
|
8
8
|
import { computeContentSha256Hex } from './request-body-hash.js';
|
|
9
9
|
import { readTrailingSlashEnv, withTrailingSlash } from '../utils/url-prefix.js';
|
|
10
|
+
import { EDITOR_MODE_HEADER, EDITOR_MODE_MISMATCH_STATUS } from '../operating-mode/editor-mode-check.js';
|
|
10
11
|
/**
|
|
11
12
|
* Typed client for the CanopyCMS API: one method per endpoint, grouped by namespace, each
|
|
12
13
|
* returning that endpoint's ApiResponse.
|
|
@@ -161,6 +162,10 @@ export class CanopyApiClient {
|
|
|
161
162
|
getUserMetadata: (params) => {
|
|
162
163
|
return this.request('GET', this.buildPath('/users/:userId', params));
|
|
163
164
|
},
|
|
165
|
+
/** POST /users/batch */
|
|
166
|
+
batchGetUserMetadata: (body) => {
|
|
167
|
+
return this.request('POST', '/users/batch', body);
|
|
168
|
+
},
|
|
164
169
|
};
|
|
165
170
|
this.groups = {
|
|
166
171
|
/** GET /groups/internal */
|
|
@@ -226,8 +231,8 @@ export class CanopyApiClient {
|
|
|
226
231
|
};
|
|
227
232
|
this.admin = {
|
|
228
233
|
/** GET /admin/branch-health */
|
|
229
|
-
branchHealth: () => {
|
|
230
|
-
return this.request('GET', '/admin/branch-health');
|
|
234
|
+
branchHealth: (params) => {
|
|
235
|
+
return this.request('GET', this.buildPath('/admin/branch-health', params));
|
|
231
236
|
},
|
|
232
237
|
/** POST /admin/branch-dirs/:dirName/purge */
|
|
233
238
|
purgeBranchDir: (params) => {
|
|
@@ -263,6 +268,8 @@ export class CanopyApiClient {
|
|
|
263
268
|
this.fetchFn = options.fetch ?? (typeof window !== 'undefined' ? fetch.bind(window) : fetch);
|
|
264
269
|
this.trailingSlash = options.trailingSlash ?? readTrailingSlashEnv();
|
|
265
270
|
this.onUnauthorized = options.onUnauthorized;
|
|
271
|
+
this.editorMode = options.editorMode;
|
|
272
|
+
this.onEditorModeMismatch = options.onEditorModeMismatch;
|
|
266
273
|
}
|
|
267
274
|
buildPath(template, params) {
|
|
268
275
|
let result = template;
|
|
@@ -298,6 +305,8 @@ export class CanopyApiClient {
|
|
|
298
305
|
const requestHeaders = {
|
|
299
306
|
...headers,
|
|
300
307
|
};
|
|
308
|
+
if (this.editorMode)
|
|
309
|
+
requestHeaders[EDITOR_MODE_HEADER] = this.editorMode;
|
|
301
310
|
let requestBody;
|
|
302
311
|
if (body !== undefined) {
|
|
303
312
|
if (body instanceof FormData) {
|
|
@@ -346,6 +355,11 @@ export class CanopyApiClient {
|
|
|
346
355
|
this.onUnauthorized?.();
|
|
347
356
|
return { ok: false, status: 401, error: errorFromBody(parsed) ?? 'Unauthorized' };
|
|
348
357
|
}
|
|
358
|
+
if (isApiResponseBody(parsed) &&
|
|
359
|
+
parsed.status === EDITOR_MODE_MISMATCH_STATUS &&
|
|
360
|
+
parsed.code === 'EDITOR_MODE_MISMATCH') {
|
|
361
|
+
this.onEditorModeMismatch?.();
|
|
362
|
+
}
|
|
349
363
|
if (isApiResponseBody(parsed))
|
|
350
364
|
return parsed;
|
|
351
365
|
const converted = {
|
package/dist/api/comments.d.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { z } from 'zod';
|
|
2
2
|
import type { ApiResponse } from './types.js';
|
|
3
3
|
import type { CommentThread, CommentType } from '../comment-store.js';
|
|
4
|
+
import type { LogicalPath } from '../paths/types.js';
|
|
4
5
|
export interface AddCommentBody {
|
|
5
6
|
text: string;
|
|
6
7
|
threadId?: string;
|
|
@@ -48,18 +49,18 @@ export declare const COMMENT_ROUTES: {
|
|
|
48
49
|
text: z.ZodString;
|
|
49
50
|
threadId: z.ZodOptional<z.ZodString>;
|
|
50
51
|
type: z.ZodEnum<["field", "entry", "branch"]>;
|
|
51
|
-
entryPath: z.ZodOptional<z.ZodType<
|
|
52
|
+
entryPath: z.ZodOptional<z.ZodType<LogicalPath, z.ZodTypeDef, LogicalPath>>;
|
|
52
53
|
canopyPath: z.ZodOptional<z.ZodString>;
|
|
53
54
|
}, "strip", z.ZodTypeAny, {
|
|
54
55
|
type: "branch" | "entry" | "field";
|
|
55
56
|
text: string;
|
|
56
|
-
entryPath?:
|
|
57
|
+
entryPath?: LogicalPath | undefined;
|
|
57
58
|
threadId?: string | undefined;
|
|
58
59
|
canopyPath?: string | undefined;
|
|
59
60
|
}, {
|
|
60
61
|
type: "branch" | "entry" | "field";
|
|
61
62
|
text: string;
|
|
62
|
-
entryPath?:
|
|
63
|
+
entryPath?: LogicalPath | undefined;
|
|
63
64
|
threadId?: string | undefined;
|
|
64
65
|
canopyPath?: string | undefined;
|
|
65
66
|
}>, AddCommentResponse>;
|
package/dist/api/comments.js
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { z } from 'zod';
|
|
2
2
|
import { CommentStore } from '../comment-store.js';
|
|
3
3
|
import { isReviewer } from '../authorization/index.js';
|
|
4
|
+
import { parseLogicalPath } from '../paths/validation.js';
|
|
5
|
+
import { normalizeFilesystemPath } from '../paths/normalize.js';
|
|
4
6
|
import { defineEndpoint } from './route-builder.js';
|
|
5
7
|
import { branchParamSchema, branchNameSchema, logicalPathSchema } from './validators.js';
|
|
6
8
|
// ============================================================================
|
|
@@ -17,13 +19,47 @@ const addCommentBodySchema = z.object({
|
|
|
17
19
|
entryPath: logicalPathSchema.optional(),
|
|
18
20
|
canopyPath: z.string().optional(), // Canopy field path, not a file path
|
|
19
21
|
});
|
|
20
|
-
|
|
22
|
+
/**
|
|
23
|
+
* Whether `entryPath` is spelled the way path rules expect an entry's logical path: rooted at
|
|
24
|
+
* the content root, with no empty or `.` segment. Rules are globs over that spelling, so
|
|
25
|
+
* `secret/plan` (which the content API reads as `content/secret/plan`) and
|
|
26
|
+
* `content/./secret/plan` both miss a rule written for `content/secret/**`.
|
|
27
|
+
*/
|
|
28
|
+
const isCanonicalEntryPath = (entryPath, contentRoot) => entryPath.startsWith(`${normalizeFilesystemPath(contentRoot)}/`) &&
|
|
29
|
+
entryPath.split('/').every((segment) => segment !== '' && segment !== '.');
|
|
30
|
+
/**
|
|
31
|
+
* Whether the user may see a thread: its entry's path rules at `read`, the level a content
|
|
32
|
+
* read needs, so a thread discloses nothing about an entry its reader could not open. A
|
|
33
|
+
* field thread is governed by its entry. A thread with no entry path (a branch thread) is
|
|
34
|
+
* governed by branch access alone, which the `branchAccess` guard has already passed. An
|
|
35
|
+
* entry path that is not a canonical logical path is denied: comments.json is read without
|
|
36
|
+
* schema validation.
|
|
37
|
+
*/
|
|
38
|
+
const canReadThreadEntry = (checkAccess, contentRoot, entryPath) => {
|
|
39
|
+
if (entryPath === undefined)
|
|
40
|
+
return true;
|
|
41
|
+
const parsed = parseLogicalPath(entryPath);
|
|
42
|
+
return (parsed.ok &&
|
|
43
|
+
isCanonicalEntryPath(parsed.path, contentRoot) &&
|
|
44
|
+
checkAccess(parsed.path, 'read').allowed);
|
|
45
|
+
};
|
|
46
|
+
/** One checker per request, bound to the content root its paths are canonical against. */
|
|
47
|
+
const createThreadReadCheck = async (ctx, req, branchContext) => {
|
|
48
|
+
const checkAccess = await ctx.services.createContentAccessChecker(branchContext, branchContext.branchRoot, req.user);
|
|
49
|
+
const contentRoot = ctx.services.config.contentRoot || 'content';
|
|
50
|
+
return (entryPath) => canReadThreadEntry(checkAccess, contentRoot, entryPath);
|
|
51
|
+
};
|
|
52
|
+
const listCommentsHandler = async (gc, ctx, req, _params) => {
|
|
21
53
|
const { branchContext } = gc;
|
|
22
54
|
const commentStore = new CommentStore(branchContext.branchRoot);
|
|
23
|
-
const
|
|
55
|
+
const [allThreads, canRead] = await Promise.all([
|
|
56
|
+
commentStore.listThreads({ includeResolved: true }),
|
|
57
|
+
createThreadReadCheck(ctx, req, branchContext),
|
|
58
|
+
]);
|
|
59
|
+
const threads = allThreads.filter((thread) => canRead(thread.entryPath));
|
|
24
60
|
return { ok: true, status: 200, data: { threads } };
|
|
25
61
|
};
|
|
26
|
-
const addCommentHandler = async (gc,
|
|
62
|
+
const addCommentHandler = async (gc, ctx, req, _params, body) => {
|
|
27
63
|
const { branchContext } = gc;
|
|
28
64
|
// Validate required fields based on type
|
|
29
65
|
if (body.type === 'field' && !body.canopyPath) {
|
|
@@ -40,22 +76,50 @@ const addCommentHandler = async (gc, _ctx, req, _params, body) => {
|
|
|
40
76
|
error: 'entryPath required for field/entry comments',
|
|
41
77
|
};
|
|
42
78
|
}
|
|
79
|
+
// Commenting needs read access to the entry the comment lands on. A reply lands on the
|
|
80
|
+
// stored thread, so it is checked against that thread's entry path, which never changes
|
|
81
|
+
// once the thread exists; a reply to a thread the user cannot read answers as missing, as
|
|
82
|
+
// resolve does.
|
|
43
83
|
const commentStore = new CommentStore(branchContext.branchRoot);
|
|
84
|
+
const [existing, canRead] = await Promise.all([
|
|
85
|
+
body.threadId ? commentStore.getThread(body.threadId) : Promise.resolve(null),
|
|
86
|
+
createThreadReadCheck(ctx, req, branchContext),
|
|
87
|
+
]);
|
|
88
|
+
if (body.threadId) {
|
|
89
|
+
if (!existing || !canRead(existing.entryPath)) {
|
|
90
|
+
return { ok: false, status: 404, error: 'Thread not found' };
|
|
91
|
+
}
|
|
92
|
+
const reply = await commentStore.addReply({
|
|
93
|
+
userId: req.user.userId,
|
|
94
|
+
text: body.text,
|
|
95
|
+
threadId: body.threadId,
|
|
96
|
+
});
|
|
97
|
+
if (!reply) {
|
|
98
|
+
return { ok: false, status: 404, error: 'Thread not found' };
|
|
99
|
+
}
|
|
100
|
+
return { ok: true, status: 201, data: reply };
|
|
101
|
+
}
|
|
102
|
+
if (!canRead(body.entryPath)) {
|
|
103
|
+
return { ok: false, status: 403, error: 'Forbidden' };
|
|
104
|
+
}
|
|
44
105
|
const result = await commentStore.addComment({
|
|
45
106
|
userId: req.user.userId,
|
|
46
107
|
text: body.text,
|
|
47
|
-
threadId: body.threadId,
|
|
48
108
|
type: body.type,
|
|
49
109
|
entryPath: body.entryPath,
|
|
50
110
|
canopyPath: body.canopyPath,
|
|
51
111
|
});
|
|
52
112
|
return { ok: true, status: 201, data: result };
|
|
53
113
|
};
|
|
54
|
-
const resolveCommentHandler = async (gc,
|
|
114
|
+
const resolveCommentHandler = async (gc, ctx, req, params) => {
|
|
55
115
|
const { branchContext } = gc;
|
|
56
116
|
const commentStore = new CommentStore(branchContext.branchRoot);
|
|
57
|
-
const thread = await
|
|
58
|
-
|
|
117
|
+
const [thread, canRead] = await Promise.all([
|
|
118
|
+
commentStore.getThread(params.threadId),
|
|
119
|
+
createThreadReadCheck(ctx, req, branchContext),
|
|
120
|
+
]);
|
|
121
|
+
// A thread the user cannot read answers as missing, so its id does not confirm it exists.
|
|
122
|
+
if (!thread || !canRead(thread.entryPath)) {
|
|
59
123
|
return { ok: false, status: 404, error: 'Thread not found' };
|
|
60
124
|
}
|
|
61
125
|
// Check permissions: thread author, reviewer, or admin
|