canopycms 0.0.64 → 0.0.66-int.81

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 CHANGED
@@ -316,9 +316,7 @@ This convention is applied consistently across the API:
316
316
  | `readByUrlPath` | Automatically tries `slug: 'index'` as a fallback when the direct entry doesn't match. Works for all paths including `/`. A path whose last segment is an index slug (any case) skips the direct-entry attempt, so an index entry is reachable only at its collapsed path. |
317
317
  | `buildContentTree` | Default `buildPath` collapses index entries so tree node paths match the URLs consumers would use. |
318
318
 
319
- The round-trip property holds in both directions: for every item from `listEntries`, `readByUrlPath(item.urlPath)` resolves to the same entry, and for an index entry no `.../index` spelling does. (Ordinary entries keep case-insensitive matching on the final slug segment.)
320
-
321
- One known exception, still open: an entry-type name is also resolvable as a URL segment, so an index entry additionally answers at `/<collection>/<entryTypeName>` -- e.g. a root index entry at both `/` and `/home`. Nothing advertises that URL (it is absent from `listEntries` and from static-param generation), so it only matters if you serve a catch-all route. See the `readbyurlpath-entry-type-candidate-phantom-url` task in the CanopyCMS repo.
319
+ The round-trip property holds in both directions: for every item from `listEntries`, `readByUrlPath(item.urlPath)` resolves to the same entry, and for an index entry no `.../index` spelling does, nor does an entry-type name -- `readByUrlPath` only accepts a candidate whose collection segment is an actual collection, so `/<collection>/<entryTypeName>` (e.g. a root index entry answering at both `/` and `/home`) is not a second URL for it. (Ordinary entries keep case-insensitive matching on the final slug segment.)
322
320
 
323
321
  Two different entries can still compute the same `urlPath` — an entry whose slug matches a sibling collection that also has an `index` entry, or two slugs differing only by case. Only one of them can be served, so a production build fails and names them. `findDuplicateUrlPaths` (exported from `canopycms/server`) runs the same scan on demand.
324
322
 
