canopycms 0.0.68-int.110 → 0.0.68-int.112
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/api/admin-branch-health.d.ts +24 -1
- package/dist/api/admin-branch-health.js +31 -4
- package/dist/api/admin.d.ts +7 -1
- package/dist/api/client.d.ts +16 -2
- package/dist/api/client.js +16 -2
- package/dist/api/permissions.d.ts +19 -0
- package/dist/api/permissions.js +47 -2
- package/dist/api/types.d.ts +3 -1
- package/dist/api/users-constants.d.ts +6 -0
- package/dist/api/users-constants.js +6 -0
- package/dist/auth/caching-auth-plugin.d.ts +2 -0
- package/dist/auth/caching-auth-plugin.js +10 -0
- package/dist/auth/plugin.d.ts +9 -0
- package/dist/auth/user-metadata-lookup.d.ts +15 -0
- package/dist/auth/user-metadata-lookup.js +78 -0
- package/dist/branch-health.d.ts +28 -7
- package/dist/branch-health.js +44 -14
- package/dist/cli/cli.js +14 -15
- package/dist/cli/generate-ai-content.js +4 -6
- package/dist/cli/init.js +7 -8
- package/dist/cli/project-detect.d.ts +2 -0
- package/dist/cli/project-detect.js +2 -0
- package/dist/cli/template-files/Dockerfile.cms.template +13 -5
- package/dist/content-id-index.js +2 -2
- package/dist/content-store.js +4 -6
- package/dist/editor/CanopyEditor.js +1 -1
- package/dist/editor/EditorAuthGate.d.ts +7 -1
- package/dist/editor/EditorAuthGate.js +32 -5
- package/dist/editor/admin/SystemHealthPanel.js +77 -5
- package/dist/editor/admin/useSystemHealth.d.ts +18 -0
- package/dist/editor/admin/useSystemHealth.js +70 -1
- package/dist/editor/context/ApiClientContext.d.ts +6 -1
- package/dist/editor/context/ApiClientContext.js +17 -7
- package/dist/editor/hooks/useGroupManager.js +3 -13
- package/dist/editor/hooks/useUserMetadata.d.ts +1 -5
- package/dist/editor/hooks/useUserMetadata.js +18 -49
- package/dist/editor/hooks/user-metadata-batcher.d.ts +7 -0
- package/dist/editor/hooks/user-metadata-batcher.js +26 -0
- package/dist/git-manager.d.ts +10 -0
- package/dist/git-manager.js +4 -3
- package/dist/http/handler.js +25 -1
- package/dist/operating-mode/editor-mode-check.d.ts +15 -0
- package/dist/operating-mode/editor-mode-check.js +24 -0
- package/dist/operating-mode/mode-env.d.ts +13 -14
- package/dist/operating-mode/mode-env.js +13 -14
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/dist/worker/cms-worker.d.ts +30 -2
- package/dist/worker/cms-worker.js +192 -14
- package/dist/worker/git-sync.js +4 -3
- package/dist/worker/github-mirror.d.ts +29 -1
- package/dist/worker/github-mirror.js +91 -1
- package/dist/worker/task-runner.js +19 -1
- package/package.json +4 -1
|
@@ -13,9 +13,26 @@ import { z } from 'zod';
|
|
|
13
13
|
import type { BranchAccessControl, BranchMetadata, BranchStatus } from '../types.js';
|
|
14
14
|
import { type BranchHealthEntry } from '../branch-health.js';
|
|
15
15
|
import type { ApiResponse } from './types.js';
|
|
16
|
+
/**
|
|
17
|
+
* Wall-clock budget the opt-in duplicate-ID scan shares across every healthy
|
|
18
|
+
* branch: well inside the CMS Lambda's default 60 s timeout
|
|
19
|
+
* (`DEFAULT_CMS_LAMBDA_TIMEOUT` in canopycms-cdk), leaving the rest of the
|
|
20
|
+
* request its headroom.
|
|
21
|
+
* @internal Exported for tests.
|
|
22
|
+
*/
|
|
23
|
+
export declare const DUPLICATE_ID_SCAN_BUDGET_MS = 20000;
|
|
16
24
|
export interface BranchHealthData {
|
|
17
25
|
entries: BranchHealthEntry[];
|
|
18
26
|
generatedAt: string;
|
|
27
|
+
/**
|
|
28
|
+
* Present only when the request asked for the duplicate-ID scan. `truncated`
|
|
29
|
+
* is true when the budget ran out before every healthy branch was scanned;
|
|
30
|
+
* those branches report `duplicateIdScan.state === 'unknown'`.
|
|
31
|
+
*/
|
|
32
|
+
duplicateIdScan?: {
|
|
33
|
+
budgetMs: number;
|
|
34
|
+
truncated: boolean;
|
|
35
|
+
};
|
|
19
36
|
}
|
|
20
37
|
/** Response type for GET /admin/branch-health */
|
|
21
38
|
export type BranchHealthResponse = ApiResponse<BranchHealthData>;
|
|
@@ -65,7 +82,13 @@ export type RepairContentDuplicatesResponse = ApiResponse<RepairContentDuplicate
|
|
|
65
82
|
* router/generate-client import.
|
|
66
83
|
*/
|
|
67
84
|
export declare const ADMIN_BRANCH_HEALTH_ROUTES: {
|
|
68
|
-
readonly branchHealth: import("./route-builder.js").RouteDefinition<
|
|
85
|
+
readonly branchHealth: import("./route-builder.js").RouteDefinition<z.ZodObject<{
|
|
86
|
+
duplicates: z.ZodOptional<z.ZodLiteral<"1">>;
|
|
87
|
+
}, "strip", z.ZodTypeAny, {
|
|
88
|
+
duplicates?: "1" | undefined;
|
|
89
|
+
}, {
|
|
90
|
+
duplicates?: "1" | undefined;
|
|
91
|
+
}>, undefined, BranchHealthResponse>;
|
|
69
92
|
readonly purgeBranchDir: import("./route-builder.js").RouteDefinition<z.ZodObject<{
|
|
70
93
|
dirName: z.ZodEffects<z.ZodEffects<z.ZodString, string, string>, string, string>;
|
|
71
94
|
}, "strip", z.ZodTypeAny, {
|
|
@@ -30,6 +30,14 @@ import { defineEndpoint } from './route-builder.js';
|
|
|
30
30
|
import { baseBranchOf } from '../utils/base-branch.js';
|
|
31
31
|
/** [H1] A fresh (< 5 min old) init lock blocks purge -- provisioning may be running. */
|
|
32
32
|
const PROVISIONING_LOCK_FRESH_MS = 5 * 60_000;
|
|
33
|
+
/**
|
|
34
|
+
* Wall-clock budget the opt-in duplicate-ID scan shares across every healthy
|
|
35
|
+
* branch: well inside the CMS Lambda's default 60 s timeout
|
|
36
|
+
* (`DEFAULT_CMS_LAMBDA_TIMEOUT` in canopycms-cdk), leaving the rest of the
|
|
37
|
+
* request its headroom.
|
|
38
|
+
* @internal Exported for tests.
|
|
39
|
+
*/
|
|
40
|
+
export const DUPLICATE_ID_SCAN_BUDGET_MS = 20_000;
|
|
33
41
|
// Mirrors deleteTaskHandler's fileName pattern in admin.ts: conservative
|
|
34
42
|
// charset plus explicit traversal/dot-prefix refinements (the regex alone
|
|
35
43
|
// technically excludes '/' already, but the refinements keep intent explicit
|
|
@@ -40,6 +48,11 @@ const dirNameSchema = z
|
|
|
40
48
|
.refine((v) => !v.includes('..'), { message: 'dirName must not contain ..' })
|
|
41
49
|
.refine((v) => !v.startsWith('.'), { message: 'dirName must not be dot-prefixed' });
|
|
42
50
|
const branchDirParamsSchema = z.object({ dirName: dirNameSchema });
|
|
51
|
+
/**
|
|
52
|
+
* `duplicates=1` opts in to the duplicate-ID scan, a full content-tree readdir
|
|
53
|
+
* per healthy branch. The panel's 30 s poll leaves it off.
|
|
54
|
+
*/
|
|
55
|
+
const branchHealthParamsSchema = z.object({ duplicates: z.literal('1').optional() });
|
|
43
56
|
/**
|
|
44
57
|
* Re-resolve dirName under baseRoot and enforce containment (belt-and-
|
|
45
58
|
* suspenders on top of the zod regex, matching deleteTaskHandler's pattern
|
|
@@ -61,19 +74,31 @@ class RepairPreconditionError extends Error {
|
|
|
61
74
|
this.status = status;
|
|
62
75
|
}
|
|
63
76
|
}
|
|
64
|
-
const getBranchHealthHandler = async (_gc, ctx, _req) => {
|
|
77
|
+
const getBranchHealthHandler = async (_gc, ctx, _req, params) => {
|
|
65
78
|
// Same derivation services.ts uses to construct the BranchRegistry --
|
|
66
79
|
// admin handlers must agree with it or the scan silently looks at the
|
|
67
80
|
// wrong directory.
|
|
68
81
|
const baseRoot = getDefaultBranchBase(ctx.services.config.mode);
|
|
69
82
|
const baseBranchName = baseBranchOf(ctx.services.config);
|
|
70
83
|
const contentRootName = ctx.services.config.contentRoot || 'content';
|
|
84
|
+
const scanDuplicates = params.duplicates === '1';
|
|
71
85
|
try {
|
|
72
|
-
const entries = await scanBranchHealth(baseRoot, {
|
|
86
|
+
const entries = await scanBranchHealth(baseRoot, {
|
|
87
|
+
baseBranchName,
|
|
88
|
+
contentRootName,
|
|
89
|
+
...(scanDuplicates ? { duplicateIdScan: { budgetMs: DUPLICATE_ID_SCAN_BUDGET_MS } } : {}),
|
|
90
|
+
});
|
|
91
|
+
const truncated = entries.some((e) => e.duplicateIdScan?.state === 'unknown' && e.duplicateIdScan.reason === 'out-of-time');
|
|
73
92
|
return {
|
|
74
93
|
ok: true,
|
|
75
94
|
status: 200,
|
|
76
|
-
data: {
|
|
95
|
+
data: {
|
|
96
|
+
entries,
|
|
97
|
+
generatedAt: new Date().toISOString(),
|
|
98
|
+
...(scanDuplicates
|
|
99
|
+
? { duplicateIdScan: { budgetMs: DUPLICATE_ID_SCAN_BUDGET_MS, truncated } }
|
|
100
|
+
: {}),
|
|
101
|
+
},
|
|
77
102
|
};
|
|
78
103
|
}
|
|
79
104
|
catch (err) {
|
|
@@ -496,13 +521,15 @@ const repairContentDuplicatesHandler = async (_gc, ctx, req, params) => {
|
|
|
496
521
|
}
|
|
497
522
|
};
|
|
498
523
|
/**
|
|
499
|
-
* Branch directory health scan (healthy/corrupt-metadata/orphan)
|
|
524
|
+
* Branch directory health scan (healthy/corrupt-metadata/orphan); `duplicates=1`
|
|
525
|
+
* adds the bounded duplicate-ID scan
|
|
500
526
|
*/
|
|
501
527
|
const getBranchHealth = defineEndpoint({
|
|
502
528
|
namespace: 'admin',
|
|
503
529
|
name: 'branchHealth',
|
|
504
530
|
method: 'GET',
|
|
505
531
|
path: '/admin/branch-health',
|
|
532
|
+
params: branchHealthParamsSchema,
|
|
506
533
|
responseType: 'BranchHealthResponse',
|
|
507
534
|
response: {},
|
|
508
535
|
defaultMockData: { entries: [], generatedAt: '2024-01-01T00:00:00.000Z' },
|
package/dist/api/admin.d.ts
CHANGED
|
@@ -105,7 +105,13 @@ export type DeleteTaskParams = z.infer<typeof deleteTaskParamsSchema>;
|
|
|
105
105
|
* `ADMIN_ROUTES` import.
|
|
106
106
|
*/
|
|
107
107
|
export declare const ADMIN_ROUTES: {
|
|
108
|
-
readonly branchHealth: import("./route-builder.js").RouteDefinition<
|
|
108
|
+
readonly branchHealth: import("./route-builder.js").RouteDefinition<z.ZodObject<{
|
|
109
|
+
duplicates: z.ZodOptional<z.ZodLiteral<"1">>;
|
|
110
|
+
}, "strip", z.ZodTypeAny, {
|
|
111
|
+
duplicates?: "1" | undefined;
|
|
112
|
+
}, {
|
|
113
|
+
duplicates?: "1" | undefined;
|
|
114
|
+
}>, undefined, import("./admin-branch-health.js").BranchHealthResponse>;
|
|
109
115
|
readonly purgeBranchDir: import("./route-builder.js").RouteDefinition<z.ZodObject<{
|
|
110
116
|
dirName: z.ZodEffects<z.ZodEffects<z.ZodString, string, string>, string, string>;
|
|
111
117
|
}, "strip", z.ZodTypeAny, {
|
package/dist/api/client.d.ts
CHANGED
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
* AUTO-GENERATED by scripts/generate-client.ts; do not edit this file manually
|
|
6
6
|
* (changes will be overwritten), edit the generator's templates instead.
|
|
7
7
|
*/
|
|
8
|
+
import type { OperatingMode } from '../operating-mode/types.js';
|
|
8
9
|
import type { BranchDeleteResponse, BranchListItemResponse, BranchListResponse, BranchResponse, CreateBranchBody, UpdateBranchAccessBody } from './branch.js';
|
|
9
10
|
import type { BranchMergeResponse } from './branch-status.js';
|
|
10
11
|
import type { AddCommentBody, AddCommentResponse, CommentsResponse, ResolveCommentResponse } from './comments.js';
|
|
@@ -13,7 +14,7 @@ import type { ReferenceOptionsResponse } from './reference-options.js';
|
|
|
13
14
|
import type { ResolveReferencesBody, ResolveReferencesResponse } from './resolve-references.js';
|
|
14
15
|
import type { DeleteEntryResponse, EntriesResponse } from './entries.js';
|
|
15
16
|
import type { AssetDeleteResponse, AssetsListResponse, FinalizeAssetBody, FinalizeAssetResponse, PresignAssetBody, PresignAssetResponse } from './assets.js';
|
|
16
|
-
import type { GetUserMetadataResponse, ListGroupsResponse, PermissionsResponse, SearchUsersResponse, UpdatePermissionsBody } from './permissions.js';
|
|
17
|
+
import type { BatchGetUserMetadataBody, BatchGetUserMetadataResponse, GetUserMetadataResponse, ListGroupsResponse, PermissionsResponse, SearchUsersResponse, UpdatePermissionsBody } from './permissions.js';
|
|
17
18
|
import type { ExternalGroupsResponse, InternalGroupsResponse, UpdateInternalGroupsBody, UpdateInternalGroupsResponse } from './groups.js';
|
|
18
19
|
import type { UserInfoResponse } from './user.js';
|
|
19
20
|
import type { AddEntryTypeApiResponse, CreateCollectionApiResponse, DeleteCollectionApiResponse, GetCollectionApiResponse, GetSchemaApiResponse, InvalidateSchemaCacheApiResponse, RemoveEntryTypeApiResponse, UpdateCollectionApiResponse, UpdateEntryTypeApiResponse, UpdateOrderApiResponse, UpdateOrderBody } from './schema.js';
|
|
@@ -37,6 +38,15 @@ export interface ApiClientOptions {
|
|
|
37
38
|
* notification, not a retry; the editor's auth gate uses it to show sign-in.
|
|
38
39
|
*/
|
|
39
40
|
onUnauthorized?: () => void;
|
|
41
|
+
/**
|
|
42
|
+
* The mode this bundle was built for, sent on every request so the server refuses a bundle built
|
|
43
|
+
* for the other mode (operating-mode/editor-mode-check.ts). Set by the editor from
|
|
44
|
+
* `config.mode`; a client without it is not checked.
|
|
45
|
+
* @internal
|
|
46
|
+
*/
|
|
47
|
+
editorMode?: OperatingMode;
|
|
48
|
+
/** Called whenever the server refuses `editorMode` (code `EDITOR_MODE_MISMATCH`). @internal */
|
|
49
|
+
onEditorModeMismatch?: () => void;
|
|
40
50
|
}
|
|
41
51
|
/**
|
|
42
52
|
* Typed client for the CanopyCMS API: one method per endpoint, grouped by namespace, each
|
|
@@ -53,6 +63,8 @@ export declare class CanopyApiClient {
|
|
|
53
63
|
private fetchFn;
|
|
54
64
|
private trailingSlash;
|
|
55
65
|
private onUnauthorized;
|
|
66
|
+
private editorMode;
|
|
67
|
+
private onEditorModeMismatch;
|
|
56
68
|
readonly branches: {
|
|
57
69
|
/** GET /branches */
|
|
58
70
|
list: () => Promise<BranchListResponse>;
|
|
@@ -130,6 +142,8 @@ export declare class CanopyApiClient {
|
|
|
130
142
|
listGroups: () => Promise<ListGroupsResponse>;
|
|
131
143
|
/** GET /users/:userId */
|
|
132
144
|
getUserMetadata: (params: Record<string, string>) => Promise<GetUserMetadataResponse>;
|
|
145
|
+
/** POST /users/batch */
|
|
146
|
+
batchGetUserMetadata: (body: BatchGetUserMetadataBody) => Promise<BatchGetUserMetadataResponse>;
|
|
133
147
|
};
|
|
134
148
|
readonly groups: {
|
|
135
149
|
/** GET /groups/internal */
|
|
@@ -167,7 +181,7 @@ export declare class CanopyApiClient {
|
|
|
167
181
|
};
|
|
168
182
|
readonly admin: {
|
|
169
183
|
/** GET /admin/branch-health */
|
|
170
|
-
branchHealth: () => Promise<BranchHealthResponse>;
|
|
184
|
+
branchHealth: (params: Record<string, string>) => Promise<BranchHealthResponse>;
|
|
171
185
|
/** POST /admin/branch-dirs/:dirName/purge */
|
|
172
186
|
purgeBranchDir: (params: Record<string, string>) => Promise<PurgeBranchDirResponse>;
|
|
173
187
|
/** POST /admin/branch-dirs/:dirName/repair-metadata */
|
package/dist/api/client.js
CHANGED
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
*/
|
|
8
8
|
import { computeContentSha256Hex } from './request-body-hash.js';
|
|
9
9
|
import { readTrailingSlashEnv, withTrailingSlash } from '../utils/url-prefix.js';
|
|
10
|
+
import { EDITOR_MODE_HEADER, EDITOR_MODE_MISMATCH_STATUS } from '../operating-mode/editor-mode-check.js';
|
|
10
11
|
/**
|
|
11
12
|
* Typed client for the CanopyCMS API: one method per endpoint, grouped by namespace, each
|
|
12
13
|
* returning that endpoint's ApiResponse.
|
|
@@ -161,6 +162,10 @@ export class CanopyApiClient {
|
|
|
161
162
|
getUserMetadata: (params) => {
|
|
162
163
|
return this.request('GET', this.buildPath('/users/:userId', params));
|
|
163
164
|
},
|
|
165
|
+
/** POST /users/batch */
|
|
166
|
+
batchGetUserMetadata: (body) => {
|
|
167
|
+
return this.request('POST', '/users/batch', body);
|
|
168
|
+
},
|
|
164
169
|
};
|
|
165
170
|
this.groups = {
|
|
166
171
|
/** GET /groups/internal */
|
|
@@ -226,8 +231,8 @@ export class CanopyApiClient {
|
|
|
226
231
|
};
|
|
227
232
|
this.admin = {
|
|
228
233
|
/** GET /admin/branch-health */
|
|
229
|
-
branchHealth: () => {
|
|
230
|
-
return this.request('GET', '/admin/branch-health');
|
|
234
|
+
branchHealth: (params) => {
|
|
235
|
+
return this.request('GET', this.buildPath('/admin/branch-health', params));
|
|
231
236
|
},
|
|
232
237
|
/** POST /admin/branch-dirs/:dirName/purge */
|
|
233
238
|
purgeBranchDir: (params) => {
|
|
@@ -263,6 +268,8 @@ export class CanopyApiClient {
|
|
|
263
268
|
this.fetchFn = options.fetch ?? (typeof window !== 'undefined' ? fetch.bind(window) : fetch);
|
|
264
269
|
this.trailingSlash = options.trailingSlash ?? readTrailingSlashEnv();
|
|
265
270
|
this.onUnauthorized = options.onUnauthorized;
|
|
271
|
+
this.editorMode = options.editorMode;
|
|
272
|
+
this.onEditorModeMismatch = options.onEditorModeMismatch;
|
|
266
273
|
}
|
|
267
274
|
buildPath(template, params) {
|
|
268
275
|
let result = template;
|
|
@@ -298,6 +305,8 @@ export class CanopyApiClient {
|
|
|
298
305
|
const requestHeaders = {
|
|
299
306
|
...headers,
|
|
300
307
|
};
|
|
308
|
+
if (this.editorMode)
|
|
309
|
+
requestHeaders[EDITOR_MODE_HEADER] = this.editorMode;
|
|
301
310
|
let requestBody;
|
|
302
311
|
if (body !== undefined) {
|
|
303
312
|
if (body instanceof FormData) {
|
|
@@ -346,6 +355,11 @@ export class CanopyApiClient {
|
|
|
346
355
|
this.onUnauthorized?.();
|
|
347
356
|
return { ok: false, status: 401, error: errorFromBody(parsed) ?? 'Unauthorized' };
|
|
348
357
|
}
|
|
358
|
+
if (isApiResponseBody(parsed) &&
|
|
359
|
+
parsed.status === EDITOR_MODE_MISMATCH_STATUS &&
|
|
360
|
+
parsed.code === 'EDITOR_MODE_MISMATCH') {
|
|
361
|
+
this.onEditorModeMismatch?.();
|
|
362
|
+
}
|
|
349
363
|
if (isApiResponseBody(parsed))
|
|
350
364
|
return parsed;
|
|
351
365
|
const converted = {
|
|
@@ -19,6 +19,10 @@ export type ListGroupsResponse = ApiResponse<{
|
|
|
19
19
|
export type GetUserMetadataResponse = ApiResponse<{
|
|
20
20
|
user: UserSearchResult | null;
|
|
21
21
|
}>;
|
|
22
|
+
/** Response type for batch user metadata: the users found, in no guaranteed order; unknown ids are absent */
|
|
23
|
+
export type BatchGetUserMetadataResponse = ApiResponse<{
|
|
24
|
+
users: UserSearchResult[];
|
|
25
|
+
}>;
|
|
22
26
|
declare const updatePermissionsBodySchema: z.ZodObject<{
|
|
23
27
|
permissions: z.ZodArray<z.ZodObject<{
|
|
24
28
|
path: z.ZodType<import("../authorization/index.js").PermissionPath, z.ZodTypeDef, import("../authorization/index.js").PermissionPath>;
|
|
@@ -142,9 +146,17 @@ declare const getUserMetadataParamsSchema: z.ZodObject<{
|
|
|
142
146
|
}, {
|
|
143
147
|
userId: string;
|
|
144
148
|
}>;
|
|
149
|
+
declare const batchGetUserMetadataBodySchema: z.ZodObject<{
|
|
150
|
+
userIds: z.ZodArray<z.ZodString, "many">;
|
|
151
|
+
}, "strip", z.ZodTypeAny, {
|
|
152
|
+
userIds: string[];
|
|
153
|
+
}, {
|
|
154
|
+
userIds: string[];
|
|
155
|
+
}>;
|
|
145
156
|
export type UpdatePermissionsBody = z.infer<typeof updatePermissionsBodySchema>;
|
|
146
157
|
/** @internal No importer; deletion candidate in knip-no-importer-deletion-candidates.md. */
|
|
147
158
|
export type SearchUsersParams = z.infer<typeof searchUsersParamsSchema>;
|
|
159
|
+
export type BatchGetUserMetadataBody = z.infer<typeof batchGetUserMetadataBodySchema>;
|
|
148
160
|
/** @internal No importer; deletion candidate in knip-no-importer-deletion-candidates.md. */
|
|
149
161
|
export type GetUserMetadataParams = z.infer<typeof getUserMetadataParamsSchema>;
|
|
150
162
|
/**
|
|
@@ -276,5 +288,12 @@ export declare const PERMISSION_ROUTES: {
|
|
|
276
288
|
}, {
|
|
277
289
|
userId: string;
|
|
278
290
|
}>, undefined, GetUserMetadataResponse>;
|
|
291
|
+
readonly batchGetUserMetadata: import("./route-builder.js").RouteDefinition<undefined, z.ZodObject<{
|
|
292
|
+
userIds: z.ZodArray<z.ZodString, "many">;
|
|
293
|
+
}, "strip", z.ZodTypeAny, {
|
|
294
|
+
userIds: string[];
|
|
295
|
+
}, {
|
|
296
|
+
userIds: string[];
|
|
297
|
+
}>, BatchGetUserMetadataResponse>;
|
|
279
298
|
};
|
|
280
299
|
export {};
|
package/dist/api/permissions.js
CHANGED
|
@@ -2,6 +2,8 @@ import { z } from 'zod';
|
|
|
2
2
|
import { loadPermissionsFile, mutatePermissionsFile, loadGroupsFile, deriveInternalGroups, SettingsVersionConflictError, SettingsFileConflictError, } from '../authorization/index.js';
|
|
3
3
|
import { permissionPathSchema } from './validators.js';
|
|
4
4
|
import { MAX_ENTRIES_PER_PAGE } from './entries-constants.js';
|
|
5
|
+
import { MAX_USER_METADATA_BATCH } from './users-constants.js';
|
|
6
|
+
import { lookupUsersMetadata } from '../auth/user-metadata-lookup.js';
|
|
5
7
|
import { defineEndpoint } from './route-builder.js';
|
|
6
8
|
import { getSettingsBranchContext, commitSettings } from './settings-helpers.js';
|
|
7
9
|
import { getErrorMessage, sanitizeErrorMessage } from '../utils/error.js';
|
|
@@ -29,6 +31,10 @@ const searchUsersParamsSchema = z.object({
|
|
|
29
31
|
const getUserMetadataParamsSchema = z.object({
|
|
30
32
|
userId: z.string(),
|
|
31
33
|
});
|
|
34
|
+
const userIdSchema = z.string().min(1).max(256);
|
|
35
|
+
const batchGetUserMetadataBodySchema = z.object({
|
|
36
|
+
userIds: z.array(userIdSchema).min(1).max(MAX_USER_METADATA_BATCH),
|
|
37
|
+
});
|
|
32
38
|
const getPermissionsHandler = async (_gc, ctx, _req) => {
|
|
33
39
|
try {
|
|
34
40
|
const result = await getSettingsBranchContext(ctx);
|
|
@@ -199,8 +205,29 @@ const getUserMetadataHandler = async (_gc, ctx, req, params) => {
|
|
|
199
205
|
return { ok: false, status: 501, error: 'Auth plugin not configured' };
|
|
200
206
|
}
|
|
201
207
|
try {
|
|
202
|
-
const
|
|
203
|
-
return { ok: true, status: 200, data: { user } };
|
|
208
|
+
const users = await lookupUsersMetadata(authPlugin, [params.userId]);
|
|
209
|
+
return { ok: true, status: 200, data: { user: users.get(params.userId) ?? null } };
|
|
210
|
+
}
|
|
211
|
+
catch (error) {
|
|
212
|
+
return {
|
|
213
|
+
ok: false,
|
|
214
|
+
status: 500,
|
|
215
|
+
error: sanitizeErrorMessage(getErrorMessage(error)),
|
|
216
|
+
};
|
|
217
|
+
}
|
|
218
|
+
};
|
|
219
|
+
/**
|
|
220
|
+
* Get metadata for many users at once (for UI display)
|
|
221
|
+
*/
|
|
222
|
+
const batchGetUserMetadataHandler = async (_gc, ctx, _req, body) => {
|
|
223
|
+
const authPlugin = ctx.authPlugin;
|
|
224
|
+
if (!authPlugin) {
|
|
225
|
+
return { ok: false, status: 501, error: 'Auth plugin not configured' };
|
|
226
|
+
}
|
|
227
|
+
try {
|
|
228
|
+
const found = await lookupUsersMetadata(authPlugin, body.userIds);
|
|
229
|
+
const users = [...found.values()].filter((user) => user !== null);
|
|
230
|
+
return { ok: true, status: 200, data: { users } };
|
|
204
231
|
}
|
|
205
232
|
catch (error) {
|
|
206
233
|
return {
|
|
@@ -289,6 +316,23 @@ const getUserMetadata = defineEndpoint({
|
|
|
289
316
|
guards: ['privileged'],
|
|
290
317
|
handler: getUserMetadataHandler,
|
|
291
318
|
});
|
|
319
|
+
/**
|
|
320
|
+
* Get metadata for up to MAX_USER_METADATA_BATCH users (admin/reviewer only)
|
|
321
|
+
* POST /users/batch
|
|
322
|
+
*/
|
|
323
|
+
const batchGetUserMetadata = defineEndpoint({
|
|
324
|
+
namespace: 'permissions',
|
|
325
|
+
name: 'batchGetUserMetadata',
|
|
326
|
+
method: 'POST',
|
|
327
|
+
path: '/users/batch',
|
|
328
|
+
body: batchGetUserMetadataBodySchema,
|
|
329
|
+
bodyType: 'BatchGetUserMetadataBody',
|
|
330
|
+
responseType: 'BatchGetUserMetadataResponse',
|
|
331
|
+
response: {},
|
|
332
|
+
defaultMockData: { users: [] },
|
|
333
|
+
guards: ['privileged'],
|
|
334
|
+
handler: batchGetUserMetadataHandler,
|
|
335
|
+
});
|
|
292
336
|
/**
|
|
293
337
|
* Exported routes for router registration
|
|
294
338
|
*/
|
|
@@ -298,4 +342,5 @@ export const PERMISSION_ROUTES = {
|
|
|
298
342
|
searchUsers: searchUsers,
|
|
299
343
|
listGroups: listGroups,
|
|
300
344
|
getUserMetadata: getUserMetadata,
|
|
345
|
+
batchGetUserMetadata: batchGetUserMetadata,
|
|
301
346
|
};
|
package/dist/api/types.d.ts
CHANGED
|
@@ -57,5 +57,7 @@ export interface ApiResponse<TData = unknown> {
|
|
|
57
57
|
* failed, so a retry will not help until an admin fixes it (a 503 with no `Retry-After`).
|
|
58
58
|
* `WRITE_OUTCOME_UNKNOWN`: an entry save ran but lost its branch lock mid-write, so it may have
|
|
59
59
|
* landed (a 409); the version the caller holds may be stale, so it must re-read before retrying.
|
|
60
|
+
* `EDITOR_MODE_MISMATCH`: the editor bundle was built for the other operating mode than the
|
|
61
|
+
* server runs (a 412, see operating-mode/editor-mode-check.ts); only a rebuild fixes it.
|
|
60
62
|
*/
|
|
61
|
-
export type ApiErrorCode = 'SCHEMA_UNAVAILABLE' | 'WORKER_FAILED' | 'WRITE_OUTCOME_UNKNOWN';
|
|
63
|
+
export type ApiErrorCode = 'SCHEMA_UNAVAILABLE' | 'WORKER_FAILED' | 'WRITE_OUTCOME_UNKNOWN' | 'EDITOR_MODE_MISMATCH';
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Constants the user endpoints share with the editor, kept dependency-free so the editor's
|
|
3
|
+
* browser bundle can import them without pulling in `permissions.ts` and its server-only deps.
|
|
4
|
+
*/
|
|
5
|
+
/** Most user ids one `POST /users/batch` request may carry; the editor splits larger sets. */
|
|
6
|
+
export declare const MAX_USER_METADATA_BATCH = 100;
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Constants the user endpoints share with the editor, kept dependency-free so the editor's
|
|
3
|
+
* browser bundle can import them without pulling in `permissions.ts` and its server-only deps.
|
|
4
|
+
*/
|
|
5
|
+
/** Most user ids one `POST /users/batch` request may carry; the editor splits larger sets. */
|
|
6
|
+
export const MAX_USER_METADATA_BATCH = 100;
|
|
@@ -38,6 +38,8 @@ export declare class CachingAuthPlugin implements AuthPlugin {
|
|
|
38
38
|
authenticate(context: unknown): Promise<AuthenticationResult>;
|
|
39
39
|
searchUsers(query: string, limit?: number): Promise<UserSearchResult[]>;
|
|
40
40
|
getUserMetadata(userId: CanopyUserId): Promise<UserSearchResult | null>;
|
|
41
|
+
/** One cache read for the whole set, where `getUserMetadata` per id re-stats the files each time. */
|
|
42
|
+
getUsersMetadata(userIds: CanopyUserId[]): Promise<UserSearchResult[]>;
|
|
41
43
|
getGroupMetadata(groupId: CanopyGroupId): Promise<GroupMetadata | null>;
|
|
42
44
|
listGroups(limit?: number): Promise<GroupMetadata[]>;
|
|
43
45
|
}
|
|
@@ -88,6 +88,16 @@ export class CachingAuthPlugin {
|
|
|
88
88
|
return null;
|
|
89
89
|
}
|
|
90
90
|
}
|
|
91
|
+
/** One cache read for the whole set, where `getUserMetadata` per id re-stats the files each time. */
|
|
92
|
+
async getUsersMetadata(userIds) {
|
|
93
|
+
try {
|
|
94
|
+
const wanted = new Set(userIds);
|
|
95
|
+
return (await this.cache.getAllUsers()).filter((u) => wanted.has(u.id));
|
|
96
|
+
}
|
|
97
|
+
catch {
|
|
98
|
+
return [];
|
|
99
|
+
}
|
|
100
|
+
}
|
|
91
101
|
async getGroupMetadata(groupId) {
|
|
92
102
|
try {
|
|
93
103
|
return await this.cache.getGroup(groupId);
|
package/dist/auth/plugin.d.ts
CHANGED
|
@@ -23,6 +23,15 @@ export interface AuthPlugin {
|
|
|
23
23
|
/** For the permission-management UI. */
|
|
24
24
|
searchUsers(query: string, limit?: number): Promise<UserSearchResult[]>;
|
|
25
25
|
getUserMetadata(userId: CanopyUserId): Promise<UserSearchResult | null>;
|
|
26
|
+
/**
|
|
27
|
+
* Looks up many users in as few provider calls as possible, resolving to the users found, in
|
|
28
|
+
* any order; unknown ids are omitted. Core never passes more than 100 ids at once. Without
|
|
29
|
+
* it, core falls back to concurrent `getUserMetadata` calls, a few at a time.
|
|
30
|
+
*
|
|
31
|
+
* Reject on a provider failure rather than omitting ids: core caches an omitted id as unknown,
|
|
32
|
+
* as it does a `null` from the `getUserMetadata` fallback.
|
|
33
|
+
*/
|
|
34
|
+
getUsersMetadata?(userIds: CanopyUserId[]): Promise<UserSearchResult[]>;
|
|
26
35
|
getGroupMetadata(groupId: CanopyGroupId): Promise<GroupMetadata | null>;
|
|
27
36
|
/** For permission UI dropdowns. */
|
|
28
37
|
listGroups(limit?: number): Promise<GroupMetadata[]>;
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { AuthPlugin } from './plugin.js';
|
|
2
|
+
import type { UserSearchResult } from './types.js';
|
|
3
|
+
import type { CanopyUserId } from '../types.js';
|
|
4
|
+
/**
|
|
5
|
+
* How long a looked-up user, or the fact that an id is unknown, is served without asking again.
|
|
6
|
+
* @internal Exported for tests.
|
|
7
|
+
*/
|
|
8
|
+
export declare const USER_METADATA_TTL_MS: number;
|
|
9
|
+
/**
|
|
10
|
+
* Resolves each id to its user, or `null` when the provider does not know it. Unknown ids are
|
|
11
|
+
* cached like found ones, so a stale id in permissions.json costs a process one provider call
|
|
12
|
+
* per TTL. A rejection caches nothing; a `getUserMetadata` that answers `null` on failure is
|
|
13
|
+
* cached as unknown.
|
|
14
|
+
*/
|
|
15
|
+
export declare function lookupUsersMetadata(plugin: AuthPlugin, userIds: readonly CanopyUserId[]): Promise<Map<CanopyUserId, UserSearchResult | null>>;
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { LRUCache } from 'lru-cache';
|
|
2
|
+
import { CachingAuthPlugin } from './caching-auth-plugin.js';
|
|
3
|
+
import { MAX_USER_METADATA_BATCH } from '../api/users-constants.js';
|
|
4
|
+
/**
|
|
5
|
+
* How long a looked-up user, or the fact that an id is unknown, is served without asking again.
|
|
6
|
+
* @internal Exported for tests.
|
|
7
|
+
*/
|
|
8
|
+
export const USER_METADATA_TTL_MS = 5 * 60 * 1000;
|
|
9
|
+
const MAX_CACHED_USERS = 5000;
|
|
10
|
+
/** Concurrent `getUserMetadata` calls for a plugin without `getUsersMetadata`. */
|
|
11
|
+
const SINGLE_LOOKUP_CONCURRENCY = 8;
|
|
12
|
+
/**
|
|
13
|
+
* Per-process, in-memory, never persisted: each process keeps its own copy, so a changed name or
|
|
14
|
+
* avatar shows everywhere within one TTL. Keyed by plugin so two plugins never share answers.
|
|
15
|
+
*/
|
|
16
|
+
const caches = new WeakMap();
|
|
17
|
+
function cacheFor(plugin) {
|
|
18
|
+
let cache = caches.get(plugin);
|
|
19
|
+
if (!cache) {
|
|
20
|
+
cache = new LRUCache({ max: MAX_CACHED_USERS, ttl: USER_METADATA_TTL_MS });
|
|
21
|
+
caches.set(plugin, cache);
|
|
22
|
+
}
|
|
23
|
+
return cache;
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* Resolves each id to its user, or `null` when the provider does not know it. Unknown ids are
|
|
27
|
+
* cached like found ones, so a stale id in permissions.json costs a process one provider call
|
|
28
|
+
* per TTL. A rejection caches nothing; a `getUserMetadata` that answers `null` on failure is
|
|
29
|
+
* cached as unknown.
|
|
30
|
+
*/
|
|
31
|
+
export async function lookupUsersMetadata(plugin, userIds) {
|
|
32
|
+
const unique = [...new Set(userIds)];
|
|
33
|
+
// Answered from an in-memory copy of the worker-written file cache; a TTL on top would only
|
|
34
|
+
// delay the worker's refreshes. canopycms-next wraps every plugin with `verifyTokenOnly`.
|
|
35
|
+
if (plugin instanceof CachingAuthPlugin)
|
|
36
|
+
return fetchFromPlugin(plugin, unique);
|
|
37
|
+
const cache = cacheFor(plugin);
|
|
38
|
+
const result = new Map();
|
|
39
|
+
const missing = [];
|
|
40
|
+
for (const id of unique) {
|
|
41
|
+
const hit = cache.get(id);
|
|
42
|
+
if (hit)
|
|
43
|
+
result.set(id, hit.user);
|
|
44
|
+
else
|
|
45
|
+
missing.push(id);
|
|
46
|
+
}
|
|
47
|
+
if (missing.length === 0)
|
|
48
|
+
return result;
|
|
49
|
+
const fetched = await fetchFromPlugin(plugin, missing);
|
|
50
|
+
for (const [id, user] of fetched) {
|
|
51
|
+
cache.set(id, { user });
|
|
52
|
+
result.set(id, user);
|
|
53
|
+
}
|
|
54
|
+
return result;
|
|
55
|
+
}
|
|
56
|
+
async function fetchFromPlugin(plugin, ids) {
|
|
57
|
+
const result = new Map(ids.map((id) => [id, null]));
|
|
58
|
+
const getUsersMetadata = plugin.getUsersMetadata?.bind(plugin);
|
|
59
|
+
if (getUsersMetadata) {
|
|
60
|
+
for (let i = 0; i < ids.length; i += MAX_USER_METADATA_BATCH) {
|
|
61
|
+
const users = await getUsersMetadata(ids.slice(i, i + MAX_USER_METADATA_BATCH));
|
|
62
|
+
// A provider answering for an id nobody asked about must not plant it in the cache.
|
|
63
|
+
for (const user of users)
|
|
64
|
+
if (result.has(user.id))
|
|
65
|
+
result.set(user.id, user);
|
|
66
|
+
}
|
|
67
|
+
return result;
|
|
68
|
+
}
|
|
69
|
+
let next = 0;
|
|
70
|
+
const worker = async () => {
|
|
71
|
+
while (next < ids.length) {
|
|
72
|
+
const id = ids[next++];
|
|
73
|
+
result.set(id, await plugin.getUserMetadata(id));
|
|
74
|
+
}
|
|
75
|
+
};
|
|
76
|
+
await Promise.all(Array.from({ length: Math.min(SINGLE_LOOKUP_CONCURRENCY, ids.length) }, worker));
|
|
77
|
+
return result;
|
|
78
|
+
}
|
package/dist/branch-health.d.ts
CHANGED
|
@@ -19,6 +19,20 @@ type BranchHealthKind = 'healthy' | 'corrupt-metadata' | 'orphan';
|
|
|
19
19
|
* worker does not quarantine it. Corrupt-metadata dirs are exempt.
|
|
20
20
|
*/
|
|
21
21
|
export declare const ORPHAN_YOUTH_THRESHOLD_MS: number;
|
|
22
|
+
/**
|
|
23
|
+
* One healthy branch's duplicate-content-ID scan (see content-id-index.ts).
|
|
24
|
+
* `unknown` means the scan did not finish — it failed, or the request's time
|
|
25
|
+
* budget ran out first — and must never be read as `none`.
|
|
26
|
+
*/
|
|
27
|
+
export type DuplicateIdScan = {
|
|
28
|
+
state: 'none';
|
|
29
|
+
} | {
|
|
30
|
+
state: 'found';
|
|
31
|
+
duplicates: DuplicateContentId[];
|
|
32
|
+
} | {
|
|
33
|
+
state: 'unknown';
|
|
34
|
+
reason: 'failed' | 'out-of-time';
|
|
35
|
+
};
|
|
22
36
|
export interface BranchHealthEntry {
|
|
23
37
|
dirName: string;
|
|
24
38
|
kind: BranchHealthKind;
|
|
@@ -27,19 +41,19 @@ export interface BranchHealthEntry {
|
|
|
27
41
|
/** healthy only */
|
|
28
42
|
branch?: BranchMetadata;
|
|
29
43
|
/**
|
|
30
|
-
* healthy only,
|
|
31
|
-
*
|
|
32
|
-
* quarantined IDs degrade, dropping out of ID-based lookups and
|
|
33
|
-
* saves (`DuplicateContentIdError
|
|
34
|
-
*
|
|
44
|
+
* healthy only, and only when the caller asked for the duplicate-ID scan: see
|
|
45
|
+
* {@link DuplicateIdScan}. The branch stays usable while IDs are duplicated —
|
|
46
|
+
* only the quarantined IDs degrade, dropping out of ID-based lookups and
|
|
47
|
+
* refusing saves (`DuplicateContentIdError`) rather than mutating an
|
|
48
|
+
* ambiguous target.
|
|
35
49
|
*/
|
|
36
|
-
|
|
50
|
+
duplicateIdScan?: DuplicateIdScan;
|
|
37
51
|
/**
|
|
38
52
|
* healthy only, true only: this clone has an interrupted rebase on disk
|
|
39
53
|
* (`.git/rebase-merge` / `.git/rebase-apply`).
|
|
40
54
|
*
|
|
41
55
|
* Advisory on `healthy` rather than its own `BranchHealthKind`, like
|
|
42
|
-
* `
|
|
56
|
+
* `duplicateIdScan`: the metadata is intact and the state is usually
|
|
43
57
|
* transient, since the worker's sync loop aborts an interrupted rebase at the
|
|
44
58
|
* top of its next per-branch pass. The flag buys visibility in the window
|
|
45
59
|
* before that, where the branch otherwise scans as plain `healthy` while
|
|
@@ -80,9 +94,16 @@ export interface BranchHealthEntry {
|
|
|
80
94
|
* metadata must not take down the whole scan, as in the registry's quarantine.
|
|
81
95
|
* A missing `baseRoot` returns `[]`, so the admin endpoint can call this
|
|
82
96
|
* without a pre-existence check.
|
|
97
|
+
*
|
|
98
|
+
* `duplicateIdScan` opts in to the per-branch duplicate-ID scan, which shares
|
|
99
|
+
* one `budgetMs` across every healthy branch, scanned in turn; a branch
|
|
100
|
+
* reached after the budget is spent reports `unknown`/`out-of-time`.
|
|
83
101
|
*/
|
|
84
102
|
export declare function scanBranchHealth(baseRoot: string, opts: {
|
|
85
103
|
baseBranchName: string;
|
|
86
104
|
contentRootName?: string;
|
|
105
|
+
duplicateIdScan?: {
|
|
106
|
+
budgetMs: number;
|
|
107
|
+
};
|
|
87
108
|
}): Promise<BranchHealthEntry[]>;
|
|
88
109
|
export {};
|