canopycms 0.0.67 → 0.0.68-int.101

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 (281) hide show
  1. package/README.md +24 -17
  2. package/dist/ai/handler.js +8 -0
  3. package/dist/api/admin-branch-health.js +17 -20
  4. package/dist/api/admin.d.ts +30 -9
  5. package/dist/api/admin.js +32 -2
  6. package/dist/api/assets.js +84 -76
  7. package/dist/api/branch-create-window.d.ts +12 -0
  8. package/dist/api/branch-create-window.js +13 -0
  9. package/dist/api/branch-review.d.ts +3 -3
  10. package/dist/api/branch-review.js +14 -5
  11. package/dist/api/branch-status.d.ts +5 -5
  12. package/dist/api/branch-status.js +26 -6
  13. package/dist/api/branch-withdraw.d.ts +2 -2
  14. package/dist/api/branch-withdraw.js +7 -2
  15. package/dist/api/branch.d.ts +15 -2
  16. package/dist/api/branch.js +118 -92
  17. package/dist/api/client.d.ts +18 -6
  18. package/dist/api/client.js +79 -12
  19. package/dist/api/content.d.ts +6 -5
  20. package/dist/api/content.js +44 -41
  21. package/dist/api/entries.js +16 -15
  22. package/dist/api/github-sync.d.ts +19 -1
  23. package/dist/api/github-sync.js +57 -10
  24. package/dist/api/index.d.ts +1 -1
  25. package/dist/api/reference-options.js +1 -1
  26. package/dist/api/resolve-references.js +14 -40
  27. package/dist/api/settings-helpers.d.ts +1 -1
  28. package/dist/api/settings-helpers.js +1 -4
  29. package/dist/api/user.d.ts +3 -0
  30. package/dist/api/user.js +3 -0
  31. package/dist/assets/asset-url.d.ts +22 -16
  32. package/dist/assets/asset-url.js +42 -25
  33. package/dist/assets/factory.d.ts +5 -0
  34. package/dist/assets/factory.js +5 -0
  35. package/dist/assets/finalize.js +3 -0
  36. package/dist/assets/index.d.ts +1 -1
  37. package/dist/assets/materialize.d.ts +153 -0
  38. package/dist/assets/materialize.js +402 -0
  39. package/dist/assets/store-local.d.ts +19 -5
  40. package/dist/assets/store-local.js +69 -10
  41. package/dist/assets/store-s3.d.ts +38 -7
  42. package/dist/assets/store-s3.js +128 -22
  43. package/dist/assets/transform-directives.d.ts +52 -13
  44. package/dist/assets/transform-directives.js +87 -27
  45. package/dist/assets/transform.d.ts +3 -3
  46. package/dist/assets/transform.js +13 -8
  47. package/dist/assets/types.d.ts +31 -4
  48. package/dist/auth/file-based-auth-cache.js +7 -8
  49. package/dist/authorization/content.d.ts +5 -4
  50. package/dist/authorization/content.js +5 -5
  51. package/dist/authorization/path.d.ts +11 -6
  52. package/dist/authorization/path.js +11 -6
  53. package/dist/authorization/types.d.ts +2 -2
  54. package/dist/branch-health.d.ts +7 -1
  55. package/dist/branch-health.js +8 -3
  56. package/dist/branch-metadata.d.ts +5 -5
  57. package/dist/branch-metadata.js +44 -36
  58. package/dist/branch-provisioning.d.ts +167 -0
  59. package/dist/branch-provisioning.js +501 -0
  60. package/dist/branch-schema-cache.d.ts +5 -3
  61. package/dist/branch-schema-cache.js +29 -11
  62. package/dist/branch-sparse.d.ts +23 -0
  63. package/dist/branch-sparse.js +118 -0
  64. package/dist/branch-workspace.d.ts +42 -3
  65. package/dist/branch-workspace.js +240 -94
  66. package/dist/build/asset-refs.d.ts +93 -0
  67. package/dist/build/asset-refs.js +251 -0
  68. package/dist/build-identity.d.ts +3 -0
  69. package/dist/build-identity.js +13 -0
  70. package/dist/cli/asset-refs.d.ts +52 -0
  71. package/dist/cli/asset-refs.js +194 -0
  72. package/dist/cli/cli.d.ts +15 -1
  73. package/dist/cli/cli.js +5928 -2195
  74. package/dist/cli/configured-asset-store.d.ts +12 -0
  75. package/dist/cli/configured-asset-store.js +46 -0
  76. package/dist/cli/generate-ai-content.js +2185 -587
  77. package/dist/cli/github-app-manifest.d.ts +4 -3
  78. package/dist/cli/github-app-manifest.js +4 -3
  79. package/dist/cli/init.js +30 -27
  80. package/dist/cli/migrate.js +12 -8
  81. package/dist/cli/sync.js +83 -37
  82. package/dist/cli/template-files/Dockerfile.cms.template +8 -0
  83. package/dist/cli/template-files/cdk-app.ts.template +4 -0
  84. package/dist/cli/template-files/cms-stack.ts.template +23 -12
  85. package/dist/cli/template-files/deploy-cms.yml.template +2 -0
  86. package/dist/client.d.ts +1 -1
  87. package/dist/client.js +1 -1
  88. package/dist/config/helpers.js +1 -2
  89. package/dist/config/schemas/config.d.ts +19 -32
  90. package/dist/config/schemas/config.js +9 -11
  91. package/dist/config/schemas/field.js +1 -0
  92. package/dist/config/schemas/media.d.ts +4 -13
  93. package/dist/config/schemas/media.js +5 -8
  94. package/dist/config/schemas/url.d.ts +4 -20
  95. package/dist/config/schemas/url.js +6 -22
  96. package/dist/config/types.d.ts +30 -20
  97. package/dist/config/validation.d.ts +6 -0
  98. package/dist/config/validation.js +37 -0
  99. package/dist/content-listing.d.ts +12 -12
  100. package/dist/content-listing.js +10 -6
  101. package/dist/content-reader.d.ts +11 -2
  102. package/dist/content-reader.js +22 -14
  103. package/dist/content-store.d.ts +37 -9
  104. package/dist/content-store.js +70 -43
  105. package/dist/content-tree.d.ts +1 -1
  106. package/dist/content-tree.js +2 -2
  107. package/dist/context.d.ts +27 -9
  108. package/dist/context.js +154 -70
  109. package/dist/editor/BranchManager.d.ts +7 -1
  110. package/dist/editor/BranchManager.js +29 -14
  111. package/dist/editor/CanopyEditor.d.ts +1 -1
  112. package/dist/editor/CanopyEditor.js +2 -6
  113. package/dist/editor/Editor.d.ts +4 -3
  114. package/dist/editor/Editor.js +32 -22
  115. package/dist/editor/FormRenderer.js +34 -18
  116. package/dist/editor/PreviewFrame.d.ts +24 -0
  117. package/dist/editor/PreviewFrame.js +140 -0
  118. package/dist/editor/admin/SystemHealthPanel.js +47 -7
  119. package/dist/editor/components/BranchesDrawer.d.ts +9 -0
  120. package/dist/editor/components/BranchesDrawer.js +4 -0
  121. package/dist/editor/components/NoEditPermissionNotice.d.ts +10 -0
  122. package/dist/editor/components/NoEditPermissionNotice.js +17 -0
  123. package/dist/editor/context/AssetContext.d.ts +18 -35
  124. package/dist/editor/context/AssetContext.js +20 -29
  125. package/dist/editor/context/index.d.ts +1 -1
  126. package/dist/editor/context/index.js +1 -1
  127. package/dist/editor/editor-config.d.ts +1 -2
  128. package/dist/editor/editor-config.js +0 -23
  129. package/dist/editor/editor-utils.d.ts +24 -21
  130. package/dist/editor/editor-utils.js +75 -62
  131. package/dist/editor/fields/BlockField.d.ts +1 -0
  132. package/dist/editor/fields/BlockField.js +6 -4
  133. package/dist/editor/fields/CodeField.d.ts +1 -0
  134. package/dist/editor/fields/CodeField.js +2 -2
  135. package/dist/editor/fields/DateTimeField.d.ts +1 -0
  136. package/dist/editor/fields/DateTimeField.js +2 -2
  137. package/dist/editor/fields/FieldDescription.d.ts +16 -0
  138. package/dist/editor/fields/FieldDescription.js +11 -0
  139. package/dist/editor/fields/ImageField.d.ts +1 -0
  140. package/dist/editor/fields/ImageField.js +3 -2
  141. package/dist/editor/fields/InlineGroupField.js +4 -1
  142. package/dist/editor/fields/MarkdownField.d.ts +1 -0
  143. package/dist/editor/fields/MarkdownField.js +115 -20
  144. package/dist/editor/fields/NumberField.d.ts +1 -0
  145. package/dist/editor/fields/NumberField.js +2 -2
  146. package/dist/editor/fields/NumberListField.d.ts +1 -0
  147. package/dist/editor/fields/NumberListField.js +2 -2
  148. package/dist/editor/fields/ObjectField.d.ts +1 -0
  149. package/dist/editor/fields/ObjectField.js +5 -2
  150. package/dist/editor/fields/ReferenceField.d.ts +1 -0
  151. package/dist/editor/fields/ReferenceField.js +5 -4
  152. package/dist/editor/fields/SelectField.d.ts +1 -0
  153. package/dist/editor/fields/SelectField.js +2 -2
  154. package/dist/editor/fields/StringListField.d.ts +1 -0
  155. package/dist/editor/fields/StringListField.js +2 -2
  156. package/dist/editor/fields/TextField.d.ts +1 -0
  157. package/dist/editor/fields/TextField.js +2 -2
  158. package/dist/editor/fields/ToggleField.d.ts +1 -0
  159. package/dist/editor/fields/ToggleField.js +7 -4
  160. package/dist/editor/fields/entry-link/InsertEntryLink.d.ts +1 -1
  161. package/dist/editor/fields/entry-link/InsertEntryLink.js +3 -2
  162. package/dist/editor/fields/mdx-jsx-support.d.ts +1 -0
  163. package/dist/editor/fields/mdx-jsx-support.js +137 -0
  164. package/dist/editor/hooks/create-branch-request.d.ts +20 -0
  165. package/dist/editor/hooks/create-branch-request.js +52 -0
  166. package/dist/editor/hooks/useBranchActions.d.ts +13 -2
  167. package/dist/editor/hooks/useBranchActions.js +48 -17
  168. package/dist/editor/hooks/useBranchManager.d.ts +21 -0
  169. package/dist/editor/hooks/useBranchManager.js +204 -37
  170. package/dist/editor/hooks/useBranchesData.d.ts +6 -0
  171. package/dist/editor/hooks/useBranchesData.js +9 -3
  172. package/dist/editor/hooks/useCommentSystem.js +25 -6
  173. package/dist/editor/hooks/useDraftManager.d.ts +16 -1
  174. package/dist/editor/hooks/useDraftManager.js +193 -36
  175. package/dist/editor/hooks/useEntriesData.js +3 -3
  176. package/dist/editor/hooks/useEntryManager.d.ts +2 -1
  177. package/dist/editor/hooks/useEntryManager.js +34 -19
  178. package/dist/editor/media/AssetCard.d.ts +1 -1
  179. package/dist/editor/media/crop-math.d.ts +3 -4
  180. package/dist/editor/media/crop-math.js +11 -22
  181. package/dist/editor/media/editor-image-src.d.ts +7 -0
  182. package/dist/editor/media/editor-image-src.js +12 -0
  183. package/dist/editor/preview-asset-base.d.ts +8 -0
  184. package/dist/editor/preview-asset-base.js +13 -0
  185. package/dist/editor/preview-bridge.d.ts +9 -21
  186. package/dist/editor/preview-bridge.js +19 -139
  187. package/dist/editor/preview-path.d.ts +10 -0
  188. package/dist/editor/preview-path.js +20 -0
  189. package/dist/editor/theme.js +2 -2
  190. package/dist/entry-schema-registry.d.ts +4 -4
  191. package/dist/entry-schema-registry.js +10 -6
  192. package/dist/entry-schema.d.ts +27 -4
  193. package/dist/entry-schema.js +20 -3
  194. package/dist/git-manager.d.ts +173 -6
  195. package/dist/git-manager.js +594 -76
  196. package/dist/github-service.d.ts +15 -0
  197. package/dist/github-service.js +19 -1
  198. package/dist/http/handler.js +37 -13
  199. package/dist/http/index.d.ts +2 -0
  200. package/dist/http/index.js +2 -0
  201. package/dist/http/router.d.ts +2 -0
  202. package/dist/http/router.js +2 -1
  203. package/dist/http/types.d.ts +2 -0
  204. package/dist/http/worker-not-ready.d.ts +10 -0
  205. package/dist/http/worker-not-ready.js +24 -0
  206. package/dist/operating-mode/client-unsafe-strategy.js +0 -6
  207. package/dist/operating-mode/types.d.ts +1 -4
  208. package/dist/paths/branch-name.d.ts +5 -0
  209. package/dist/paths/branch-name.js +9 -0
  210. package/dist/paths/branch.d.ts +6 -2
  211. package/dist/paths/branch.js +16 -10
  212. package/dist/paths/index.d.ts +3 -3
  213. package/dist/paths/index.js +3 -3
  214. package/dist/paths/normalize.d.ts +7 -0
  215. package/dist/paths/normalize.js +9 -0
  216. package/dist/paths/validation.js +0 -2
  217. package/dist/preview.d.ts +8 -0
  218. package/dist/preview.js +7 -0
  219. package/dist/reference-resolver.d.ts +5 -21
  220. package/dist/reference-resolver.js +9 -44
  221. package/dist/resolve-canopy-user.js +2 -1
  222. package/dist/schema/schema-store.d.ts +54 -31
  223. package/dist/schema/schema-store.js +77 -31
  224. package/dist/server.d.ts +64 -9
  225. package/dist/server.js +48 -8
  226. package/dist/services.d.ts +18 -6
  227. package/dist/services.js +85 -70
  228. package/dist/settings-workspace.d.ts +23 -3
  229. package/dist/settings-workspace.js +197 -45
  230. package/dist/static/seo.d.ts +2 -14
  231. package/dist/static/seo.js +2 -25
  232. package/dist/submission-attribution.d.ts +73 -0
  233. package/dist/submission-attribution.js +221 -0
  234. package/dist/sync-core.d.ts +12 -1
  235. package/dist/sync-core.js +31 -13
  236. package/dist/task-queue/worker-status.d.ts +8 -0
  237. package/dist/task-queue/worker-status.js +17 -0
  238. package/dist/types.d.ts +31 -2
  239. package/dist/utils/content-serialize.d.ts +5 -2
  240. package/dist/utils/content-serialize.js +98 -24
  241. package/dist/utils/content-write-lock.d.ts +18 -7
  242. package/dist/utils/content-write-lock.js +18 -9
  243. package/dist/utils/debug.d.ts +8 -0
  244. package/dist/utils/debug.js +10 -2
  245. package/dist/utils/git.d.ts +32 -0
  246. package/dist/utils/git.js +42 -0
  247. package/dist/utils/occ-json-write.js +10 -2
  248. package/dist/utils/provision-log.d.ts +25 -0
  249. package/dist/utils/provision-log.js +49 -0
  250. package/dist/utils/provisioning-lock.d.ts +35 -5
  251. package/dist/utils/provisioning-lock.js +73 -12
  252. package/dist/utils/request-timing.d.ts +25 -0
  253. package/dist/utils/request-timing.js +101 -0
  254. package/dist/utils/sanitize-href.d.ts +8 -22
  255. package/dist/utils/sanitize-href.js +11 -28
  256. package/dist/utils/url-prefix.d.ts +30 -0
  257. package/dist/utils/url-prefix.js +61 -0
  258. package/dist/utils/yaml-source-splice.d.ts +58 -0
  259. package/dist/utils/yaml-source-splice.js +515 -0
  260. package/dist/validation/entry-validator.js +9 -3
  261. package/dist/version.d.ts +1 -0
  262. package/dist/version.js +3 -0
  263. package/dist/worker/canopy-state.d.ts +45 -0
  264. package/dist/worker/canopy-state.js +75 -0
  265. package/dist/worker/cms-worker.d.ts +8 -3
  266. package/dist/worker/cms-worker.js +38 -2
  267. package/dist/worker/git-sync.d.ts +30 -12
  268. package/dist/worker/git-sync.js +253 -58
  269. package/dist/worker/history-rewrite.d.ts +1 -1
  270. package/dist/worker/history-rewrite.js +1 -1
  271. package/dist/worker/provisioned-workspace.d.ts +35 -0
  272. package/dist/worker/provisioned-workspace.js +50 -0
  273. package/dist/worker/rebase.d.ts +1 -1
  274. package/dist/worker/rebase.js +95 -23
  275. package/dist/worker/remote-git-maintenance.d.ts +3 -0
  276. package/dist/worker/remote-git-maintenance.js +11 -0
  277. package/dist/worker/sparse-cone.d.ts +20 -0
  278. package/dist/worker/sparse-cone.js +132 -0
  279. package/dist/worker/task-runner.js +99 -14
  280. package/dist/worker/worker-context.d.ts +6 -1
  281. package/package.json +8 -2
