canopycms 0.0.68-int.110 → 0.0.68-int.111

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.
Files changed (46) hide show
  1. package/dist/api/admin-branch-health.d.ts +24 -1
  2. package/dist/api/admin-branch-health.js +31 -4
  3. package/dist/api/admin.d.ts +7 -1
  4. package/dist/api/client.d.ts +16 -2
  5. package/dist/api/client.js +16 -2
  6. package/dist/api/permissions.d.ts +19 -0
  7. package/dist/api/permissions.js +47 -2
  8. package/dist/api/types.d.ts +3 -1
  9. package/dist/api/users-constants.d.ts +6 -0
  10. package/dist/api/users-constants.js +6 -0
  11. package/dist/auth/caching-auth-plugin.d.ts +2 -0
  12. package/dist/auth/caching-auth-plugin.js +10 -0
  13. package/dist/auth/plugin.d.ts +9 -0
  14. package/dist/auth/user-metadata-lookup.d.ts +15 -0
  15. package/dist/auth/user-metadata-lookup.js +78 -0
  16. package/dist/branch-health.d.ts +28 -7
  17. package/dist/branch-health.js +39 -14
  18. package/dist/cli/cli.js +14 -15
  19. package/dist/cli/generate-ai-content.js +4 -6
  20. package/dist/cli/init.js +7 -8
  21. package/dist/cli/project-detect.d.ts +2 -0
  22. package/dist/cli/project-detect.js +2 -0
  23. package/dist/cli/template-files/Dockerfile.cms.template +13 -5
  24. package/dist/content-id-index.js +2 -2
  25. package/dist/content-store.js +4 -6
  26. package/dist/editor/CanopyEditor.js +1 -1
  27. package/dist/editor/EditorAuthGate.d.ts +7 -1
  28. package/dist/editor/EditorAuthGate.js +32 -5
  29. package/dist/editor/admin/SystemHealthPanel.js +77 -5
  30. package/dist/editor/admin/useSystemHealth.d.ts +18 -0
  31. package/dist/editor/admin/useSystemHealth.js +70 -1
  32. package/dist/editor/context/ApiClientContext.d.ts +6 -1
  33. package/dist/editor/context/ApiClientContext.js +17 -7
  34. package/dist/editor/hooks/useGroupManager.js +3 -13
  35. package/dist/editor/hooks/useUserMetadata.d.ts +1 -5
  36. package/dist/editor/hooks/useUserMetadata.js +18 -49
  37. package/dist/editor/hooks/user-metadata-batcher.d.ts +7 -0
  38. package/dist/editor/hooks/user-metadata-batcher.js +26 -0
  39. package/dist/http/handler.js +25 -1
  40. package/dist/operating-mode/editor-mode-check.d.ts +15 -0
  41. package/dist/operating-mode/editor-mode-check.js +24 -0
  42. package/dist/operating-mode/mode-env.d.ts +13 -14
  43. package/dist/operating-mode/mode-env.js +13 -14
  44. package/dist/version.d.ts +1 -1
  45. package/dist/version.js +1 -1
  46. 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<undefined, undefined, BranchHealthResponse>;
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, { baseBranchName, contentRootName });
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: { entries, generatedAt: new Date().toISOString() },
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' },
@@ -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<undefined, undefined, import("./admin-branch-health.js").BranchHealthResponse>;
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, {
@@ -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 */
@@ -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 {};
@@ -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 user = await authPlugin.getUserMetadata(params.userId);
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
  };
@@ -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);
@@ -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
+ }
@@ -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, non-empty only: duplicate content IDs in this branch's
31
- * content tree (see content-id-index.ts). The branch stays usable — only the
32
- * quarantined IDs degrade, dropping out of ID-based lookups and refusing
33
- * saves (`DuplicateContentIdError`, a 409 naming the repair action) rather
34
- * than mutating an ambiguous target.
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
- duplicateContentIds?: DuplicateContentId[];
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
- * `duplicateContentIds`: the metadata is intact and the state is usually
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 {};