canopycms 0.0.67 → 0.0.68-int.93
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/ai/handler.js +8 -0
- package/dist/api/admin-branch-health.js +12 -7
- package/dist/api/admin.d.ts +8 -8
- package/dist/api/branch-status.js +6 -2
- package/dist/api/client.d.ts +12 -0
- package/dist/api/client.js +35 -11
- package/dist/api/content.d.ts +6 -5
- package/dist/api/content.js +38 -39
- package/dist/api/entries.js +6 -8
- 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/request-url.d.ts +8 -0
- package/dist/api/request-url.js +15 -0
- package/dist/api/resolve-references.js +2 -2
- package/dist/api/settings-helpers.d.ts +1 -1
- package/dist/api/settings-helpers.js +1 -4
- 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 +2 -2
- package/dist/cli/cli.js +285 -168
- package/dist/cli/generate-ai-content.js +190 -109
- package/dist/cli/github-app-manifest.d.ts +4 -3
- package/dist/cli/github-app-manifest.js +4 -3
- package/dist/cli/init.js +15 -10
- package/dist/cli/sync.js +15 -13
- package/dist/config/schemas/config.d.ts +6 -3
- package/dist/config/schemas/config.js +3 -1
- package/dist/config/schemas/field.js +1 -0
- package/dist/config/types.d.ts +11 -2
- package/dist/config/validation.d.ts +6 -0
- package/dist/config/validation.js +37 -0
- package/dist/content-listing.d.ts +2 -2
- package/dist/content-listing.js +3 -3
- package/dist/content-reader.js +4 -4
- package/dist/content-store.d.ts +2 -0
- package/dist/content-store.js +2 -1
- package/dist/content-tree.js +1 -1
- package/dist/context.d.ts +16 -7
- package/dist/context.js +116 -67
- package/dist/editor/Editor.js +4 -4
- package/dist/editor/FormRenderer.js +20 -5
- package/dist/editor/admin/SystemHealthPanel.js +37 -6
- package/dist/editor/components/NoEditPermissionNotice.d.ts +10 -0
- package/dist/editor/components/NoEditPermissionNotice.js +17 -0
- package/dist/editor/hooks/useBranchesData.js +3 -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/entry-schema-registry.js +2 -1
- package/dist/git-manager.d.ts +20 -0
- package/dist/git-manager.js +50 -4
- package/dist/github-service.d.ts +8 -0
- package/dist/github-service.js +5 -1
- package/dist/http/handler.js +24 -9
- 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/index.d.ts +1 -1
- package/dist/paths/index.js +1 -1
- package/dist/paths/normalize.d.ts +7 -0
- package/dist/paths/normalize.js +9 -0
- package/dist/reference-resolver.d.ts +3 -3
- package/dist/reference-resolver.js +3 -2
- package/dist/resolve-canopy-user.js +2 -1
- package/dist/services.d.ts +12 -5
- package/dist/services.js +56 -56
- package/dist/settings-workspace.js +36 -0
- 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.js +7 -8
- package/dist/types.d.ts +23 -2
- package/dist/utils/content-write-lock.d.ts +3 -7
- package/dist/utils/content-write-lock.js +6 -4
- 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 +15 -0
- package/dist/utils/url-prefix.js +26 -0
- package/dist/worker/canopy-state.d.ts +45 -0
- package/dist/worker/canopy-state.js +75 -0
- package/dist/worker/git-sync.d.ts +4 -2
- package/dist/worker/git-sync.js +111 -24
- package/dist/worker/provisioned-workspace.d.ts +35 -0
- package/dist/worker/provisioned-workspace.js +50 -0
- package/dist/worker/rebase.js +75 -12
- package/dist/worker/task-runner.js +39 -4
- package/package.json +1 -1
package/dist/git-manager.js
CHANGED
|
@@ -20,7 +20,7 @@ import { invalidateContentIndexesForRoot } from './content-index-registry.js';
|
|
|
20
20
|
import { invalidateBranchContentCaches } from './content-index-generation.js';
|
|
21
21
|
import { createDebugLogger } from './utils/debug.js';
|
|
22
22
|
import { getErrorMessage, isNotFoundError } from './utils/error.js';
|
|
23
|
-
import { isMissingRemoteRefFailure, isNetworkRemoteUrl, resolveBaseBranch } from './utils/git.js';
|
|
23
|
+
import { isMissingRemoteRefFailure, isNetworkRemoteUrl, resolveBaseBranch, stageAllExceptCanopyState, } from './utils/git.js';
|
|
24
24
|
import { acquireProvisioningLock } from './utils/provisioning-lock.js';
|
|
25
25
|
const log = createDebugLogger({ prefix: 'GitManager' });
|
|
26
26
|
/**
|
|
@@ -166,6 +166,21 @@ export class GitRemoteRefMissingError extends Error {
|
|
|
166
166
|
this.name = 'GitRemoteRefMissingError';
|
|
167
167
|
}
|
|
168
168
|
}
|
|
169
|
+
/**
|
|
170
|
+
* No git remote is configured and the one the strategy auto-detects does not
|
|
171
|
+
* exist yet: in prod, the EC2 worker creates `{workspaceRoot}/remote.git` on
|
|
172
|
+
* its first boot, so until then every request that needs a workspace fails
|
|
173
|
+
* here. Usually transient, unlike a missing remote in a mode with nothing to wait for.
|
|
174
|
+
* The HTTP layer maps it to a 503 (`http/worker-not-ready.ts`).
|
|
175
|
+
*/
|
|
176
|
+
export class RemoteNotReadyError extends Error {
|
|
177
|
+
constructor(expectedRemotePath) {
|
|
178
|
+
super(`CanopyCMS: no git remote is available yet. defaultRemoteUrl (or CANOPYCMS_REMOTE_URL) is ` +
|
|
179
|
+
`not set and the CMS worker has not created ${expectedRemotePath}; the worker may still be starting.`);
|
|
180
|
+
this.expectedRemotePath = expectedRemotePath;
|
|
181
|
+
this.name = 'RemoteNotReadyError';
|
|
182
|
+
}
|
|
183
|
+
}
|
|
169
184
|
export class GitManager {
|
|
170
185
|
constructor(options, gitOptions) {
|
|
171
186
|
this.repoPath = path.resolve(options.repoPath);
|
|
@@ -696,6 +711,10 @@ export class GitManager {
|
|
|
696
711
|
allowNetworkRemoteInProd: options.allowNetworkRemoteInProd,
|
|
697
712
|
});
|
|
698
713
|
if (!remoteUrl) {
|
|
714
|
+
const { operatingStrategy } = await import('./operating-mode/index.js');
|
|
715
|
+
const { autoDetectRemotePath } = operatingStrategy(options.mode).getRemoteUrlConfig();
|
|
716
|
+
if (autoDetectRemotePath)
|
|
717
|
+
throw new RemoteNotReadyError(autoDetectRemotePath);
|
|
699
718
|
throw new Error('CanopyCMS: defaultRemoteUrl (or CANOPYCMS_REMOTE_URL) is required to initialize workspace');
|
|
700
719
|
}
|
|
701
720
|
try {
|
|
@@ -970,6 +989,10 @@ export class GitManager {
|
|
|
970
989
|
const fileArray = Array.isArray(files) ? files : [files];
|
|
971
990
|
await this.git.add(fileArray);
|
|
972
991
|
}
|
|
992
|
+
/** Stage every working-tree change except canopycms's own state. See {@link stageAllExceptCanopyState}. */
|
|
993
|
+
async addAllExceptCanopyState() {
|
|
994
|
+
await stageAllExceptCanopyState(this.git);
|
|
995
|
+
}
|
|
973
996
|
async commit(message) {
|
|
974
997
|
await this.git.commit(message);
|
|
975
998
|
}
|
|
@@ -1010,12 +1033,14 @@ export class GitManager {
|
|
|
1010
1033
|
// `--end-of-options` before every caller-influenced ref name, as in push()
|
|
1011
1034
|
// above: names are sanitized upstream, but this file's rule is that a
|
|
1012
1035
|
// positional is guarded where it is passed, not where it was validated.
|
|
1013
|
-
|
|
1014
|
-
|
|
1036
|
+
// Each rev-parse needs `--verify`: without it, rev-parse echoes
|
|
1037
|
+
// `--end-of-options` as its own output line ahead of the result.
|
|
1038
|
+
const target = branch ?? (await this.git.revparse(['--verify', '--abbrev-ref', '--end-of-options', 'HEAD']));
|
|
1039
|
+
const localSha = (await this.git.revparse(['--verify', '--end-of-options', target])).trim();
|
|
1015
1040
|
let fetchedTip;
|
|
1016
1041
|
try {
|
|
1017
1042
|
await this.git.raw(['fetch', '--end-of-options', this.remote, target]);
|
|
1018
|
-
fetchedTip = (await this.git.revparse(['--end-of-options', 'FETCH_HEAD'])).trim();
|
|
1043
|
+
fetchedTip = (await this.git.revparse(['--verify', '--end-of-options', 'FETCH_HEAD'])).trim();
|
|
1019
1044
|
}
|
|
1020
1045
|
catch {
|
|
1021
1046
|
// No ref on the remote yet -- the branch has never been pushed.
|
|
@@ -1028,6 +1053,27 @@ export class GitManager {
|
|
|
1028
1053
|
const aheadCount = (await this.git.raw(['rev-list', '--count', `${fetchedTip}..${localSha}`])).trim();
|
|
1029
1054
|
return aheadCount !== '0';
|
|
1030
1055
|
}
|
|
1056
|
+
/**
|
|
1057
|
+
* Repo-relative paths that differ between HEAD and its merge base with the
|
|
1058
|
+
* base branch: everything the branch changes, across all of its commits.
|
|
1059
|
+
* Diffs against the just-fetched base tip pinned to a SHA — the pullBaseInner
|
|
1060
|
+
* constraint.
|
|
1061
|
+
*/
|
|
1062
|
+
async listChangedPathsSinceBase() {
|
|
1063
|
+
await this.git.raw(['fetch', '--end-of-options', this.remote, this.baseBranch]);
|
|
1064
|
+
// `--verify`: without it rev-parse echoes `--end-of-options` back as output.
|
|
1065
|
+
const baseTip = (await this.git.revparse(['--verify', '--end-of-options', 'FETCH_HEAD'])).trim();
|
|
1066
|
+
// -z: NUL-separated and unquoted, so no path needs unescaping.
|
|
1067
|
+
const output = await this.git.raw([
|
|
1068
|
+
'diff',
|
|
1069
|
+
'--name-only',
|
|
1070
|
+
'-z',
|
|
1071
|
+
'--no-renames',
|
|
1072
|
+
`${baseTip}...HEAD`,
|
|
1073
|
+
'--',
|
|
1074
|
+
]);
|
|
1075
|
+
return output.split('\0').filter((p) => p.length > 0);
|
|
1076
|
+
}
|
|
1031
1077
|
async ensureAuthor(author) {
|
|
1032
1078
|
const config = (await this.git.listConfig());
|
|
1033
1079
|
// Verify this is a CanopyCMS-managed workspace before setting author
|
package/dist/github-service.d.ts
CHANGED
|
@@ -76,6 +76,7 @@ export interface PullRequestDetails {
|
|
|
76
76
|
state: 'open' | 'closed';
|
|
77
77
|
merged: boolean;
|
|
78
78
|
draft: boolean;
|
|
79
|
+
body: string;
|
|
79
80
|
}
|
|
80
81
|
export interface CreateOrUpdatePullRequestParams {
|
|
81
82
|
octokit: Octokit;
|
|
@@ -87,6 +88,11 @@ export interface CreateOrUpdatePullRequestParams {
|
|
|
87
88
|
body: string;
|
|
88
89
|
/** Convert a pre-existing draft PR to ready-for-review after updating it. */
|
|
89
90
|
markReadyIfDraft?: boolean;
|
|
91
|
+
/**
|
|
92
|
+
* `body` is the canopycms PR section: an existing PR keeps the text outside
|
|
93
|
+
* its section (see mergePrSection) instead of having its body replaced.
|
|
94
|
+
*/
|
|
95
|
+
mergeSectionIntoBody?: boolean;
|
|
90
96
|
/** Forwarded to all GitHub requests (worker task-timeout abort). */
|
|
91
97
|
signal?: AbortSignal;
|
|
92
98
|
}
|
|
@@ -128,6 +134,8 @@ export declare class GitHubService {
|
|
|
128
134
|
body: string;
|
|
129
135
|
/** Convert a pre-existing draft PR to ready-for-review after updating it. */
|
|
130
136
|
markReadyIfDraft?: boolean;
|
|
137
|
+
/** See {@link CreateOrUpdatePullRequestParams.mergeSectionIntoBody}. */
|
|
138
|
+
mergeSectionIntoBody?: boolean;
|
|
131
139
|
}): Promise<{
|
|
132
140
|
number: number;
|
|
133
141
|
url: string;
|
package/dist/github-service.js
CHANGED
|
@@ -7,6 +7,7 @@ import { getErrorMessage } from './utils/error.js';
|
|
|
7
7
|
// or CloudWatch folds it into the previous event. Under Lambda/dev the helper
|
|
8
8
|
// is plain console. See utils/logger.ts.
|
|
9
9
|
import { canopyLogWarn } from './utils/logger.js';
|
|
10
|
+
import { mergePrSection } from './submission-attribution.js';
|
|
10
11
|
const ThrottledOctokit = Octokit.plugin(throttling);
|
|
11
12
|
/**
|
|
12
13
|
* Retry primary rate limits at most twice and only for short waits; beyond
|
|
@@ -67,6 +68,7 @@ export function createCanopyOctokit(options) {
|
|
|
67
68
|
*/
|
|
68
69
|
export async function createOrUpdatePullRequest(params) {
|
|
69
70
|
const { octokit, owner, repo, head, base, title, body, markReadyIfDraft, signal } = params;
|
|
71
|
+
const mergeSection = params.mergeSectionIntoBody === true;
|
|
70
72
|
// CONDITIONAL spread: with no signal, the request objects below carry no
|
|
71
73
|
// `request` key at all (github-service.test.ts asserts on their exact shape).
|
|
72
74
|
const requestOption = signal ? { request: { signal } } : {};
|
|
@@ -92,7 +94,7 @@ export async function createOrUpdatePullRequest(params) {
|
|
|
92
94
|
repo,
|
|
93
95
|
pull_number: existing.number,
|
|
94
96
|
title,
|
|
95
|
-
body,
|
|
97
|
+
body: mergeSection ? mergePrSection(existing.body, body) : body,
|
|
96
98
|
...requestOption,
|
|
97
99
|
});
|
|
98
100
|
// Only a pre-existing PR can be a draft on this path — pulls.create
|
|
@@ -185,6 +187,7 @@ export class GitHubService {
|
|
|
185
187
|
title: options.title,
|
|
186
188
|
body: options.body,
|
|
187
189
|
markReadyIfDraft: options.markReadyIfDraft,
|
|
190
|
+
mergeSectionIntoBody: options.mergeSectionIntoBody,
|
|
188
191
|
});
|
|
189
192
|
return { number: result.number, url: result.url };
|
|
190
193
|
}
|
|
@@ -200,6 +203,7 @@ export class GitHubService {
|
|
|
200
203
|
state: response.data.state,
|
|
201
204
|
merged: response.data.merged ?? false,
|
|
202
205
|
draft: response.data.draft ?? false,
|
|
206
|
+
body: response.data.body ?? '',
|
|
203
207
|
};
|
|
204
208
|
}
|
|
205
209
|
async convertToDraft(prNumber) {
|
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';
|
|
@@ -13,6 +14,7 @@ import { getErrorMessage, redactCredentials, sanitizeErrorMessage } from '../uti
|
|
|
13
14
|
// stay out of the worker's runtime import closure, so new log lines here go
|
|
14
15
|
// through the indirection (utils/logger.ts).
|
|
15
16
|
import { canopyLogError } from '../utils/logger.js';
|
|
17
|
+
import { runWithRequestTiming, setRequestTimingRoute, timeRequestPhase, } from '../utils/request-timing.js';
|
|
16
18
|
const buildContext = async (options) => {
|
|
17
19
|
const services = options.services ?? (options.config ? await createCanopyServices(options.config) : undefined);
|
|
18
20
|
if (!services) {
|
|
@@ -62,7 +64,7 @@ const buildContext = async (options) => {
|
|
|
62
64
|
return {
|
|
63
65
|
services,
|
|
64
66
|
assetStore: options.assetStore,
|
|
65
|
-
getBranchContext,
|
|
67
|
+
getBranchContext: (branch, opts) => timeRequestPhase('branchContext', () => getBranchContext(branch, opts)),
|
|
66
68
|
authPlugin: options.authPlugin,
|
|
67
69
|
};
|
|
68
70
|
};
|
|
@@ -109,10 +111,11 @@ export function createCanopyRequestHandler(options) {
|
|
|
109
111
|
if (!match) {
|
|
110
112
|
return jsonResponse({ ok: false, status: 404, error: 'Not found' }, 404);
|
|
111
113
|
}
|
|
112
|
-
|
|
114
|
+
setRequestTimingRoute(match.pattern.join('/') || '(malformed path)');
|
|
115
|
+
const apiCtx = await timeRequestPhase('context', getContext);
|
|
113
116
|
// In dev mode, re-check if the developer switched git branches
|
|
114
|
-
await apiCtx.services.refreshActiveBranch();
|
|
115
|
-
const authResult = await options.authPlugin.authenticate(req);
|
|
117
|
+
await timeRequestPhase('refreshBranch', () => apiCtx.services.refreshActiveBranch());
|
|
118
|
+
const authResult = await timeRequestPhase('auth', () => options.authPlugin.authenticate(req));
|
|
116
119
|
// API routes require authentication. Anonymous callers are rejected BEFORE
|
|
117
120
|
// any workspace provisioning below, so they can neither trigger expensive
|
|
118
121
|
// git operations nor read provisioning error details.
|
|
@@ -141,6 +144,9 @@ export function createCanopyRequestHandler(options) {
|
|
|
141
144
|
// (authenticated) client. Credentials, which git errors can embed, are
|
|
142
145
|
// redacted even from the server logs.
|
|
143
146
|
console.error(`CanopyCMS: Failed to provision workspace for base branch '${baseBranch}': ${redactCredentials(message)}`);
|
|
147
|
+
const notReady = workerNotReadyResponse(err);
|
|
148
|
+
if (notReady)
|
|
149
|
+
return notReady;
|
|
144
150
|
return jsonResponse({
|
|
145
151
|
ok: false,
|
|
146
152
|
status: 503,
|
|
@@ -157,15 +163,21 @@ export function createCanopyRequestHandler(options) {
|
|
|
157
163
|
// a silent authorization change.
|
|
158
164
|
let user;
|
|
159
165
|
try {
|
|
160
|
-
user = await resolveCanopyUser(authResult, {
|
|
166
|
+
user = await timeRequestPhase('user', () => resolveCanopyUser(authResult, {
|
|
161
167
|
getSettingsBranchRoot: apiCtx.services.getSettingsBranchRoot,
|
|
162
168
|
mode: apiCtx.services.config.mode,
|
|
163
169
|
bootstrapAdminIds: apiCtx.services.bootstrapAdminIds,
|
|
164
|
-
});
|
|
170
|
+
}));
|
|
165
171
|
}
|
|
166
172
|
catch (err) {
|
|
167
173
|
const message = getErrorMessage(err);
|
|
168
174
|
canopyLogError(`CanopyCMS: Failed to resolve internal groups from the settings workspace: ${redactCredentials(message)}`);
|
|
175
|
+
// No remote means no settings workspace, so /admin cannot load either:
|
|
176
|
+
// the worker has not created the remote yet, so every caller gets the
|
|
177
|
+
// not-ready 503, bootstrap admins included.
|
|
178
|
+
const notReady = workerNotReadyResponse(err);
|
|
179
|
+
if (notReady)
|
|
180
|
+
return notReady;
|
|
169
181
|
// Same trade as the base-branch degradation above: /admin is the recovery
|
|
170
182
|
// surface for exactly this failure (a renamed settings branch trips
|
|
171
183
|
// assertSettingsWorkspaceIdentity until a human intervenes), so 503ing it
|
|
@@ -231,7 +243,7 @@ export function createCanopyRequestHandler(options) {
|
|
|
231
243
|
if (validationResult.body !== undefined) {
|
|
232
244
|
handlerArgs.push(validationResult.body);
|
|
233
245
|
}
|
|
234
|
-
const result = await match.handler(...handlerArgs);
|
|
246
|
+
const result = await timeRequestPhase('route', () => match.handler(...handlerArgs));
|
|
235
247
|
// Binary routes carry their own status and headers and MUST reach the
|
|
236
248
|
// adapter untouched: jsonResponse would serialize raw bytes and drop
|
|
237
249
|
// contentType/contentDisposition.
|
|
@@ -242,7 +254,7 @@ export function createCanopyRequestHandler(options) {
|
|
|
242
254
|
else {
|
|
243
255
|
// Every route should use defineEndpoint; this is the safety net for one
|
|
244
256
|
// that carries no validate function.
|
|
245
|
-
const result = await match.handler(apiCtx, apiReq, mergedParams);
|
|
257
|
+
const result = await timeRequestPhase('route', () => match.handler(apiCtx, apiReq, mergedParams));
|
|
246
258
|
if (isCanopyBinaryResponse(result))
|
|
247
259
|
return result;
|
|
248
260
|
return jsonResponse(result, result.status);
|
|
@@ -250,12 +262,15 @@ export function createCanopyRequestHandler(options) {
|
|
|
250
262
|
};
|
|
251
263
|
return async (req, pathSegments) => {
|
|
252
264
|
try {
|
|
253
|
-
return await handleRequest(req, pathSegments);
|
|
265
|
+
return await runWithRequestTiming(req.method, () => handleRequest(req, pathSegments), (response) => response.status);
|
|
254
266
|
}
|
|
255
267
|
catch (err) {
|
|
256
268
|
// Last-resort boundary (API-C1): see handleRequest's doc comment above.
|
|
257
269
|
const message = getErrorMessage(err);
|
|
258
270
|
console.error('CanopyCMS: Unhandled error in API request handler:', message);
|
|
271
|
+
const notReady = workerNotReadyResponse(err);
|
|
272
|
+
if (notReady)
|
|
273
|
+
return notReady;
|
|
259
274
|
return jsonResponse({ ok: false, status: 500, error: sanitizeErrorMessage(message) }, 500);
|
|
260
275
|
}
|
|
261
276
|
};
|
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
|
}
|
package/dist/paths/index.d.ts
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
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';
|
package/dist/paths/index.js
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
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';
|
|
@@ -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,6 +1,6 @@
|
|
|
1
1
|
import type { ContentStore } from './content-store.js';
|
|
2
2
|
import type { ContentIdIndex } from './content-id-index.js';
|
|
3
|
-
import type
|
|
3
|
+
import { type LogicalPath, type Slug } from './paths/index.js';
|
|
4
4
|
export interface ResolvedReference {
|
|
5
5
|
id: string;
|
|
6
6
|
exists: boolean;
|
|
@@ -32,11 +32,11 @@ export declare class ReferenceResolver {
|
|
|
32
32
|
* At least one of `collections` or `entryTypes` should be provided.
|
|
33
33
|
*
|
|
34
34
|
* @param canAccess - Optional permission predicate, called with each candidate's
|
|
35
|
-
*
|
|
35
|
+
* logical path (`entryLogicalPath`) before it is read. Returning false skips the entry entirely --
|
|
36
36
|
* no file I/O, no label, no option -- so a caller who can't read a path never
|
|
37
37
|
* triggers a read for content they won't be allowed to see anyway.
|
|
38
38
|
*/
|
|
39
|
-
loadReferenceOptions(collections?: LogicalPath[], displayField?: string, search?: string, entryTypes?: string[], canAccess?: (
|
|
39
|
+
loadReferenceOptions(collections?: LogicalPath[], displayField?: string, search?: string, entryTypes?: string[], canAccess?: (logicalPath: LogicalPath) => boolean): Promise<ReferenceOption[]>;
|
|
40
40
|
/**
|
|
41
41
|
* Resolve multiple IDs at once.
|
|
42
42
|
* Useful for displaying lists of referenced items.
|
|
@@ -1,5 +1,6 @@
|
|
|
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
|
* ReferenceResolver resolves content IDs to display values for reference fields.
|
|
5
6
|
*/
|
|
@@ -48,7 +49,7 @@ export class ReferenceResolver {
|
|
|
48
49
|
* At least one of `collections` or `entryTypes` should be provided.
|
|
49
50
|
*
|
|
50
51
|
* @param canAccess - Optional permission predicate, called with each candidate's
|
|
51
|
-
*
|
|
52
|
+
* logical path (`entryLogicalPath`) before it is read. Returning false skips the entry entirely --
|
|
52
53
|
* no file I/O, no label, no option -- so a caller who can't read a path never
|
|
53
54
|
* triggers a read for content they won't be allowed to see anyway.
|
|
54
55
|
*/
|
|
@@ -74,7 +75,7 @@ export class ReferenceResolver {
|
|
|
74
75
|
if (!location.collection || !location.slug)
|
|
75
76
|
continue;
|
|
76
77
|
// Skip denied paths before any file I/O.
|
|
77
|
-
if (canAccess && !canAccess(location.
|
|
78
|
+
if (canAccess && !canAccess(entryLogicalPath(location.collection, location.slug)))
|
|
78
79
|
continue;
|
|
79
80
|
const id = this.idIndex.findByPath(location.relativePath);
|
|
80
81
|
if (!id)
|
|
@@ -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
|
}
|
package/dist/services.d.ts
CHANGED
|
@@ -24,11 +24,16 @@ import { GitManager } from './git-manager.js';
|
|
|
24
24
|
import { BranchRegistry } from './branch-registry.js';
|
|
25
25
|
import { type GitHubService } from './github-service.js';
|
|
26
26
|
import { BranchSchemaCache } from './branch-schema-cache.js';
|
|
27
|
+
import { type SubmissionEditor } from './submission-attribution.js';
|
|
27
28
|
/**
|
|
28
29
|
* Bootstrap admin IDs from the environment. These users are treated as Admins
|
|
29
30
|
* whatever their group membership.
|
|
30
31
|
*/
|
|
31
32
|
export declare const getBootstrapAdminIds: () => Set<string>;
|
|
33
|
+
export interface SubmitBranchResult {
|
|
34
|
+
/** Repo-relative paths the branch changes, excluding canopycms runtime metadata. */
|
|
35
|
+
changedPaths: string[];
|
|
36
|
+
}
|
|
32
37
|
export interface CanopyServices {
|
|
33
38
|
config: CanopyConfig;
|
|
34
39
|
/**
|
|
@@ -64,21 +69,23 @@ export interface CanopyServices {
|
|
|
64
69
|
files: string | string[];
|
|
65
70
|
message: string;
|
|
66
71
|
}) => Promise<void>;
|
|
67
|
-
/**
|
|
72
|
+
/**
|
|
73
|
+
* Submit branch: commit all changes (with the submitter's trailers) and push
|
|
74
|
+
* to remote. Resolves with every path the branch changes against its base.
|
|
75
|
+
*/
|
|
68
76
|
submitBranch: (options: {
|
|
69
77
|
context: BranchContext;
|
|
78
|
+
submitter?: SubmissionEditor;
|
|
70
79
|
message?: string;
|
|
71
|
-
}) => Promise<
|
|
72
|
-
/** Commit to settings branch (for permissions/groups)
|
|
80
|
+
}) => Promise<SubmitBranchResult>;
|
|
81
|
+
/** Commit to the settings branch (for permissions/groups) and push it; never opens a PR */
|
|
73
82
|
commitToSettingsBranch: (options: {
|
|
74
83
|
branchRoot: string;
|
|
75
84
|
files: string | string[];
|
|
76
85
|
message: string;
|
|
77
|
-
createPR?: boolean;
|
|
78
86
|
}) => Promise<{
|
|
79
87
|
committed: boolean;
|
|
80
88
|
pushed: boolean;
|
|
81
|
-
prUrl?: string;
|
|
82
89
|
error?: string;
|
|
83
90
|
syncStatus?: 'pending-sync' | 'synced' | 'sync-failed';
|
|
84
91
|
}>;
|