canopycms 0.0.67 → 0.0.68-int.94
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 +3 -2
- package/dist/ai/handler.js +8 -0
- package/dist/api/admin-branch-health.js +12 -7
- package/dist/api/admin.d.ts +21 -9
- package/dist/api/admin.js +18 -2
- package/dist/api/branch-status.js +19 -4
- package/dist/api/branch.js +11 -11
- package/dist/api/client.d.ts +12 -0
- package/dist/api/client.js +34 -11
- package/dist/api/content.d.ts +6 -5
- package/dist/api/content.js +44 -41
- package/dist/api/entries.js +16 -15
- package/dist/api/github-sync.d.ts +10 -1
- package/dist/api/github-sync.js +20 -10
- package/dist/api/reference-options.js +1 -1
- package/dist/api/resolve-references.js +14 -40
- package/dist/api/settings-helpers.d.ts +1 -1
- package/dist/api/settings-helpers.js +1 -4
- package/dist/api/user.d.ts +3 -0
- package/dist/api/user.js +3 -0
- package/dist/assets/factory.d.ts +5 -0
- package/dist/assets/factory.js +5 -0
- package/dist/authorization/content.d.ts +5 -4
- package/dist/authorization/content.js +5 -5
- package/dist/authorization/path.d.ts +11 -6
- package/dist/authorization/path.js +11 -6
- package/dist/authorization/types.d.ts +2 -2
- package/dist/branch-health.js +3 -3
- package/dist/branch-schema-cache.d.ts +5 -3
- package/dist/branch-schema-cache.js +29 -11
- package/dist/branch-workspace.js +8 -3
- package/dist/build-identity.d.ts +3 -0
- package/dist/build-identity.js +13 -0
- package/dist/cli/cli.js +1070 -388
- package/dist/cli/generate-ai-content.js +537 -150
- package/dist/cli/github-app-manifest.d.ts +4 -3
- package/dist/cli/github-app-manifest.js +4 -3
- package/dist/cli/init.js +21 -11
- package/dist/cli/migrate.js +12 -8
- package/dist/cli/sync.js +83 -37
- package/dist/cli/template-files/Dockerfile.cms.template +8 -0
- package/dist/cli/template-files/cdk-app.ts.template +4 -0
- package/dist/cli/template-files/cms-stack.ts.template +6 -0
- package/dist/cli/template-files/deploy-cms.yml.template +2 -0
- package/dist/config/schemas/config.d.ts +11 -3
- package/dist/config/schemas/config.js +5 -1
- package/dist/config/schemas/field.js +1 -0
- package/dist/config/schemas/url.d.ts +2 -0
- package/dist/config/schemas/url.js +7 -0
- package/dist/config/types.d.ts +28 -4
- package/dist/config/validation.d.ts +6 -0
- package/dist/config/validation.js +37 -0
- package/dist/content-listing.d.ts +12 -12
- package/dist/content-listing.js +10 -6
- package/dist/content-reader.d.ts +11 -2
- package/dist/content-reader.js +22 -14
- package/dist/content-store.d.ts +37 -9
- package/dist/content-store.js +70 -43
- package/dist/content-tree.d.ts +1 -1
- package/dist/content-tree.js +2 -2
- package/dist/context.d.ts +27 -9
- package/dist/context.js +154 -70
- package/dist/editor/CanopyEditor.d.ts +1 -1
- package/dist/editor/CanopyEditor.js +1 -1
- package/dist/editor/Editor.d.ts +2 -0
- package/dist/editor/Editor.js +6 -5
- package/dist/editor/FormRenderer.js +20 -5
- package/dist/editor/admin/SystemHealthPanel.js +45 -7
- package/dist/editor/components/NoEditPermissionNotice.d.ts +10 -0
- package/dist/editor/components/NoEditPermissionNotice.js +17 -0
- package/dist/editor/editor-utils.d.ts +17 -15
- package/dist/editor/editor-utils.js +43 -36
- package/dist/editor/hooks/useBranchesData.js +3 -1
- package/dist/editor/hooks/useCommentSystem.js +5 -1
- package/dist/editor/hooks/useDraftManager.d.ts +1 -1
- package/dist/editor/hooks/useDraftManager.js +2 -1
- package/dist/editor/hooks/useEntriesData.js +2 -2
- package/dist/editor/hooks/useEntryManager.js +17 -11
- package/dist/editor/preview-bridge.js +4 -1
- package/dist/editor/preview-path.d.ts +10 -0
- package/dist/editor/preview-path.js +20 -0
- package/dist/entry-schema-registry.d.ts +4 -4
- package/dist/entry-schema-registry.js +10 -6
- package/dist/entry-schema.d.ts +27 -4
- package/dist/entry-schema.js +20 -3
- package/dist/git-manager.d.ts +79 -5
- package/dist/git-manager.js +308 -18
- package/dist/github-service.d.ts +8 -0
- package/dist/github-service.js +5 -1
- package/dist/http/handler.js +35 -13
- package/dist/http/index.d.ts +2 -0
- package/dist/http/index.js +2 -0
- package/dist/http/router.d.ts +2 -0
- package/dist/http/router.js +2 -1
- package/dist/http/worker-not-ready.d.ts +9 -0
- package/dist/http/worker-not-ready.js +17 -0
- package/dist/operating-mode/client-unsafe-strategy.js +0 -6
- package/dist/operating-mode/types.d.ts +0 -3
- package/dist/paths/branch-name.d.ts +5 -0
- package/dist/paths/branch-name.js +9 -0
- package/dist/paths/branch.d.ts +6 -1
- package/dist/paths/branch.js +15 -2
- package/dist/paths/index.d.ts +3 -3
- package/dist/paths/index.js +3 -3
- package/dist/paths/normalize.d.ts +7 -0
- package/dist/paths/normalize.js +9 -0
- package/dist/reference-resolver.d.ts +5 -21
- package/dist/reference-resolver.js +9 -44
- package/dist/resolve-canopy-user.js +2 -1
- package/dist/schema/schema-store.d.ts +48 -22
- package/dist/schema/schema-store.js +66 -22
- package/dist/services.d.ts +18 -6
- package/dist/services.js +85 -70
- package/dist/settings-workspace.js +42 -4
- package/dist/static/seo.d.ts +2 -14
- package/dist/static/seo.js +2 -25
- package/dist/submission-attribution.d.ts +73 -0
- package/dist/submission-attribution.js +221 -0
- package/dist/sync-core.d.ts +12 -1
- package/dist/sync-core.js +31 -13
- package/dist/task-queue/worker-status.d.ts +8 -0
- package/dist/task-queue/worker-status.js +17 -0
- package/dist/types.d.ts +31 -2
- package/dist/utils/content-write-lock.d.ts +18 -7
- package/dist/utils/content-write-lock.js +18 -9
- package/dist/utils/debug.d.ts +8 -0
- package/dist/utils/debug.js +10 -2
- package/dist/utils/git.d.ts +28 -0
- package/dist/utils/git.js +38 -0
- package/dist/utils/provisioning-lock.d.ts +15 -5
- package/dist/utils/provisioning-lock.js +31 -11
- package/dist/utils/request-timing.d.ts +25 -0
- package/dist/utils/request-timing.js +101 -0
- package/dist/utils/url-prefix.d.ts +30 -0
- package/dist/utils/url-prefix.js +61 -0
- package/dist/version.d.ts +1 -0
- package/dist/version.js +3 -0
- package/dist/worker/canopy-state.d.ts +45 -0
- package/dist/worker/canopy-state.js +75 -0
- package/dist/worker/cms-worker.js +23 -2
- package/dist/worker/git-sync.d.ts +8 -2
- package/dist/worker/git-sync.js +138 -24
- package/dist/worker/provisioned-workspace.d.ts +35 -0
- package/dist/worker/provisioned-workspace.js +50 -0
- package/dist/worker/rebase.d.ts +1 -1
- package/dist/worker/rebase.js +83 -12
- package/dist/worker/task-runner.js +39 -4
- package/package.json +1 -1
package/dist/api/content.js
CHANGED
|
@@ -5,7 +5,7 @@ import { ReferenceValidator } from '../validation/reference-validator.js';
|
|
|
5
5
|
import { findUnknownKeys, mergeBodyIntoData, normalizeReferenceValues, validateEntryData, } from '../validation/entry-validator.js';
|
|
6
6
|
import { validateEntryLinks } from '../validation/entry-link-validator.js';
|
|
7
7
|
import { branchNameSchema, logicalPathSchema, slugSchema } from './validators.js';
|
|
8
|
-
import { parseSlug } from '../paths/index.js';
|
|
8
|
+
import { entryLogicalPath, parseSlug } from '../paths/index.js';
|
|
9
9
|
import { getErrorMessage, isNotFoundError, sanitizeErrorMessage } from '../utils/error.js';
|
|
10
10
|
import { isDataOnlyFormat } from '../utils/format.js';
|
|
11
11
|
/**
|
|
@@ -52,7 +52,7 @@ const writeContentBodySchema = z.object({
|
|
|
52
52
|
format: z.enum(['json', 'md', 'mdx', 'yaml']),
|
|
53
53
|
data: boundedContentDataSchema.optional(),
|
|
54
54
|
body: z.string().max(MAX_CONTENT_BODY_CHARS).optional(),
|
|
55
|
-
// null = create
|
|
55
|
+
// null or omitted = create ("must not already exist"); see WriteContentBody.
|
|
56
56
|
expectedVersion: z.number().nullish(),
|
|
57
57
|
});
|
|
58
58
|
const validateReferencesParamsSchema = z.object({
|
|
@@ -80,24 +80,27 @@ const readContentHandler = async (gc, ctx, req, params) => {
|
|
|
80
80
|
const logicalPathSegments = parseApiPath(params.path, contentRoot);
|
|
81
81
|
let schemaItem;
|
|
82
82
|
let slug;
|
|
83
|
-
let relativePath;
|
|
84
83
|
try {
|
|
85
84
|
const resolved = store.resolvePath(logicalPathSegments);
|
|
86
85
|
schemaItem = resolved.schemaItem;
|
|
87
86
|
slug = resolved.slug;
|
|
88
|
-
|
|
89
|
-
|
|
87
|
+
// Runs the store's slug and traversal checks before the permission check.
|
|
88
|
+
await store.resolveDocumentPath(schemaItem.logicalPath, slug);
|
|
90
89
|
}
|
|
91
90
|
catch (err) {
|
|
92
91
|
const message = err instanceof ContentStoreError ? err.message : 'Invalid content request';
|
|
93
92
|
return { ok: false, status: 400, error: sanitizeErrorMessage(message) };
|
|
94
93
|
}
|
|
95
|
-
|
|
96
|
-
|
|
94
|
+
// One checker for the entry and every reference target it resolves, so a reference cannot
|
|
95
|
+
// carry a target's data past the rules that would refuse a direct read of it.
|
|
96
|
+
const checkAccess = await ctx.services.createContentAccessChecker(branchContext, branchContext.branchRoot, req.user);
|
|
97
|
+
if (!checkAccess(entryLogicalPath(schemaItem.logicalPath, slug), 'read').allowed) {
|
|
97
98
|
return { ok: false, status: 403, error: 'Forbidden' };
|
|
98
99
|
}
|
|
99
100
|
try {
|
|
100
|
-
const doc = await store.read(schemaItem.logicalPath, slug
|
|
101
|
+
const doc = await store.read(schemaItem.logicalPath, slug, {
|
|
102
|
+
referenceAccess: (targetPath) => checkAccess(targetPath, 'read').allowed,
|
|
103
|
+
});
|
|
101
104
|
return { ok: true, status: 200, data: doc };
|
|
102
105
|
}
|
|
103
106
|
catch (err) {
|
|
@@ -117,19 +120,18 @@ const writeContentHandler = async (gc, ctx, req, params, body) => {
|
|
|
117
120
|
const logicalPathSegments = parseApiPath(params.path, contentRoot);
|
|
118
121
|
let schemaItem;
|
|
119
122
|
let slug;
|
|
120
|
-
let relativePath;
|
|
121
123
|
try {
|
|
122
124
|
const resolved = store.resolvePath(logicalPathSegments);
|
|
123
125
|
schemaItem = resolved.schemaItem;
|
|
124
126
|
slug = resolved.slug;
|
|
125
|
-
|
|
126
|
-
|
|
127
|
+
// Runs the store's slug and traversal checks before the permission check.
|
|
128
|
+
await store.resolveDocumentPath(schemaItem.logicalPath, slug);
|
|
127
129
|
}
|
|
128
130
|
catch (err) {
|
|
129
131
|
const message = err instanceof ContentStoreError ? err.message : 'Invalid content request';
|
|
130
132
|
return { ok: false, status: 400, error: sanitizeErrorMessage(message) };
|
|
131
133
|
}
|
|
132
|
-
const access = await ctx.services.checkContentAccess(branchContext, branchContext.branchRoot,
|
|
134
|
+
const access = await ctx.services.checkContentAccess(branchContext, branchContext.branchRoot, entryLogicalPath(schemaItem.logicalPath, slug), req.user, 'edit');
|
|
133
135
|
if (!access.allowed) {
|
|
134
136
|
return { ok: false, status: 403, error: 'Forbidden' };
|
|
135
137
|
}
|
|
@@ -198,20 +200,21 @@ const writeContentHandler = async (gc, ctx, req, params, body) => {
|
|
|
198
200
|
}
|
|
199
201
|
const data = body.data ?? {};
|
|
200
202
|
const isDataOnly = isDataOnlyFormat(body.format);
|
|
203
|
+
// The store's own `undefined` (blind write) is never reachable from here: an omitted token is
|
|
204
|
+
// a create, so an update that lost its token 409s instead of overwriting unchecked.
|
|
205
|
+
const expectedVersion = body.expectedVersion ?? null;
|
|
206
|
+
const createConflictError = body.expectedVersion === null
|
|
207
|
+
? `An entry with slug "${slug}" already exists`
|
|
208
|
+
: `An entry with slug "${slug}" already exists; an update must send the expectedVersion from its last read`;
|
|
201
209
|
try {
|
|
202
210
|
const exists = await store.documentExists(schemaItem.logicalPath, slug);
|
|
203
|
-
// Create
|
|
204
|
-
//
|
|
205
|
-
//
|
|
206
|
-
//
|
|
207
|
-
//
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
return {
|
|
211
|
-
ok: false,
|
|
212
|
-
status: 409,
|
|
213
|
-
error: `An entry with slug "${slug}" already exists`,
|
|
214
|
-
};
|
|
211
|
+
// Create guard: a create against a slug that already has content must never silently
|
|
212
|
+
// overwrite it — short-circuit with 409 before field validation runs, so the error names the
|
|
213
|
+
// real problem instead of "field is required" or a bare conflict. store.write() re-enforces
|
|
214
|
+
// this itself inside its per-entry lock against a fresh stat (the race-safe authoritative
|
|
215
|
+
// check); this is just a cheaper fast path for the common case.
|
|
216
|
+
if (expectedVersion === null && exists) {
|
|
217
|
+
return { ok: false, status: 409, error: createConflictError };
|
|
215
218
|
}
|
|
216
219
|
// [SLUG] Create-only routability check: `writeContentParamsSchema.path`'s `parseLogicalPath`
|
|
217
220
|
// has no charset rule, and `ContentStore.resolvePath` only lowercases the slug — so this is
|
|
@@ -360,13 +363,13 @@ const writeContentHandler = async (gc, ctx, req, params, body) => {
|
|
|
360
363
|
? {
|
|
361
364
|
format: body.format,
|
|
362
365
|
data: normalizedData ?? {},
|
|
363
|
-
expectedVersion
|
|
366
|
+
expectedVersion,
|
|
364
367
|
}
|
|
365
368
|
: {
|
|
366
369
|
format: body.format,
|
|
367
370
|
data: normalizedData,
|
|
368
371
|
body: body.body ?? '',
|
|
369
|
-
expectedVersion
|
|
372
|
+
expectedVersion,
|
|
370
373
|
};
|
|
371
374
|
// Pass the resolved entryTypeName (not the raw, possibly-omitted
|
|
372
375
|
// params.entryType) so the store's own format check agrees with the type
|
|
@@ -412,12 +415,8 @@ const writeContentHandler = async (gc, ctx, req, params, body) => {
|
|
|
412
415
|
}
|
|
413
416
|
// Race-safe fallback: the early `exists` check above catches the common case; this covers a
|
|
414
417
|
// collision that lands between that check and store.write()'s in-lock stat.
|
|
415
|
-
if (
|
|
416
|
-
return {
|
|
417
|
-
ok: false,
|
|
418
|
-
status: 409,
|
|
419
|
-
error: `An entry with slug "${slug}" already exists`,
|
|
420
|
-
};
|
|
418
|
+
if (expectedVersion === null) {
|
|
419
|
+
return { ok: false, status: 409, error: createConflictError };
|
|
421
420
|
}
|
|
422
421
|
return {
|
|
423
422
|
ok: false,
|
|
@@ -443,19 +442,19 @@ const validateReferencesHandler = async (gc, ctx, req, params, body) => {
|
|
|
443
442
|
const contentRoot = ctx.services.config.contentRoot || 'content';
|
|
444
443
|
const logicalPathSegments = parseApiPath(params.path, contentRoot);
|
|
445
444
|
let schemaItem;
|
|
446
|
-
let
|
|
445
|
+
let slug;
|
|
447
446
|
try {
|
|
448
447
|
const resolved = store.resolvePath(logicalPathSegments);
|
|
449
448
|
schemaItem = resolved.schemaItem;
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
449
|
+
slug = resolved.slug;
|
|
450
|
+
// Runs the store's slug and traversal checks before the permission check.
|
|
451
|
+
await store.resolveDocumentPath(schemaItem.logicalPath, slug);
|
|
453
452
|
}
|
|
454
453
|
catch (err) {
|
|
455
454
|
const message = err instanceof ContentStoreError ? err.message : 'Invalid content request';
|
|
456
455
|
return { ok: false, status: 400, error: sanitizeErrorMessage(message) };
|
|
457
456
|
}
|
|
458
|
-
const access = await ctx.services.checkContentAccess(branchContext, branchContext.branchRoot,
|
|
457
|
+
const access = await ctx.services.checkContentAccess(branchContext, branchContext.branchRoot, entryLogicalPath(schemaItem.logicalPath, slug), req.user, 'read');
|
|
459
458
|
if (!access.allowed) {
|
|
460
459
|
return { ok: false, status: 403, error: 'Forbidden' };
|
|
461
460
|
}
|
|
@@ -510,22 +509,26 @@ const renameEntryHandler = async (gc, ctx, req, params, body) => {
|
|
|
510
509
|
const logicalPathSegments = parseApiPath(params.path, contentRoot);
|
|
511
510
|
let schemaItem;
|
|
512
511
|
let currentSlug;
|
|
513
|
-
let relativePath;
|
|
514
512
|
try {
|
|
515
513
|
const resolved = store.resolvePath(logicalPathSegments);
|
|
516
514
|
schemaItem = resolved.schemaItem;
|
|
517
515
|
currentSlug = resolved.slug;
|
|
518
|
-
|
|
519
|
-
|
|
516
|
+
// Runs the store's slug and traversal checks before the permission check.
|
|
517
|
+
await store.resolveDocumentPath(schemaItem.logicalPath, currentSlug);
|
|
520
518
|
}
|
|
521
519
|
catch (err) {
|
|
522
520
|
const message = err instanceof ContentStoreError ? err.message : 'Invalid content request';
|
|
523
521
|
return { ok: false, status: 400, error: sanitizeErrorMessage(message) };
|
|
524
522
|
}
|
|
525
|
-
const access = await ctx.services.checkContentAccess(branchContext, branchContext.branchRoot,
|
|
523
|
+
const access = await ctx.services.checkContentAccess(branchContext, branchContext.branchRoot, entryLogicalPath(schemaItem.logicalPath, currentSlug), req.user, 'edit');
|
|
526
524
|
if (!access.allowed) {
|
|
527
525
|
return { ok: false, status: 403, error: 'Forbidden' };
|
|
528
526
|
}
|
|
527
|
+
// A rename creates the entry at its new path, so that path needs edit access too.
|
|
528
|
+
const destinationAccess = await ctx.services.checkContentAccess(branchContext, branchContext.branchRoot, entryLogicalPath(schemaItem.logicalPath, body.newSlug), req.user, 'edit');
|
|
529
|
+
if (!destinationAccess.allowed) {
|
|
530
|
+
return { ok: false, status: 403, error: 'Forbidden' };
|
|
531
|
+
}
|
|
529
532
|
try {
|
|
530
533
|
const result = await store.renameEntry(schemaItem.logicalPath, currentSlug, body.newSlug);
|
|
531
534
|
return { ok: true, status: 200, data: { newPath: result.newPath } };
|
package/dist/api/entries.js
CHANGED
|
@@ -2,7 +2,7 @@ import path from 'node:path';
|
|
|
2
2
|
import { z } from 'zod';
|
|
3
3
|
import { BranchSyncingError, ContentConflictError, ContentStore, ContentStoreError, } from '../content-store.js';
|
|
4
4
|
import { defineEndpoint } from './route-builder.js';
|
|
5
|
-
import { normalizeFilesystemPath, parseSlug, parseLogicalPath } from '../paths/index.js';
|
|
5
|
+
import { entryLogicalPath, normalizeFilesystemPath, parseSlug, parseLogicalPath } from '../paths/index.js';
|
|
6
6
|
import { getErrorMessage, isNotFoundError, sanitizeErrorMessage } from '../utils/error.js';
|
|
7
7
|
import { createDebugLogger } from '../utils/debug.js';
|
|
8
8
|
import { resolveEntryTitle } from '../utils/title-field.js';
|
|
@@ -81,7 +81,7 @@ const listCollectionEntriesRecursive = async (root, targetPath, flatCollections)
|
|
|
81
81
|
const filterWithAccessControl = (items, checkAccess, search) => {
|
|
82
82
|
const results = [];
|
|
83
83
|
for (const item of items) {
|
|
84
|
-
const readAccess = checkAccess(item.
|
|
84
|
+
const readAccess = checkAccess(item.logicalPath, 'read');
|
|
85
85
|
if (!readAccess.allowed)
|
|
86
86
|
continue;
|
|
87
87
|
if (search) {
|
|
@@ -90,7 +90,7 @@ const filterWithAccessControl = (items, checkAccess, search) => {
|
|
|
90
90
|
continue;
|
|
91
91
|
}
|
|
92
92
|
// Compute edit access only for items that survive the read + search filters.
|
|
93
|
-
const editAccess = checkAccess(item.
|
|
93
|
+
const editAccess = checkAccess(item.logicalPath, 'edit');
|
|
94
94
|
results.push({ ...item, canEdit: editAccess.allowed });
|
|
95
95
|
}
|
|
96
96
|
return results;
|
|
@@ -255,11 +255,9 @@ const deleteEntryHandler = async (gc, ctx, req, params) => {
|
|
|
255
255
|
};
|
|
256
256
|
}
|
|
257
257
|
const entrySlug = slugResult.slug;
|
|
258
|
-
//
|
|
259
|
-
let physicalPath;
|
|
258
|
+
// Resolution runs the store's slug and traversal checks before the permission check.
|
|
260
259
|
try {
|
|
261
|
-
|
|
262
|
-
physicalPath = resolved.relativePath;
|
|
260
|
+
await contentStore.resolveDocumentPath(collectionLogicalPath, entrySlug);
|
|
263
261
|
}
|
|
264
262
|
catch (err) {
|
|
265
263
|
if (isNotFoundError(err)) {
|
|
@@ -272,7 +270,7 @@ const deleteEntryHandler = async (gc, ctx, req, params) => {
|
|
|
272
270
|
}
|
|
273
271
|
throw err;
|
|
274
272
|
}
|
|
275
|
-
const editAccess = await ctx.services.checkContentAccess(branchContext, branchContext.branchRoot,
|
|
273
|
+
const editAccess = await ctx.services.checkContentAccess(branchContext, branchContext.branchRoot, entryLogicalPath(collection.logicalPath, entrySlug), req.user, 'edit');
|
|
276
274
|
if (!editAccess.allowed) {
|
|
277
275
|
return {
|
|
278
276
|
ok: false,
|
|
@@ -301,13 +299,16 @@ const deleteEntryHandler = async (gc, ctx, req, params) => {
|
|
|
301
299
|
// C6: order cleanup is best-effort hygiene after the entry is already deleted, so no
|
|
302
300
|
// failure here may turn an otherwise-successful delete into an error (a retry would
|
|
303
301
|
// just 404). Surface it as a warning instead (mirrors content.ts's `validationWarnings`).
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
302
|
+
// An `'unknown'` outcome means the update ran to completion: nothing to warn of.
|
|
303
|
+
if (!(err instanceof SchemaStoreBusyError && err.outcome === 'unknown')) {
|
|
304
|
+
const reason = err instanceof SchemaStoreBusyError ? 'schema is busy' : getErrorMessage(err);
|
|
305
|
+
log.warn('delete-entry', 'Skipped order cleanup', {
|
|
306
|
+
collectionPath,
|
|
307
|
+
contentId,
|
|
308
|
+
error: getErrorMessage(err),
|
|
309
|
+
});
|
|
310
|
+
orderCleanupWarning = `Entry deleted, but the collection order list could not be updated (${sanitizeErrorMessage(reason)}); it will still list this entry until the next schema change.`;
|
|
311
|
+
}
|
|
311
312
|
}
|
|
312
313
|
}
|
|
313
314
|
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { ApiContext } from './types.js';
|
|
2
2
|
import type { BranchContext, SyncStatus } from '../types.js';
|
|
3
|
+
import { type SubmissionEditor } from '../submission-attribution.js';
|
|
3
4
|
/**
|
|
4
5
|
* The caller uses this to update branch metadata.
|
|
5
6
|
*/
|
|
@@ -8,10 +9,18 @@ export interface GitHubSyncResult {
|
|
|
8
9
|
prNumber?: number;
|
|
9
10
|
syncStatus?: SyncStatus;
|
|
10
11
|
}
|
|
12
|
+
/** What the PR body records about a submit. */
|
|
13
|
+
export interface SubmissionRecord {
|
|
14
|
+
submitter?: SubmissionEditor;
|
|
15
|
+
changedPaths: readonly string[];
|
|
16
|
+
}
|
|
11
17
|
/**
|
|
12
18
|
* Uses githubService directly if available, otherwise queues a task for the worker.
|
|
19
|
+
*
|
|
20
|
+
* The body is the canopycms PR section (submission-attribution.ts): an existing
|
|
21
|
+
* PR keeps whatever a human wrote outside it.
|
|
13
22
|
*/
|
|
14
|
-
export declare function syncSubmitPr(ctx: ApiContext, context: BranchContext): Promise<GitHubSyncResult>;
|
|
23
|
+
export declare function syncSubmitPr(ctx: ApiContext, context: BranchContext, submission: SubmissionRecord): Promise<GitHubSyncResult>;
|
|
15
24
|
/**
|
|
16
25
|
* Used by withdraw and request-changes. Uses githubService directly if
|
|
17
26
|
* available, otherwise queues a task.
|
package/dist/api/github-sync.js
CHANGED
|
@@ -3,14 +3,22 @@ import { getTaskQueueDir } from '../task-queue/task-queue-config.js';
|
|
|
3
3
|
import { clientOperatingStrategy } from '../operating-mode/index.js';
|
|
4
4
|
import { getErrorMessage } from '../utils/error.js';
|
|
5
5
|
import { sanitizeBranchName } from '../paths/branch-name.js';
|
|
6
|
+
import { buildPrSection, mergePrSection } from '../submission-attribution.js';
|
|
6
7
|
/**
|
|
7
8
|
* Uses githubService directly if available, otherwise queues a task for the worker.
|
|
9
|
+
*
|
|
10
|
+
* The body is the canopycms PR section (submission-attribution.ts): an existing
|
|
11
|
+
* PR keeps whatever a human wrote outside it.
|
|
8
12
|
*/
|
|
9
|
-
export async function syncSubmitPr(ctx, context) {
|
|
13
|
+
export async function syncSubmitPr(ctx, context, submission) {
|
|
10
14
|
const { githubService } = ctx.services;
|
|
11
15
|
const mode = ctx.services.config.mode;
|
|
12
16
|
const prTitle = context.branch.title || `Submit ${context.branch.name}`;
|
|
13
|
-
const
|
|
17
|
+
const prSection = buildPrSection({
|
|
18
|
+
description: context.branch.description,
|
|
19
|
+
submitter: submission.submitter,
|
|
20
|
+
changedPaths: submission.changedPaths,
|
|
21
|
+
});
|
|
14
22
|
// Target the fork point recorded at branch creation when available.
|
|
15
23
|
const baseBranch = context.branch.baseBranch ?? ctx.services.config.defaultBaseBranch ?? 'main';
|
|
16
24
|
if (!clientOperatingStrategy(mode).supportsPullRequests()) {
|
|
@@ -29,9 +37,12 @@ export async function syncSubmitPr(ctx, context) {
|
|
|
29
37
|
if (githubService) {
|
|
30
38
|
try {
|
|
31
39
|
if (context.branch.pullRequestNumber) {
|
|
40
|
+
// Read before writing: the update keeps the human text around the
|
|
41
|
+
// canopycms section, and the same read says whether the PR is a draft.
|
|
42
|
+
const pr = await githubService.getPullRequest(context.branch.pullRequestNumber);
|
|
32
43
|
await githubService.updatePullRequest(context.branch.pullRequestNumber, {
|
|
33
44
|
title: prTitle,
|
|
34
|
-
body:
|
|
45
|
+
body: mergePrSection(pr.body, prSection),
|
|
35
46
|
});
|
|
36
47
|
// Best-effort draft->ready conversion. This branch updates a known
|
|
37
48
|
// PR number directly (not through createOrUpdatePR), so it doesn't
|
|
@@ -42,7 +53,6 @@ export async function syncSubmitPr(ctx, context) {
|
|
|
42
53
|
// consistent with createOrUpdatePullRequest's best-effort handling
|
|
43
54
|
// in github-service.ts.
|
|
44
55
|
try {
|
|
45
|
-
const pr = await githubService.getPullRequest(context.branch.pullRequestNumber);
|
|
46
56
|
if (pr.draft) {
|
|
47
57
|
await githubService.convertToReady(context.branch.pullRequestNumber);
|
|
48
58
|
}
|
|
@@ -73,8 +83,9 @@ export async function syncSubmitPr(ctx, context) {
|
|
|
73
83
|
head: context.branch.name,
|
|
74
84
|
base: baseBranch,
|
|
75
85
|
title: prTitle,
|
|
76
|
-
body:
|
|
86
|
+
body: prSection,
|
|
77
87
|
markReadyIfDraft: true,
|
|
88
|
+
mergeSectionIntoBody: true,
|
|
78
89
|
});
|
|
79
90
|
return {
|
|
80
91
|
prUrl: result.url,
|
|
@@ -105,13 +116,12 @@ export async function syncSubmitPr(ctx, context) {
|
|
|
105
116
|
payload: {
|
|
106
117
|
branch: context.branch.name,
|
|
107
118
|
title: prTitle,
|
|
108
|
-
body:
|
|
119
|
+
body: prSection,
|
|
120
|
+
mergeSectionIntoBody: true,
|
|
109
121
|
baseBranch,
|
|
110
122
|
pullRequestNumber: context.branch.pullRequestNumber,
|
|
111
|
-
//
|
|
112
|
-
//
|
|
113
|
-
// path (services.ts commitToSettingsBranch), which enqueues the same
|
|
114
|
-
// action without this flag.
|
|
123
|
+
// A content submit is an explicit "ready for review" action: convert a
|
|
124
|
+
// pre-existing draft PR to ready.
|
|
115
125
|
markReadyIfDraft: true,
|
|
116
126
|
},
|
|
117
127
|
});
|
|
@@ -70,7 +70,7 @@ const getReferenceOptionsHandler = async (gc, ctx, req, _params) => {
|
|
|
70
70
|
// for an option the caller isn't allowed to see.
|
|
71
71
|
const checkAccess = await ctx.services.createContentAccessChecker(branchContext, branchContext.branchRoot, req.user);
|
|
72
72
|
const resolver = new ReferenceResolver(store, idIndex);
|
|
73
|
-
const options = await resolver.loadReferenceOptions(collections, displayField, search, entryTypes, (
|
|
73
|
+
const options = await resolver.loadReferenceOptions(collections, displayField, search, entryTypes, (logicalPath) => checkAccess(logicalPath, 'read').allowed);
|
|
74
74
|
return {
|
|
75
75
|
ok: true,
|
|
76
76
|
status: 200,
|
|
@@ -1,9 +1,6 @@
|
|
|
1
1
|
import { z } from 'zod';
|
|
2
2
|
import { ContentStore } from '../content-store.js';
|
|
3
3
|
import { defineEndpoint } from './route-builder.js';
|
|
4
|
-
import { ReferenceResolver } from '../reference-resolver.js';
|
|
5
|
-
import { buildResolvedReference } from '../entry-schema.js';
|
|
6
|
-
import { computeEntryUrl } from '../utils/entry-url.js';
|
|
7
4
|
import { branchNameSchema, contentIdSchema } from './validators.js';
|
|
8
5
|
/**
|
|
9
6
|
* Resolution does sequential per-ID file I/O, so the request body caps how much filesystem work
|
|
@@ -20,49 +17,25 @@ const resolveReferencesBodySchema = z.object({
|
|
|
20
17
|
const resolveReferencesHandler = async (gc, ctx, req, params, body) => {
|
|
21
18
|
const { branchContext } = gc;
|
|
22
19
|
const { ids } = body;
|
|
23
|
-
const
|
|
24
|
-
|
|
25
|
-
const store = new ContentStore(branchContext.branchRoot, flatSchema, {
|
|
26
|
-
contentRootName,
|
|
20
|
+
const store = new ContentStore(branchContext.branchRoot, branchContext.flatSchema, {
|
|
21
|
+
contentRootName: ctx.services.config.contentRoot || 'content',
|
|
27
22
|
});
|
|
28
|
-
//
|
|
29
|
-
|
|
30
|
-
// Resolve each ID to full document
|
|
31
|
-
const resolver = new ReferenceResolver(store, idIndex);
|
|
32
|
-
// Build the access checker once, reused for every id instead of re-loading per id in the loop.
|
|
33
|
-
// A failure here (e.g. settings workspace unavailable) surfaces as a handler error, not
|
|
34
|
-
// swallowed silently per id.
|
|
23
|
+
// Built once for every id. A failure here (e.g. settings workspace unavailable) surfaces as a
|
|
24
|
+
// handler error rather than being swallowed per id.
|
|
35
25
|
const checkAccess = await ctx.services.createContentAccessChecker(branchContext, branchContext.branchRoot, req.user);
|
|
26
|
+
const access = (logicalPath) => checkAccess(logicalPath, 'read').allowed;
|
|
27
|
+
// The resolution `read()` applies to a reference field, so live preview shows this user what
|
|
28
|
+
// `read()` would: the target's data, or a `RestrictedReference` for a target they may not
|
|
29
|
+
// read. The target's own references stay ids, as in `read()`.
|
|
36
30
|
const resolved = {};
|
|
37
31
|
for (const id of ids) {
|
|
38
32
|
try {
|
|
39
|
-
const
|
|
40
|
-
if (
|
|
41
|
-
|
|
42
|
-
const resolvedPath = await store.resolveDocumentPath(result.collection, result.slug);
|
|
43
|
-
const access = checkAccess(resolvedPath.relativePath, 'read');
|
|
44
|
-
if (!access.allowed)
|
|
45
|
-
continue;
|
|
46
|
-
// `resolveReferences: false` matches the server-side resolver (content-store.ts's
|
|
47
|
-
// resolveSingleReferenceOnce), so a nested reference inside a target renders the same way
|
|
48
|
-
// in live preview as on the published site, instead of resolving one level deeper.
|
|
49
|
-
const doc = await store.read(result.collection, result.slug, { resolveReferences: false });
|
|
50
|
-
if (doc && doc.data) {
|
|
51
|
-
// Same shape as the server-side resolver: target data first, then the reserved keys.
|
|
52
|
-
// Order is the corruption guard (a target modelling `id` as content must not shadow the
|
|
53
|
-
// real content ID); the extra keys (incl. `urlPath`) keep live preview and production
|
|
54
|
-
// in sync for consumers like client-reference-resolver.ts.
|
|
55
|
-
resolved[id] = buildResolvedReference(doc.data, {
|
|
56
|
-
id,
|
|
57
|
-
slug: result.slug,
|
|
58
|
-
collection: result.collection,
|
|
59
|
-
urlPath: computeEntryUrl(result.collection, result.slug, contentRootName),
|
|
60
|
-
});
|
|
61
|
-
}
|
|
62
|
-
}
|
|
33
|
+
const value = await store.resolveReferenceTarget(id, access);
|
|
34
|
+
if (value)
|
|
35
|
+
resolved[id] = value;
|
|
63
36
|
}
|
|
64
37
|
catch (error) {
|
|
65
|
-
//
|
|
38
|
+
// An id that fails to resolve is omitted rather than failing the whole request.
|
|
66
39
|
console.error(`Failed to resolve reference ID ${id}:`, error);
|
|
67
40
|
}
|
|
68
41
|
}
|
|
@@ -73,7 +46,8 @@ const resolveReferencesHandler = async (gc, ctx, req, params, body) => {
|
|
|
73
46
|
};
|
|
74
47
|
};
|
|
75
48
|
/**
|
|
76
|
-
* Resolve reference IDs
|
|
49
|
+
* Resolve reference IDs as a reference field would: full target data, or title + URL tagged
|
|
50
|
+
* `unavailable` for a target the user may not read. An id naming no entry is omitted.
|
|
77
51
|
* POST /:branch/resolve-references
|
|
78
52
|
* Body: { ids: string[] }
|
|
79
53
|
*/
|
|
@@ -30,7 +30,7 @@ export interface CommitSettingsResult {
|
|
|
30
30
|
/**
|
|
31
31
|
* Commit and push settings changes based on the mode.
|
|
32
32
|
* Both prod and dev use commitToSettingsBranch.
|
|
33
|
-
*
|
|
33
|
+
* Settings changes are never reviewed through a PR (see commitToSettingsBranch).
|
|
34
34
|
*/
|
|
35
35
|
export declare function commitSettings(ctx: ApiContext, options: {
|
|
36
36
|
context: {
|
|
@@ -23,7 +23,7 @@ export async function getSettingsBranchContext(ctx) {
|
|
|
23
23
|
/**
|
|
24
24
|
* Commit and push settings changes based on the mode.
|
|
25
25
|
* Both prod and dev use commitToSettingsBranch.
|
|
26
|
-
*
|
|
26
|
+
* Settings changes are never reviewed through a PR (see commitToSettingsBranch).
|
|
27
27
|
*/
|
|
28
28
|
export async function commitSettings(ctx, options) {
|
|
29
29
|
const strategy = operatingStrategy(options.mode);
|
|
@@ -38,9 +38,6 @@ export async function commitSettings(ctx, options) {
|
|
|
38
38
|
branchRoot: options.branchRoot,
|
|
39
39
|
files: options.fileName,
|
|
40
40
|
message: options.message,
|
|
41
|
-
createPR: strategy.shouldCreateSettingsPR({
|
|
42
|
-
autoCreateSettingsPR: ctx.services.config.autoCreateSettingsPR,
|
|
43
|
-
}),
|
|
44
41
|
});
|
|
45
42
|
if (!result.pushed) {
|
|
46
43
|
console.warn(`${options.message} committed but not pushed:`, result.error);
|
package/dist/api/user.d.ts
CHANGED
|
@@ -1,7 +1,10 @@
|
|
|
1
1
|
import type { ApiResponse } from './types.js';
|
|
2
|
+
import type { BuildIdentity } from '../types.js';
|
|
2
3
|
export type UserInfoResponse = ApiResponse<{
|
|
3
4
|
userId: string;
|
|
4
5
|
groups: string[];
|
|
6
|
+
/** Admins only: the deployed version is reconnaissance for anyone else. */
|
|
7
|
+
build?: BuildIdentity;
|
|
5
8
|
}>;
|
|
6
9
|
export declare const USER_ROUTES: {
|
|
7
10
|
readonly whoami: import("./route-builder.js").RouteDefinition<undefined, undefined, UserInfoResponse>;
|
package/dist/api/user.js
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import { defineEndpoint } from './route-builder.js';
|
|
2
|
+
import { isAdmin } from '../authorization/helpers.js';
|
|
3
|
+
import { getBuildIdentity } from '../build-identity.js';
|
|
2
4
|
/**
|
|
3
5
|
* This is a PUBLIC endpoint - no special permissions required
|
|
4
6
|
*/
|
|
@@ -10,6 +12,7 @@ const getUserInfoHandler = async (ctx, req) => {
|
|
|
10
12
|
data: {
|
|
11
13
|
userId: req.user.userId,
|
|
12
14
|
groups: req.user.groups,
|
|
15
|
+
...(isAdmin(req.user.groups) ? { build: getBuildIdentity() } : {}),
|
|
13
16
|
},
|
|
14
17
|
};
|
|
15
18
|
};
|
package/dist/assets/factory.d.ts
CHANGED
|
@@ -3,6 +3,11 @@
|
|
|
3
3
|
*
|
|
4
4
|
* This is the one place that turns the (currently-unconsumed) `media` config
|
|
5
5
|
* into a real store — see .claude/future-tasks/resolved/assets-media-system.md.
|
|
6
|
+
*
|
|
7
|
+
* The store is branch-agnostic: a local one is rooted at the dev workspace's
|
|
8
|
+
* `assets/` or at `media.directory` resolved against the process's working
|
|
9
|
+
* directory, never inside a branch clone. So asset writes take no [SYNC-C1]
|
|
10
|
+
* content-write lock — the worker's rebase never touches them.
|
|
6
11
|
*/
|
|
7
12
|
import type { MediaConfig } from '../config/types.js';
|
|
8
13
|
import type { AssetStore } from './types.js';
|
package/dist/assets/factory.js
CHANGED
|
@@ -3,6 +3,11 @@
|
|
|
3
3
|
*
|
|
4
4
|
* This is the one place that turns the (currently-unconsumed) `media` config
|
|
5
5
|
* into a real store — see .claude/future-tasks/resolved/assets-media-system.md.
|
|
6
|
+
*
|
|
7
|
+
* The store is branch-agnostic: a local one is rooted at the dev workspace's
|
|
8
|
+
* `assets/` or at `media.directory` resolved against the process's working
|
|
9
|
+
* directory, never inside a branch clone. So asset writes take no [SYNC-C1]
|
|
10
|
+
* content-write lock — the worker's rebase never touches them.
|
|
6
11
|
*/
|
|
7
12
|
import { LocalAssetStore } from './store-local.js';
|
|
8
13
|
import { S3AssetStore } from './store-s3.js';
|
|
@@ -6,12 +6,13 @@ import type { BranchContext } from '../types.js';
|
|
|
6
6
|
import type { PermissionLevel } from '../config/index.js';
|
|
7
7
|
import type { CanopyUser } from '../user.js';
|
|
8
8
|
import type { ContentAccessResult, ContentAccessDeps } from './types.js';
|
|
9
|
-
import type {
|
|
9
|
+
import type { LogicalPath } from '../paths/types.js';
|
|
10
10
|
/**
|
|
11
11
|
* A bound content-access checker: evaluates a single path/level synchronously
|
|
12
12
|
* against permissions that were already loaded when the checker was created.
|
|
13
|
+
* The path is the entry's logical path (see `checkPathAccess`).
|
|
13
14
|
*/
|
|
14
|
-
export type ContentAccessChecker = (
|
|
15
|
+
export type ContentAccessChecker = (logicalPath: LogicalPath, level: PermissionLevel) => ContentAccessResult;
|
|
15
16
|
/**
|
|
16
17
|
* Resolve the request-constant work once -- branch access, the
|
|
17
18
|
* settings/permissions root and the path-permission rules, all identical for a
|
|
@@ -26,5 +27,5 @@ export declare function createContentAccessChecker(deps: ContentAccessDeps, cont
|
|
|
26
27
|
* Single-path check; loads the path permissions from the branch root per call.
|
|
27
28
|
* @internal Exported for tests.
|
|
28
29
|
*/
|
|
29
|
-
export declare function checkContentAccess(deps: ContentAccessDeps, context: BranchContext, branchRoot: string,
|
|
30
|
-
export declare function createCheckContentAccess(deps: ContentAccessDeps): (context: BranchContext, branchRoot: string,
|
|
30
|
+
export declare function checkContentAccess(deps: ContentAccessDeps, context: BranchContext, branchRoot: string, logicalPath: LogicalPath, user: CanopyUser, level: PermissionLevel): Promise<ContentAccessResult>;
|
|
31
|
+
export declare function createCheckContentAccess(deps: ContentAccessDeps): (context: BranchContext, branchRoot: string, logicalPath: LogicalPath, user: CanopyUser, level: PermissionLevel) => Promise<ContentAccessResult>;
|
|
@@ -28,9 +28,9 @@ export async function createContentAccessChecker(deps, context, branchRoot, user
|
|
|
28
28
|
}
|
|
29
29
|
const rules = await deps.loadPathPermissions(permissionsRoot, deps.mode);
|
|
30
30
|
const pathChecker = createCheckPathAccess(rules, deps.defaultPathAccess);
|
|
31
|
-
return (
|
|
31
|
+
return (logicalPath, level) => {
|
|
32
32
|
const path = pathChecker({
|
|
33
|
-
|
|
33
|
+
logicalPath,
|
|
34
34
|
user,
|
|
35
35
|
level,
|
|
36
36
|
});
|
|
@@ -45,10 +45,10 @@ export async function createContentAccessChecker(deps, context, branchRoot, user
|
|
|
45
45
|
* Single-path check; loads the path permissions from the branch root per call.
|
|
46
46
|
* @internal Exported for tests.
|
|
47
47
|
*/
|
|
48
|
-
export async function checkContentAccess(deps, context, branchRoot,
|
|
48
|
+
export async function checkContentAccess(deps, context, branchRoot, logicalPath, user, level) {
|
|
49
49
|
const check = await createContentAccessChecker(deps, context, branchRoot, user);
|
|
50
|
-
return check(
|
|
50
|
+
return check(logicalPath, level);
|
|
51
51
|
}
|
|
52
52
|
export function createCheckContentAccess(deps) {
|
|
53
|
-
return (context, branchRoot,
|
|
53
|
+
return (context, branchRoot, logicalPath, user, level) => checkContentAccess(deps, context, branchRoot, logicalPath, user, level);
|
|
54
54
|
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import type { PathPermission, DefaultPathAccess, PermissionLevel } from '../config/index.js';
|
|
2
2
|
import type { CanopyUser } from '../user.js';
|
|
3
3
|
import type { PathPermissionResult } from './types.js';
|
|
4
|
-
import type {
|
|
4
|
+
import type { LogicalPath } from '../paths/types.js';
|
|
5
5
|
/**
|
|
6
6
|
* Resolve `defaultPathAccess` to an 'allow'/'deny' verdict for one permission
|
|
7
7
|
* level. String form applies to every level; object form looks up the level,
|
|
@@ -11,19 +11,24 @@ import type { PhysicalPath } from '../paths/types.js';
|
|
|
11
11
|
*/
|
|
12
12
|
export declare function resolveDefaultPathAccess(defaultAccess: DefaultPathAccess, level: PermissionLevel): 'allow' | 'deny';
|
|
13
13
|
/**
|
|
14
|
-
* Evaluate access for a
|
|
15
|
-
*
|
|
14
|
+
* Evaluate access for a path against config-defined rules. First matching rule wins;
|
|
15
|
+
* defaultAccess applies when none matches.
|
|
16
|
+
*
|
|
17
|
+
* Rule globs and the checked path are both LOGICAL paths: content-root-prefixed and
|
|
18
|
+
* id-free, the space the Permission Manager writes rules in. An entry's logical path is
|
|
19
|
+
* `<collection logical path>/<slug>` (`entryLogicalPath`), e.g. `content/blog/my-post`, never
|
|
20
|
+
* its on-disk `content/blog.<id>/post.my-post.<id>.json`.
|
|
16
21
|
* @internal Exported for tests.
|
|
17
22
|
*/
|
|
18
|
-
export declare function checkPathAccess({ rules,
|
|
23
|
+
export declare function checkPathAccess({ rules, logicalPath, user, defaultAccess, level, }: {
|
|
19
24
|
rules: PathPermission[];
|
|
20
|
-
|
|
25
|
+
logicalPath: LogicalPath;
|
|
21
26
|
user: CanopyUser;
|
|
22
27
|
defaultAccess: DefaultPathAccess;
|
|
23
28
|
level: PermissionLevel;
|
|
24
29
|
}): PathPermissionResult;
|
|
25
30
|
export declare function createCheckPathAccess(rules: PathPermission[], defaultAccess: DefaultPathAccess): (input: {
|
|
26
|
-
|
|
31
|
+
logicalPath: LogicalPath;
|
|
27
32
|
user: CanopyUser;
|
|
28
33
|
level: PermissionLevel;
|
|
29
34
|
}) => PathPermissionResult;
|