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/ai/handler.js
CHANGED
|
@@ -13,6 +13,7 @@
|
|
|
13
13
|
import { ContentStore } from '../content-store.js';
|
|
14
14
|
import { BranchSchemaCache } from '../branch-schema-cache.js';
|
|
15
15
|
import { getErrorMessage } from '../utils/error.js';
|
|
16
|
+
import { workerNotReadyResponse } from '../http/worker-not-ready.js';
|
|
16
17
|
import { generateAIContent } from './generate.js';
|
|
17
18
|
import { resolveBranchRoot } from './resolve-branch.js';
|
|
18
19
|
/**
|
|
@@ -76,6 +77,13 @@ export function createAIContentHandler(options) {
|
|
|
76
77
|
catch (error) {
|
|
77
78
|
// Log the real error server-side; don't leak internals to unauthenticated callers
|
|
78
79
|
console.error('AI content handler error:', getErrorMessage(error));
|
|
80
|
+
const notReady = workerNotReadyResponse(error);
|
|
81
|
+
if (notReady) {
|
|
82
|
+
return new Response(JSON.stringify({ error: notReady.body.error }), {
|
|
83
|
+
status: notReady.status,
|
|
84
|
+
headers: { 'Content-Type': 'application/json', ...notReady.headers },
|
|
85
|
+
});
|
|
86
|
+
}
|
|
79
87
|
return new Response(JSON.stringify({ error: 'Internal server error' }), {
|
|
80
88
|
status: 500,
|
|
81
89
|
headers: { 'Content-Type': 'application/json' },
|
|
@@ -22,7 +22,7 @@ import { ContentIdIndex } from '../content-id-index.js';
|
|
|
22
22
|
import { invalidateContentIndexesDurable } from '../content-index-generation.js';
|
|
23
23
|
import { getDefaultBranchBase, sanitizeBranchName } from '../paths/index.js';
|
|
24
24
|
import { withOccFileLock } from '../utils/occ-json-write.js';
|
|
25
|
-
import { tryAcquireProvisioningLock } from '../utils/provisioning-lock.js';
|
|
25
|
+
import { branchProvisioningLockName, tryAcquireProvisioningLock } from '../utils/provisioning-lock.js';
|
|
26
26
|
import { withContentWriteLock, ContentWriteLockBusyError, DEFAULT_CONTENT_WRITE_LOCK_WAIT_MS, } from '../utils/content-write-lock.js';
|
|
27
27
|
import { getErrorMessage, isNodeError, isNotFoundError } from '../utils/error.js';
|
|
28
28
|
import { defineEndpoint } from './route-builder.js';
|
|
@@ -147,12 +147,17 @@ const purgeBranchDirHandler = async (_gc, ctx, _req, params) => {
|
|
|
147
147
|
// True orphan (no branch.json, no load error) vs. corrupt-metadata
|
|
148
148
|
// (loadOnly threw). Only true orphans are subject to the youth rail below.
|
|
149
149
|
const isTrueOrphan = !hadLoadError;
|
|
150
|
-
// [H1] freshness rail: a fresh init lock means provisioning
|
|
151
|
-
// be in progress; a stale one is just
|
|
152
|
-
|
|
150
|
+
// [H1] freshness rail: a fresh init lock means provisioning, or the worker's
|
|
151
|
+
// sync of this branch, may genuinely be in progress; a stale one is just
|
|
152
|
+
// crash debris and does not block.
|
|
153
|
+
const lockPath = path.join(baseRoot, branchProvisioningLockName(params.dirName));
|
|
153
154
|
const lockStat = await fs.stat(lockPath).catch(() => null);
|
|
154
155
|
if (lockStat && Date.now() - lockStat.mtimeMs < PROVISIONING_LOCK_FRESH_MS) {
|
|
155
|
-
return {
|
|
156
|
+
return {
|
|
157
|
+
ok: false,
|
|
158
|
+
status: 409,
|
|
159
|
+
error: 'Provisioning, or the worker syncing this branch, may be in progress',
|
|
160
|
+
};
|
|
156
161
|
}
|
|
157
162
|
// Youth rail: a brand-new orphan dir may be a clone that just hasn't
|
|
158
163
|
// written branch.json yet. Corrupt-metadata dirs are exempt -- a
|
|
@@ -171,7 +176,7 @@ const purgeBranchDirHandler = async (_gc, ctx, _req, params) => {
|
|
|
171
176
|
// contention instead of hanging the request for ~5 minutes.
|
|
172
177
|
let releaseProvisioningLock;
|
|
173
178
|
try {
|
|
174
|
-
releaseProvisioningLock = await tryAcquireProvisioningLock(baseRoot,
|
|
179
|
+
releaseProvisioningLock = await tryAcquireProvisioningLock(baseRoot, branchProvisioningLockName(params.dirName));
|
|
175
180
|
}
|
|
176
181
|
catch (err) {
|
|
177
182
|
if (isNodeError(err) && err.code === 'ELOCKED') {
|
|
@@ -279,7 +284,7 @@ const repairBranchDirHandler = async (_gc, ctx, req, params) => {
|
|
|
279
284
|
// Held until the `finally` below, AFTER save() completes.
|
|
280
285
|
let releaseProvisioningLock;
|
|
281
286
|
try {
|
|
282
|
-
releaseProvisioningLock = await tryAcquireProvisioningLock(baseRoot,
|
|
287
|
+
releaseProvisioningLock = await tryAcquireProvisioningLock(baseRoot, branchProvisioningLockName(params.dirName));
|
|
283
288
|
}
|
|
284
289
|
catch (err) {
|
|
285
290
|
if (isNodeError(err) && err.code === 'ELOCKED') {
|
package/dist/api/admin.d.ts
CHANGED
|
@@ -54,10 +54,10 @@ declare const listAdminTasksParamsSchema: z.ZodObject<{
|
|
|
54
54
|
status: z.ZodEnum<["pending", "processing", "completed", "failed", "corrupt"]>;
|
|
55
55
|
limit: z.ZodOptional<z.ZodNumber>;
|
|
56
56
|
}, "strip", z.ZodTypeAny, {
|
|
57
|
-
status: "
|
|
57
|
+
status: "failed" | "pending" | "processing" | "completed" | "corrupt";
|
|
58
58
|
limit?: number | undefined;
|
|
59
59
|
}, {
|
|
60
|
-
status: "
|
|
60
|
+
status: "failed" | "pending" | "processing" | "completed" | "corrupt";
|
|
61
61
|
limit?: number | undefined;
|
|
62
62
|
}>;
|
|
63
63
|
export type ListAdminTasksParams = z.infer<typeof listAdminTasksParamsSchema>;
|
|
@@ -65,10 +65,10 @@ declare const deleteTaskParamsSchema: z.ZodObject<{
|
|
|
65
65
|
status: z.ZodEnum<["pending", "failed", "corrupt"]>;
|
|
66
66
|
fileName: z.ZodEffects<z.ZodString, string, string>;
|
|
67
67
|
}, "strip", z.ZodTypeAny, {
|
|
68
|
-
status: "
|
|
68
|
+
status: "failed" | "pending" | "corrupt";
|
|
69
69
|
fileName: string;
|
|
70
70
|
}, {
|
|
71
|
-
status: "
|
|
71
|
+
status: "failed" | "pending" | "corrupt";
|
|
72
72
|
fileName: string;
|
|
73
73
|
}>;
|
|
74
74
|
export type DeleteTaskParams = z.infer<typeof deleteTaskParamsSchema>;
|
|
@@ -108,10 +108,10 @@ export declare const ADMIN_ROUTES: {
|
|
|
108
108
|
status: z.ZodEnum<["pending", "processing", "completed", "failed", "corrupt"]>;
|
|
109
109
|
limit: z.ZodOptional<z.ZodNumber>;
|
|
110
110
|
}, "strip", z.ZodTypeAny, {
|
|
111
|
-
status: "
|
|
111
|
+
status: "failed" | "pending" | "processing" | "completed" | "corrupt";
|
|
112
112
|
limit?: number | undefined;
|
|
113
113
|
}, {
|
|
114
|
-
status: "
|
|
114
|
+
status: "failed" | "pending" | "processing" | "completed" | "corrupt";
|
|
115
115
|
limit?: number | undefined;
|
|
116
116
|
}>, undefined, AdminTasksResponse>;
|
|
117
117
|
readonly retryTask: import("./route-builder.js").RouteDefinition<z.ZodObject<{
|
|
@@ -125,10 +125,10 @@ export declare const ADMIN_ROUTES: {
|
|
|
125
125
|
status: z.ZodEnum<["pending", "failed", "corrupt"]>;
|
|
126
126
|
fileName: z.ZodEffects<z.ZodString, string, string>;
|
|
127
127
|
}, "strip", z.ZodTypeAny, {
|
|
128
|
-
status: "
|
|
128
|
+
status: "failed" | "pending" | "corrupt";
|
|
129
129
|
fileName: string;
|
|
130
130
|
}, {
|
|
131
|
-
status: "
|
|
131
|
+
status: "failed" | "pending" | "corrupt";
|
|
132
132
|
fileName: string;
|
|
133
133
|
}>, undefined, AdminDeleteTaskResponse>;
|
|
134
134
|
};
|
|
@@ -8,6 +8,7 @@ import { canPerformWorkflowAction, getBranchProtection } from '../authorization/
|
|
|
8
8
|
import { syncSubmitPr } from './github-sync.js';
|
|
9
9
|
import { getErrorMessage, redactCredentials, sanitizeErrorMessage } from '../utils/error.js';
|
|
10
10
|
import { isNonFastForwardRejection } from '../utils/git.js';
|
|
11
|
+
import { submissionEditorFromUser } from '../submission-attribution.js';
|
|
11
12
|
const getBranchStatusHandler = async (gc, _ctx, _req, _params) => {
|
|
12
13
|
const { branchContext } = gc;
|
|
13
14
|
return { ok: true, status: 200, data: { branch: branchContext.branch } };
|
|
@@ -49,8 +50,11 @@ const submitBranchForMergeHandler = async (gc, ctx, req, _params) => {
|
|
|
49
50
|
};
|
|
50
51
|
}
|
|
51
52
|
// Commit and push changes
|
|
53
|
+
const submitter = submissionEditorFromUser(req.user);
|
|
54
|
+
let changedPaths;
|
|
52
55
|
try {
|
|
53
|
-
|
|
56
|
+
;
|
|
57
|
+
({ changedPaths } = await ctx.services.submitBranch({ context: branchContext, submitter }));
|
|
54
58
|
}
|
|
55
59
|
catch (err) {
|
|
56
60
|
const message = getErrorMessage(err);
|
|
@@ -91,7 +95,7 @@ const submitBranchForMergeHandler = async (gc, ctx, req, _params) => {
|
|
|
91
95
|
};
|
|
92
96
|
}
|
|
93
97
|
// Create or update PR (sync via githubService, or async via task queue)
|
|
94
|
-
const prResult = await syncSubmitPr(ctx, branchContext);
|
|
98
|
+
const prResult = await syncSubmitPr(ctx, branchContext, { submitter, changedPaths });
|
|
95
99
|
// Update metadata with status and PR info
|
|
96
100
|
const meta = getBranchMetadataFileManager(branchContext.branchRoot, branchContext.baseRoot);
|
|
97
101
|
const updated = await meta.save({
|
package/dist/api/client.d.ts
CHANGED
|
@@ -25,6 +25,12 @@ export interface ApiClientOptions {
|
|
|
25
25
|
baseUrl?: string;
|
|
26
26
|
/** Custom fetch implementation, e.g. a mock in tests. */
|
|
27
27
|
fetch?: typeof fetch;
|
|
28
|
+
/**
|
|
29
|
+
* End request paths with `/` (by `withTrailingSlash`'s rule), matching a Next host built with
|
|
30
|
+
* `trailingSlash: true`. Defaults to the value `withCanopy` inlines at build time (see
|
|
31
|
+
* `request-url.ts`), else false.
|
|
32
|
+
*/
|
|
33
|
+
trailingSlash?: boolean;
|
|
28
34
|
/**
|
|
29
35
|
* Called whenever a response comes back 401: the credential is no longer accepted. A
|
|
30
36
|
* notification, not a retry; the editor's auth gate uses it to show sign-in.
|
|
@@ -44,6 +50,7 @@ export interface ApiClientOptions {
|
|
|
44
50
|
export declare class CanopyApiClient {
|
|
45
51
|
private baseUrl;
|
|
46
52
|
private fetchFn;
|
|
53
|
+
private trailingSlash;
|
|
47
54
|
private onUnauthorized;
|
|
48
55
|
readonly branches: {
|
|
49
56
|
/** GET /branches */
|
|
@@ -179,5 +186,10 @@ export declare class CanopyApiClient {
|
|
|
179
186
|
private buildPath;
|
|
180
187
|
private request;
|
|
181
188
|
}
|
|
189
|
+
/**
|
|
190
|
+
* Whether `result` was converted from a body no CanopyCMS handler wrote (a proxy or CDN
|
|
191
|
+
* error page), so its status says nothing about the API itself.
|
|
192
|
+
*/
|
|
193
|
+
export declare function isNonApiResponse(result: object): boolean;
|
|
182
194
|
/** Create a {@link CanopyApiClient}; pass a custom fetch for tests. */
|
|
183
195
|
export declare function createApiClient(options?: ApiClientOptions): CanopyApiClient;
|
package/dist/api/client.js
CHANGED
|
@@ -6,6 +6,8 @@
|
|
|
6
6
|
* (changes will be overwritten), edit the generator's templates instead.
|
|
7
7
|
*/
|
|
8
8
|
import { computeContentSha256Hex } from './request-body-hash.js';
|
|
9
|
+
import { readApiTrailingSlashEnv } from './request-url.js';
|
|
10
|
+
import { withTrailingSlash } from '../utils/url-prefix.js';
|
|
9
11
|
/**
|
|
10
12
|
* Typed client for the CanopyCMS API: one method per endpoint, grouped by namespace, each
|
|
11
13
|
* returning that endpoint's ApiResponse.
|
|
@@ -260,6 +262,7 @@ export class CanopyApiClient {
|
|
|
260
262
|
this.baseUrl = options.baseUrl ?? '/api/canopycms';
|
|
261
263
|
// An unbound fetch throws "Illegal invocation" in browsers; Node has no window.
|
|
262
264
|
this.fetchFn = options.fetch ?? (typeof window !== 'undefined' ? fetch.bind(window) : fetch);
|
|
265
|
+
this.trailingSlash = options.trailingSlash ?? readApiTrailingSlashEnv();
|
|
263
266
|
this.onUnauthorized = options.onUnauthorized;
|
|
264
267
|
}
|
|
265
268
|
buildPath(template, params) {
|
|
@@ -292,7 +295,7 @@ export class CanopyApiClient {
|
|
|
292
295
|
return result;
|
|
293
296
|
}
|
|
294
297
|
async request(method, path, body, headers = {}) {
|
|
295
|
-
const url = `${this.baseUrl}${path}`;
|
|
298
|
+
const url = `${this.baseUrl}${this.trailingSlash ? withTrailingSlash(path) : path}`;
|
|
296
299
|
const requestHeaders = {
|
|
297
300
|
...headers,
|
|
298
301
|
};
|
|
@@ -325,21 +328,42 @@ export class CanopyApiClient {
|
|
|
325
328
|
init.body = requestBody;
|
|
326
329
|
}
|
|
327
330
|
const response = await this.fetchFn(url, init);
|
|
331
|
+
// A body from in front of the API (a proxy or CDN error page) may be empty, not JSON, or
|
|
332
|
+
// not an ApiResponse; every such body becomes an `ok: false` ApiResponse, never a throw.
|
|
333
|
+
const parsed = await response.json().catch(() => undefined);
|
|
328
334
|
if (response.status === 401) {
|
|
329
335
|
this.onUnauthorized?.();
|
|
330
|
-
|
|
331
|
-
// not be an ApiResponse, or not JSON. Always return it as one rather than throwing.
|
|
332
|
-
const body = await response.json().catch(() => undefined);
|
|
333
|
-
const error = typeof body === 'object' && body !== null && 'error' in body && typeof body.error === 'string'
|
|
334
|
-
? body.error
|
|
335
|
-
: 'Unauthorized';
|
|
336
|
-
return { ok: false, status: 401, error };
|
|
336
|
+
return { ok: false, status: 401, error: errorFromBody(parsed) ?? 'Unauthorized' };
|
|
337
337
|
}
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
338
|
+
if (isApiResponseBody(parsed))
|
|
339
|
+
return parsed;
|
|
340
|
+
const converted = {
|
|
341
|
+
ok: false,
|
|
342
|
+
status: response.status,
|
|
343
|
+
error: errorFromBody(parsed) ?? `Unexpected response from server (HTTP ${response.status})`,
|
|
344
|
+
};
|
|
345
|
+
convertedResponses.add(converted);
|
|
346
|
+
return converted;
|
|
341
347
|
}
|
|
342
348
|
}
|
|
349
|
+
const convertedResponses = new WeakSet();
|
|
350
|
+
/**
|
|
351
|
+
* Whether `result` was converted from a body no CanopyCMS handler wrote (a proxy or CDN
|
|
352
|
+
* error page), so its status says nothing about the API itself.
|
|
353
|
+
*/
|
|
354
|
+
export function isNonApiResponse(result) {
|
|
355
|
+
return convertedResponses.has(result);
|
|
356
|
+
}
|
|
357
|
+
/** The `error` string of a JSON body, when it has one. */
|
|
358
|
+
function errorFromBody(body) {
|
|
359
|
+
return typeof body === 'object' && body !== null && 'error' in body && typeof body.error === 'string'
|
|
360
|
+
? body.error
|
|
361
|
+
: undefined;
|
|
362
|
+
}
|
|
363
|
+
/** Whether a parsed body has the `{ ok, status, ... }` shape every handler returns. */
|
|
364
|
+
function isApiResponseBody(body) {
|
|
365
|
+
return typeof body === 'object' && body !== null && 'ok' in body && typeof body.ok === 'boolean';
|
|
366
|
+
}
|
|
343
367
|
/** Create a {@link CanopyApiClient}; pass a custom fetch for tests. */
|
|
344
368
|
export function createApiClient(options) {
|
|
345
369
|
return new CanopyApiClient(options);
|
package/dist/api/content.d.ts
CHANGED
|
@@ -40,11 +40,12 @@ export interface WriteContentBody {
|
|
|
40
40
|
data?: Record<string, unknown>;
|
|
41
41
|
body?: string;
|
|
42
42
|
/**
|
|
43
|
-
* OCC
|
|
44
|
-
*
|
|
45
|
-
*
|
|
46
|
-
*
|
|
47
|
-
*
|
|
43
|
+
* OCC token. A number from a prior read/write response makes this an update,
|
|
44
|
+
* rejected with 409 if the file's mtime no longer matches (a file deleted
|
|
45
|
+
* since is written anew). `null` or omitted makes it
|
|
46
|
+
* a create, rejected with 409 if the entry already exists. There is no blind
|
|
47
|
+
* write: without a token, "no conflict detection" would be indistinguishable
|
|
48
|
+
* from "lost the token", so an update has to prove which version it read.
|
|
48
49
|
*/
|
|
49
50
|
expectedVersion?: number | null;
|
|
50
51
|
}
|
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,19 +80,18 @@ 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
|
-
const access = await ctx.services.checkContentAccess(branchContext, branchContext.branchRoot,
|
|
94
|
+
const access = await ctx.services.checkContentAccess(branchContext, branchContext.branchRoot, entryLogicalPath(schemaItem.logicalPath, slug), req.user, 'read');
|
|
96
95
|
if (!access.allowed) {
|
|
97
96
|
return { ok: false, status: 403, error: 'Forbidden' };
|
|
98
97
|
}
|
|
@@ -117,19 +116,18 @@ const writeContentHandler = async (gc, ctx, req, params, body) => {
|
|
|
117
116
|
const logicalPathSegments = parseApiPath(params.path, contentRoot);
|
|
118
117
|
let schemaItem;
|
|
119
118
|
let slug;
|
|
120
|
-
let relativePath;
|
|
121
119
|
try {
|
|
122
120
|
const resolved = store.resolvePath(logicalPathSegments);
|
|
123
121
|
schemaItem = resolved.schemaItem;
|
|
124
122
|
slug = resolved.slug;
|
|
125
|
-
|
|
126
|
-
|
|
123
|
+
// Runs the store's slug and traversal checks before the permission check.
|
|
124
|
+
await store.resolveDocumentPath(schemaItem.logicalPath, slug);
|
|
127
125
|
}
|
|
128
126
|
catch (err) {
|
|
129
127
|
const message = err instanceof ContentStoreError ? err.message : 'Invalid content request';
|
|
130
128
|
return { ok: false, status: 400, error: sanitizeErrorMessage(message) };
|
|
131
129
|
}
|
|
132
|
-
const access = await ctx.services.checkContentAccess(branchContext, branchContext.branchRoot,
|
|
130
|
+
const access = await ctx.services.checkContentAccess(branchContext, branchContext.branchRoot, entryLogicalPath(schemaItem.logicalPath, slug), req.user, 'edit');
|
|
133
131
|
if (!access.allowed) {
|
|
134
132
|
return { ok: false, status: 403, error: 'Forbidden' };
|
|
135
133
|
}
|
|
@@ -198,20 +196,21 @@ const writeContentHandler = async (gc, ctx, req, params, body) => {
|
|
|
198
196
|
}
|
|
199
197
|
const data = body.data ?? {};
|
|
200
198
|
const isDataOnly = isDataOnlyFormat(body.format);
|
|
199
|
+
// The store's own `undefined` (blind write) is never reachable from here: an omitted token is
|
|
200
|
+
// a create, so an update that lost its token 409s instead of overwriting unchecked.
|
|
201
|
+
const expectedVersion = body.expectedVersion ?? null;
|
|
202
|
+
const createConflictError = body.expectedVersion === null
|
|
203
|
+
? `An entry with slug "${slug}" already exists`
|
|
204
|
+
: `An entry with slug "${slug}" already exists; an update must send the expectedVersion from its last read`;
|
|
201
205
|
try {
|
|
202
206
|
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
|
-
};
|
|
207
|
+
// Create guard: a create against a slug that already has content must never silently
|
|
208
|
+
// overwrite it — short-circuit with 409 before field validation runs, so the error names the
|
|
209
|
+
// real problem instead of "field is required" or a bare conflict. store.write() re-enforces
|
|
210
|
+
// this itself inside its per-entry lock against a fresh stat (the race-safe authoritative
|
|
211
|
+
// check); this is just a cheaper fast path for the common case.
|
|
212
|
+
if (expectedVersion === null && exists) {
|
|
213
|
+
return { ok: false, status: 409, error: createConflictError };
|
|
215
214
|
}
|
|
216
215
|
// [SLUG] Create-only routability check: `writeContentParamsSchema.path`'s `parseLogicalPath`
|
|
217
216
|
// has no charset rule, and `ContentStore.resolvePath` only lowercases the slug — so this is
|
|
@@ -360,13 +359,13 @@ const writeContentHandler = async (gc, ctx, req, params, body) => {
|
|
|
360
359
|
? {
|
|
361
360
|
format: body.format,
|
|
362
361
|
data: normalizedData ?? {},
|
|
363
|
-
expectedVersion
|
|
362
|
+
expectedVersion,
|
|
364
363
|
}
|
|
365
364
|
: {
|
|
366
365
|
format: body.format,
|
|
367
366
|
data: normalizedData,
|
|
368
367
|
body: body.body ?? '',
|
|
369
|
-
expectedVersion
|
|
368
|
+
expectedVersion,
|
|
370
369
|
};
|
|
371
370
|
// Pass the resolved entryTypeName (not the raw, possibly-omitted
|
|
372
371
|
// params.entryType) so the store's own format check agrees with the type
|
|
@@ -412,12 +411,8 @@ const writeContentHandler = async (gc, ctx, req, params, body) => {
|
|
|
412
411
|
}
|
|
413
412
|
// Race-safe fallback: the early `exists` check above catches the common case; this covers a
|
|
414
413
|
// 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
|
-
};
|
|
414
|
+
if (expectedVersion === null) {
|
|
415
|
+
return { ok: false, status: 409, error: createConflictError };
|
|
421
416
|
}
|
|
422
417
|
return {
|
|
423
418
|
ok: false,
|
|
@@ -443,19 +438,19 @@ const validateReferencesHandler = async (gc, ctx, req, params, body) => {
|
|
|
443
438
|
const contentRoot = ctx.services.config.contentRoot || 'content';
|
|
444
439
|
const logicalPathSegments = parseApiPath(params.path, contentRoot);
|
|
445
440
|
let schemaItem;
|
|
446
|
-
let
|
|
441
|
+
let slug;
|
|
447
442
|
try {
|
|
448
443
|
const resolved = store.resolvePath(logicalPathSegments);
|
|
449
444
|
schemaItem = resolved.schemaItem;
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
445
|
+
slug = resolved.slug;
|
|
446
|
+
// Runs the store's slug and traversal checks before the permission check.
|
|
447
|
+
await store.resolveDocumentPath(schemaItem.logicalPath, slug);
|
|
453
448
|
}
|
|
454
449
|
catch (err) {
|
|
455
450
|
const message = err instanceof ContentStoreError ? err.message : 'Invalid content request';
|
|
456
451
|
return { ok: false, status: 400, error: sanitizeErrorMessage(message) };
|
|
457
452
|
}
|
|
458
|
-
const access = await ctx.services.checkContentAccess(branchContext, branchContext.branchRoot,
|
|
453
|
+
const access = await ctx.services.checkContentAccess(branchContext, branchContext.branchRoot, entryLogicalPath(schemaItem.logicalPath, slug), req.user, 'read');
|
|
459
454
|
if (!access.allowed) {
|
|
460
455
|
return { ok: false, status: 403, error: 'Forbidden' };
|
|
461
456
|
}
|
|
@@ -510,22 +505,26 @@ const renameEntryHandler = async (gc, ctx, req, params, body) => {
|
|
|
510
505
|
const logicalPathSegments = parseApiPath(params.path, contentRoot);
|
|
511
506
|
let schemaItem;
|
|
512
507
|
let currentSlug;
|
|
513
|
-
let relativePath;
|
|
514
508
|
try {
|
|
515
509
|
const resolved = store.resolvePath(logicalPathSegments);
|
|
516
510
|
schemaItem = resolved.schemaItem;
|
|
517
511
|
currentSlug = resolved.slug;
|
|
518
|
-
|
|
519
|
-
|
|
512
|
+
// Runs the store's slug and traversal checks before the permission check.
|
|
513
|
+
await store.resolveDocumentPath(schemaItem.logicalPath, currentSlug);
|
|
520
514
|
}
|
|
521
515
|
catch (err) {
|
|
522
516
|
const message = err instanceof ContentStoreError ? err.message : 'Invalid content request';
|
|
523
517
|
return { ok: false, status: 400, error: sanitizeErrorMessage(message) };
|
|
524
518
|
}
|
|
525
|
-
const access = await ctx.services.checkContentAccess(branchContext, branchContext.branchRoot,
|
|
519
|
+
const access = await ctx.services.checkContentAccess(branchContext, branchContext.branchRoot, entryLogicalPath(schemaItem.logicalPath, currentSlug), req.user, 'edit');
|
|
526
520
|
if (!access.allowed) {
|
|
527
521
|
return { ok: false, status: 403, error: 'Forbidden' };
|
|
528
522
|
}
|
|
523
|
+
// A rename creates the entry at its new path, so that path needs edit access too.
|
|
524
|
+
const destinationAccess = await ctx.services.checkContentAccess(branchContext, branchContext.branchRoot, entryLogicalPath(schemaItem.logicalPath, body.newSlug), req.user, 'edit');
|
|
525
|
+
if (!destinationAccess.allowed) {
|
|
526
|
+
return { ok: false, status: 403, error: 'Forbidden' };
|
|
527
|
+
}
|
|
529
528
|
try {
|
|
530
529
|
const result = await store.renameEntry(schemaItem.logicalPath, currentSlug, body.newSlug);
|
|
531
530
|
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,
|
|
@@ -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,
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Whether the API client ends request paths with `/`: on a `trailingSlash: true` Next host every
|
|
3
|
+
* unslashed call draws a 308 first. `withCanopy` sets `CANOPY_API_TRAILING_SLASH` in Next's `env`
|
|
4
|
+
* config, which Next substitutes for this literal member expression in server and browser bundles
|
|
5
|
+
* (`getNextConfigEnv`, `next/dist/build/define-env.js:54`). The try/catch covers a host whose
|
|
6
|
+
* bundler neither substitutes it nor shims `process` in the browser.
|
|
7
|
+
*/
|
|
8
|
+
export declare function readApiTrailingSlashEnv(): boolean;
|