@@ -41,6 +41,12 @@ export interface PublicObject {
41
41
  contentDisposition?: string;
42
42
  cacheControl?: string;
43
43
  }
44
+ /**
45
+ * What a create-only write did. Every key the store writes this way is content-addressed, so no
46
+ * correct writer ever replaces an object: `already-exists` means an object was already at the key
47
+ * and was left alone.
48
+ */
49
+ export type CreateOnlyResult = 'created' | 'already-exists';
44
50
  export interface AssetStore {
45
51
  readonly capabilities: {
46
52
  directUpload: boolean;
@@ -49,26 +55,47 @@ export interface AssetStore {
49
55
  writeStaging(stagingKey: string, data: Uint8Array, contentType?: string): Promise<void>;
50
56
  readStaging(stagingKey: string): Promise<Uint8Array | null>;
51
57
  deleteStaging(stagingKey: string): Promise<void>;
58
+ /** Create-only; see `CreateOnlyResult`. */
52
59
  putOriginal(input: {
53
60
  hash32: string;
54
61
  ext: string;
55
62
  data: Uint8Array;
56
63
  contentType: string;
57
- }): Promise<void>;
58
- readOriginal(hash32: string): Promise<{
64
+ }): Promise<CreateOnlyResult>;
65
+ /**
66
+ * `ext` is where the original is expected (its meta's `ext`): that key is read first, and any
67
+ * `{hash32}.*` is looked for only on a miss. A hit needs no list permission; on S3 without
68
+ * `s3:ListBucket` a miss is a 403, which throws.
69
+ */
70
+ readOriginal(hash32: string, ext?: string): Promise<{
59
71
  data: Uint8Array;
60
72
  ext: string;
61
73
  contentType?: string;
62
74
  } | null>;
75
+ /** Create-only; see `CreateOnlyResult`. */
63
76
  putPublicObject(input: {
64
77
  key: string;
65
78
  data: Uint8Array;
66
79
  contentType: string;
67
80
  contentDisposition?: string;
68
81
  cacheControl?: string;
69
- }): Promise<void>;
82
+ /** Object tags; bucket lifecycle rules can filter on them. */
83
+ tags?: Readonly<Record<string, string>>;
84
+ }): Promise<CreateOnlyResult>;
85
+ /**
86
+ * Copy the public object at `sourceKey` to `destKey` inside the store, create-only like
87
+ * `putPublicObject`. The copy keeps the source's headers and carries no tags. `source-missing`
88
+ * when nothing is at `sourceKey`.
89
+ */
90
+ copyPublicObject(sourceKey: string, destKey: string): Promise<CreateOnlyResult | 'source-missing'>;
70
91
  readPublicObject(key: string): Promise<PublicObject | null>;
