canopycms 0.0.67 → 0.0.68-int.100
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/README.md +21 -14
- package/dist/ai/handler.js +8 -0
- package/dist/api/admin-branch-health.js +17 -20
- package/dist/api/admin.d.ts +30 -9
- package/dist/api/admin.js +32 -2
- package/dist/api/assets.js +84 -76
- package/dist/api/branch-create-window.d.ts +12 -0
- package/dist/api/branch-create-window.js +13 -0
- package/dist/api/branch-review.d.ts +3 -3
- package/dist/api/branch-review.js +14 -5
- package/dist/api/branch-status.d.ts +5 -5
- package/dist/api/branch-status.js +26 -6
- package/dist/api/branch-withdraw.d.ts +2 -2
- package/dist/api/branch-withdraw.js +7 -2
- package/dist/api/branch.d.ts +15 -2
- package/dist/api/branch.js +111 -92
- package/dist/api/client.d.ts +18 -6
- package/dist/api/client.js +79 -12
- package/dist/api/content.d.ts +6 -5
- package/dist/api/content.js +44 -41
- package/dist/api/entries.js +16 -15
- package/dist/api/github-sync.d.ts +10 -1
- package/dist/api/github-sync.js +20 -10
- package/dist/api/index.d.ts +1 -1
- package/dist/api/reference-options.js +1 -1
- package/dist/api/resolve-references.js +14 -40
- package/dist/api/settings-helpers.d.ts +1 -1
- package/dist/api/settings-helpers.js +1 -4
- package/dist/api/user.d.ts +3 -0
- package/dist/api/user.js +3 -0
- package/dist/assets/asset-url.d.ts +22 -16
- package/dist/assets/asset-url.js +42 -25
- package/dist/assets/factory.d.ts +5 -0
- package/dist/assets/factory.js +5 -0
- package/dist/assets/index.d.ts +1 -1
- package/dist/assets/materialize.d.ts +122 -0
- package/dist/assets/materialize.js +305 -0
- package/dist/assets/store-local.d.ts +4 -0
- package/dist/assets/store-local.js +11 -0
- package/dist/assets/store-s3.d.ts +10 -1
- package/dist/assets/store-s3.js +55 -3
- package/dist/assets/transform-directives.d.ts +52 -13
- package/dist/assets/transform-directives.js +87 -27
- package/dist/assets/transform.d.ts +3 -3
- package/dist/assets/transform.js +13 -8
- package/dist/assets/types.d.ts +14 -1
- package/dist/auth/file-based-auth-cache.js +7 -8
- package/dist/authorization/content.d.ts +5 -4
- package/dist/authorization/content.js +5 -5
- package/dist/authorization/path.d.ts +11 -6
- package/dist/authorization/path.js +11 -6
- package/dist/authorization/types.d.ts +2 -2
- package/dist/branch-health.d.ts +7 -1
- package/dist/branch-health.js +8 -3
- package/dist/branch-metadata.d.ts +5 -5
- package/dist/branch-metadata.js +44 -36
- package/dist/branch-provisioning.d.ts +167 -0
- package/dist/branch-provisioning.js +501 -0
- package/dist/branch-schema-cache.d.ts +5 -3
- package/dist/branch-schema-cache.js +29 -11
- package/dist/branch-sparse.d.ts +23 -0
- package/dist/branch-sparse.js +118 -0
- package/dist/branch-workspace.d.ts +42 -3
- package/dist/branch-workspace.js +240 -94
- package/dist/build/asset-refs.d.ts +93 -0
- package/dist/build/asset-refs.js +251 -0
- package/dist/build-identity.d.ts +3 -0
- package/dist/build-identity.js +13 -0
- package/dist/cli/asset-refs.d.ts +30 -0
- package/dist/cli/asset-refs.js +168 -0
- package/dist/cli/cli.d.ts +2 -1
- package/dist/cli/cli.js +4843 -1870
- package/dist/cli/generate-ai-content.js +1584 -444
- package/dist/cli/github-app-manifest.d.ts +4 -3
- package/dist/cli/github-app-manifest.js +4 -3
- package/dist/cli/init.js +30 -27
- package/dist/cli/migrate.js +12 -8
- package/dist/cli/sync.js +83 -37
- package/dist/cli/template-files/Dockerfile.cms.template +8 -0
- package/dist/cli/template-files/cdk-app.ts.template +4 -0
- package/dist/cli/template-files/cms-stack.ts.template +23 -12
- package/dist/cli/template-files/deploy-cms.yml.template +2 -0
- package/dist/config/helpers.js +1 -2
- package/dist/config/schemas/config.d.ts +19 -32
- package/dist/config/schemas/config.js +9 -11
- package/dist/config/schemas/field.js +1 -0
- package/dist/config/schemas/media.d.ts +4 -13
- package/dist/config/schemas/media.js +5 -8
- package/dist/config/schemas/url.d.ts +4 -20
- package/dist/config/schemas/url.js +6 -22
- package/dist/config/types.d.ts +30 -20
- package/dist/config/validation.d.ts +6 -0
- package/dist/config/validation.js +37 -0
- package/dist/content-listing.d.ts +12 -12
- package/dist/content-listing.js +10 -6
- package/dist/content-reader.d.ts +11 -2
- package/dist/content-reader.js +22 -14
- package/dist/content-store.d.ts +37 -9
- package/dist/content-store.js +70 -43
- package/dist/content-tree.d.ts +1 -1
- package/dist/content-tree.js +2 -2
- package/dist/context.d.ts +27 -9
- package/dist/context.js +154 -70
- package/dist/editor/BranchManager.d.ts +7 -1
- package/dist/editor/BranchManager.js +29 -14
- package/dist/editor/CanopyEditor.d.ts +1 -1
- package/dist/editor/CanopyEditor.js +2 -6
- package/dist/editor/Editor.d.ts +4 -3
- package/dist/editor/Editor.js +24 -20
- package/dist/editor/FormRenderer.js +34 -18
- package/dist/editor/admin/SystemHealthPanel.js +47 -7
- package/dist/editor/components/BranchesDrawer.d.ts +9 -0
- package/dist/editor/components/BranchesDrawer.js +4 -0
- package/dist/editor/components/NoEditPermissionNotice.d.ts +10 -0
- package/dist/editor/components/NoEditPermissionNotice.js +17 -0
- package/dist/editor/context/AssetContext.d.ts +18 -35
- package/dist/editor/context/AssetContext.js +20 -29
- package/dist/editor/context/index.d.ts +1 -1
- package/dist/editor/context/index.js +1 -1
- package/dist/editor/editor-config.d.ts +1 -2
- package/dist/editor/editor-config.js +0 -23
- package/dist/editor/editor-utils.d.ts +24 -21
- package/dist/editor/editor-utils.js +75 -62
- package/dist/editor/fields/BlockField.d.ts +1 -0
- package/dist/editor/fields/BlockField.js +6 -4
- package/dist/editor/fields/CodeField.d.ts +1 -0
- package/dist/editor/fields/CodeField.js +2 -2
- package/dist/editor/fields/DateTimeField.d.ts +1 -0
- package/dist/editor/fields/DateTimeField.js +2 -2
- package/dist/editor/fields/FieldDescription.d.ts +16 -0
- package/dist/editor/fields/FieldDescription.js +11 -0
- package/dist/editor/fields/ImageField.d.ts +1 -0
- package/dist/editor/fields/ImageField.js +3 -2
- package/dist/editor/fields/InlineGroupField.js +4 -1
- package/dist/editor/fields/MarkdownField.d.ts +1 -0
- package/dist/editor/fields/MarkdownField.js +115 -20
- package/dist/editor/fields/NumberField.d.ts +1 -0
- package/dist/editor/fields/NumberField.js +2 -2
- package/dist/editor/fields/NumberListField.d.ts +1 -0
- package/dist/editor/fields/NumberListField.js +2 -2
- package/dist/editor/fields/ObjectField.d.ts +1 -0
- package/dist/editor/fields/ObjectField.js +5 -2
- package/dist/editor/fields/ReferenceField.d.ts +1 -0
- package/dist/editor/fields/ReferenceField.js +5 -4
- package/dist/editor/fields/SelectField.d.ts +1 -0
- package/dist/editor/fields/SelectField.js +2 -2
- package/dist/editor/fields/StringListField.d.ts +1 -0
- package/dist/editor/fields/StringListField.js +2 -2
- package/dist/editor/fields/TextField.d.ts +1 -0
- package/dist/editor/fields/TextField.js +2 -2
- package/dist/editor/fields/ToggleField.d.ts +1 -0
- package/dist/editor/fields/ToggleField.js +7 -4
- package/dist/editor/fields/entry-link/InsertEntryLink.d.ts +1 -1
- package/dist/editor/fields/entry-link/InsertEntryLink.js +3 -2
- package/dist/editor/fields/mdx-jsx-support.d.ts +1 -0
- package/dist/editor/fields/mdx-jsx-support.js +137 -0
- package/dist/editor/hooks/create-branch-request.d.ts +20 -0
- package/dist/editor/hooks/create-branch-request.js +52 -0
- package/dist/editor/hooks/useBranchActions.d.ts +13 -2
- package/dist/editor/hooks/useBranchActions.js +48 -17
- package/dist/editor/hooks/useBranchManager.d.ts +11 -0
- package/dist/editor/hooks/useBranchManager.js +80 -2
- package/dist/editor/hooks/useBranchesData.d.ts +6 -0
- package/dist/editor/hooks/useBranchesData.js +9 -3
- package/dist/editor/hooks/useCommentSystem.js +25 -6
- package/dist/editor/hooks/useDraftManager.d.ts +16 -1
- package/dist/editor/hooks/useDraftManager.js +193 -36
- package/dist/editor/hooks/useEntriesData.js +3 -3
- package/dist/editor/hooks/useEntryManager.d.ts +2 -1
- package/dist/editor/hooks/useEntryManager.js +34 -19
- package/dist/editor/media/AssetCard.d.ts +1 -1
- package/dist/editor/media/crop-math.d.ts +3 -4
- package/dist/editor/media/crop-math.js +11 -22
- package/dist/editor/media/editor-image-src.d.ts +7 -0
- package/dist/editor/media/editor-image-src.js +12 -0
- package/dist/editor/preview-asset-base.d.ts +8 -0
- package/dist/editor/preview-asset-base.js +13 -0
- package/dist/editor/preview-bridge.d.ts +8 -1
- package/dist/editor/preview-bridge.js +15 -2
- package/dist/editor/preview-path.d.ts +10 -0
- package/dist/editor/preview-path.js +20 -0
- package/dist/editor/theme.js +2 -2
- package/dist/entry-schema-registry.d.ts +4 -4
- package/dist/entry-schema-registry.js +10 -6
- package/dist/entry-schema.d.ts +27 -4
- package/dist/entry-schema.js +20 -3
- package/dist/git-manager.d.ts +173 -6
- package/dist/git-manager.js +594 -76
- package/dist/github-service.d.ts +8 -0
- package/dist/github-service.js +5 -1
- package/dist/http/handler.js +37 -13
- package/dist/http/index.d.ts +2 -0
- package/dist/http/index.js +2 -0
- package/dist/http/router.d.ts +2 -0
- package/dist/http/router.js +2 -1
- package/dist/http/types.d.ts +2 -0
- package/dist/http/worker-not-ready.d.ts +10 -0
- package/dist/http/worker-not-ready.js +24 -0
- package/dist/operating-mode/client-unsafe-strategy.js +0 -6
- package/dist/operating-mode/types.d.ts +1 -4
- package/dist/paths/branch-name.d.ts +5 -0
- package/dist/paths/branch-name.js +9 -0
- package/dist/paths/branch.d.ts +6 -2
- package/dist/paths/branch.js +16 -10
- package/dist/paths/index.d.ts +3 -3
- package/dist/paths/index.js +3 -3
- package/dist/paths/normalize.d.ts +7 -0
- package/dist/paths/normalize.js +9 -0
- package/dist/paths/validation.js +0 -2
- package/dist/reference-resolver.d.ts +5 -21
- package/dist/reference-resolver.js +9 -44
- package/dist/resolve-canopy-user.js +2 -1
- package/dist/schema/schema-store.d.ts +54 -31
- package/dist/schema/schema-store.js +77 -31
- package/dist/server.d.ts +24 -9
- package/dist/server.js +22 -8
- package/dist/services.d.ts +18 -6
- package/dist/services.js +85 -70
- package/dist/settings-workspace.d.ts +23 -3
- package/dist/settings-workspace.js +197 -45
- package/dist/static/seo.d.ts +2 -14
- package/dist/static/seo.js +2 -25
- package/dist/submission-attribution.d.ts +73 -0
- package/dist/submission-attribution.js +221 -0
- package/dist/sync-core.d.ts +12 -1
- package/dist/sync-core.js +31 -13
- package/dist/task-queue/worker-status.d.ts +8 -0
- package/dist/task-queue/worker-status.js +17 -0
- package/dist/types.d.ts +31 -2
- package/dist/utils/content-write-lock.d.ts +18 -7
- package/dist/utils/content-write-lock.js +18 -9
- package/dist/utils/debug.d.ts +8 -0
- package/dist/utils/debug.js +10 -2
- package/dist/utils/git.d.ts +32 -0
- package/dist/utils/git.js +42 -0
- package/dist/utils/occ-json-write.js +10 -2
- package/dist/utils/provision-log.d.ts +25 -0
- package/dist/utils/provision-log.js +49 -0
- package/dist/utils/provisioning-lock.d.ts +35 -5
- package/dist/utils/provisioning-lock.js +73 -12
- package/dist/utils/request-timing.d.ts +25 -0
- package/dist/utils/request-timing.js +101 -0
- package/dist/utils/sanitize-href.d.ts +8 -22
- package/dist/utils/sanitize-href.js +11 -28
- package/dist/utils/url-prefix.d.ts +30 -0
- package/dist/utils/url-prefix.js +61 -0
- package/dist/validation/entry-validator.js +9 -3
- package/dist/version.d.ts +1 -0
- package/dist/version.js +3 -0
- package/dist/worker/canopy-state.d.ts +45 -0
- package/dist/worker/canopy-state.js +75 -0
- package/dist/worker/cms-worker.d.ts +8 -3
- package/dist/worker/cms-worker.js +38 -2
- package/dist/worker/git-sync.d.ts +30 -12
- package/dist/worker/git-sync.js +253 -58
- package/dist/worker/history-rewrite.d.ts +1 -1
- package/dist/worker/history-rewrite.js +1 -1
- package/dist/worker/provisioned-workspace.d.ts +35 -0
- package/dist/worker/provisioned-workspace.js +50 -0
- package/dist/worker/rebase.d.ts +1 -1
- package/dist/worker/rebase.js +95 -23
- package/dist/worker/remote-git-maintenance.d.ts +3 -0
- package/dist/worker/remote-git-maintenance.js +11 -0
- package/dist/worker/sparse-cone.d.ts +20 -0
- package/dist/worker/sparse-cone.js +132 -0
- package/dist/worker/task-runner.js +39 -4
- package/dist/worker/worker-context.d.ts +6 -1
- package/package.json +2 -1
|
@@ -4,17 +4,16 @@
|
|
|
4
4
|
* NO imports (not even other files in this directory) so it can be imported
|
|
5
5
|
* from client bundles (via `assetUrl`/`assetSrcSet` in asset-url.ts, exported
|
|
6
6
|
* off the package's main entry) as well as from the server-only transform
|
|
7
|
-
* engine (transform.ts) and the
|
|
7
|
+
* engine (transform.ts) and the lazy transform Lambda, without
|
|
8
8
|
* ever pulling in node:crypto, sharp, or any other server-only dependency.
|
|
9
9
|
*
|
|
10
10
|
* `{directives}` is either the literal identity token (`orig`) or a
|
|
11
11
|
* comma-separated list of `key=value` pairs drawn from:
|
|
12
|
-
* w={int} output width
|
|
13
|
-
*
|
|
14
|
-
* time via `withoutEnlargement`, not here)
|
|
12
|
+
* w={int} output width, accepted per `TransformWidthPolicy` (upscaling
|
|
13
|
+
* is rejected at transform time via `withoutEnlargement`, not here)
|
|
15
14
|
* f={fmt} output format: webp | jpeg | png - when present, the URL's
|
|
16
15
|
* `{ext}` must equal it exactly
|
|
17
|
-
* q={int} quality
|
|
16
|
+
* q={int} quality, a multiple of 5 in [30, 95] under either policy
|
|
18
17
|
* c={rect} normalized crop rect `x:y:w:h`, four floats in [0,1] with
|
|
19
18
|
* x+w<=1 and y+h<=1, w>0 and h>0 (colon-separated - commas are
|
|
20
19
|
* the directive separator)
|
|
@@ -24,9 +23,11 @@
|
|
|
24
23
|
*/
|
|
25
24
|
/** The identity transform directive name - EXIF-strip only, no other change. */
|
|
26
25
|
export const IDENTITY_TRANSFORM_DIRECTIVE = 'orig';
|
|
27
|
-
const
|
|
28
|
-
const
|
|
29
|
-
const
|
|
26
|
+
const MIN_ALLOWLIST_WIDTH = 160;
|
|
27
|
+
const MAX_ALLOWLIST_WIDTH = 4096;
|
|
28
|
+
const ALLOWLIST_WIDTH_STEP = 160;
|
|
29
|
+
const SMALL_ALLOWLIST_WIDTHS = new Set([32, 48, 64, 96, 128]);
|
|
30
|
+
const MAX_ANY_WIDTH = 8192;
|
|
30
31
|
/**
|
|
31
32
|
* Decompression-bomb cap: the max `width * height` (post-decode pixel count)
|
|
32
33
|
* any raster the pipeline handles may have - checked both at upload finalize
|
|
@@ -34,12 +35,14 @@ const WIDTH_STEP = 160;
|
|
|
34
35
|
* time (transform.ts, via sharp's `limitInputPixels`). Shared here (rather
|
|
35
36
|
* than duplicated in each server-only module) so it stays a single number,
|
|
36
37
|
* and because this file is dependency-free/isomorphic it can be imported by
|
|
37
|
-
* both without pulling sharp or file-type into anything.
|
|
38
|
-
*
|
|
39
|
-
*
|
|
40
|
-
*
|
|
38
|
+
* both without pulling sharp or file-type into anything. 24 MP (a 6000x4000
|
|
39
|
+
* camera frame) is sized by measured peak memory: the worst case, a full-size
|
|
40
|
+
* WebP encode of an RGBA WebP source, peaks at 1246 MiB here and grows about
|
|
41
|
+
* 37 MiB per MP, so this is the largest cap that fits the 2048 MB CMS and
|
|
42
|
+
* transform Lambdas with allocator headroom. For an animation the cap bounds
|
|
43
|
+
* all decoded frames together.
|
|
41
44
|
*/
|
|
42
|
-
export const MAX_INPUT_PIXELS =
|
|
45
|
+
export const MAX_INPUT_PIXELS = 6000 * 4000;
|
|
43
46
|
/**
|
|
44
47
|
* Cap on decoded animated frames (GIF/WebP) - without this, `{ animated: true }`
|
|
45
48
|
* (sharp's `pages: -1`) decodes every frame of a maliciously-crafted
|
|
@@ -61,12 +64,17 @@ export const MAX_ANIMATED_FRAMES = 60;
|
|
|
61
64
|
// Quality is allowlisted (multiples of 5 in [30, 95] - 14 values) for the
|
|
62
65
|
// same cache-stuffing reason as width: every accepted directive combination
|
|
63
66
|
// becomes a stored cache object in prod, so unbounded q would multiply the
|
|
64
|
-
// per-asset variant space by 100. Crop
|
|
65
|
-
//
|
|
66
|
-
//
|
|
67
|
+
// per-asset variant space by 100. Crop stays effectively unbounded (editor
|
|
68
|
+
// rects need float precision); the public path answers it by computing nothing
|
|
69
|
+
// (ARCHITECTURE.md, "Why transform by URL directive and materialize at release?").
|
|
67
70
|
const MIN_QUALITY = 30;
|
|
68
71
|
const MAX_QUALITY = 95;
|
|
69
72
|
const QUALITY_STEP = 5;
|
|
73
|
+
/**
|
|
74
|
+
* Decimal places a crop value keeps in its canonical form. The editor rounds a
|
|
75
|
+
* new crop to this (editor/media/crop-math.ts), so a crop the editor stores is
|
|
76
|
+
* already canonical; a URL carrying more decimals canonicalizes to the rounded rect.
|
|
77
|
+
*/
|
|
70
78
|
const CROP_PRECISION = 4;
|
|
71
79
|
const HASH32_RE = /^[a-f0-9]{32}$/;
|
|
72
80
|
const SLUG_RE = /^[a-z0-9-]+$/;
|
|
@@ -85,15 +93,22 @@ const KNOWN_DIRECTIVE_KEYS = new Set(['w', 'f', 'q', 'c']);
|
|
|
85
93
|
function isOutputFormat(value) {
|
|
86
94
|
return value === 'webp' || value === 'jpeg' || value === 'png';
|
|
87
95
|
}
|
|
88
|
-
/** True if `
|
|
89
|
-
export function isAllowedTransformWidth(width) {
|
|
90
|
-
|
|
96
|
+
/** True if `policy` accepts `width`; see `TransformWidthPolicy`. */
|
|
97
|
+
export function isAllowedTransformWidth(width, policy) {
|
|
98
|
+
if (!Number.isInteger(width))
|
|
99
|
+
return false;
|
|
100
|
+
if (policy === 'any')
|
|
101
|
+
return width >= 1 && width <= MAX_ANY_WIDTH;
|
|
102
|
+
return (SMALL_ALLOWLIST_WIDTHS.has(width) ||
|
|
103
|
+
(width >= MIN_ALLOWLIST_WIDTH &&
|
|
104
|
+
width <= MAX_ALLOWLIST_WIDTH &&
|
|
105
|
+
width % ALLOWLIST_WIDTH_STEP === 0));
|
|
91
106
|
}
|
|
92
|
-
function parseWidth(value) {
|
|
107
|
+
function parseWidth(value, policy) {
|
|
93
108
|
if (!POSITIVE_INT_RE.test(value))
|
|
94
109
|
return null;
|
|
95
110
|
const n = Number(value);
|
|
96
|
-
return isAllowedTransformWidth(n) ? n : null;
|
|
111
|
+
return isAllowedTransformWidth(n, policy) ? n : null;
|
|
97
112
|
}
|
|
98
113
|
/** True if `quality` is on the allowlist: a multiple of 5 in [30, 95]. */
|
|
99
114
|
function isAllowedTransformQuality(quality) {
|
|
@@ -145,6 +160,27 @@ function parseCrop(value) {
|
|
|
145
160
|
return null;
|
|
146
161
|
return { x, y, w, h };
|
|
147
162
|
}
|
|
163
|
+
function roundToCropPrecision(n) {
|
|
164
|
+
const factor = 10 ** CROP_PRECISION;
|
|
165
|
+
return Math.round(n * factor) / factor;
|
|
166
|
+
}
|
|
167
|
+
/**
|
|
168
|
+
* Round a crop rect to `CROP_PRECISION` decimals, shrinking `w`/`h` to the
|
|
169
|
+
* space left when rounding pushes `x+w` or `y+h` past 1 (0.66665 + 0.33335
|
|
170
|
+
* rounds to 0.6667 + 0.3334). The result can still be degenerate (a `w` or `h`
|
|
171
|
+
* that rounds to 0), which `isValidCropRect` rejects.
|
|
172
|
+
*/
|
|
173
|
+
export function roundCropRect(rect) {
|
|
174
|
+
const x = roundToCropPrecision(rect.x);
|
|
175
|
+
const y = roundToCropPrecision(rect.y);
|
|
176
|
+
let w = roundToCropPrecision(rect.w);
|
|
177
|
+
let h = roundToCropPrecision(rect.h);
|
|
178
|
+
if (x + w > 1)
|
|
179
|
+
w = roundToCropPrecision(1 - x);
|
|
180
|
+
if (y + h > 1)
|
|
181
|
+
h = roundToCropPrecision(1 - y);
|
|
182
|
+
return { x, y, w, h };
|
|
183
|
+
}
|
|
148
184
|
function formatUnitFloat(n) {
|
|
149
185
|
return n.toFixed(CROP_PRECISION);
|
|
150
186
|
}
|
|
@@ -156,7 +192,7 @@ function err(error) {
|
|
|
156
192
|
* `assets/t/` and the hash32 segment) into a `TransformDirectives`, or
|
|
157
193
|
* `orig` into the identity directive.
|
|
158
194
|
*/
|
|
159
|
-
function parseDirectivesString(raw) {
|
|
195
|
+
function parseDirectivesString(raw, policy) {
|
|
160
196
|
if (raw === IDENTITY_TRANSFORM_DIRECTIVE) {
|
|
161
197
|
return { ok: true, directives: { identity: true } };
|
|
162
198
|
}
|
|
@@ -187,7 +223,7 @@ function parseDirectivesString(raw) {
|
|
|
187
223
|
seen.add(key);
|
|
188
224
|
switch (key) {
|
|
189
225
|
case 'w': {
|
|
190
|
-
const w = parseWidth(value);
|
|
226
|
+
const w = parseWidth(value, policy);
|
|
191
227
|
if (w === null)
|
|
192
228
|
return err(`Invalid width: '${value}'`);
|
|
193
229
|
width = w;
|
|
@@ -222,7 +258,7 @@ function parseDirectivesString(raw) {
|
|
|
222
258
|
* `[directivesRaw, hash32, "{slug}.{ext}"]`. The raw route strips the
|
|
223
259
|
* `assets/t/` prefix and splits the remaining key by `/` before calling this.
|
|
224
260
|
*/
|
|
225
|
-
export function parseTransformPath(segments) {
|
|
261
|
+
export function parseTransformPath(segments, policy) {
|
|
226
262
|
if (segments.length !== 3) {
|
|
227
263
|
return err(`Expected 3 path segments (directives/hash32/slug.ext), got ${segments.length}`);
|
|
228
264
|
}
|
|
@@ -242,7 +278,7 @@ export function parseTransformPath(segments) {
|
|
|
242
278
|
if (!EXT_RE.test(ext)) {
|
|
243
279
|
return err(`Invalid ext: '${ext}'`);
|
|
244
280
|
}
|
|
245
|
-
const parsedDirectives = parseDirectivesString(directivesRaw);
|
|
281
|
+
const parsedDirectives = parseDirectivesString(directivesRaw, policy);
|
|
246
282
|
if (!parsedDirectives.ok) {
|
|
247
283
|
return parsedDirectives;
|
|
248
284
|
}
|
|
@@ -255,7 +291,8 @@ export function parseTransformPath(segments) {
|
|
|
255
291
|
/**
|
|
256
292
|
* Canonical string form of a directive set, so equivalent directive sets
|
|
257
293
|
* (different key order, different float formatting) always map to the same
|
|
258
|
-
* cache key. Order is fixed alphabetically by key: c, f, q, w
|
|
294
|
+
* cache key. Order is fixed alphabetically by key: c, f, q, w, and crop is
|
|
295
|
+
* rounded by `roundCropRect`.
|
|
259
296
|
*/
|
|
260
297
|
export function formatDirectives(directives) {
|
|
261
298
|
if (directives.identity) {
|
|
@@ -263,7 +300,7 @@ export function formatDirectives(directives) {
|
|
|
263
300
|
}
|
|
264
301
|
const parts = [];
|
|
265
302
|
if (directives.crop) {
|
|
266
|
-
const { x, y, w, h } = directives.crop;
|
|
303
|
+
const { x, y, w, h } = roundCropRect(directives.crop);
|
|
267
304
|
parts.push(`c=${formatUnitFloat(x)}:${formatUnitFloat(y)}:${formatUnitFloat(w)}:${formatUnitFloat(h)}`);
|
|
268
305
|
}
|
|
269
306
|
if (directives.format !== undefined) {
|
|
@@ -282,3 +319,26 @@ export function formatDirectives(directives) {
|
|
|
282
319
|
// empty directives segment.
|
|
283
320
|
return parts.length > 0 ? parts.join(',') : IDENTITY_TRANSFORM_DIRECTIVE;
|
|
284
321
|
}
|
|
322
|
+
/**
|
|
323
|
+
* Parse transform-path segments and resolve them to the one spelling their
|
|
324
|
+
* output is stored under. The returned `directives` are parsed back from that
|
|
325
|
+
* canonical spelling, never taken from the request, so a transform computed
|
|
326
|
+
* from them always matches its key. A crop that rounds to zero extent has no
|
|
327
|
+
* canonical spelling and is rejected.
|
|
328
|
+
*/
|
|
329
|
+
export function canonicalizeTransformPath(segments, policy) {
|
|
330
|
+
const parsed = parseTransformPath(segments, policy);
|
|
331
|
+
if (!parsed.ok)
|
|
332
|
+
return parsed;
|
|
333
|
+
const canonicalSegments = [
|
|
334
|
+
formatDirectives(parsed.directives),
|
|
335
|
+
parsed.hash32,
|
|
336
|
+
`${parsed.slug}.${parsed.ext}`,
|
|
337
|
+
];
|
|
338
|
+
const canonical = parseTransformPath(canonicalSegments, policy);
|
|
339
|
+
if (!canonical.ok) {
|
|
340
|
+
return err(`No valid form at ${CROP_PRECISION} crop decimals: ${canonical.error}`);
|
|
341
|
+
}
|
|
342
|
+
const canonicalPath = canonicalSegments.join('/');
|
|
343
|
+
return { ...canonical, canonicalPath, isCanonical: canonicalPath === segments.join('/') };
|
|
344
|
+
}
|
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* The shared transform engine: applies a parsed `TransformDirectives` to source image bytes
|
|
3
3
|
* with sharp. Server-only - never import this from client/editor code (kept in its own file,
|
|
4
|
-
* separate from the dependency-free transform-directives.ts).
|
|
5
|
-
*
|
|
6
|
-
*
|
|
4
|
+
* separate from the dependency-free transform-directives.ts). Its one caller here is
|
|
5
|
+
* `storeTransform` (materialize.ts), which the raw route, `materialize-assets` and the lazy
|
|
6
|
+
* transform Lambda share.
|
|
7
7
|
*
|
|
8
8
|
* sharp is imported for its TYPES only and loaded on first use through `loadSharp()`
|
|
9
9
|
* (sharp-loader.ts), so importing this module never loads libvips - see sharp-loader.ts for
|
package/dist/assets/transform.js
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* The shared transform engine: applies a parsed `TransformDirectives` to source image bytes
|
|
3
3
|
* with sharp. Server-only - never import this from client/editor code (kept in its own file,
|
|
4
|
-
* separate from the dependency-free transform-directives.ts).
|
|
5
|
-
*
|
|
6
|
-
*
|
|
4
|
+
* separate from the dependency-free transform-directives.ts). Its one caller here is
|
|
5
|
+
* `storeTransform` (materialize.ts), which the raw route, `materialize-assets` and the lazy
|
|
6
|
+
* transform Lambda share.
|
|
7
7
|
*
|
|
8
8
|
* sharp is imported for its TYPES only and loaded on first use through `loadSharp()`
|
|
9
9
|
* (sharp-loader.ts), so importing this module never loads libvips - see sharp-loader.ts for
|
|
@@ -27,7 +27,12 @@ import { loadSharp } from './sharp-loader.js';
|
|
|
27
27
|
import { MAX_ANIMATED_FRAMES, MAX_INPUT_PIXELS, } from './transform-directives.js';
|
|
28
28
|
/** Raster formats the transform engine accepts as input. svg/pdf never reach here - they're served statically. */
|
|
29
29
|
const ALLOWED_INPUT_EXTS = new Set(['png', 'jpg', 'jpeg', 'webp', 'gif']);
|
|
30
|
-
/**
|
|
30
|
+
/**
|
|
31
|
+
* Cap on a resize's encoded output, against cache-stuffing with giant re-encodes. An identity
|
|
32
|
+
* (`orig`) is exempt: an asset has one, its input is bounded by the upload and pixel caps, and a
|
|
33
|
+
* noisy PNG over about 10 MiB re-encodes to at least its own size, so the cap would refuse an
|
|
34
|
+
* ordinary upload's `orig` and fail the release that references it.
|
|
35
|
+
*/
|
|
31
36
|
const MAX_OUTPUT_BYTES = 10 * 1024 * 1024;
|
|
32
37
|
const CONTENT_TYPE_BY_FORMAT = {
|
|
33
38
|
webp: 'image/webp',
|
|
@@ -154,9 +159,9 @@ export async function applyTransform(input, directives) {
|
|
|
154
159
|
// `pages` value that EXCEEDS the source's actual page count makes sharp
|
|
155
160
|
// throw ("bad page number") rather than clamping, so `MAX_ANIMATED_FRAMES`
|
|
156
161
|
// can only be applied as `Math.min(totalPages, MAX_ANIMATED_FRAMES)`.
|
|
157
|
-
// `limitInputPixels`
|
|
158
|
-
//
|
|
159
|
-
//
|
|
162
|
+
// `limitInputPixels` on this probe counts one frame, so an oversized still
|
|
163
|
+
// throws here; the load below counts every frame it reads, so an over-cap
|
|
164
|
+
// animation throws there (measured at the cap: at most about one frame's buffer).
|
|
160
165
|
const probeMeta = await sharp(input.data, { limitInputPixels: MAX_INPUT_PIXELS }).metadata();
|
|
161
166
|
const totalPages = probeMeta.pages ?? 1;
|
|
162
167
|
const pagesToRead = Math.min(totalPages, MAX_ANIMATED_FRAMES);
|
|
@@ -178,7 +183,7 @@ export async function applyTransform(input, directives) {
|
|
|
178
183
|
? encode(pipeline, format, quality)
|
|
179
184
|
: encodeSourceFormat(pipeline, sourceExt, quality);
|
|
180
185
|
const data = await pipeline.toBuffer();
|
|
181
|
-
if (data.byteLength > MAX_OUTPUT_BYTES) {
|
|
186
|
+
if (!directives.identity && data.byteLength > MAX_OUTPUT_BYTES) {
|
|
182
187
|
return {
|
|
183
188
|
ok: false,
|
|
184
189
|
status: 413,
|
package/dist/assets/types.d.ts
CHANGED
|
@@ -55,7 +55,12 @@ export interface AssetStore {
|
|
|
55
55
|
data: Uint8Array;
|
|
56
56
|
contentType: string;
|
|
57
57
|
}): Promise<void>;
|
|
58
|
-
|
|
58
|
+
/**
|
|
59
|
+
* `ext` is where the original is expected (its meta's `ext`): that key is read first, and any
|
|
60
|
+
* `{hash32}.*` is looked for only on a miss. A hit needs no list permission; on S3 without
|
|
61
|
+
* `s3:ListBucket` a miss is a 403, which throws.
|
|
62
|
+
*/
|
|
63
|
+
readOriginal(hash32: string, ext?: string): Promise<{
|
|
59
64
|
data: Uint8Array;
|
|
60
65
|
ext: string;
|
|
61
66
|
contentType?: string;
|
|
@@ -66,8 +71,16 @@ export interface AssetStore {
|
|
|
66
71
|
contentType: string;
|
|
67
72
|
contentDisposition?: string;
|
|
68
73
|
cacheControl?: string;
|
|
74
|
+
/** Object tags; bucket lifecycle rules can filter on them. */
|
|
75
|
+
tags?: Readonly<Record<string, string>>;
|
|
69
76
|
}): Promise<void>;
|
|
70
77
|
readPublicObject(key: string): Promise<PublicObject | null>;
|
|
78
|
+
/** Whether a public object exists at `key`, without reading its body. */
|
|
79
|
+
hasPublicObject(key: string): Promise<boolean>;
|
|
80
|
+
/** Every public object key under `prefix`, in the store's own pages. S3 only. */
|
|
81
|
+
listPublicObjectKeys?(prefix: string): AsyncIterable<string>;
|
|
82
|
+
/** A short-lived URL a browser can GET `key` from directly, or `null` if absent. S3 only. */
|
|
83
|
+
presignPublicObjectRead?(key: string): Promise<string | null>;
|
|
71
84
|
putMetaIfAbsent(hash32: string, meta: AssetMeta): Promise<'created' | 'already-exists'>;
|
|
72
85
|
getMeta(hash32: string): Promise<AssetMeta | null>;
|
|
73
86
|
listMeta(input?: {
|
|
@@ -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
|
|
172
|
-
//
|
|
173
|
-
//
|
|
174
|
-
//
|
|
175
|
-
//
|
|
176
|
-
//
|
|
177
|
-
//
|
|
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 {
|
|
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 = (
|
|
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,
|
|
30
|
-
export declare function createCheckContentAccess(deps: ContentAccessDeps): (context: BranchContext, branchRoot: string,
|
|
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 (
|
|
31
|
+
return (logicalPath, level) => {
|
|
32
32
|
const path = pathChecker({
|
|
33
|
-
|
|
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,
|
|
48
|
+
export async function checkContentAccess(deps, context, branchRoot, logicalPath, user, level) {
|
|
49
49
|
const check = await createContentAccessChecker(deps, context, branchRoot, user);
|
|
50
|
-
return check(
|
|
50
|
+
return check(logicalPath, level);
|
|
51
51
|
}
|
|
52
52
|
export function createCheckContentAccess(deps) {
|
|
53
|
-
return (context, branchRoot,
|
|
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 {
|
|
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
|
|
15
|
-
*
|
|
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,
|
|
23
|
+
export declare function checkPathAccess({ rules, logicalPath, user, defaultAccess, level, }: {
|
|
19
24
|
rules: PathPermission[];
|
|
20
|
-
|
|
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
|
-
|
|
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,
|
|
9
|
-
return minimatch(
|
|
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
|
|
36
|
-
*
|
|
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,
|
|
40
|
-
const normalizedPath = normalize(
|
|
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';
|
package/dist/branch-health.d.ts
CHANGED
|
@@ -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
|
|
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;
|
package/dist/branch-health.js
CHANGED
|
@@ -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
|
-
*
|
|
10
|
-
*
|
|
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,
|
|
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
|
-
*
|
|
67
|
-
*
|
|
68
|
-
*
|
|
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.
|
package/dist/branch-metadata.js
CHANGED
|
@@ -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
|
-
*
|
|
105
|
-
*
|
|
106
|
-
*
|
|
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
|
|
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
|