@@ -256,6 +256,15 @@ async function serveLazyTransform(assetStore, key) {
256
256
  if (meta.kind !== 'raster') {
257
257
  return { ok: false, status: 400, error: 'Not a raster asset - svg/pdf are served statically' };
258
258
  }
259
+ // The slug is decorative in the URL but load-bearing in the stored key, so
260
+ // it must equal the asset's real slug: `[a-z0-9-]+` is all the parser can
261
+ // enforce, and every other string that passes it aliases the same image into
262
+ // a new cache key and a new stored object. Canopy's own URLs always carry
263
+ // `meta.slug` (assets/asset-url.ts). Mirrors the prod transform Lambda's
264
+ // handler.ts check - the two paths must agree, or dev accepts URLs prod 404s.
265
+ if (parsed.slug !== meta.slug) {
266
+ return { ok: false, status: 404, error: 'Not found' };
267
+ }
259
268
  // When the URL omits an explicit `f=` format, the transform preserves the
260
269
  // source format, so the URL's `{ext}` must match the source's real ext
261
270
  // exactly - the parser alone can't check this (it doesn't know the source
@@ -189,10 +189,22 @@ export async function writeAuthCacheSnapshot(cachePath, files) {
189
189
  await fs.writeFile(tmpPath, JSON.stringify(data, null, 2), 'utf-8');
190
190
  await fs.rename(tmpPath, finalPath);
191
191
  }
192
- // Atomic symlink swap: create temp symlink, rename over current
192
+ // Atomic symlink swap: create temp symlink, rename over current.
193
+ //
194
+ // The target MUST be relative (the bare `snapshot-<ts>` basename), because
195
+ // writer and reader do not always share a mount namespace. In prod the EC2
196
+ // worker mounts the EFS filesystem root and writes through
197
+ // CANOPYCMS_WORKSPACE_ROOT=/mnt/efs/workspace (cachePath
198
+ // /mnt/efs/workspace/.cache), while the CMS Lambda mounts the /workspace
199
+ // access point at /mnt/efs and reads the SAME directory as /mnt/efs/.cache.
200
+ // An absolute target recorded by one is a nonexistent path to the other -
201
+ // `resolveActiveCacheDir`'s escape guard then correctly rejects it and falls
202
+ // back to the flat layout, where the worker never writes, leaving the Lambda
203
+ // with a permanently empty cache. A relative target resolves against
204
+ // whichever cachePath the reader was given, so it is correct from both.
193
205
  const currentLink = path.join(cachePath, 'current');
194
206
  const tmpLink = path.join(cachePath, `current-${timestamp}`);
195
- await fs.symlink(snapshotDir, tmpLink);
207
+ await fs.symlink(path.basename(snapshotDir), tmpLink);
196
208
  await fs.rename(tmpLink, currentLink);
197
209
  // Clean up old snapshots (keep the 2 most recent)
198
210
  await cleanupOldSnapshots(cachePath, 2);
@@ -34,6 +34,26 @@ export interface BranchHealthEntry {
34
34
  * repair-content-duplicates admin action.
35
35
  */
36
36
  duplicateContentIds?: DuplicateContentId[];
37
+ /**
38
+ * healthy only, and only when true: this clone has an interrupted rebase on
39
+ * disk (`.git/rebase-merge` / `.git/rebase-apply`).
40
+ *
41
+ * Deliberately an advisory flag on `healthy` rather than its own
42
+ * `BranchHealthKind`, for the same reason `duplicateContentIds` is: the
43
+ * branch's metadata is intact and the state is USUALLY transient -- the
44
+ * worker's sync loop aborts an interrupted rebase at the top of its next
45
+ * per-branch pass. What this flag buys is visibility in the window BEFORE
46
+ * that pass runs, where the branch otherwise scanned as unqualified
47
+ * `healthy` while being skipped as dirty every cycle.
48
+ *
49
+ * NOT self-recovering in every case, so a persisting value is the real
50
+ * signal and needs an operator. Two ways it sticks: the abort itself keeps
51
+ * failing, or the branch's status moved off `editing` after it wedged --
52
+ * the rebase loop filters by status BEFORE reaching the recovery step, so a
53
+ * clone that crashed mid-rebase and was then submitted or archived is never
54
+ * revisited, and this flag is the only thing that surfaces it.
55
+ */
56
+ rebaseInProgress?: boolean;
37
57
  /** corrupt-metadata only: message describing why the file failed to load. */
38
58
  parseError?: string;
39
59
  /** corrupt-metadata only: branch.json's mtime, ISO. Omitted if branch.json itself couldn't be stat'd. */
@@ -4,6 +4,7 @@ import { BranchMetadataFileManager, BranchMetadataCorruptError } from './branch-
4
4
  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
+ import { isRebaseInProgress } from './utils/git.js';
7
8
  /**
8
9
  * The on-disk path of the cross-process provisioning lock marker for a
9
10
  * given branch directory, matching `branch-workspace.ts`'s
@@ -139,13 +140,17 @@ export async function scanBranchHealth(baseRoot, opts) {
139
140
  continue;
140
141
  }
141
142
  if (meta) {
142
- const duplicateContentIds = await scanDuplicateContentIds(branchRoot, contentRootName);
143
+ const [duplicateContentIds, rebaseInProgress] = await Promise.all([
144
+ scanDuplicateContentIds(branchRoot, contentRootName),
145
+ isRebaseInProgress(branchRoot),
146
+ ]);
143
147
  entries.push({
144
148
  dirName,
145
149
  kind: 'healthy',
146
150
  ...(isBaseBranch ? { isBaseBranch } : {}),
147
151
  branch: meta.branch,
148
152
  ...(duplicateContentIds.length ? { duplicateContentIds } : {}),
153
+ ...(rebaseInProgress ? { rebaseInProgress } : {}),
149
154
  });
150
155
  continue;
151
156
  }
package/dist/cli/cli.d.ts CHANGED
@@ -37,6 +37,23 @@ export declare function parseAuthFlag(value: string | boolean | undefined): Auth
37
37
  export declare function parseDualBuildFlag(value: unknown): boolean | undefined;
38
38
  declare const SYNC_SUBCOMMANDS: readonly ["push", "pull", "both", "abort"];
39
39
  type SyncSubcommand = (typeof SYNC_SUBCOMMANDS)[number];
40
+ /** The auth modes `worker run-once` knows how to build a plugin for. */
41
+ export declare const KNOWN_AUTH_MODES: readonly ["clerk", "dev"];
42
+ export type KnownAuthMode = (typeof KNOWN_AUTH_MODES)[number];
43
+ /**
44
+ * Whether `CANOPY_AUTH_MODE` names a provider the CLI can actually construct.
45
+ *
46
+ * Pure and exported so it is testable: the dispatch below branches on 'clerk'
47
+ * and 'dev' only, and the catch around plugin loading fires solely on an
48
+ * IMPORT failure -- so before this guard existed, any other value (a typo,
49
+ * wrong casing like 'Clerk', a stale value from another system) selected no
50
+ * plugin, skipped the auth refresh entirely, and let the command run to "Done"
51
+ * with exit code 0. A cron'd `CANOPY_AUTH_MODE=clerk canopycms worker
52
+ * run-once` that became `Clerk` refreshed nothing for as long as the typo
53
+ * survived, while the cache aged and a user removed from the Clerk org kept
54
+ * editor access.
55
+ */
56
+ export declare function isKnownAuthMode(value: string): value is KnownAuthMode;
40
57
  /** Resolve sync subcommand from positional arg. Returns null if missing or invalid. Exported for testing. */
41
58
  export declare function resolveSyncSubcommand(sub: string | undefined): SyncSubcommand | null;
42
59
  export {};
package/dist/cli/cli.js CHANGED
@@ -592,7 +592,7 @@ async function recoverOrphanedTasks(taskDir, maxAgeMs = 5 * 6e4, logger = nullLo
592
592
  async function cleanupOldTasks(taskDir, maxAgeMs = 30 * 24 * 60 * 6e4, logger = nullLogger) {
593
593
  const now = Date.now();
594
594
  let cleaned = 0;
595
- for (const subdir of ["completed", "failed"]) {
595
+ for (const subdir of ["completed", "failed", "corrupt"]) {
596
596
  const dir = path6.join(taskDir, subdir);
597
597
  let files;
598
598
  try {
@@ -810,6 +810,13 @@ function configImportPath(appDir, subdirs) {
810
810
  const totalDepth = appDepth + subdirs;
811
811
  return "../".repeat(totalDepth) + "canopycms.config";
812
812
  }
813
+ async function firstExistingPath(projectDir, candidates) {
814
+ for (const candidate of candidates) {
815
+ if (await filePathExists2(path7.join(projectDir, candidate)))
816
+ return candidate;
817
+ }
818
+ return null;
819
+ }
813
820
  async function init(options) {
814
821
  const { projectDir, mode, appDir, ai, force, nonInteractive } = options;
815
822
  const writeOpts = { force, nonInteractive };
@@ -864,15 +871,35 @@ async function init(options) {
864
871
  await writeFile(path7.join(projectDir, appDir, "ai/config.ts"), await aiConfig2(), writeOpts);
865
872
  await writeFile(path7.join(projectDir, appDir, `ai/[...path]/${serverRouteExt}`), await aiRoute2({ configImport: configImportPath(appDir, 2) }), writeOpts);
866
873
  }
867
- await writeFile(path7.join(projectDir, "next.config.ts"), await nextConfig2({ staticBuild }), writeOpts);
868
- await writeFile(path7.join(projectDir, "middleware.ts"), await middleware2({ authProvider }), writeOpts);
874
+ const existingJsConfig = await firstExistingPath(projectDir, [
875
+ "next.config.js",
876
+ "next.config.mjs"
877
+ ]);
878
+ if (existingJsConfig) {
879
+ p.note([
880
+ `Found ${existingJsConfig}, which Next loads in preference to next.config.ts.`,
881
+ "Left it untouched rather than writing a second config Next would ignore.",
882
+ "",
883
+ "Wrap your existing config by hand:",
884
+ "",
885
+ " import { withCanopy } from 'canopycms-next/config'",
886
+ " export default withCanopy(yourConfig)"
887
+ ].join("\n"), "Manual step");
888
+ } else {
889
+ await writeFile(path7.join(projectDir, "next.config.ts"), await nextConfig2({ staticBuild }), writeOpts);
890
+ }
891
+ await writeFile(path7.join(projectDir, path7.dirname(appDir), "middleware.ts"), await middleware2({ authProvider }), writeOpts);
869
892
  const gitignorePath = path7.join(projectDir, ".gitignore");
893
+ const CANOPY_GITIGNORE_BLOCK = "# CanopyCMS\n.canopy-dev/\n";
870
894
  if (await filePathExists2(gitignorePath)) {
871
895
  const content = await fs6.readFile(gitignorePath, "utf-8");
872
896
  if (!content.includes(".canopy-dev")) {
873
- await fs6.appendFile(gitignorePath, "\n# CanopyCMS\n.canopy-dev/\n");
897
+ await fs6.appendFile(gitignorePath, `
898
+ ${CANOPY_GITIGNORE_BLOCK}`);
874
899
  p.log.success("updated: .gitignore");
875
900
  }
901
+ } else {
902
+ await writeFile(gitignorePath, CANOPY_GITIGNORE_BLOCK, writeOpts);
876
903
  }
877
904
  const packages = authProvider === "clerk" ? "canopycms canopycms-next canopycms-auth-clerk canopycms-auth-dev" : "canopycms canopycms-next canopycms-auth-dev";
878
905
  p.note([
@@ -6237,6 +6264,54 @@ var init_generate_ai_content = __esm({
6237
6264
  }
6238
6265
  return item;
6239
6266
  }
6267
+ /**
6268
+ * Is this path a COLLECTION schema item?
6269
+ *
6270
+ * The non-throwing form of `assertCollection`, reading the same `schemaIndex` -- which is the
6271
+ * point. A caller that gates on this cannot disagree with what `buildPaths` will then do: the
6272
+ * Map is last-wins, so where a subcollection's path collides with a parent's entry-type name
6273
+ * both this and `buildPaths` see the collection. A `find` over the flat schema LIST is
6274
+ * first-wins and would not.
6275
+ *
6276
+ * Type-only, deliberately -- `resolvePath` additionally requires `entries`, but a collection
6277
+ * with subcollections and no entries of its own is legal, and mirroring that stricter test here
6278
+ * would reject something `buildPaths` accepts.
6279
+ *
6280
+ * Exists for `readByUrlPath`'s URL-addressability gate; see `ReadContentInput`'s
6281
+ * `urlAddressableOnly` and the note on `buildPaths`' entry-type branch below.
6282
+ */
6283
+ isCollectionPath(collectionPath) {
6284
+ return this.schemaIndex.get(normalizeFilesystemPath(collectionPath))?.type === "collection";
6285
+ }
6286
+ /**
6287
+ * Does this collection declare `entryTypeName` in its `entries` config?
6288
+ *
6289
+ * Mirrors `parseTypedFilename(filename, collection.entries)`, which is how `listEntries` decides
6290
+ * whether a file on disk is one of the collection's entries at all. `buildPaths`' own directory
6291
+ * scan deliberately does NOT check this -- it matches on slug alone, so that an entry whose type
6292
+ * was renamed out of the schema stays findable and therefore still editable, renameable and
6293
+ * deletable. Only URL resolution consults this, so what enumeration hides is not served.
6294
+ *
6295
+ * Returns FALSE when the collection declares no `entries` at all, which is stricter than
6296
+ * `parseTypedFilename`'s own `if (entryTypes && ...)` guard and deliberately so: the enumerating
6297
+ * surface is not `parseTypedFilename`, it is `listCollectionEntries`, and that returns `[]`
6298
+ * outright for a collection with no `entries`. A collections-only container therefore publishes
6299
+ * nothing, so a URL read that resolved a file sitting in one would be answering where nothing is
6300
+ * advertised -- the exact disagreement this predicate exists to close. Such a file cannot have
6301
+ * been created by the CMS (there is no entry type to create it as); it arrived by hand, by merge
6302
+ * or by retrofit, and it is invisible to the sitemap and to static params either way.
6303
+ *
6304
+ * Returns true for a path that is not a collection at all, because that is rule 1's question,
6305
+ * not this one's -- and under `urlAddressableOnly` rule 1 has already rejected it.
6306
+ */
6307
+ declaresEntryType(collectionPath, entryTypeName) {
6308
+ const item = this.schemaIndex.get(normalizeFilesystemPath(collectionPath));
6309
+ if (item?.type !== "collection")
6310
+ return true;
6311
+ if (!item.entries)
6312
+ return false;
6313
+ return item.entries.some((e) => e.name === entryTypeName);
6314
+ }
6240
6315
  assertCollection(collectionPath) {
6241
6316
  const item = this.assertSchemaItem(collectionPath);
6242
6317
  if (item.type !== "collection") {
@@ -11196,6 +11271,10 @@ async function requireProjectRoot(command) {
11196
11271
  }
11197
11272
  return root;
11198
11273
  }
11274
+ var KNOWN_AUTH_MODES = ["clerk", "dev"];
11275
+ function isKnownAuthMode(value) {
11276
+ return KNOWN_AUTH_MODES.includes(value);
11277
+ }
11199
11278
  function resolveSyncSubcommand(sub) {
11200
11279
  if (sub && SYNC_SUBCOMMANDS.includes(sub))
11201
11280
  return sub;
@@ -11287,6 +11366,10 @@ async function main() {
11287
11366
  process.exit(1);
11288
11367
  }
11289
11368
  const authMode = process.env.CANOPY_AUTH_MODE || "dev";
11369
+ if (!isKnownAuthMode(authMode)) {
11370
+ console.error(`Unknown CANOPY_AUTH_MODE "${authMode}" \u2014 expected one of: ${KNOWN_AUTH_MODES.join(", ")}. No auth plugin was loaded, so the auth cache will NOT be refreshed.`);
11371
+ process.exitCode = 1;
11372
+ }
11290
11373
  let authPlugin;
11291
11374
  try {
11292
11375
  if (authMode === "clerk") {
@@ -11398,6 +11481,8 @@ if (isDirectRun) {
11398
11481
  });
11399
11482
  }
11400
11483
  export {
11484
+ KNOWN_AUTH_MODES,
11485
+ isKnownAuthMode,
11401
11486
  parseArgs,
11402
11487
  parseAuthFlag,
11403
11488
  parseDualBuildFlag,
@@ -2926,6 +2926,54 @@ var ContentStore = class {
2926
2926
  }
2927
2927
  return item;
2928
2928
  }
2929
+ /**
2930
+ * Is this path a COLLECTION schema item?
2931
+ *
2932
+ * The non-throwing form of `assertCollection`, reading the same `schemaIndex` -- which is the
2933
+ * point. A caller that gates on this cannot disagree with what `buildPaths` will then do: the
2934
+ * Map is last-wins, so where a subcollection's path collides with a parent's entry-type name
2935
+ * both this and `buildPaths` see the collection. A `find` over the flat schema LIST is
2936
+ * first-wins and would not.
2937
+ *
2938
+ * Type-only, deliberately -- `resolvePath` additionally requires `entries`, but a collection
2939
+ * with subcollections and no entries of its own is legal, and mirroring that stricter test here
2940
+ * would reject something `buildPaths` accepts.
2941
+ *
2942
+ * Exists for `readByUrlPath`'s URL-addressability gate; see `ReadContentInput`'s
2943
+ * `urlAddressableOnly` and the note on `buildPaths`' entry-type branch below.
2944
+ */
2945
+ isCollectionPath(collectionPath) {
2946
+ return this.schemaIndex.get(normalizeFilesystemPath(collectionPath))?.type === "collection";
2947
+ }
2948
+ /**
2949
+ * Does this collection declare `entryTypeName` in its `entries` config?
2950
+ *
2951
+ * Mirrors `parseTypedFilename(filename, collection.entries)`, which is how `listEntries` decides
2952
+ * whether a file on disk is one of the collection's entries at all. `buildPaths`' own directory
2953
+ * scan deliberately does NOT check this -- it matches on slug alone, so that an entry whose type
2954
+ * was renamed out of the schema stays findable and therefore still editable, renameable and
2955
+ * deletable. Only URL resolution consults this, so what enumeration hides is not served.
2956
+ *
2957
+ * Returns FALSE when the collection declares no `entries` at all, which is stricter than
2958
+ * `parseTypedFilename`'s own `if (entryTypes && ...)` guard and deliberately so: the enumerating
2959
+ * surface is not `parseTypedFilename`, it is `listCollectionEntries`, and that returns `[]`
2960
+ * outright for a collection with no `entries`. A collections-only container therefore publishes
2961
+ * nothing, so a URL read that resolved a file sitting in one would be answering where nothing is
2962
+ * advertised -- the exact disagreement this predicate exists to close. Such a file cannot have
2963
+ * been created by the CMS (there is no entry type to create it as); it arrived by hand, by merge
2964
+ * or by retrofit, and it is invisible to the sitemap and to static params either way.
2965
+ *
2966
+ * Returns true for a path that is not a collection at all, because that is rule 1's question,
2967
+ * not this one's -- and under `urlAddressableOnly` rule 1 has already rejected it.
2968
+ */
2969
+ declaresEntryType(collectionPath, entryTypeName) {
2970
+ const item = this.schemaIndex.get(normalizeFilesystemPath(collectionPath));
2971
+ if (item?.type !== "collection")
2972
+ return true;
2973
+ if (!item.entries)
2974
+ return false;
2975
+ return item.entries.some((e) => e.name === entryTypeName);
2976
+ }
2929
2977
  assertCollection(collectionPath) {
2930
2978
  const item = this.assertSchemaItem(collectionPath);
2931
2979
  if (item.type !== "collection") {
package/dist/cli/init.js CHANGED
@@ -1117,7 +1117,7 @@ async function recoverOrphanedTasks(taskDir, maxAgeMs = 5 * 6e4, logger = nullLo
1117
1117
  async function cleanupOldTasks(taskDir, maxAgeMs = 30 * 24 * 60 * 6e4, logger = nullLogger) {
1118
1118
  const now = Date.now();
1119
1119
  let cleaned = 0;
1120
- for (const subdir of ["completed", "failed"]) {
1120
+ for (const subdir of ["completed", "failed", "corrupt"]) {
1121
1121
  const dir = path6.join(taskDir, subdir);
1122
1122
  let files;
1123
1123
  try {
@@ -1586,6 +1586,13 @@ function configImportPath(appDir, subdirs) {
1586
1586
  const totalDepth = appDepth + subdirs;
1587
1587
  return "../".repeat(totalDepth) + "canopycms.config";
1588
1588
  }
1589
+ async function firstExistingPath(projectDir, candidates) {
1590
+ for (const candidate of candidates) {
1591
+ if (await filePathExists(path7.join(projectDir, candidate)))
1592
+ return candidate;
1593
+ }
1594
+ return null;
1595
+ }
1589
1596
  async function init(options) {
1590
1597
  const { projectDir, mode, appDir, ai, force, nonInteractive } = options;
1591
1598
  const writeOpts = { force, nonInteractive };
@@ -1640,15 +1647,35 @@ async function init(options) {
1640
1647
  await writeFile(path7.join(projectDir, appDir, "ai/config.ts"), await aiConfig2(), writeOpts);
1641
1648
  await writeFile(path7.join(projectDir, appDir, `ai/[...path]/${serverRouteExt}`), await aiRoute2({ configImport: configImportPath(appDir, 2) }), writeOpts);
1642
1649
  }
1643
- await writeFile(path7.join(projectDir, "next.config.ts"), await nextConfig2({ staticBuild }), writeOpts);
1644
- await writeFile(path7.join(projectDir, "middleware.ts"), await middleware2({ authProvider }), writeOpts);
1650
+ const existingJsConfig = await firstExistingPath(projectDir, [
1651
+ "next.config.js",
1652
+ "next.config.mjs"
1653
+ ]);
1654
+ if (existingJsConfig) {
1655
+ p.note([
1656
+ `Found ${existingJsConfig}, which Next loads in preference to next.config.ts.`,
1657
+ "Left it untouched rather than writing a second config Next would ignore.",
1658
+ "",
1659
+ "Wrap your existing config by hand:",
1660
+ "",
1661
+ " import { withCanopy } from 'canopycms-next/config'",
1662
+ " export default withCanopy(yourConfig)"
1663
+ ].join("\n"), "Manual step");
1664
+ } else {
1665
+ await writeFile(path7.join(projectDir, "next.config.ts"), await nextConfig2({ staticBuild }), writeOpts);
1666
+ }
1667
+ await writeFile(path7.join(projectDir, path7.dirname(appDir), "middleware.ts"), await middleware2({ authProvider }), writeOpts);
1645
1668
  const gitignorePath = path7.join(projectDir, ".gitignore");
1669
+ const CANOPY_GITIGNORE_BLOCK = "# CanopyCMS\n.canopy-dev/\n";
1646
1670
  if (await filePathExists(gitignorePath)) {
1647
1671
  const content = await fs6.readFile(gitignorePath, "utf-8");
1648
1672
  if (!content.includes(".canopy-dev")) {
1649
- await fs6.appendFile(gitignorePath, "\n# CanopyCMS\n.canopy-dev/\n");
1673
+ await fs6.appendFile(gitignorePath, `
1674
+ ${CANOPY_GITIGNORE_BLOCK}`);
1650
1675
  p.log.success("updated: .gitignore");
1651
1676
  }
1677
+ } else {
1678
+ await writeFile(gitignorePath, CANOPY_GITIGNORE_BLOCK, writeOpts);
1652
1679
  }
1653
1680
  const packages = authProvider === "clerk" ? "canopycms canopycms-next canopycms-auth-clerk canopycms-auth-dev" : "canopycms canopycms-next canopycms-auth-dev";
1654
1681
  p.note([
@@ -1,4 +1,10 @@
1
- FROM public.ecr.aws/docker/library/node:20-slim AS builder
1
+ # Node 22. Node 20 reached upstream EOL on 2026-04-30, so it receives no
2
+ # further security patches -- and the CMS Lambda runs this image.
3
+ #
4
+ # NOTE: the published canopycms packages declare `engines.node: ">=18"`,
5
+ # so this is not an installability floor -- it is the runtime CanopyCMS is
6
+ # actually built and tested on (.nvmrc is v22, as is the transform Lambda).
7
+ FROM public.ecr.aws/docker/library/node:22-slim AS builder
2
8
  WORKDIR /app
3
9
  {{DOCKER_COPY}}
4
10
  # If your app uses `file:` dependencies (e.g. vendored tarballs under vendor/),
@@ -53,7 +59,7 @@ ARG NEXT_PUBLIC_CANOPY_MODE=dev
53
59
  ENV NEXT_PUBLIC_CANOPY_MODE=$NEXT_PUBLIC_CANOPY_MODE
54
60
  RUN {{DOCKER_BUILD}}
55
61
 
56
- FROM public.ecr.aws/docker/library/node:20-slim AS runner
62
+ FROM public.ecr.aws/docker/library/node:22-slim AS runner
57
63
  # Git is required by CanopyCMS for branch operations
58
64
  RUN apt-get update && apt-get install -y git && rm -rf /var/lib/apt/lists/*
59
65
  # The runtime user's uid differs from the uid that owns EFS workspace files
@@ -107,13 +107,35 @@ export class CmsStack extends Stack {
107
107
  reservedConcurrency: 10,
108
108
  })
109
109
 
110
- // Media support (uploads, on-demand image transforms). Uncomment to give
111
- // the deployed editor an asset backend, pass `assetBucket:
112
- // assetSupport.bucket` to CanopyCmsService above, and wire
113
- // `assetSupport.cloudFrontBehaviors` into the distribution below. See the
114
- // assets section of docs/deploying-to-aws.md.
110
+ // Media support (uploads, on-demand image transforms). To enable it:
115
111
  //
116
- // const assetSupport = new AssetSupport(this, 'Assets', {})
112
+ // 1. add `AssetSupport` to the `canopycms-cdk` import at the top;
113
+ // 2. uncomment the block below, moving it ABOVE `cmsService` (it has to
114
+ // exist before you can pass its bucket);
115
+ // 3. pass `assetBucket: assetSupport.bucket` to CanopyCmsService;
116
+ // 4. pass the behaviors to CanopyCmsDistribution, `/assets/t/*` FIRST
117
+ // (CloudFront matches in order, so the general pattern would
118
+ // otherwise swallow the transform one):
119
+ //
120
+ // additionalBehaviors: {
121
+ // '/assets/t/*': assetSupport.assetBehaviors().assetsTransform,
122
+ // '/assets/*': assetSupport.assetBehaviors().assets,
123
+ // },
124
+ //
125
+ // `editorOrigins` is REQUIRED: it is the S3 CORS allowlist for the
126
+ // editor's presigned uploads, so it must list the origin the editor is
127
+ // served from. Include http://localhost:3000 only if you upload from a
128
+ // local dev editor against this bucket.
129
+ //
130
+ // Run `pnpm --filter canopycms-cdk run build:lambda` before deploying --
131
+ // the transform Lambda needs its native sharp binary, and the construct
132
+ // refuses to synth without it.
133
+ //
134
+ // See the assets section of docs/deploying-to-aws.md.
135
+ //
136
+ // const assetSupport = new AssetSupport(this, 'Assets', {
137
+ // editorOrigins: [`https://${props.domainName}`],
138
+ // })
117
139
 
118
140
  // CloudFront + Route53, only when a domain is configured. Skipping this
119
141
  // leaves `cmsService.functionUrl` as the entry point, which is enough to
@@ -124,6 +146,11 @@ export class CmsStack extends Stack {
124
146
  functionUrl: cmsService.functionUrl,
125
147
  domainName: props.domainName,
126
148
  hostedZoneDomain: props.hostedZoneDomain,
149
+ // Keep CloudFront's origin read timeout equal to the Lambda's own.
150
+ // Both default to 60s, so this line changes nothing today -- it is
151
+ // here so that raising `timeout` above cannot silently leave
152
+ // CloudFront answering 504 at the edge on requests that succeed.
153
+ originReadTimeout: cmsService.timeout,
127
154
  })
128
155
  }
129
156
  }
@@ -39,10 +39,15 @@
39
39
  # 3. Install the CDK dependencies the app entry point needs:
40
40
  # `{{ADD_DEV}} canopycms canopycms-cdk aws-cdk-lib constructs tsx aws-cdk`
41
41
  # 4. Set these repository secrets (Settings -> Secrets and variables ->
42
- # Actions -> Secrets). Every one of them is read by `required()` in
43
- # infrastructure/bin/app.ts, so a missing value fails the deploy at synth:
42
+ # Actions -> Secrets). All but AWS_DEPLOY_ROLE_ARN are read by `required()`
43
+ # in infrastructure/bin/app.ts, so a missing value fails the deploy at
44
+ # synth; a missing role ARN fails earlier, at the AWS credentials step:
44
45
  # AWS_DEPLOY_ROLE_ARN OIDC role ARN from step 2
45
- # GITHUB_TOKEN_SECRET_ARN FULL Secrets Manager ARN (with suffix)
46
+ # CANOPY_GITHUB_TOKEN_SECRET_ARN FULL Secrets Manager ARN (with suffix)
47
+ # NOTE: the CANOPY_ prefix is required, not cosmetic. GitHub rejects any
48
+ # Actions secret whose name starts with GITHUB_ (a reserved prefix), so
49
+ # the otherwise-obvious GITHUB_TOKEN_SECRET_ARN cannot be created at all.
50
+ # The env var handed to CDK below keeps the unprefixed name.
46
51
  # CLERK_SECRET_KEY_SECRET_ARN FULL Secrets Manager ARN (with suffix)
47
52
  # CLERK_JWT_KEY Clerk's public JWKS PEM
48
53
  # 5. Set these repository variables (same page -> Variables):
@@ -64,6 +69,14 @@ on:
64
69
  - 'src/**'
65
70
  - 'content/**'
66
71
  - 'canopycms.config.ts'
72
+ # The Next config is what `init-deploy aws` itself tells you to edit for
73
+ # the dual build, and `public/**` is copied into the runner image (and is
74
+ # load-bearing for editor/public preview parity). Omitting them meant an
75
+ # edit shipped days later, piggybacked on an unrelated content change --
76
+ # so any breakage was attributed to the wrong commit.
77
+ - 'next.config.*'
78
+ - 'middleware.ts'
79
+ - 'public/**'
67
80
  - 'Dockerfile.cms'
68
81
  - 'infrastructure/**'
69
82
  - 'cdk.json'
@@ -89,11 +102,11 @@ jobs:
89
102
  contents: read
90
103
 
91
104
  steps:
92
- - uses: actions/checkout@v4
105
+ - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
93
106
 
94
- - uses: actions/setup-node@v4
107
+ - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
95
108
  with:
96
- node-version: 20
109
+ node-version: 22
97
110
 
98
111
  - name: Install dependencies
99
112
  run: {{CI_INSTALL}}
@@ -124,7 +137,7 @@ jobs:
124
137
  fi
125
138
 
126
139
  - name: Configure AWS credentials
127
- uses: aws-actions/configure-aws-credentials@v4
140
+ uses: aws-actions/configure-aws-credentials@7474bc4690e29a8392af63c5b98e7449536d5c3a # v4
128
141
  with:
129
142
  role-to-assume: ${{ secrets.AWS_DEPLOY_ROLE_ARN }}
130
143
  aws-region: ${{ vars.AWS_REGION }}
@@ -138,7 +151,7 @@ jobs:
138
151
  # in one place and not the other fails the deploy at synth.
139
152
  - name: Deploy
140
153
  env:
141
- GITHUB_TOKEN_SECRET_ARN: ${{ secrets.GITHUB_TOKEN_SECRET_ARN }}
154
+ GITHUB_TOKEN_SECRET_ARN: ${{ secrets.CANOPY_GITHUB_TOKEN_SECRET_ARN }}
142
155
  CLERK_SECRET_KEY_SECRET_ARN: ${{ secrets.CLERK_SECRET_KEY_SECRET_ARN }}
143
156
  CLERK_JWT_KEY: ${{ secrets.CLERK_JWT_KEY }}
144
157
  NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY: ${{ vars.NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY }}
@@ -11,7 +11,11 @@ export interface ContentReaderOptions {
11
11
  getBranchContext?: (branch: string) => Promise<BranchContext | null>;
12
12
  }
13
13
  export interface ReadContentInput {
14
- /** Resolved schema path (e.g., content/posts or content/home). */
14
+ /**
15
+ * Resolved schema path (e.g., content/posts or content/home). An entry-TYPE path like
16
+ * `content/home` is valid and resolves that singleton -- except under `urlAddressableOnly`
17
+ * below, which rejects it.
18
+ */
15
19
  entryPath: LogicalPath;
16
20
  slug?: Slug;
17
21
  branch?: string;
@@ -21,6 +25,26 @@ export interface ReadContentInput {
21
25
  resolveReferences?: boolean;
22
26
  /** Whether to resolve entry:ID links in body/markdown fields. Defaults to true. */
23
27
  resolveEntryLinks?: boolean;
28
+ /**
29
+ * This read is addressing an entry by its PUBLISHED URL, so accept only what enumeration
30
+ * publishes. Two rules, both off by default:
31
+ *
32
+ * 1. `entryPath` must be a COLLECTION. A published URL is `/<collectionSegments>/<slug>` (or
33
+ * the collapsed collection path, for an index entry), so its non-slug segments are always
34
+ * collection names. An `entryPath` that resolves to an entry-TYPE item instead would be
35
+ * delegated by `ContentStore.buildPaths` to the parent collection -- answering at
36
+ * `/<collection>/<typeName>` and `/<collection>/<typeName>/<slug>`, neither of which any
37
+ * forward surface emits.
38
+ * 2. The resolved entry's type must be one its collection declares, matching the
39
+ * `parseTypedFilename(filename, collection.entries)` check `listEntries` applies. A legacy
40
+ * untyped file has no type token to fail on -- see `declaresEntryType`.
41
+ *
42
+ * Set by `readByUrlPath` and nothing else. Note what it does NOT do: `read({ entryPath:
43
+ * 'content/home' })` and direct `ContentStore` use keep the entry-type delegation, which is a
44
+ * supported API and the only way to address a singleton structurally. Misusing this flag can
45
+ * only make a read stricter, never looser.
46
+ */
47
+ urlAddressableOnly?: boolean;
24
48
  }
25
49
  /**
26
50
  * Structural metadata surfaced alongside a resolved content read. Shared by
@@ -122,6 +122,13 @@ export const createContentReader = (options) => {
122
122
  const readDocument = async (input) => {
123
123
  const { entryPath, slug, branchName, user } = resolveTarget(input);
124
124
  const { context, branchRoot, store } = await resolveStore(branchName);
125
+ // Rule 1 of urlAddressableOnly (see ReadContentInput): a published URL's non-slug segments
126
+ // are collection names, so a candidate landing on an entry-TYPE item can only ever produce a
127
+ // URL enumeration never emits. NO_SCHEMA_ITEM is what assertCollection raises for exactly
128
+ // this condition, and readByUrlPath treats it as a miss and tries the next candidate.
129
+ if (input.urlAddressableOnly && !store.isCollectionPath(entryPath)) {
130
+ throw new ContentStoreError(`Path is not a collection: ${entryPath}`, 'NO_SCHEMA_ITEM');
131
+ }
125
132
  // Get the path WITHOUT reading the file
126
133
  let relativePath;
127
134
  // Absolute filesystem path to the entry file. Surfaced on read() / readByUrlPath()
@@ -151,6 +158,14 @@ export const createContentReader = (options) => {
151
158
  const code = err instanceof ContentStoreError ? err.code : 'VALIDATION';
152
159
  throw new ContentStoreError(message, code);
153
160
  }
161
+ // Rule 2 of urlAddressableOnly (see ReadContentInput): buildPaths' directory scan matches on
162
+ // slug alone, so it happily returns a file whose type token the collection no longer (or
163
+ // never did) declare -- a file listEntries skips. Checked here rather than inside the scan so
164
+ // the write path can still find, edit and rename it; making it unfindable would make the
165
+ // mistake unrecoverable through the editor.
166
+ if (input.urlAddressableOnly && !store.declaresEntryType(entryPath, entryType)) {
167
+ throw new ContentStoreError(`Entry type '${entryType}' is not declared by ${entryPath}`, 'NO_SCHEMA_ITEM');
168
+ }
154
169
  // Check permissions BEFORE reading the file (security)
155
170
  const shouldCheckPermissions = !(isDeployedStatic(services.config) || isBuildMode());
156
171
  if (shouldCheckPermissions) {