canopycms 0.0.66-int.85 → 0.0.66

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.
@@ -4,10 +4,10 @@
4
4
  import { z } from 'zod';
5
5
  export declare const mediaSchema: z.ZodDiscriminatedUnion<"adapter", [z.ZodObject<{
6
6
  adapter: z.ZodLiteral<"local">;
7
- publicBaseUrl: z.ZodOptional<z.ZodString>;
7
+ publicBaseUrl: z.ZodOptional<z.ZodEffects<z.ZodString, string, string>>;
8
8
  /** Root directory for local asset storage. Defaults to the caller's dev-assets dir. */
9
9
  directory: z.ZodOptional<z.ZodEffects<z.ZodEffects<z.ZodEffects<z.ZodString, string, string>, string, string>, string, string>>;
10
- }, "strip", z.ZodTypeAny, {
10
+ }, "strict", z.ZodTypeAny, {
11
11
  adapter: "local";
12
12
  publicBaseUrl?: string | undefined;
13
13
  directory?: string | undefined;
@@ -19,25 +19,42 @@ export declare const mediaSchema: z.ZodDiscriminatedUnion<"adapter", [z.ZodObjec
19
19
  adapter: z.ZodLiteral<"s3">;
20
20
  bucket: z.ZodString;
21
21
  region: z.ZodString;
22
- publicBaseUrl: z.ZodOptional<z.ZodString>;
22
+ publicBaseUrl: z.ZodOptional<z.ZodEffects<z.ZodString, string, string>>;
23
+ /**
24
+ * Where the browser POSTs a presigned direct upload. Absolute http(s) URL or a
25
+ * site-relative path; defaults to the S3 REST endpoint the AWS SDK returns.
26
+ *
27
+ * Set this to route uploads through your own CDN so they are same-origin with the
28
+ * editor, which removes the need for a bucket CORS rule naming an exact origin. The
29
+ * signature is unaffected — a presigned POST's string-to-sign is the base64 policy
30
+ * alone, so the host never enters it (pinned by store-s3.test.ts).
31
+ *
32
+ * NOT a prefix. Unlike `publicBaseUrl`, nothing is joined onto this value; it replaces
33
+ * the POST target outright and is passed through byte-for-byte. Do not route it through
34
+ * `joinUrlPrefix` "for consistency" — that would strip the trailing slash and silently
35
+ * stop a CDN path pattern like `/asset-upload/*` from matching.
36
+ */
37
+ uploadUrl: z.ZodOptional<z.ZodEffects<z.ZodString, string, string>>;
23
38
  /** Max upload size in bytes for presigned direct uploads. Defaults to 50 MiB. */
24
39
  maxUploadBytes: z.ZodOptional<z.ZodNumber>;
25
- }, "strip", z.ZodTypeAny, {
40
+ }, "strict", z.ZodTypeAny, {
26
41
  adapter: "s3";
27
42
  bucket: string;
28
43
  region: string;
29
44
  publicBaseUrl?: string | undefined;
45
+ uploadUrl?: string | undefined;
30
46
  maxUploadBytes?: number | undefined;
31
47
  }, {
32
48
  adapter: "s3";
33
49
  bucket: string;
34
50
  region: string;
35
51
  publicBaseUrl?: string | undefined;
52
+ uploadUrl?: string | undefined;
36
53
  maxUploadBytes?: number | undefined;
37
54
  }>, z.ZodObject<{
38
55
  adapter: z.ZodLiteral<"lfs">;
39
- publicBaseUrl: z.ZodOptional<z.ZodString>;
40
- }, "strip", z.ZodTypeAny, {
56
+ publicBaseUrl: z.ZodOptional<z.ZodEffects<z.ZodString, string, string>>;
57
+ }, "strict", z.ZodTypeAny, {
41
58
  adapter: "lfs";
42
59
  publicBaseUrl?: string | undefined;
43
60
  }, {
@@ -3,6 +3,7 @@
3
3
  */
4
4
  import { z } from 'zod';
5
5
  import { relativePathSchema } from './collection.js';
6
+ import { assetMountUrlSchema, uploadTargetUrlSchema } from './url.js';
6
7
  // Media adapter configuration schema.
7
8
  // Keyed as a discriminated union on `adapter` so each adapter's required fields are
8
9
  // enforced at parse time. With a plain z.union, a malformed s3 config (e.g. missing
@@ -14,23 +15,51 @@ import { relativePathSchema } from './collection.js';
14
15
  // Bucket-prefix layout (asset-originals/, asset-staging/, asset-meta/, assets/) is
15
16
  // intentionally NOT configurable here — those are constants in assets/keys.ts, not
16
17
  // per-site config (see .claude/future-tasks/assets-media-system.md).
18
+ //
19
+ // Every branch is .strict(). This is load-bearing, not tidiness: CanopyConfigSchema's own
20
+ // .strict() does NOT recurse into nested schemas, so until these branches carried their own,
21
+ // `media: { adapter: 's3', …, uploadURL: '/x' }` parsed successfully with the misspelled key
22
+ // silently stripped — and the adopter got the raw S3 endpoint back with no diagnostic
23
+ // anywhere. A config field whose whole purpose is to fail loudly at parse time cannot sit in
24
+ // a container that swallows typos.
17
25
  export const mediaSchema = z.discriminatedUnion('adapter', [
18
- z.object({
26
+ z
27
+ .object({
19
28
  adapter: z.literal('local'),
20
- publicBaseUrl: z.string().url().optional(),
29
+ publicBaseUrl: assetMountUrlSchema.optional(),
21
30
  /** Root directory for local asset storage. Defaults to the caller's dev-assets dir. */
22
31
  directory: relativePathSchema.optional(),
23
- }),
24
- z.object({
32
+ })
33
+ .strict(),
34
+ z
35
+ .object({
25
36
  adapter: z.literal('s3'),
26
37
  bucket: z.string().min(1),
27
38
  region: z.string().min(1),
28
- publicBaseUrl: z.string().url().optional(),
39
+ publicBaseUrl: assetMountUrlSchema.optional(),
40
+ /**
41
+ * Where the browser POSTs a presigned direct upload. Absolute http(s) URL or a
42
+ * site-relative path; defaults to the S3 REST endpoint the AWS SDK returns.
43
+ *
44
+ * Set this to route uploads through your own CDN so they are same-origin with the
45
+ * editor, which removes the need for a bucket CORS rule naming an exact origin. The
46
+ * signature is unaffected — a presigned POST's string-to-sign is the base64 policy
47
+ * alone, so the host never enters it (pinned by store-s3.test.ts).
48
+ *
49
+ * NOT a prefix. Unlike `publicBaseUrl`, nothing is joined onto this value; it replaces
50
+ * the POST target outright and is passed through byte-for-byte. Do not route it through
51
+ * `joinUrlPrefix` "for consistency" — that would strip the trailing slash and silently
52
+ * stop a CDN path pattern like `/asset-upload/*` from matching.
53
+ */
54
+ uploadUrl: uploadTargetUrlSchema.optional(),
29
55
  /** Max upload size in bytes for presigned direct uploads. Defaults to 50 MiB. */
30
56
  maxUploadBytes: z.number().int().positive().optional(),
31
- }),
32
- z.object({
57
+ })
58
+ .strict(),
59
+ z
60
+ .object({
33
61
  adapter: z.literal('lfs'),
34
- publicBaseUrl: z.string().url().optional(),
35
- }),
62
+ publicBaseUrl: assetMountUrlSchema.optional(),
63
+ })
64
+ .strict(),
36
65
  ]);
@@ -0,0 +1,38 @@
1
+ /**
2
+ * Zod schemas for adopter-configured URL values.
3
+ *
4
+ * Deliberately a separate file rather than an addition to `config.ts`: `config.ts` already
5
+ * imports `media.ts`, and `media.ts` needs these, so defining them there would close a cycle
6
+ * that `.dependency-cruiser.mjs`'s `no-circular` rule fails the build on.
7
+ *
8
+ * These are thin wrappers. The rule itself lives in `utils/sanitize-href.ts` alongside the
9
+ * package's only other http(s) allowlist, so config validation and content sanitization cannot
10
+ * drift into disagreeing about which URLs are acceptable.
11
+ */
12
+ import { z } from 'zod';
13
+ /**
14
+ * Where the browser POSTs a presigned direct upload (`media.uploadUrl`).
15
+ *
16
+ * Stricter than `assetMountUrlSchema` by exactly one case — no protocol-relative `//host` —
17
+ * because that spelling is ambiguous rather than insecure: it resolves to http or https
18
+ * depending on the editor page issuing the upload, so the config would not determine where a
19
+ * live credential is sent. `isHttpUrlOrSameOriginPath`'s doc carries the full reasoning,
20
+ * including why bare `http://` is nonetheless accepted.
21
+ *
22
+ * "Exactly one case" is measured, not asserted: a census over every 4-character string
23
+ * drawn from an 11-symbol alphabet (slash, backslash, dot, colon, %2e, ?, #, tab, and three
24
+ * letters), plus hand-written shapes, found 39 values the two schemas treat differently — and
25
+ * every one is a literal `//host` (three only after `.trim()` strips a leading tab).
26
+ */
27
+ export declare const uploadTargetUrlSchema: z.ZodEffects<z.ZodString, string, string>;
28
+ /**
29
+ * Where `/assets/…` is mounted for the editor's own previews (`media.publicBaseUrl`).
30
+ *
31
+ * Two changes from the `z.string().url()` this replaced, both deliberate. It now accepts a
32
+ * site-relative path, which is what `.claude/future-tasks/editor-asset-mount-topology.md`
33
+ * option 1 asks for — the absolute-only constraint was a validation choice, and the `basePath`
34
+ * fallback in `editor/context/AssetContext.tsx` exists only to work around it. And it now
35
+ * rejects non-http(s) schemes, which `z.string().url()` accepted: `publicBaseUrl: 'mailto:a@b.c'`
36
+ * used to parse and then produce `/mailto:a@b.c/assets/…` at render time.
37
+ */
38
+ export declare const assetMountUrlSchema: z.ZodEffects<z.ZodString, string, string>;
@@ -0,0 +1,49 @@
1
+ /**
2
+ * Zod schemas for adopter-configured URL values.
3
+ *
4
+ * Deliberately a separate file rather than an addition to `config.ts`: `config.ts` already
5
+ * imports `media.ts`, and `media.ts` needs these, so defining them there would close a cycle
6
+ * that `.dependency-cruiser.mjs`'s `no-circular` rule fails the build on.
7
+ *
8
+ * These are thin wrappers. The rule itself lives in `utils/sanitize-href.ts` alongside the
9
+ * package's only other http(s) allowlist, so config validation and content sanitization cannot
10
+ * drift into disagreeing about which URLs are acceptable.
11
+ */
12
+ import { z } from 'zod';
13
+ import { isHttpUrlOrSameOriginPath } from '../../utils/sanitize-href.js';
14
+ /**
15
+ * Where the browser POSTs a presigned direct upload (`media.uploadUrl`).
16
+ *
17
+ * Stricter than `assetMountUrlSchema` by exactly one case — no protocol-relative `//host` —
18
+ * because that spelling is ambiguous rather than insecure: it resolves to http or https
19
+ * depending on the editor page issuing the upload, so the config would not determine where a
20
+ * live credential is sent. `isHttpUrlOrSameOriginPath`'s doc carries the full reasoning,
21
+ * including why bare `http://` is nonetheless accepted.
22
+ *
23
+ * "Exactly one case" is measured, not asserted: a census over every 4-character string
24
+ * drawn from an 11-symbol alphabet (slash, backslash, dot, colon, %2e, ?, #, tab, and three
25
+ * letters), plus hand-written shapes, found 39 values the two schemas treat differently — and
26
+ * every one is a literal `//host` (three only after `.trim()` strips a leading tab).
27
+ */
28
+ export const uploadTargetUrlSchema = z
29
+ .string()
30
+ .trim()
31
+ .refine((value) => isHttpUrlOrSameOriginPath(value), {
32
+ message: 'must be an absolute http(s) URL or a site-relative path beginning with a single "/", with no query or fragment',
33
+ });
34
+ /**
35
+ * Where `/assets/…` is mounted for the editor's own previews (`media.publicBaseUrl`).
36
+ *
37
+ * Two changes from the `z.string().url()` this replaced, both deliberate. It now accepts a
38
+ * site-relative path, which is what `.claude/future-tasks/editor-asset-mount-topology.md`
39
+ * option 1 asks for — the absolute-only constraint was a validation choice, and the `basePath`
40
+ * fallback in `editor/context/AssetContext.tsx` exists only to work around it. And it now
41
+ * rejects non-http(s) schemes, which `z.string().url()` accepted: `publicBaseUrl: 'mailto:a@b.c'`
42
+ * used to parse and then produce `/mailto:a@b.c/assets/…` at render time.
43
+ */
44
+ export const assetMountUrlSchema = z
45
+ .string()
46
+ .trim()
47
+ .refine((value) => isHttpUrlOrSameOriginPath(value, { allowProtocolRelative: true }), {
48
+ message: 'must be an absolute http(s) URL, a protocol-relative "//host" URL, or a site-relative path beginning with a single "/", with no query or fragment',
49
+ });
@@ -164,6 +164,8 @@ export type MediaConfig = {
164
164
  bucket: string;
165
165
  region: string;
166
166
  publicBaseUrl?: string;
167
+ /** POST target for presigned direct uploads; see mediaSchema's s3 branch. */
168
+ uploadUrl?: string;
167
169
  maxUploadBytes?: number;
168
170
  } | {
169
171
  adapter: 'lfs';
@@ -27,14 +27,19 @@ export interface AssetContextProviderProps extends AssetContextValue {
27
27
  /**
28
28
  * `baseUrl` (`media.publicBaseUrl`) wins; `basePath` is the fallback.
29
29
  *
30
- * They are ALTERNATIVES, never composed: `publicBaseUrl` is validated absolute-only, so it names
31
- * another origin serving `/assets` at THAT origin's root, and a deployment `basePath` cannot apply
32
- * on top of it. Where the two differ is topology, not precedence - see the asset-mount table in
33
- * the README.
30
+ * They are ALTERNATIVES, never composed: whichever is used names where `/assets` is mounted, in
31
+ * full, and a deployment `basePath` cannot apply on top of it. Where the two differ is topology,
32
+ * not precedence - see the asset-mount table in the README.
34
33
  *
35
- * The fallback exists because `publicBaseUrl` cannot express a bare path (it is validated
36
- * absolute-only), so without it the editor's thumbnails, previews and crop images stay
37
- * root-relative on a basePath deployment where Next serves `/assets` — and 404.
34
+ * WHY THE FALLBACK EXISTS, AND WHY THAT REASON IS NOW GONE. It was added because
35
+ * `publicBaseUrl` was validated absolute-only (`z.string().url()`) and so could not express a
36
+ * bare path, leaving the editor's thumbnails, previews and crop images root-relative — and 404 —
37
+ * on a basePath deployment where Next serves `/assets`. That constraint was lifted when
38
+ * `publicBaseUrl` moved to `assetMountUrlSchema`, which accepts a site-relative path: an adopter
39
+ * can now simply set `publicBaseUrl: '/preview-123'`. The fallback is kept only so that existing
40
+ * basePath deployments do not break on upgrade, which makes removing it a deliberate decision
41
+ * rather than a cleanup - it is option 1 of
42
+ * `.claude/future-tasks/editor-asset-mount-topology.md`, still open.
38
43
  *
39
44
  * KNOWN LIMITATION: the fallback assumes that topology, and consults none. On a CloudFront/CDK
40
45
  * deployment a basePath does NOT move the asset space (behaviors are anchored at the distribution
@@ -18,14 +18,19 @@ const AssetContext = createContext({});
18
18
  /**
19
19
  * `baseUrl` (`media.publicBaseUrl`) wins; `basePath` is the fallback.
20
20
  *
21
- * They are ALTERNATIVES, never composed: `publicBaseUrl` is validated absolute-only, so it names
22
- * another origin serving `/assets` at THAT origin's root, and a deployment `basePath` cannot apply
23
- * on top of it. Where the two differ is topology, not precedence - see the asset-mount table in
24
- * the README.
21
+ * They are ALTERNATIVES, never composed: whichever is used names where `/assets` is mounted, in
22
+ * full, and a deployment `basePath` cannot apply on top of it. Where the two differ is topology,
23
+ * not precedence - see the asset-mount table in the README.
25
24
  *
26
- * The fallback exists because `publicBaseUrl` cannot express a bare path (it is validated
27
- * absolute-only), so without it the editor's thumbnails, previews and crop images stay
28
- * root-relative on a basePath deployment where Next serves `/assets` — and 404.
25
+ * WHY THE FALLBACK EXISTS, AND WHY THAT REASON IS NOW GONE. It was added because
26
+ * `publicBaseUrl` was validated absolute-only (`z.string().url()`) and so could not express a
27
+ * bare path, leaving the editor's thumbnails, previews and crop images root-relative — and 404 —
28
+ * on a basePath deployment where Next serves `/assets`. That constraint was lifted when
29
+ * `publicBaseUrl` moved to `assetMountUrlSchema`, which accepts a site-relative path: an adopter
30
+ * can now simply set `publicBaseUrl: '/preview-123'`. The fallback is kept only so that existing
31
+ * basePath deployments do not break on upgrade, which makes removing it a deliberate decision
32
+ * rather than a cleanup - it is option 1 of
33
+ * `.claude/future-tasks/editor-asset-mount-topology.md`, still open.
29
34
  *
30
35
  * KNOWN LIMITATION: the fallback assumes that topology, and consults none. On a CloudFront/CDK
31
36
  * deployment a basePath does NOT move the asset space (behaviors are anchored at the distribution
@@ -44,6 +44,47 @@ export declare function isImplicitlyOffOrigin(url: string): boolean;
44
44
  * same-origin string).
45
45
  */
46
46
  export declare function neutralizeImplicitOffOrigin(url: string): string;
47
+ /**
48
+ * Whether `value` is usable as an adopter-CONFIGURED URL base or endpoint: an absolute
49
+ * `http(s)` URL, or a site-relative path with exactly one leading slash.
50
+ *
51
+ * This is the config-validation counterpart to `sanitizeHref`, which does the same job for
52
+ * untrusted CONTENT. The difference is what happens to a bad value: content is coerced to a
53
+ * fallback so a page still renders, whereas config should fail loudly at parse time — nobody
54
+ * is served by a silently-rewritten deployment setting. The http(s) allowlist itself lives in
55
+ * this file either way, so the two surfaces cannot drift into disagreeing about which schemes
56
+ * are acceptable (that drift is exactly why `utils/url-prefix.ts` exists — see its header).
57
+ *
58
+ * `allowProtocolRelative` exists because the two callers genuinely differ, and the difference
59
+ * is blast radius rather than taste:
60
+ *
61
+ * - A READ-side prefix (`media.publicBaseUrl`, joined onto `/assets/…`) may legitimately be
62
+ * `//cdn.example.com`. `utils/url-prefix.ts`'s `isAbsoluteUrl` documents that literal
63
+ * spelling as an intentionally-supported off-site pointer, so rejecting it here would break
64
+ * a working configuration. A bad value costs a broken `<img>`.
65
+ * - A WRITE-side endpoint (`media.uploadUrl`, where the browser POSTs a presigned upload) must
66
+ * not be protocol-relative even when spelled literally, because such a URL is AMBIGUOUS: it
67
+ * resolves to http or https depending on the scheme of whichever editor page happens to
68
+ * issue the upload, so the stored config does not determine where a live presigned credential
69
+ * and the user's file bytes are sent. That is the same principle every other rule here
70
+ * enforces — a config value must describe what actually happens.
71
+ *
72
+ * Note this is NOT an argument that http is forbidden on the write side; bare `http://` is
73
+ * accepted, because a loopback or in-cluster S3-compatible endpoint is a real need and a
74
+ * browser on an https editor blocks the mixed-content request anyway, so it fails closed. The
75
+ * objection to `//host` is that it is undetermined, not that it might be insecure.
76
+ *
77
+ * Every spelling that READS as site-relative while redefining the authority — `/\host`,
78
+ * `\\host`, `\/host`, `///host` — is rejected for both, in both modes. Those look site-relative
79
+ * to a human and to a naive `startsWith('/')` check, but WHATWG URL resolves each to a
80
+ * different authority (measured: `///x` resolves to host `x`, not to pathname `/x`), so they
81
+ * are never what an adopter meant. A scheme-qualified absolute URL is a separate case and is
82
+ * accepted — `https:///cdn.example.com/` is unusual but unambiguous, resolving identically
83
+ * standalone and against any base.
84
+ */
85
+ export declare function isHttpUrlOrSameOriginPath(value: string, opts?: {
86
+ allowProtocolRelative?: boolean;
87
+ }): boolean;
47
88
  /**
48
89
  * Sanitize an untrusted URL for use in `href` attributes.
49
90
  *
@@ -2,6 +2,21 @@
2
2
  // fragment-only, query-only) resolve instead of throwing under `new URL()`.
3
3
  // Never exposed in output - see the relative-reference branch below.
4
4
  const SENTINEL_BASE = 'https://relative.invalid';
5
+ /**
6
+ * A path segment that is exactly `.` or `..`, in any spelling a URL parser collapses.
7
+ *
8
+ * `%2e` must be covered, not just the literal dot: WHATWG's single-dot and double-dot path
9
+ * segment definitions are case-insensitively percent-decoded, so `/upload/%2e%2e/` collapses in
10
+ * a browser exactly as `/upload/../` does. A literal-only guard reads as complete and is one
11
+ * encoding away from useless — measured before this was widened.
12
+ *
13
+ * Bounded repetition over an alternation of two literals, so no nested quantifier and no
14
+ * backtracking blowup — see `utils/url-prefix.ts`'s `stripTrailingSlashes` for why regex shape
15
+ * is watched in this area. Measured under 2ms across five ~400KB adversarial inputs, the slowest
16
+ * consistently being a string of 400k slashes. Stated as a bound rather than a range because
17
+ * the figures move run to run.
18
+ */
19
+ const DOT_SEGMENT = /(^|\/)(\.|%2e){1,2}(\/|$)/i;
5
20
  /**
6
21
  * Whether `url` declares an explicit scheme (`https:`, `mailto:`, `javascript:`, ...).
7
22
  *
@@ -64,6 +79,107 @@ export function neutralizeImplicitOffOrigin(url) {
64
79
  const parsed = new URL(url, SENTINEL_BASE);
65
80
  return parsed.pathname + parsed.search + parsed.hash;
66
81
  }
82
+ /**
83
+ * Whether `value` is usable as an adopter-CONFIGURED URL base or endpoint: an absolute
84
+ * `http(s)` URL, or a site-relative path with exactly one leading slash.
85
+ *
86
+ * This is the config-validation counterpart to `sanitizeHref`, which does the same job for
87
+ * untrusted CONTENT. The difference is what happens to a bad value: content is coerced to a
88
+ * fallback so a page still renders, whereas config should fail loudly at parse time — nobody
89
+ * is served by a silently-rewritten deployment setting. The http(s) allowlist itself lives in
90
+ * this file either way, so the two surfaces cannot drift into disagreeing about which schemes
91
+ * are acceptable (that drift is exactly why `utils/url-prefix.ts` exists — see its header).
92
+ *
93
+ * `allowProtocolRelative` exists because the two callers genuinely differ, and the difference
94
+ * is blast radius rather than taste:
95
+ *
96
+ * - A READ-side prefix (`media.publicBaseUrl`, joined onto `/assets/…`) may legitimately be
97
+ * `//cdn.example.com`. `utils/url-prefix.ts`'s `isAbsoluteUrl` documents that literal
98
+ * spelling as an intentionally-supported off-site pointer, so rejecting it here would break
99
+ * a working configuration. A bad value costs a broken `<img>`.
100
+ * - A WRITE-side endpoint (`media.uploadUrl`, where the browser POSTs a presigned upload) must
101
+ * not be protocol-relative even when spelled literally, because such a URL is AMBIGUOUS: it
102
+ * resolves to http or https depending on the scheme of whichever editor page happens to
103
+ * issue the upload, so the stored config does not determine where a live presigned credential
104
+ * and the user's file bytes are sent. That is the same principle every other rule here
105
+ * enforces — a config value must describe what actually happens.
106
+ *
107
+ * Note this is NOT an argument that http is forbidden on the write side; bare `http://` is
108
+ * accepted, because a loopback or in-cluster S3-compatible endpoint is a real need and a
109
+ * browser on an https editor blocks the mixed-content request anyway, so it fails closed. The
110
+ * objection to `//host` is that it is undetermined, not that it might be insecure.
111
+ *
112
+ * Every spelling that READS as site-relative while redefining the authority — `/\host`,
113
+ * `\\host`, `\/host`, `///host` — is rejected for both, in both modes. Those look site-relative
114
+ * to a human and to a naive `startsWith('/')` check, but WHATWG URL resolves each to a
115
+ * different authority (measured: `///x` resolves to host `x`, not to pathname `/x`), so they
116
+ * are never what an adopter meant. A scheme-qualified absolute URL is a separate case and is
117
+ * accepted — `https:///cdn.example.com/` is unusual but unambiguous, resolving identically
118
+ * standalone and against any base.
119
+ */
120
+ export function isHttpUrlOrSameOriginPath(value, opts = {}) {
121
+ if (value === '')
122
+ return false;
123
+ // Reject ASCII control characters and spaces ANYWHERE in the value, not only at the edges.
124
+ // WHATWG URL strips tab/CR/LF during parsing wherever they occur, so `/\tx` parses as a
125
+ // perfectly ordinary same-origin path and would satisfy every check below — while the
126
+ // browser actually requests `/x`. A stored config value that does not describe what is sent
127
+ // is a defect even when it happens to work, so reject it rather than normalize it away.
128
+ for (let index = 0; index < value.length; index++) {
129
+ const code = value.charCodeAt(index);
130
+ if (code <= 0x20 || code === 0x7f)
131
+ return false;
132
+ }
133
+ // A query or fragment is meaningless on both sides this predicate serves. S3's POST Object
134
+ // takes no query parameters and a browser never transmits a fragment; and on the read side a
135
+ // prefix carrying either produces `https://cdn.example.com/?x=1/assets/…` once joined.
136
+ if (value.includes('?') || value.includes('#'))
137
+ return false;
138
+ // A backslash is never legitimate in a configured URL, and it is not merely cosmetic: WHATWG
139
+ // treats it as a path separator for special schemes, so `/asset\upload/` is SENT as
140
+ // `/asset/upload/`. Worse on the read side, where the value becomes a prefix — `/\` is a
141
+ // string `new URL()` REJECTS, so it slips past the off-origin check below and joins into
142
+ // `/\/assets/a.png`, which a browser then resolves to `https://assets/a.png` — a request to a
143
+ // host literally named `assets` (measured). Anyone
144
+ // wanting a literal backslash in a path must percent-encode it.
145
+ if (value.includes('\\'))
146
+ return false;
147
+ // Dot segments resolve away before the request is sent — `/asset-upload/..` is SENT as `/`
148
+ // and `/./x` as `/x` — so the stored value again fails to describe what happens.
149
+ if (DOT_SEGMENT.test(value))
150
+ return false;
151
+ if (declaresScheme(value)) {
152
+ // The scheme must be followed by a literal `//`. Parsing to an http(s) URL is NOT enough:
153
+ // WHATWG resolves a same-scheme reference carrying no authority as RELATIVE, so a browser
154
+ // on `https://editor.example.com/admin/media` sends `https:cdn.example.com` to
155
+ // `https://editor.example.com/admin/cdn.example.com` — while `new URL()` here would report
156
+ // `https://cdn.example.com/`. Accepting it would post the presigned credential and the
157
+ // user's bytes to a page-dependent path on the editor's own origin, surfacing only as a
158
+ // 404 with nothing pointing at the config. Dropping the `//` is an ordinary typo.
159
+ if (!/^https?:\/\//i.test(value))
160
+ return false;
161
+ // `http:` is deliberately allowed alongside `https:`: a local S3-compatible endpoint
162
+ // (MinIO, LocalStack) is `http://localhost:9000`, and that is the one setup in which an
163
+ // adopter would most want to exercise this path before deploying.
164
+ try {
165
+ new URL(value);
166
+ return true;
167
+ }
168
+ catch {
169
+ return false;
170
+ }
171
+ }
172
+ // A LITERAL `//host` is the only off-origin spelling that can be intentional, and only for
173
+ // callers that opt in. `value[2] !== '/'` matters: `///x` and `////x` are also "implicitly
174
+ // off-origin" and also start with `//`, but resolve to a host named `x` rather than to a
175
+ // path — so they must not ride in on the opt-in.
176
+ if (isImplicitlyOffOrigin(value)) {
177
+ return opts.allowProtocolRelative === true && value.startsWith('//') && value[2] !== '/';
178
+ }
179
+ // Site-relative, with exactly one leading slash. The second clause also covers `//`, which
180
+ // `isImplicitlyOffOrigin` reports as false only because `new URL('//', base)` throws.
181
+ return value.startsWith('/') && value[1] !== '/';
182
+ }
67
183
  /**
68
184
  * Sanitize an untrusted URL for use in `href` attributes.
69
185
  *
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "//": "@codemirror/language, @lezer/highlight: workaround — @mdxeditor/editor uses cm6-theme-basic-light which peer-requires these but mdxeditor doesn't declare them as dependencies",
3
3
  "name": "canopycms",
4
- "version": "0.0.66-int.85",
4
+ "version": "0.0.66",
5
5
  "description": "CanopyCMS core package: schema-driven content, branch-aware editing, and editor UI for Next.js.",
6
6
  "license": "MIT",
7
7
  "repository": {
@@ -18,59 +18,71 @@
18
18
  },
19
19
  "exports": {
20
20
  ".": {
21
+ "types": "./dist/index.d.ts",
21
22
  "import": "./dist/index.js",
22
- "types": "./dist/index.d.ts"
23
+ "require": "./dist/index.js"
23
24
  },
24
25
  "./client": {
26
+ "types": "./dist/client.d.ts",
25
27
  "import": "./dist/client.js",
26
- "types": "./dist/client.d.ts"
28
+ "require": "./dist/client.js"
27
29
  },
28
30
  "./server": {
31
+ "types": "./dist/server.d.ts",
29
32
  "import": "./dist/server.js",
30
- "types": "./dist/server.d.ts"
33
+ "require": "./dist/server.js"
31
34
  },
32
35
  "./auth": {
36
+ "types": "./dist/auth/index.d.ts",
33
37
  "import": "./dist/auth/index.js",
34
- "types": "./dist/auth/index.d.ts"
38
+ "require": "./dist/auth/index.js"
35
39
  },
36
40
  "./auth/cache": {
41
+ "types": "./dist/auth/cache.d.ts",
37
42
  "import": "./dist/auth/cache.js",
38
- "types": "./dist/auth/cache.d.ts"
43
+ "require": "./dist/auth/cache.js"
39
44
  },
40
45
  "./http": {
46
+ "types": "./dist/http/index.d.ts",
41
47
  "import": "./dist/http/index.js",
42
- "types": "./dist/http/index.d.ts"
48
+ "require": "./dist/http/index.js"
43
49
  },
44
50
  "./task-queue": {
51
+ "types": "./dist/task-queue/index.d.ts",
45
52
  "import": "./dist/task-queue/index.js",
46
- "types": "./dist/task-queue/index.d.ts"
53
+ "require": "./dist/task-queue/index.js"
47
54
  },
48
55
  "./worker/task-queue": {
56
+ "types": "./dist/worker/task-queue.d.ts",
49
57
  "import": "./dist/worker/task-queue.js",
50
- "types": "./dist/worker/task-queue.d.ts"
58
+ "require": "./dist/worker/task-queue.js"
51
59
  },
52
60
  "./worker/cms-worker": {
61
+ "types": "./dist/worker/cms-worker.d.ts",
53
62
  "import": "./dist/worker/cms-worker.js",
54
- "types": "./dist/worker/cms-worker.d.ts"
63
+ "require": "./dist/worker/cms-worker.js"
55
64
  },
56
65
  "./ai": {
66
+ "types": "./dist/ai/index.d.ts",
57
67
  "import": "./dist/ai/index.js",
58
- "types": "./dist/ai/index.d.ts"
68
+ "require": "./dist/ai/index.js"
59
69
  },
60
70
  "./build": {
71
+ "types": "./dist/build/index.d.ts",
61
72
  "import": "./dist/build/index.js",
62
- "types": "./dist/build/index.d.ts"
73
+ "require": "./dist/build/index.js"
63
74
  },
64
75
  "./utils/error": {
76
+ "types": "./dist/utils/error.d.ts",
65
77
  "import": "./dist/utils/error.js",
66
- "types": "./dist/utils/error.d.ts"
78
+ "require": "./dist/utils/error.js"
67
79
  }
68
80
  },
69
81
  "files": [
70
82
  "dist"
71
83
  ],
72
84
  "engines": {
73
- "node": ">=18"
85
+ "node": ">=22.12.0"
74
86
  },
75
87
  "dependencies": {
76
88
  "@aws-sdk/client-s3": "^3.600.0",