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/http/handler.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { jsonResponse, isCanopyBinaryResponse } from './types.js';
|
|
2
|
+
import { workerNotReadyResponse } from './worker-not-ready.js';
|
|
2
3
|
import { createCanopyRouter } from './router.js';
|
|
3
4
|
import { assertAuthPluginAllowedForMode } from '../auth/plugin.js';
|
|
4
5
|
import { createCanopyServices } from '../services.js';
|
|
@@ -7,21 +8,22 @@ import { BranchMetadataCorruptError } from '../branch-metadata.js';
|
|
|
7
8
|
import { resolveCanopyUser } from '../resolve-canopy-user.js';
|
|
8
9
|
import { authResultToCanopyUser } from '../user.js';
|
|
9
10
|
import { isAdmin } from '../authorization/index.js';
|
|
11
|
+
import { isSettingsBranchName } from '../paths/branch-name.js';
|
|
10
12
|
import { clientOperatingStrategy, operatingStrategy } from '../operating-mode/index.js';
|
|
11
13
|
import { getErrorMessage, redactCredentials, sanitizeErrorMessage } from '../utils/error.js';
|
|
12
14
|
// canopyLogError, not console.error: this is shared code and not guaranteed to
|
|
13
15
|
// stay out of the worker's runtime import closure, so new log lines here go
|
|
14
16
|
// through the indirection (utils/logger.ts).
|
|
15
17
|
import { canopyLogError } from '../utils/logger.js';
|
|
18
|
+
import { runWithRequestTiming, setRequestTimingRoute, timeRequestPhase, } from '../utils/request-timing.js';
|
|
16
19
|
const buildContext = async (options) => {
|
|
17
20
|
const services = options.services ?? (options.config ? await createCanopyServices(options.config) : undefined);
|
|
18
21
|
if (!services) {
|
|
19
22
|
throw new Error('CanopyCMS: config or services is required');
|
|
20
23
|
}
|
|
21
24
|
const operatingMode = services.config.mode;
|
|
22
|
-
//
|
|
23
|
-
// would
|
|
24
|
-
// canopycms-settings-acme), so getBranchContext could never auto-create it.
|
|
25
|
+
// Derived from the strategy, which resolves deploymentName; a literal here
|
|
26
|
+
// would miss a deployment-namespaced settings branch (say canopycms-settings-acme).
|
|
25
27
|
const settingsBranch = operatingStrategy(operatingMode).getSettingsBranchName(services.config);
|
|
26
28
|
const getBranchContext = options.getBranchContext ??
|
|
27
29
|
(async (branch, opts) => {
|
|
@@ -42,7 +44,7 @@ const buildContext = async (options) => {
|
|
|
42
44
|
const baseBranch = services.config.defaultBaseBranch ?? 'main';
|
|
43
45
|
const activeBranch = services.config.defaultActiveBranch ?? baseBranch;
|
|
44
46
|
const shouldAutoCreate = clientOperatingStrategy(operatingMode).supportsBranching() &&
|
|
45
|
-
(branch === baseBranch || branch === activeBranch
|
|
47
|
+
(branch === baseBranch || branch === activeBranch);
|
|
46
48
|
if (shouldAutoCreate) {
|
|
47
49
|
const manager = new BranchWorkspaceManager(services.config);
|
|
48
50
|
const context = await manager.openOrCreateBranch({
|
|
@@ -62,7 +64,14 @@ const buildContext = async (options) => {
|
|
|
62
64
|
return {
|
|
63
65
|
services,
|
|
64
66
|
assetStore: options.assetStore,
|
|
65
|
-
|
|
67
|
+
// The settings branch lives only in the settings workspace. Resolving it as a
|
|
68
|
+
// content branch would let any request clone a content-history copy that a
|
|
69
|
+
// submit pushes under the settings name, which settings provisioning then refuses,
|
|
70
|
+
// leaving settings unavailable.
|
|
71
|
+
// Not found, like any branch the caller cannot resolve, even a workspace left on disk.
|
|
72
|
+
getBranchContext: (branch, opts) => isSettingsBranchName(branch, settingsBranch)
|
|
73
|
+
? Promise.resolve(null)
|
|
74
|
+
: timeRequestPhase('branchContext', () => getBranchContext(branch, opts)),
|
|
66
75
|
authPlugin: options.authPlugin,
|
|
67
76
|
};
|
|
68
77
|
};
|
|
@@ -109,10 +118,11 @@ export function createCanopyRequestHandler(options) {
|
|
|
109
118
|
if (!match) {
|
|
110
119
|
return jsonResponse({ ok: false, status: 404, error: 'Not found' }, 404);
|
|
111
120
|
}
|
|
112
|
-
|
|
121
|
+
setRequestTimingRoute(match.pattern.join('/') || '(malformed path)');
|
|
122
|
+
const apiCtx = await timeRequestPhase('context', getContext);
|
|
113
123
|
// In dev mode, re-check if the developer switched git branches
|
|
114
|
-
await apiCtx.services.refreshActiveBranch();
|
|
115
|
-
const authResult = await options.authPlugin.authenticate(req);
|
|
124
|
+
await timeRequestPhase('refreshBranch', () => apiCtx.services.refreshActiveBranch());
|
|
125
|
+
const authResult = await timeRequestPhase('auth', () => options.authPlugin.authenticate(req));
|
|
116
126
|
// API routes require authentication. Anonymous callers are rejected BEFORE
|
|
117
127
|
// any workspace provisioning below, so they can neither trigger expensive
|
|
118
128
|
// git operations nor read provisioning error details.
|
|
@@ -141,6 +151,9 @@ export function createCanopyRequestHandler(options) {
|
|
|
141
151
|
// (authenticated) client. Credentials, which git errors can embed, are
|
|
142
152
|
// redacted even from the server logs.
|
|
143
153
|
console.error(`CanopyCMS: Failed to provision workspace for base branch '${baseBranch}': ${redactCredentials(message)}`);
|
|
154
|
+
const notReady = workerNotReadyResponse(err);
|
|
155
|
+
if (notReady)
|
|
156
|
+
return notReady;
|
|
144
157
|
return jsonResponse({
|
|
145
158
|
ok: false,
|
|
146
159
|
status: 503,
|
|
@@ -157,15 +170,21 @@ export function createCanopyRequestHandler(options) {
|
|
|
157
170
|
// a silent authorization change.
|
|
158
171
|
let user;
|
|
159
172
|
try {
|
|
160
|
-
user = await resolveCanopyUser(authResult, {
|
|
173
|
+
user = await timeRequestPhase('user', () => resolveCanopyUser(authResult, {
|
|
161
174
|
getSettingsBranchRoot: apiCtx.services.getSettingsBranchRoot,
|
|
162
175
|
mode: apiCtx.services.config.mode,
|
|
163
176
|
bootstrapAdminIds: apiCtx.services.bootstrapAdminIds,
|
|
164
|
-
});
|
|
177
|
+
}));
|
|
165
178
|
}
|
|
166
179
|
catch (err) {
|
|
167
180
|
const message = getErrorMessage(err);
|
|
168
181
|
canopyLogError(`CanopyCMS: Failed to resolve internal groups from the settings workspace: ${redactCredentials(message)}`);
|
|
182
|
+
// No remote means no settings workspace, so /admin cannot load either:
|
|
183
|
+
// the worker has not created the remote yet, so every caller gets the
|
|
184
|
+
// not-ready 503, bootstrap admins included.
|
|
185
|
+
const notReady = workerNotReadyResponse(err);
|
|
186
|
+
if (notReady)
|
|
187
|
+
return notReady;
|
|
169
188
|
// Same trade as the base-branch degradation above: /admin is the recovery
|
|
170
189
|
// surface for exactly this failure (a renamed settings branch trips
|
|
171
190
|
// assertSettingsWorkspaceIdentity until a human intervenes), so 503ing it
|
|
@@ -231,7 +250,7 @@ export function createCanopyRequestHandler(options) {
|
|
|
231
250
|
if (validationResult.body !== undefined) {
|
|
232
251
|
handlerArgs.push(validationResult.body);
|
|
233
252
|
}
|
|
234
|
-
const result = await match.handler(...handlerArgs);
|
|
253
|
+
const result = await timeRequestPhase('route', () => match.handler(...handlerArgs));
|
|
235
254
|
// Binary routes carry their own status and headers and MUST reach the
|
|
236
255
|
// adapter untouched: jsonResponse would serialize raw bytes and drop
|
|
237
256
|
// contentType/contentDisposition.
|
|
@@ -242,7 +261,7 @@ export function createCanopyRequestHandler(options) {
|
|
|
242
261
|
else {
|
|
243
262
|
// Every route should use defineEndpoint; this is the safety net for one
|
|
244
263
|
// that carries no validate function.
|
|
245
|
-
const result = await match.handler(apiCtx, apiReq, mergedParams);
|
|
264
|
+
const result = await timeRequestPhase('route', () => match.handler(apiCtx, apiReq, mergedParams));
|
|
246
265
|
if (isCanopyBinaryResponse(result))
|
|
247
266
|
return result;
|
|
248
267
|
return jsonResponse(result, result.status);
|
|
@@ -250,12 +269,15 @@ export function createCanopyRequestHandler(options) {
|
|
|
250
269
|
};
|
|
251
270
|
return async (req, pathSegments) => {
|
|
252
271
|
try {
|
|
253
|
-
return await handleRequest(req, pathSegments);
|
|
272
|
+
return await runWithRequestTiming(req.method, () => handleRequest(req, pathSegments), (response) => response.status);
|
|
254
273
|
}
|
|
255
274
|
catch (err) {
|
|
256
275
|
// Last-resort boundary (API-C1): see handleRequest's doc comment above.
|
|
257
276
|
const message = getErrorMessage(err);
|
|
258
277
|
console.error('CanopyCMS: Unhandled error in API request handler:', message);
|
|
278
|
+
const notReady = workerNotReadyResponse(err);
|
|
279
|
+
if (notReady)
|
|
280
|
+
return notReady;
|
|
259
281
|
return jsonResponse({ ok: false, status: 500, error: sanitizeErrorMessage(message) }, 500);
|
|
260
282
|
}
|
|
261
283
|
};
|
package/dist/http/index.d.ts
CHANGED
|
@@ -4,3 +4,5 @@ export type { CanopyHandler, RouteDefinition, RouteMatch, CanopyRouter } from '.
|
|
|
4
4
|
export { createCanopyRouter } from './router.js';
|
|
5
5
|
export type { CanopyHandlerOptions, CanopyRequestHandler } from './handler.js';
|
|
6
6
|
export { createCanopyRequestHandler, createCanopyRequestHandlerFromConfig } from './handler.js';
|
|
7
|
+
export { RemoteNotReadyError } from '../git-manager.js';
|
|
8
|
+
export { workerNotReadyResponse, WORKER_NOT_READY_MESSAGE } from './worker-not-ready.js';
|
package/dist/http/index.js
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
1
|
export { jsonResponse, isCanopyBinaryResponse } from './types.js';
|
|
2
2
|
export { createCanopyRouter } from './router.js';
|
|
3
3
|
export { createCanopyRequestHandler, createCanopyRequestHandlerFromConfig } from './handler.js';
|
|
4
|
+
export { RemoteNotReadyError } from '../git-manager.js';
|
|
5
|
+
export { workerNotReadyResponse, WORKER_NOT_READY_MESSAGE } from './worker-not-ready.js';
|
package/dist/http/router.d.ts
CHANGED
|
@@ -32,6 +32,8 @@ export interface RouteDefinition {
|
|
|
32
32
|
}
|
|
33
33
|
export interface RouteMatch {
|
|
34
34
|
handler: CanopyHandler;
|
|
35
|
+
/** The matched route's pattern; empty for the synthetic malformed-path match. */
|
|
36
|
+
pattern: readonly string[];
|
|
35
37
|
params: Record<string, string>;
|
|
36
38
|
validate?: RouteDefinition['validate'];
|
|
37
39
|
bodyFormat?: RouteDefinition['bodyFormat'];
|
package/dist/http/router.js
CHANGED
|
@@ -122,10 +122,11 @@ export function matchRoute(routes, method, segments) {
|
|
|
122
122
|
decodedParams = Object.fromEntries(Object.entries(best.params).map(([key, value]) => [key, decodeURIComponent(value)]));
|
|
123
123
|
}
|
|
124
124
|
catch {
|
|
125
|
-
return { handler: malformedPathHandler, params: {} };
|
|
125
|
+
return { handler: malformedPathHandler, pattern: [], params: {} };
|
|
126
126
|
}
|
|
127
127
|
return {
|
|
128
128
|
handler: best.route.handler,
|
|
129
|
+
pattern: best.route.pattern,
|
|
129
130
|
params: decodedParams,
|
|
130
131
|
validate: best.route.validate, // Include validation function if present
|
|
131
132
|
bodyFormat: best.route.bodyFormat,
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { ApiResponse } from '../api/types.js';
|
|
2
|
+
import { type CanopyResponse } from './types.js';
|
|
3
|
+
export declare const WORKER_NOT_READY_MESSAGE = "CMS worker not ready \u2014 it may still be starting. Try again in a minute; if this persists, ask an admin to check the CMS worker.";
|
|
4
|
+
/**
|
|
5
|
+
* The 503 for a request that hit {@link RemoteNotReadyError}, or `undefined`
|
|
6
|
+
* for any other error. The single mapping point for the core handler, the AI route and
|
|
7
|
+
* every framework adapter's backstop, so the message and `Retry-After` cannot diverge.
|
|
8
|
+
*/
|
|
9
|
+
export declare function workerNotReadyResponse(err: unknown): CanopyResponse<ApiResponse> | undefined;
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { RemoteNotReadyError } from '../git-manager.js';
|
|
2
|
+
import { jsonResponse } from './types.js';
|
|
3
|
+
/** Seconds a client should wait before retrying while the worker boots. */
|
|
4
|
+
const RETRY_AFTER_SECONDS = '30';
|
|
5
|
+
export const WORKER_NOT_READY_MESSAGE = 'CMS worker not ready — it may still be starting. Try again in a minute; if this persists, ask an admin to check the CMS worker.';
|
|
6
|
+
/**
|
|
7
|
+
* The 503 for a request that hit {@link RemoteNotReadyError}, or `undefined`
|
|
8
|
+
* for any other error. The single mapping point for the core handler, the AI route and
|
|
9
|
+
* every framework adapter's backstop, so the message and `Retry-After` cannot diverge.
|
|
10
|
+
*/
|
|
11
|
+
export function workerNotReadyResponse(err) {
|
|
12
|
+
if (!(err instanceof RemoteNotReadyError))
|
|
13
|
+
return undefined;
|
|
14
|
+
return jsonResponse({ ok: false, status: 503, error: WORKER_NOT_READY_MESSAGE }, 503, {
|
|
15
|
+
'Retry-After': RETRY_AFTER_SECONDS,
|
|
16
|
+
});
|
|
17
|
+
}
|
|
@@ -57,9 +57,6 @@ class ProdStrategy extends ProdClientSafeStrategy {
|
|
|
57
57
|
throw new Error('gitBotAuthorName and gitBotAuthorEmail are required in prod mode');
|
|
58
58
|
}
|
|
59
59
|
}
|
|
60
|
-
shouldCreateSettingsPR(config) {
|
|
61
|
-
return config.autoCreateSettingsPR ?? true;
|
|
62
|
-
}
|
|
63
60
|
}
|
|
64
61
|
class DevStrategy extends DevClientSafeStrategy {
|
|
65
62
|
getWorkspaceRoot(sourceRoot) {
|
|
@@ -107,9 +104,6 @@ class DevStrategy extends DevClientSafeStrategy {
|
|
|
107
104
|
validateConfig(_config) {
|
|
108
105
|
// No special validation for dev mode
|
|
109
106
|
}
|
|
110
|
-
shouldCreateSettingsPR(_config) {
|
|
111
|
-
return false; // No real GitHub in local dev mode
|
|
112
|
-
}
|
|
113
107
|
}
|
|
114
108
|
const strategyCache = new Map();
|
|
115
109
|
/**
|
|
@@ -89,7 +89,4 @@ export interface ClientUnsafeStrategy extends ClientSafeStrategy {
|
|
|
89
89
|
getSettingsRoot(sourceRoot?: string): string;
|
|
90
90
|
usesSeparateSettingsBranch(): boolean;
|
|
91
91
|
validateConfig(config: Partial<CanopyConfig>): void;
|
|
92
|
-
shouldCreateSettingsPR(config: {
|
|
93
|
-
autoCreateSettingsPR?: boolean;
|
|
94
|
-
}): boolean;
|
|
95
92
|
}
|
|
@@ -29,6 +29,11 @@ export declare function sanitizeBranchName(branchName: string): SanitizedBranchN
|
|
|
29
29
|
* branch.
|
|
30
30
|
*/
|
|
31
31
|
export declare const RESERVED_SETTINGS_BRANCH_PREFIX = "canopycms-settings-";
|
|
32
|
+
/**
|
|
33
|
+
* Whether `branchName` is a settings branch, never a content branch: the reserved prefix, or the
|
|
34
|
+
* configured name (which need not carry it). Compared sanitized, the form git and disk use.
|
|
35
|
+
*/
|
|
36
|
+
export declare function isSettingsBranchName(branchName: string, settingsBranchName?: string): boolean;
|
|
32
37
|
/**
|
|
33
38
|
* Branch names that collide with a static top-level API route namespace.
|
|
34
39
|
*
|
|
@@ -33,6 +33,15 @@ export function sanitizeBranchName(branchName) {
|
|
|
33
33
|
* branch.
|
|
34
34
|
*/
|
|
35
35
|
export const RESERVED_SETTINGS_BRANCH_PREFIX = 'canopycms-settings-';
|
|
36
|
+
/**
|
|
37
|
+
* Whether `branchName` is a settings branch, never a content branch: the reserved prefix, or the
|
|
38
|
+
* configured name (which need not carry it). Compared sanitized, the form git and disk use.
|
|
39
|
+
*/
|
|
40
|
+
export function isSettingsBranchName(branchName, settingsBranchName) {
|
|
41
|
+
const sanitized = sanitizeBranchName(branchName);
|
|
42
|
+
return (sanitized.startsWith(RESERVED_SETTINGS_BRANCH_PREFIX) ||
|
|
43
|
+
(settingsBranchName !== undefined && sanitized === sanitizeBranchName(settingsBranchName)));
|
|
44
|
+
}
|
|
36
45
|
/**
|
|
37
46
|
* Branch names that collide with a static top-level API route namespace.
|
|
38
47
|
*
|
package/dist/paths/branch.d.ts
CHANGED
|
@@ -14,10 +14,15 @@ export interface BranchPathResult {
|
|
|
14
14
|
/** @internal Exported for tests. */
|
|
15
15
|
export declare class BranchPathError extends Error {
|
|
16
16
|
}
|
|
17
|
+
/**
|
|
18
|
+
* True when a branch load failed because the name names no workspace: a traversal segment, a name
|
|
19
|
+
* too long for a filename, or a path through a file (ENOTDIR), as `?branch=branches.json` gives.
|
|
20
|
+
*/
|
|
21
|
+
export declare function namesNoWorkspace(err: unknown): boolean;
|
|
17
22
|
import { sanitizeBranchName } from './branch-name.js';
|
|
18
23
|
/** @internal Exported for tests. */
|
|
19
24
|
export { sanitizeBranchName };
|
|
20
|
-
/** Resolve a branch name to workspace paths, rejecting
|
|
25
|
+
/** Resolve a branch name to workspace paths, rejecting traversal and settings-branch names. */
|
|
21
26
|
export declare function resolveBranchPath(options: BranchPathOptions): BranchPathResult;
|
|
22
27
|
export declare function ensureBranchRoot(options: BranchPathOptions): Promise<BranchPathResult>;
|
|
23
28
|
export declare function getDefaultBranchBase(mode: OperatingMode, override?: string): string;
|
package/dist/paths/branch.js
CHANGED
|
@@ -2,22 +2,35 @@
|
|
|
2
2
|
import fs from 'node:fs/promises';
|
|
3
3
|
import path from 'node:path';
|
|
4
4
|
import { operatingStrategy } from '../operating-mode/index.js';
|
|
5
|
+
import { isNodeError } from '../utils/error.js';
|
|
5
6
|
/** @internal Exported for tests. */
|
|
6
7
|
export class BranchPathError extends Error {
|
|
7
8
|
}
|
|
9
|
+
/**
|
|
10
|
+
* True when a branch load failed because the name names no workspace: a traversal segment, a name
|
|
11
|
+
* too long for a filename, or a path through a file (ENOTDIR), as `?branch=branches.json` gives.
|
|
12
|
+
*/
|
|
13
|
+
export function namesNoWorkspace(err) {
|
|
14
|
+
if (err instanceof BranchPathError)
|
|
15
|
+
return true;
|
|
16
|
+
return isNodeError(err) && (err.code === 'ENAMETOOLONG' || err.code === 'ENOTDIR');
|
|
17
|
+
}
|
|
8
18
|
// Lives in ./branch-name (dependency-free); re-exported here for server-side
|
|
9
19
|
// importers, who may safely reach this module's node:fs imports.
|
|
10
|
-
import { sanitizeBranchName } from './branch-name.js';
|
|
20
|
+
import { sanitizeBranchName, isSettingsBranchName } from './branch-name.js';
|
|
11
21
|
/** @internal Exported for tests. */
|
|
12
22
|
export { sanitizeBranchName };
|
|
13
23
|
const resolveContentBranchesRoot = (mode, override) => {
|
|
14
24
|
return operatingStrategy(mode).getContentBranchesRoot(override);
|
|
15
25
|
};
|
|
16
|
-
/** Resolve a branch name to workspace paths, rejecting
|
|
26
|
+
/** Resolve a branch name to workspace paths, rejecting traversal and settings-branch names. */
|
|
17
27
|
export function resolveBranchPath(options) {
|
|
18
28
|
if (options.branchName.includes('..')) {
|
|
19
29
|
throw new BranchPathError('Branch name cannot contain traversal segments');
|
|
20
30
|
}
|
|
31
|
+
if (isSettingsBranchName(options.branchName)) {
|
|
32
|
+
throw new BranchPathError('Settings branches are not content branches');
|
|
33
|
+
}
|
|
21
34
|
const safeBranch = sanitizeBranchName(options.branchName);
|
|
22
35
|
const strategy = operatingStrategy(options.mode);
|
|
23
36
|
const baseRoot = resolveContentBranchesRoot(options.mode, options.basePathOverride);
|
package/dist/paths/index.d.ts
CHANGED
|
@@ -3,9 +3,9 @@
|
|
|
3
3
|
* because this barrel pulls in node:fs; `pnpm lint:bundle` enforces that.
|
|
4
4
|
*/
|
|
5
5
|
export type { LogicalPath, PhysicalPath, SanitizedBranchName, BranchName, ContentId, Slug, PathValidationResult, } from './types.js';
|
|
6
|
-
export { normalizeFilesystemPath, normalizeCollectionPath, hasTraversalSequence, createLogicalPath, createPhysicalPath, joinPath, trimSlashes, } from './normalize.js';
|
|
6
|
+
export { normalizeFilesystemPath, normalizeCollectionPath, hasTraversalSequence, createLogicalPath, createPhysicalPath, entryLogicalPath, joinPath, trimSlashes, } from './normalize.js';
|
|
7
7
|
export { validateAndNormalizePath } from './normalize-server.js';
|
|
8
8
|
export { validateContentPath, isValidCollectionPath, sanitizeForPath, hasEmbeddedContentId, looksLikePhysicalPath, looksLikeLogicalPath, parseLogicalPath, parsePhysicalPath, isValidContentId, parseContentId, parseBranchName, parseSlug, } from './validation.js';
|
|
9
9
|
export { resolveLogicalPath } from './resolve.js';
|
|
10
|
-
export { resolveBranchPath, ensureBranchRoot, getDefaultBranchBase, resolveBranchPaths, BranchPathError, type BranchPathOptions, type BranchPathResult, } from './branch.js';
|
|
11
|
-
export { sanitizeBranchName, RESERVED_SETTINGS_BRANCH_PREFIX, RESERVED_ROUTE_BRANCH_NAMES, } from './branch-name.js';
|
|
10
|
+
export { resolveBranchPath, ensureBranchRoot, getDefaultBranchBase, resolveBranchPaths, BranchPathError, namesNoWorkspace, type BranchPathOptions, type BranchPathResult, } from './branch.js';
|
|
11
|
+
export { sanitizeBranchName, RESERVED_SETTINGS_BRANCH_PREFIX, RESERVED_ROUTE_BRANCH_NAMES, isSettingsBranchName, } from './branch-name.js';
|
package/dist/paths/index.js
CHANGED
|
@@ -3,10 +3,10 @@
|
|
|
3
3
|
* because this barrel pulls in node:fs; `pnpm lint:bundle` enforces that.
|
|
4
4
|
*/
|
|
5
5
|
// Normalization utilities (client-safe)
|
|
6
|
-
export { normalizeFilesystemPath, normalizeCollectionPath, hasTraversalSequence, createLogicalPath, createPhysicalPath, joinPath, trimSlashes, } from './normalize.js';
|
|
6
|
+
export { normalizeFilesystemPath, normalizeCollectionPath, hasTraversalSequence, createLogicalPath, createPhysicalPath, entryLogicalPath, joinPath, trimSlashes, } from './normalize.js';
|
|
7
7
|
// Normalization utilities (server-only, requires Node.js path module)
|
|
8
8
|
export { validateAndNormalizePath } from './normalize-server.js';
|
|
9
9
|
export { validateContentPath, isValidCollectionPath, sanitizeForPath, hasEmbeddedContentId, looksLikePhysicalPath, looksLikeLogicalPath, parseLogicalPath, parsePhysicalPath, isValidContentId, parseContentId, parseBranchName, parseSlug, } from './validation.js';
|
|
10
10
|
export { resolveLogicalPath } from './resolve.js';
|
|
11
|
-
export { resolveBranchPath, ensureBranchRoot, getDefaultBranchBase, resolveBranchPaths, BranchPathError, } from './branch.js';
|
|
12
|
-
export { sanitizeBranchName, RESERVED_SETTINGS_BRANCH_PREFIX, RESERVED_ROUTE_BRANCH_NAMES, } from './branch-name.js';
|
|
11
|
+
export { resolveBranchPath, ensureBranchRoot, getDefaultBranchBase, resolveBranchPaths, BranchPathError, namesNoWorkspace, } from './branch.js';
|
|
12
|
+
export { sanitizeBranchName, RESERVED_SETTINGS_BRANCH_PREFIX, RESERVED_ROUTE_BRANCH_NAMES, isSettingsBranchName, } from './branch-name.js';
|
|
@@ -25,6 +25,13 @@ export declare function normalizeCollectionPath(collectionPath: string, contentR
|
|
|
25
25
|
export declare function hasTraversalSequence(path: string): boolean;
|
|
26
26
|
/** Join segments into a LogicalPath, throwing on a traversal sequence. */
|
|
27
27
|
export declare function createLogicalPath(...segments: string[]): LogicalPath;
|
|
28
|
+
/**
|
|
29
|
+
* An entry's logical path: `<collection logical path>/<slug>`, e.g. `content/blog/my-post`,
|
|
30
|
+
* or `content/about` for an entry of the root collection. Path-permission rules match this
|
|
31
|
+
* form. No traversal check, unlike `createLogicalPath`: a slug read off disk may contain
|
|
32
|
+
* `..`, and listing must not throw on one.
|
|
33
|
+
*/
|
|
34
|
+
export declare function entryLogicalPath(collectionLogicalPath: LogicalPath | string, slug: string): LogicalPath;
|
|
28
35
|
/**
|
|
29
36
|
* Join segments into a PhysicalPath (segments may carry embedded content IDs,
|
|
30
37
|
* e.g. `my-post.ABC123.mdx`), throwing on a traversal sequence.
|
package/dist/paths/normalize.js
CHANGED
|
@@ -48,6 +48,15 @@ export function createLogicalPath(...segments) {
|
|
|
48
48
|
}
|
|
49
49
|
return normalized;
|
|
50
50
|
}
|
|
51
|
+
/**
|
|
52
|
+
* An entry's logical path: `<collection logical path>/<slug>`, e.g. `content/blog/my-post`,
|
|
53
|
+
* or `content/about` for an entry of the root collection. Path-permission rules match this
|
|
54
|
+
* form. No traversal check, unlike `createLogicalPath`: a slug read off disk may contain
|
|
55
|
+
* `..`, and listing must not throw on one.
|
|
56
|
+
*/
|
|
57
|
+
export function entryLogicalPath(collectionLogicalPath, slug) {
|
|
58
|
+
return (collectionLogicalPath ? `${collectionLogicalPath}/${slug}` : slug);
|
|
59
|
+
}
|
|
51
60
|
/**
|
|
52
61
|
* Join segments into a PhysicalPath (segments may carry embedded content IDs,
|
|
53
62
|
* e.g. `my-post.ABC123.mdx`), throwing on a traversal sequence.
|
|
@@ -1,30 +1,19 @@
|
|
|
1
1
|
import type { ContentStore } from './content-store.js';
|
|
2
2
|
import type { ContentIdIndex } from './content-id-index.js';
|
|
3
|
-
import type
|
|
4
|
-
export interface ResolvedReference {
|
|
5
|
-
id: string;
|
|
6
|
-
exists: boolean;
|
|
7
|
-
displayValue: string;
|
|
8
|
-
collection?: LogicalPath;
|
|
9
|
-
slug?: Slug;
|
|
10
|
-
}
|
|
3
|
+
import { type LogicalPath } from './paths/index.js';
|
|
11
4
|
export interface ReferenceOption {
|
|
12
5
|
id: string;
|
|
13
6
|
label: string;
|
|
14
7
|
collection: string;
|
|
15
8
|
}
|
|
16
9
|
/**
|
|
17
|
-
*
|
|
10
|
+
* Loads the options a reference field's picker offers. Resolving a stored reference to its
|
|
11
|
+
* target is `ContentStore`'s job (`read()`, `resolveReferenceTarget`).
|
|
18
12
|
*/
|
|
19
13
|
export declare class ReferenceResolver {
|
|
20
14
|
private store;
|
|
21
15
|
private idIndex;
|
|
22
16
|
constructor(store: ContentStore, idIndex: ContentIdIndex);
|
|
23
|
-
/**
|
|
24
|
-
* Resolve a content ID to a display value.
|
|
25
|
-
* Returns null if the ID doesn't exist or points to a collection.
|
|
26
|
-
*/
|
|
27
|
-
resolve(id: string, displayField?: string): Promise<ResolvedReference | null>;
|
|
28
17
|
/**
|
|
29
18
|
* Load all available reference options for a reference field.
|
|
30
19
|
*
|
|
@@ -32,14 +21,9 @@ export declare class ReferenceResolver {
|
|
|
32
21
|
* At least one of `collections` or `entryTypes` should be provided.
|
|
33
22
|
*
|
|
34
23
|
* @param canAccess - Optional permission predicate, called with each candidate's
|
|
35
|
-
*
|
|
24
|
+
* logical path (`entryLogicalPath`) before it is read. Returning false skips the entry entirely --
|
|
36
25
|
* no file I/O, no label, no option -- so a caller who can't read a path never
|
|
37
26
|
* triggers a read for content they won't be allowed to see anyway.
|
|
38
27
|
*/
|
|
39
|
-
loadReferenceOptions(collections?: LogicalPath[], displayField?: string, search?: string, entryTypes?: string[], canAccess?: (
|
|
40
|
-
/**
|
|
41
|
-
* Resolve multiple IDs at once.
|
|
42
|
-
* Useful for displaying lists of referenced items.
|
|
43
|
-
*/
|
|
44
|
-
resolveMany(ids: string[], displayField?: string): Promise<(ResolvedReference | null)[]>;
|
|
28
|
+
loadReferenceOptions(collections?: LogicalPath[], displayField?: string, search?: string, entryTypes?: string[], canAccess?: (logicalPath: LogicalPath) => boolean): Promise<ReferenceOption[]>;
|
|
45
29
|
}
|
|
@@ -1,46 +1,15 @@
|
|
|
1
1
|
import nodePath from 'node:path';
|
|
2
2
|
import { extractSlugFromFilename, extractEntryTypeFromFilename } from './content-id-index.js';
|
|
3
|
+
import { entryLogicalPath } from './paths/index.js';
|
|
3
4
|
/**
|
|
4
|
-
*
|
|
5
|
+
* Loads the options a reference field's picker offers. Resolving a stored reference to its
|
|
6
|
+
* target is `ContentStore`'s job (`read()`, `resolveReferenceTarget`).
|
|
5
7
|
*/
|
|
6
8
|
export class ReferenceResolver {
|
|
7
9
|
constructor(store, idIndex) {
|
|
8
10
|
this.store = store;
|
|
9
11
|
this.idIndex = idIndex;
|
|
10
12
|
}
|
|
11
|
-
/**
|
|
12
|
-
* Resolve a content ID to a display value.
|
|
13
|
-
* Returns null if the ID doesn't exist or points to a collection.
|
|
14
|
-
*/
|
|
15
|
-
async resolve(id, displayField = 'title') {
|
|
16
|
-
const location = this.idIndex.findById(id);
|
|
17
|
-
if (!location || location.type !== 'entry') {
|
|
18
|
-
return {
|
|
19
|
-
id,
|
|
20
|
-
exists: false,
|
|
21
|
-
displayValue: id, // Fallback to showing the ID itself
|
|
22
|
-
};
|
|
23
|
-
}
|
|
24
|
-
try {
|
|
25
|
-
const doc = await this.store.read(location.collection, location.slug);
|
|
26
|
-
const displayValue = String(doc.data[displayField] || doc.data.title || location.slug);
|
|
27
|
-
return {
|
|
28
|
-
id,
|
|
29
|
-
exists: true,
|
|
30
|
-
displayValue,
|
|
31
|
-
collection: location.collection,
|
|
32
|
-
slug: location.slug,
|
|
33
|
-
};
|
|
34
|
-
}
|
|
35
|
-
catch (error) {
|
|
36
|
-
console.error('Failed to resolve reference:', { id, error });
|
|
37
|
-
return {
|
|
38
|
-
id,
|
|
39
|
-
exists: false,
|
|
40
|
-
displayValue: id,
|
|
41
|
-
};
|
|
42
|
-
}
|
|
43
|
-
}
|
|
44
13
|
/**
|
|
45
14
|
* Load all available reference options for a reference field.
|
|
46
15
|
*
|
|
@@ -48,7 +17,7 @@ export class ReferenceResolver {
|
|
|
48
17
|
* At least one of `collections` or `entryTypes` should be provided.
|
|
49
18
|
*
|
|
50
19
|
* @param canAccess - Optional permission predicate, called with each candidate's
|
|
51
|
-
*
|
|
20
|
+
* logical path (`entryLogicalPath`) before it is read. Returning false skips the entry entirely --
|
|
52
21
|
* no file I/O, no label, no option -- so a caller who can't read a path never
|
|
53
22
|
* triggers a read for content they won't be allowed to see anyway.
|
|
54
23
|
*/
|
|
@@ -74,7 +43,7 @@ export class ReferenceResolver {
|
|
|
74
43
|
if (!location.collection || !location.slug)
|
|
75
44
|
continue;
|
|
76
45
|
// Skip denied paths before any file I/O.
|
|
77
|
-
if (canAccess && !canAccess(location.
|
|
46
|
+
if (canAccess && !canAccess(entryLogicalPath(location.collection, location.slug)))
|
|
78
47
|
continue;
|
|
79
48
|
const id = this.idIndex.findByPath(location.relativePath);
|
|
80
49
|
if (!id)
|
|
@@ -82,7 +51,10 @@ export class ReferenceResolver {
|
|
|
82
51
|
try {
|
|
83
52
|
const filename = nodePath.basename(location.relativePath);
|
|
84
53
|
const normalizedSlug = extractSlugFromFilename(filename);
|
|
85
|
-
|
|
54
|
+
// Only the label is read, so the candidate's own references stay unresolved.
|
|
55
|
+
const doc = await this.store.read(location.collection, normalizedSlug, {
|
|
56
|
+
resolveReferences: false,
|
|
57
|
+
});
|
|
86
58
|
const label = String(doc.data[displayField] || doc.data.title || normalizedSlug);
|
|
87
59
|
if (search && !label.toLowerCase().includes(search.toLowerCase())) {
|
|
88
60
|
continue;
|
|
@@ -104,11 +76,4 @@ export class ReferenceResolver {
|
|
|
104
76
|
}
|
|
105
77
|
return options.sort((a, b) => a.label.localeCompare(b.label));
|
|
106
78
|
}
|
|
107
|
-
/**
|
|
108
|
-
* Resolve multiple IDs at once.
|
|
109
|
-
* Useful for displaying lists of referenced items.
|
|
110
|
-
*/
|
|
111
|
-
async resolveMany(ids, displayField = 'title') {
|
|
112
|
-
return Promise.all(ids.map((id) => this.resolve(id, displayField)));
|
|
113
|
-
}
|
|
114
79
|
}
|
|
@@ -12,6 +12,7 @@
|
|
|
12
12
|
import { canopyLogWarn } from './utils/logger.js';
|
|
13
13
|
import { authResultToCanopyUser } from './user.js';
|
|
14
14
|
import { loadInternalGroups, RESERVED_GROUPS } from './authorization/index.js';
|
|
15
|
+
import { timeRequestPhase } from './utils/request-timing.js';
|
|
15
16
|
/** Module-level: warn at most once per process. */
|
|
16
17
|
let warnedNoAdmins = false;
|
|
17
18
|
/**
|
|
@@ -39,7 +40,7 @@ function warnIfNoAdmins(internalGroups, bootstrapAdminIds) {
|
|
|
39
40
|
*/
|
|
40
41
|
export async function resolveCanopyUser(authResult, deps) {
|
|
41
42
|
const settingsRoot = await deps.getSettingsBranchRoot();
|
|
42
|
-
const internalGroups = await loadInternalGroups(settingsRoot, deps.mode, deps.bootstrapAdminIds);
|
|
43
|
+
const internalGroups = await timeRequestPhase('groups', () => loadInternalGroups(settingsRoot, deps.mode, deps.bootstrapAdminIds));
|
|
43
44
|
warnIfNoAdmins(internalGroups, deps.bootstrapAdminIds);
|
|
44
45
|
return authResultToCanopyUser(authResult, deps.bootstrapAdminIds, internalGroups);
|
|
45
46
|
}
|