71
- putMetaIfAbsent(hash32: string, meta: AssetMeta): Promise<'created' | 'already-exists'>;
92
+ /** Whether a public object exists at `key`, without reading its body. */
93
+ hasPublicObject(key: string): Promise<boolean>;
94
+ /** Every public object key under `prefix`, in the store's own pages. S3 only. */
95
+ listPublicObjectKeys?(prefix: string): AsyncIterable<string>;
96
+ /** A short-lived URL a browser can GET `key` from directly, or `null` if absent. S3 only. */
97
+ presignPublicObjectRead?(key: string): Promise<string | null>;
98
+ putMetaIfAbsent(hash32: string, meta: AssetMeta): Promise<CreateOnlyResult>;
72
99
  getMeta(hash32: string): Promise<AssetMeta | null>;
73
100
  listMeta(input?: {
74
101
  cursor?: string;
@@ -168,14 +168,13 @@ export async function writeAuthCacheSnapshot(cachePath, files) {
168
168
  await fs.rename(tmpPath, finalPath);
169
169
  }
170
170
  // The symlink target MUST be relative (the bare `snapshot-<ts>` basename),
171
- // because writer and reader do not share a mount namespace: in prod the EC2
172
- // worker writes through /mnt/efs/workspace/.cache while the CMS Lambda mounts
173
- // the /workspace access point and reads the SAME directory as /mnt/efs/.cache.
174
- // An absolute target recorded by one is a nonexistent path to the other, and
175
- // `resolveActiveCacheDir`'s escape guard then falls back to the flat layout,
176
- // where the worker never writes — leaving the Lambda a permanently empty
177
- // cache. A relative target resolves against whichever cachePath the reader
178
- // was given, so it is correct from both.
171
+ // because writer and reader are separate hosts (in prod, the EC2 worker and
172
+ // the CMS Lambda) that each mount EFS themselves, so nothing guarantees they
173
+ // reach this directory by the same path. An absolute target recorded by one
174
+ // can be a nonexistent path to the other, and `resolveActiveCacheDir`'s
175
+ // escape guard then falls back to the flat layout, where the worker never
176
+ // writes, leaving the reader a permanently empty cache. A relative target
177
+ // resolves against whichever cachePath the reader was given.
179
178
  const currentLink = path.join(cachePath, 'current');
180
179
  const tmpLink = path.join(cachePath, `current-${timestamp}`);
181
180
  await fs.symlink(path.basename(snapshotDir), tmpLink);
@@ -6,12 +6,13 @@ import type { BranchContext } from '../types.js';
6
6
  import type { PermissionLevel } from '../config/index.js';
7
7
  import type { CanopyUser } from '../user.js';
8
8
  import type { ContentAccessResult, ContentAccessDeps } from './types.js';
9
- import type { PhysicalPath } from '../paths/types.js';
9
+ import type { LogicalPath } from '../paths/types.js';
10
10
  /**
11
11
  * A bound content-access checker: evaluates a single path/level synchronously
12
12
  * against permissions that were already loaded when the checker was created.
13
+ * The path is the entry's logical path (see `checkPathAccess`).
13
14
  */
14
- export type ContentAccessChecker = (relativePath: PhysicalPath, level: PermissionLevel) => ContentAccessResult;
15
+ export type ContentAccessChecker = (logicalPath: LogicalPath, level: PermissionLevel) => ContentAccessResult;
15
16
  /**
16
17
  * Resolve the request-constant work once -- branch access, the
17
18
  * settings/permissions root and the path-permission rules, all identical for a
@@ -26,5 +27,5 @@ export declare function createContentAccessChecker(deps: ContentAccessDeps, cont
26
27
  * Single-path check; loads the path permissions from the branch root per call.
27
28
  * @internal Exported for tests.
28
29
  */
29
- export declare function checkContentAccess(deps: ContentAccessDeps, context: BranchContext, branchRoot: string, relativePath: PhysicalPath, user: CanopyUser, level: PermissionLevel): Promise<ContentAccessResult>;
30
- export declare function createCheckContentAccess(deps: ContentAccessDeps): (context: BranchContext, branchRoot: string, relativePath: PhysicalPath, user: CanopyUser, level: PermissionLevel) => Promise<ContentAccessResult>;
30
+ export declare function checkContentAccess(deps: ContentAccessDeps, context: BranchContext, branchRoot: string, logicalPath: LogicalPath, user: CanopyUser, level: PermissionLevel): Promise<ContentAccessResult>;
31
+ export declare function createCheckContentAccess(deps: ContentAccessDeps): (context: BranchContext, branchRoot: string, logicalPath: LogicalPath, user: CanopyUser, level: PermissionLevel) => Promise<ContentAccessResult>;
@@ -28,9 +28,9 @@ export async function createContentAccessChecker(deps, context, branchRoot, user
28
28
  }
29
29
  const rules = await deps.loadPathPermissions(permissionsRoot, deps.mode);
30
30
  const pathChecker = createCheckPathAccess(rules, deps.defaultPathAccess);
31
- return (relativePath, level) => {
31
+ return (logicalPath, level) => {
32
32
  const path = pathChecker({
33
- relativePath,
33
+ logicalPath,
34
34
  user,
35
35
  level,
36
36
  });
@@ -45,10 +45,10 @@ export async function createContentAccessChecker(deps, context, branchRoot, user
45
45
  * Single-path check; loads the path permissions from the branch root per call.
46
46
  * @internal Exported for tests.
47
47
  */
48
- export async function checkContentAccess(deps, context, branchRoot, relativePath, user, level) {
48
+ export async function checkContentAccess(deps, context, branchRoot, logicalPath, user, level) {
49
49
  const check = await createContentAccessChecker(deps, context, branchRoot, user);
50
- return check(relativePath, level);
50
+ return check(logicalPath, level);
51
51
  }
52
52
  export function createCheckContentAccess(deps) {
53
- return (context, branchRoot, relativePath, user, level) => checkContentAccess(deps, context, branchRoot, relativePath, user, level);
53
+ return (context, branchRoot, logicalPath, user, level) => checkContentAccess(deps, context, branchRoot, logicalPath, user, level);
54
54
  }
@@ -1,7 +1,7 @@
1
1
  import type { PathPermission, DefaultPathAccess, PermissionLevel } from '../config/index.js';
2
2
  import type { CanopyUser } from '../user.js';
3
3
  import type { PathPermissionResult } from './types.js';
4
- import type { PhysicalPath } from '../paths/types.js';
4
+ import type { LogicalPath } from '../paths/types.js';
5
5
  /**
6
6
  * Resolve `defaultPathAccess` to an 'allow'/'deny' verdict for one permission
7
7
  * level. String form applies to every level; object form looks up the level,
@@ -11,19 +11,24 @@ import type { PhysicalPath } from '../paths/types.js';
11
11
  */
12
12
  export declare function resolveDefaultPathAccess(defaultAccess: DefaultPathAccess, level: PermissionLevel): 'allow' | 'deny';
13
13
  /**
14
- * Evaluate access for a relative path against config-defined rules.
15
- * Uses defaultAccess when no rule matches. First matching rule wins.
14
+ * Evaluate access for a path against config-defined rules. First matching rule wins;
15
+ * defaultAccess applies when none matches.
16
+ *
17
+ * Rule globs and the checked path are both LOGICAL paths: content-root-prefixed and
18
+ * id-free, the space the Permission Manager writes rules in. An entry's logical path is
19
+ * `<collection logical path>/<slug>` (`entryLogicalPath`), e.g. `content/blog/my-post`, never
20
+ * its on-disk `content/blog.<id>/post.my-post.<id>.json`.
16
21
  * @internal Exported for tests.
17
22
  */
18
- export declare function checkPathAccess({ rules, relativePath, user, defaultAccess, level, }: {
23
+ export declare function checkPathAccess({ rules, logicalPath, user, defaultAccess, level, }: {
19
24
  rules: PathPermission[];
20
- relativePath: PhysicalPath;
25
+ logicalPath: LogicalPath;
21
26
  user: CanopyUser;
22
27
  defaultAccess: DefaultPathAccess;
23
28
  level: PermissionLevel;
24
29
  }): PathPermissionResult;
25
30
  export declare function createCheckPathAccess(rules: PathPermission[], defaultAccess: DefaultPathAccess): (input: {
26
- relativePath: PhysicalPath;
31
+ logicalPath: LogicalPath;
27
32
  user: CanopyUser;
28
33
  level: PermissionLevel;
29
34
  }) => PathPermissionResult;
@@ -5,8 +5,8 @@ function normalize(p) {
5
5
  const normalized = p.split(path.sep).join('/');
6
6
  return normalized.replace(/^\.?\/*/, '');
7
7
  }
8
- function matchesRule(rule, relativePath) {
9
- return minimatch(relativePath, rule.path, { dot: true });
8
+ function matchesRule(rule, logicalPath) {
9
+ return minimatch(logicalPath, rule.path, { dot: true });
10
10
  }
11
11
  function isAllowedByTarget(target, user) {
12
12
  const hasUserConstraint = !!target.allowedUsers?.length;
@@ -32,12 +32,17 @@ export function resolveDefaultPathAccess(defaultAccess, level) {
32
32
  return defaultAccess[level] ?? 'deny';
33
33
  }
34
34
  /**
35
- * Evaluate access for a relative path against config-defined rules.
36
- * Uses defaultAccess when no rule matches. First matching rule wins.
35
+ * Evaluate access for a path against config-defined rules. First matching rule wins;
36
+ * defaultAccess applies when none matches.
37
+ *
38
+ * Rule globs and the checked path are both LOGICAL paths: content-root-prefixed and
39
+ * id-free, the space the Permission Manager writes rules in. An entry's logical path is
40
+ * `<collection logical path>/<slug>` (`entryLogicalPath`), e.g. `content/blog/my-post`, never
41
+ * its on-disk `content/blog.<id>/post.my-post.<id>.json`.
37
42
  * @internal Exported for tests.
38
43
  */
39
- export function checkPathAccess({ rules, relativePath, user, defaultAccess, level, }) {
40
- const normalizedPath = normalize(relativePath);
44
+ export function checkPathAccess({ rules, logicalPath, user, defaultAccess, level, }) {
45
+ const normalizedPath = normalize(logicalPath);
41
46
  // Only Admins bypass all path permissions
42
47
  if (isAdmin(user.groups)) {
43
48
  return { allowed: true, reason: 'admin' };
@@ -1,6 +1,6 @@
1
1
  /**
2
- * A path used in permission rules, e.g. "content/posts".
3
- * SECURITY CRITICAL: always validated to prevent path traversal.
2
+ * A logical-path glob used in permission rules, e.g. "content/posts/**" (see
3
+ * `PathPermission.path`). SECURITY CRITICAL: always validated to prevent path traversal.
4
4
  */
5
5
  export type PermissionPath = string & {
6
6
  readonly __brand: 'PermissionPath';
@@ -6,13 +6,19 @@ import { type DuplicateContentId } from './content-id-index.js';
6
6
  * Without it two stuck states have no in-product recovery, and prod admins have
7
7
  * no filesystem access to see them: a branch dir whose `.canopy-meta/branch.json`
8
8
  * is corrupt (the registry drops it silently) and an orphan dir with no
9
- * `branch.json` at all (a partial delete crash — see `api/branch.ts`).
9
+ * `branch.json` at all (a clone or delete killed in place, until the worker
10
+ * quarantines it — see branch-provisioning.ts).
10
11
  *
11
12
  * `scanBranchHealth` mirrors the registry's own directory-listing rules (skip
12
13
  * non-directories and dot-prefixed names) so the two never disagree about what
13
14
  * counts as missing.
14
15
  */
15
16
  type BranchHealthKind = 'healthy' | 'corrupt-metadata' | 'orphan';
17
+ /**
18
+ * An orphan younger than this may still be a clone in progress: admin purge refuses it and the
19
+ * worker does not quarantine it. Corrupt-metadata dirs are exempt.
20
+ */
21
+ export declare const ORPHAN_YOUTH_THRESHOLD_MS: number;
16
22
  export interface BranchHealthEntry {
17
23
  dirName: string;
18
24
  kind: BranchHealthKind;
@@ -5,16 +5,21 @@ import { ContentIdIndex } from './content-id-index.js';
5
5
  import { sanitizeBranchName } from './paths/branch-name.js';
6
6
  import { getErrorMessage, isNodeError, isNotFoundError } from './utils/error.js';
7
7
  import { isRebaseInProgress } from './utils/git.js';
8
+ import { branchProvisioningLockName } from './utils/provisioning-lock.js';
8
9
  /**
9
- * The provisioning lock marker's path for a branch directory, matching what
10
- * `branch-workspace.ts`'s `ensureGitWorkspace()` passes exactly.
10
+ * An orphan younger than this may still be a clone in progress: admin purge refuses it and the
11
+ * worker does not quarantine it. Corrupt-metadata dirs are exempt.
12
+ */
13
+ export const ORPHAN_YOUTH_THRESHOLD_MS = 15 * 60_000;
14
+ /**
15
+ * The provisioning lock marker's path for a branch directory.
11
16
  *
12
17
  * `acquireProvisioningLock` passes the name as `lockfilePath`, overriding
13
18
  * proper-lockfile's default `${target}.lock`, so the marker (a mkdir-based
14
19
  * directory) sits at exactly this path — no extra `.lock` suffix.
15
20
  */
16
21
  function provisioningLockPath(baseRoot, dirName) {
17
- return path.join(baseRoot, `.${dirName}.init.lock`);
22
+ return path.join(baseRoot, branchProvisioningLockName(dirName));
18
23
  }
19
24
  /** Stat the provisioning lock marker, if present. Never throws. */
20
25
  async function readProvisioningLock(baseRoot, dirName) {
@@ -63,16 +63,16 @@ export declare class BranchMetadataFileManager {
63
63
  * entry with no clone behind it. It runs BEFORE the lock stack so a doomed
64
64
  * save fails fast instead of paying for a lock.
65
65
  *
66
- * Accepted residual window: a save that passes the check can still race a
67
- * `rm` that starts moments later and is mid-flight when the write lands.
68
- * Closing that needs a tombstone OUTSIDE the tree being removed, and the
69
- * lockfile taken next lives INSIDE `branchRoot`, so it can promise no more
70
- * than "the directory existed a moment ago".
66
+ * A save that passes the check just before a delete still cannot write: the
67
+ * delete renames the tree away under the same lockfile, and that lockfile
68
+ * creates only its own directory (`withOccFileLock`), so the save fails there.
71
69
  */
72
70
  save(incoming: BranchMetadataUpdate): Promise<BranchMetadataFile>;
73
71
  /** Invalidate the registry cache so the next list() regenerates. */
74
72
  private invalidateRegistry;
75
73
  }
74
+ /** The branch.json a brand-new branch starts with: {@link mergeBranchMetadata} over nothing. */
75
+ export declare function buildInitialBranchMetadata(incoming: BranchMetadataUpdate, now?: string): BranchMetadataFile;
76
76
  /**
77
77
  * Fields save() accepts. `createdBy` applies on creation only and is ignored
78
78
  * once metadata exists; createdAt/updatedAt are managed here, not by callers.
@@ -101,11 +101,9 @@ export class BranchMetadataFileManager {
101
101
  * entry with no clone behind it. It runs BEFORE the lock stack so a doomed
102
102
  * save fails fast instead of paying for a lock.
103
103
  *
104
- * Accepted residual window: a save that passes the check can still race a
105
- * `rm` that starts moments later and is mid-flight when the write lands.
106
- * Closing that needs a tombstone OUTSIDE the tree being removed, and the
107
- * lockfile taken next lives INSIDE `branchRoot`, so it can promise no more
108
- * than "the directory existed a moment ago".
104
+ * A save that passes the check just before a delete still cannot write: the
105
+ * delete renames the tree away under the same lockfile, and that lockfile
106
+ * creates only its own directory (`withOccFileLock`), so the save fails there.
109
107
  */
110
108
  async save(incoming) {
111
109
  try {
@@ -121,37 +119,7 @@ export class BranchMetadataFileManager {
121
119
  try {
122
120
  saved = await withLock(this.filePath, () => withOccFileLock(this.filePath, () => withOccRetry(async () => {
123
121
  const { meta: existing, version } = await this.load();
124
- const now = new Date().toISOString();
125
- const defaults = {
126
- name: 'unknown',
127
- status: 'editing',
128
- access: {},
129
- createdBy: 'unknown',
130
- createdAt: now,
131
- updatedAt: now,
132
- };
133
- const merged = {
134
- schemaVersion: CURRENT_SCHEMA_VERSION,
135
- version: version ?? 0,
136
- branch: {
137
- ...defaults,
138
- ...existing?.branch,
139
- ...incoming.branch,
140
- access: {
141
- ...existing?.branch?.access,
142
- ...incoming.branch?.access,
143
- },
144
- // Immutable after creation
145
- createdBy: existing?.branch.createdBy ?? incoming.branch?.createdBy ?? defaults.createdBy,
146
- createdAt: existing?.branch.createdAt ?? defaults.createdAt,
147
- // Fork point is recorded once at creation; later saves must not move it
148
- baseBranch: existing?.branch.baseBranch ?? incoming.branch?.baseBranch,
149
- // Always stamped fresh; the spreads above would otherwise let
150
- // the creation-time value win forever, freezing the timestamp
151
- // the editor's Branches panel sorts and displays by
152
- updatedAt: now,
153
- },
154
- };
122
+ const merged = mergeBranchMetadata(existing, version, incoming);
155
123
  const written = await this.write(merged, version);
156
124
  merged.version = written.version;
157
125
  merged.writeId = written.writeId;
@@ -178,6 +146,46 @@ export class BranchMetadataFileManager {
178
146
  await registry.invalidate();
179
147
  }
180
148
  }
149
+ /**
150
+ * The branch.json that {@link BranchMetadataFileManager.save} writes for `incoming` over
151
+ * `existing`. Pure, so a staged workspace's first branch.json (branch-provisioning.ts) is built by
152
+ * the same rules as a save and the two cannot drift.
153
+ */
154
+ function mergeBranchMetadata(existing, version, incoming, now = new Date().toISOString()) {
155
+ const defaults = {
156
+ name: 'unknown',
157
+ status: 'editing',
158
+ access: {},
159
+ createdBy: 'unknown',
160
+ createdAt: now,
161
+ updatedAt: now,
162
+ };
163
+ return {
164
+ schemaVersion: CURRENT_SCHEMA_VERSION,
165
+ version: version ?? 0,
166
+ branch: {
167
+ ...defaults,
168
+ ...existing?.branch,
169
+ ...incoming.branch,
170
+ access: {
171
+ ...existing?.branch?.access,
172
+ ...incoming.branch?.access,
173
+ },
174
+ // Immutable after creation
175
+ createdBy: existing?.branch.createdBy ?? incoming.branch?.createdBy ?? defaults.createdBy,
176
+ createdAt: existing?.branch.createdAt ?? defaults.createdAt,
177
+ // Fork point is recorded once at creation; later saves must not move it
178
+ baseBranch: existing?.branch.baseBranch ?? incoming.branch?.baseBranch,
179
+ // Always stamped fresh; the spreads above would otherwise let the creation-time value win
180
+ // forever, freezing the timestamp the editor's Branches panel sorts and displays by
181
+ updatedAt: now,
182
+ },
183
+ };
184
+ }
185
+ /** The branch.json a brand-new branch starts with: {@link mergeBranchMetadata} over nothing. */
186
+ export function buildInitialBranchMetadata(incoming, now) {
187
+ return mergeBranchMetadata(null, null, incoming, now);
188
+ }
181
189
  /**
182
190
  * The metadata update for archiving a branch whose PR merged. Shared by the
183
191
  * worker's merge-poll (CmsWorker.pollMergeState) and the manual markAsMerged
@@ -0,0 +1,167 @@
1
+ import { type BranchMetadataFile } from './branch-metadata-file.js';
2
+ import { type CloneRepoOptions } from './git-manager.js';
3
+ import type { ProvisionLog } from './utils/provision-log.js';
4
+ /** How long residue must have been untouched before a create request moves it aside. */
5
+ export declare const CREATE_RESIDUE_MIN_QUIET_MS = 60000;
6
+ /**
7
+ * Retriable: another process is setting this branch up, or what is at its path is too recently
8
+ * touched (or under an admin repair) to judge. Callers answer 503.
9
+ */
10
+ export declare class BranchProvisioningBusyError extends Error {
11
+ readonly dirName: string;
12
+ constructor(dirName: string, message?: string);
13
+ }
14
+ /** The branch's path holds a git repository this deployment did not create (prod only). */
15
+ export declare class BranchDirOccupiedError extends Error {
16
+ readonly dirName: string;
17
+ constructor(dirName: string);
18
+ }
19
+ /** `YYYYMMDDTHHMMSSZ` in UTC, with no colons. */
20
+ export declare function formatDirStamp(date: Date): string;
21
+ /**
22
+ * The date in a name ending `-{STAMP}`, or null. Leftovers are aged by this alone, never by
23
+ * mtime: `rename()` keeps a directory's mtime, so a months-old residue would look old the
24
+ * moment it was moved.
25
+ */
26
+ export declare function parseDirStamp(name: string): Date | null;
27
+ /**
28
+ * A branch directory name cut to a bounded, still-unique form: branch names run to 250 chars.
29
+ * @internal Exported for tests.
30
+ */
31
+ export declare function shortDirName(dirName: string): string;
32
+ /** Where a branch directory goes the moment it is deleted, before its `rm`. */
33
+ export declare const deletingDirName: (dirName: string, now?: Date) => string;
34
+ /** The settings workspace's staging and trash siblings, under the workspace root. */
35
+ export declare const settingsStagingDirName: (now?: Date) => string;
36
+ export declare const settingsTrashDirName: (now?: Date) => string;
37
+ interface ResidueSignature {
38
+ hasGitDir: boolean;
39
+ configLock: boolean;
40
+ hasIndex: boolean;
41
+ }
42
+ /**
43
+ * What occupies a branch's final path.
44
+ *
45
+ * - `live` / `corrupt`: branch.json is there. Never touched by provisioning.
46
+ * - `protected`: an admin repair-metadata is mid-way (`branch.json.corrupt-*` with no
47
+ * branch.json), or branch.json vanished between the listing and the read. Left alone.
48
+ * - `residue`: nothing this deployment could still be using — no `.git`, or a `.git` whose config
49
+ * is missing or unreadable (an interrupted clone), or one whose remote is this deployment's own
50
+ * (an interrupted clone or delete).
51
+ * - `foreign`: any other repository, such as a dev CLI-sync workspace, which has no remote.
52
+ */
53
+ export type FinalDirState = {
54
+ kind: 'vacant';
55
+ } | {
56
+ kind: 'live';
57
+ } | {
58
+ kind: 'corrupt';
59
+ } | {
60
+ kind: 'protected';
61
+ } | {
62
+ kind: 'foreign';
63
+ } | {
64
+ kind: 'residue';
65
+ signature: ResidueSignature;
66
+ };
67
+ /** Classify what is at `dirPath`; see {@link FinalDirState}. */
68
+ export declare function classifyFinalDir(dirPath: string, expectedRemoteUrl: string): Promise<FinalDirState>;
69
+ /**
70
+ * Milliseconds since anything was last created or removed in the directory, its `.git` or its
71
+ * `.canopy-meta`. A clone killed inside `git config` touched `.git` when it made `config.lock`.
72
+ */
73
+ export declare function quietAgeMs(dirPath: string, now?: number): Promise<number>;
74
+ type KeptReason = 'live' | 'corrupt' | 'protected' | 'foreign' | 'too-young' | 'replaced';
75
+ export type QuarantineResult = {
76
+ kind: 'quarantined';
77
+ trashName: string;
78
+ } | {
79
+ kind: 'vacant';
80
+ } | {
81
+ kind: 'kept';
82
+ reason: KeptReason;
83
+ };
84
+ export interface QuarantineOptions {
85
+ minQuietMs: number;
86
+ expectedRemoteUrl: string;
87
+ now?: number;
88
+ }
89
+ /**
90
+ * Move residue at `baseRoot/dirName` to `.trash-*`. The caller MUST hold the branch's
91
+ * provisioning lock: admin repair-metadata holds it while it legitimately leaves a directory
92
+ * with real edits and no branch.json.
93
+ *
94
+ * Move-then-verify: the name is renamed aside first and only what arrived at the new name is
95
+ * judged — the same inode the old name showed, still residue, quiet for `minQuietMs` — because
96
+ * a look through the old name can come from a stale NFS dentry for a directory another host has
97
+ * since replaced with a live clone. Anything that fails a check, or cannot be checked, is
98
+ * renamed straight back: the sweep would trash a `.repair-*` without branch.json.
99
+ */
100
+ export declare function quarantineResidueAt(baseRoot: string, dirName: string, options: QuarantineOptions): Promise<QuarantineResult>;
101
+ export interface StageBranchOptions {
102
+ baseRoot: string;
103
+ dirName: string;
104
+ branchName: string;
105
+ baseBranch: string;
106
+ remoteUrl: string;
107
+ clone: Pick<CloneRepoOptions, 'remoteName' | 'config'>;
108
+ gitExcludePattern: string;
109
+ metadata: BranchMetadataFile;
110
+ provisionLog: ProvisionLog;
111
+ /** The sparse-checkout cone (branch-sparse.ts), set before the checkout; none for a full clone. */
112
+ sparseCone?: readonly string[] | null;
113
+ }
114
+ export type StageResult = {
115
+ kind: 'staged';
116
+ stagingPath: string;
117
+ meta: BranchMetadataFile;
118
+ } | {
119
+ kind: 'exists';
120
+ };
121
+ /** Remove a staging or deleted directory, leaving it to the worker's sweep on failure. */
122
+ export declare function removeLeftoverDir(dirPath: string): Promise<void>;
123
+ /**
124
+ * Build a complete branch workspace, branch.json included, in a staging sibling of the final
125
+ * path. Takes no lock. Returns `exists` without checking out when a competitor published first.
126
+ * A failure removes the staging directory; a kill leaves it for the worker.
127
+ */
128
+ export declare function stageBranchWorkspace(options: StageBranchOptions): Promise<StageResult>;
129
+ /**
130
+ * The branch's provisioning lock with a bounded wait; contention is
131
+ * {@link BranchProvisioningBusyError}.
132
+ */
133
+ export declare function lockBranchName(baseRoot: string, dirName: string): Promise<() => Promise<void>>;
134
+ /** What blocks a refused publish rename, as {@link publishStaging} sees it. */
135
+ export type BlockingKind = 'live' | 'protected' | 'residue';
136
+ export interface PublishOptions {
137
+ baseRoot: string;
138
+ dirName: string;
139
+ stagingPath: string;
140
+ /** branch.json's `writeId` in the staged workspace: proof the rename was ours. */
141
+ writeId: string | undefined;
142
+ expectedRemoteUrl: string;
143
+ /** Test seam replacing the look at what blocks a refused rename. */
144
+ inspectBlocked?: (finalPath: string) => Promise<BlockingKind>;
145
+ }
146
+ /**
147
+ * Publish a staged workspace at its final path, under the branch's provisioning lock.
148
+ *
149
+ * A refused rename is inspected: branch.json there means another request won (`exists`);
150
+ * residue is quarantined and the rename retried once. ENOENT can be an NFS retransmission of a
151
+ * rename that already landed, so it counts as published when the final branch.json carries our
152
+ * `writeId`. The caller removes the staging directory on any outcome but `published`.
153
+ */
154
+ export declare function publishStaging(options: PublishOptions): Promise<'published' | 'exists'>;
155
+ export interface LeftoverAction {
156
+ name: string;
157
+ action: 'removed' | 'restored' | 'trashed' | 'failed';
158
+ detail?: string;
159
+ }
160
+ /**
161
+ * Clear what killed processes left beside the branch directories (`baseRoot`) and the settings
162
+ * workspace (`workspaceRoot`): stale `.prov-*` builds, `.deleting-*` directories whose `rm` never
163
+ * finished, `.repair-*` from an interrupted quarantine, and expired settings trash. Ages come
164
+ * from the stamp in each name.
165
+ */
166
+ export declare function sweepProvisioningLeftovers(baseRoot: string, workspaceRoot: string, now?: number): Promise<LeftoverAction[]>;
167
+ export {};