@jant/core 0.3.49 → 0.4.0

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.
@@ -1,47 +1,28 @@
1
- import { timingSafeEqualBytes } from "./crypto.js";
2
-
1
+ const VERIFICATION_HMAC_VERSION = "v1";
3
2
  const textEncoder = new TextEncoder();
4
- const textDecoder = new TextDecoder();
5
-
6
- export interface HostedDomainCheckClaims {
7
- aud: "jant-cloud";
8
- domainId: string;
9
- host: string;
10
- iat: number;
11
- iss: "jant-core";
12
- nonce: string;
13
- }
14
3
 
15
- function toBase64Url(bytes: Uint8Array): string {
16
- let binary = "";
4
+ function bytesToHex(bytes: Uint8Array): string {
5
+ let out = "";
17
6
  for (const byte of bytes) {
18
- binary += String.fromCharCode(byte);
19
- }
20
-
21
- return btoa(binary)
22
- .replace(/\+/g, "-")
23
- .replace(/\//g, "_")
24
- .replace(/=+$/g, "");
25
- }
26
-
27
- function fromBase64Url(value: string): Uint8Array {
28
- const normalized = value.replace(/-/g, "+").replace(/_/g, "/");
29
- const padding =
30
- normalized.length % 4 === 0 ? "" : "=".repeat(4 - (normalized.length % 4));
31
- const binary = atob(`${normalized}${padding}`);
32
- const bytes = new Uint8Array(binary.length);
33
-
34
- for (let index = 0; index < binary.length; index += 1) {
35
- bytes[index] = binary.charCodeAt(index);
7
+ out += byte.toString(16).padStart(2, "0");
36
8
  }
37
-
38
- return bytes;
9
+ return out;
39
10
  }
40
11
 
41
- async function createHmacSignature(
12
+ /**
13
+ * Compute the plaintext HMAC token returned from
14
+ * `/.well-known/jant-verification`.
15
+ *
16
+ * The control plane sends a nonce in the query string; the site replies with
17
+ * `jant-verification=<hex>` where `<hex>` is `HMAC-SHA256(secret, payload)`
18
+ * over `payload = "v1:" + host + ":" + nonce`. The shared secret is
19
+ * `HOSTED_CONTROL_PLANE_DOMAIN_CHECK_SECRET`.
20
+ */
21
+ export async function computeHostedVerificationToken(
42
22
  secret: string,
43
- payload: string,
44
- ): Promise<Uint8Array> {
23
+ host: string,
24
+ nonce: string,
25
+ ): Promise<string> {
45
26
  const key = await crypto.subtle.importKey(
46
27
  "raw",
47
28
  textEncoder.encode(secret),
@@ -50,49 +31,9 @@ async function createHmacSignature(
50
31
  ["sign"],
51
32
  );
52
33
 
53
- return new Uint8Array(
34
+ const payload = `${VERIFICATION_HMAC_VERSION}:${host.trim().toLowerCase()}:${nonce}`;
35
+ const signature = new Uint8Array(
54
36
  await crypto.subtle.sign("HMAC", key, textEncoder.encode(payload)),
55
37
  );
56
- }
57
-
58
- export async function signHostedDomainCheckToken(
59
- secret: string,
60
- claims: HostedDomainCheckClaims,
61
- ): Promise<string> {
62
- const payload = toBase64Url(textEncoder.encode(JSON.stringify(claims)));
63
- const signature = await createHmacSignature(secret, payload);
64
- return `${payload}.${toBase64Url(signature)}`;
65
- }
66
-
67
- export async function verifyHostedDomainCheckToken(
68
- secret: string,
69
- token: string,
70
- ): Promise<HostedDomainCheckClaims> {
71
- const [payloadPart, signaturePart, ...rest] = token.split(".");
72
- if (!payloadPart || !signaturePart || rest.length > 0) {
73
- throw new Error("Malformed hosted domain check token.");
74
- }
75
-
76
- const expectedSignature = await createHmacSignature(secret, payloadPart);
77
- const providedSignature = fromBase64Url(signaturePart);
78
- if (!timingSafeEqualBytes(expectedSignature, providedSignature)) {
79
- throw new Error("Invalid hosted domain check token signature.");
80
- }
81
-
82
- const claims = JSON.parse(
83
- textDecoder.decode(fromBase64Url(payloadPart)),
84
- ) as Partial<HostedDomainCheckClaims>;
85
-
86
- if (
87
- claims.iss !== "jant-core" ||
88
- claims.aud !== "jant-cloud" ||
89
- typeof claims.host !== "string" ||
90
- typeof claims.domainId !== "string" ||
91
- typeof claims.nonce !== "string" ||
92
- typeof claims.iat !== "number"
93
- ) {
94
- throw new Error("Invalid hosted domain check token payload.");
95
- }
96
-
97
- return claims as HostedDomainCheckClaims;
38
+ return bytesToHex(signature);
98
39
  }
@@ -2,7 +2,7 @@ import type { Site, SiteDomain } from "../types.js";
2
2
  import type { Services } from "../services/index.js";
3
3
 
4
4
  const CANONICAL_REDIRECT_BYPASS_PREFIXES = [
5
- "/.well-known/jant-domain-check",
5
+ "/.well-known/jant-verification",
6
6
  "/__dev",
7
7
  "/__sso",
8
8
  "/api/",
@@ -143,6 +143,88 @@ describe("Internal site admin routes", () => {
143
143
  ]);
144
144
  });
145
145
 
146
+ it("reports an unused key as available", async () => {
147
+ const { app } = createTestApp({
148
+ authenticated: false,
149
+ internalAdminToken: "internal-secret",
150
+ siteResolutionMode: "host-based",
151
+ });
152
+ app.route("/api/internal/sites", internalSitesRoutes);
153
+
154
+ const res = await app.request(
155
+ "/api/internal/sites/availability?key=Fresh-Key",
156
+ {
157
+ headers: { Authorization: "Bearer internal-secret" },
158
+ },
159
+ );
160
+
161
+ expect(res.status).toBe(200);
162
+ expect(await res.json()).toEqual({ available: true, key: "fresh-key" });
163
+ });
164
+
165
+ it("reports an existing key as unavailable", async () => {
166
+ const { app } = createTestApp({
167
+ authenticated: false,
168
+ internalAdminToken: "internal-secret",
169
+ siteResolutionMode: "host-based",
170
+ });
171
+ app.route("/api/internal/sites", internalSitesRoutes);
172
+
173
+ const createRes = await app.request("/api/internal/sites", {
174
+ method: "POST",
175
+ headers: {
176
+ Authorization: "Bearer internal-secret",
177
+ "Content-Type": "application/json",
178
+ },
179
+ body: JSON.stringify({
180
+ key: "taken-key",
181
+ primaryHost: "taken-key.example.com",
182
+ siteName: "Taken Key",
183
+ }),
184
+ });
185
+ expect(createRes.status).toBe(201);
186
+
187
+ const res = await app.request(
188
+ "/api/internal/sites/availability?key=taken-key",
189
+ {
190
+ headers: { Authorization: "Bearer internal-secret" },
191
+ },
192
+ );
193
+
194
+ expect(res.status).toBe(200);
195
+ expect(await res.json()).toEqual({ available: false, key: "taken-key" });
196
+ });
197
+
198
+ it("rejects availability checks without an admin token", async () => {
199
+ const { app } = createTestApp({
200
+ authenticated: false,
201
+ internalAdminToken: "internal-secret",
202
+ siteResolutionMode: "host-based",
203
+ });
204
+ app.route("/api/internal/sites", internalSitesRoutes);
205
+
206
+ const res = await app.request(
207
+ "/api/internal/sites/availability?key=any-key",
208
+ );
209
+
210
+ expect(res.status).toBe(401);
211
+ });
212
+
213
+ it("rejects availability checks with an invalid key", async () => {
214
+ const { app } = createTestApp({
215
+ authenticated: false,
216
+ internalAdminToken: "internal-secret",
217
+ siteResolutionMode: "host-based",
218
+ });
219
+ app.route("/api/internal/sites", internalSitesRoutes);
220
+
221
+ const res = await app.request("/api/internal/sites/availability?key=ab", {
222
+ headers: { Authorization: "Bearer internal-secret" },
223
+ });
224
+
225
+ expect(res.status).toBe(400);
226
+ });
227
+
146
228
  it("returns managed site media usage in host-based mode", async () => {
147
229
  const { app, services } = createTestApp({
148
230
  authenticated: false,
@@ -512,4 +594,90 @@ describe("Internal site admin routes", () => {
512
594
  ],
513
595
  });
514
596
  });
597
+
598
+ it("leaves the demoted alias serving directly when adding a new primary", async () => {
599
+ const { app } = createTestApp({
600
+ authenticated: false,
601
+ internalAdminToken: "internal-secret",
602
+ siteResolutionMode: "host-based",
603
+ });
604
+ app.route("/api/internal/sites", internalSitesRoutes);
605
+
606
+ const createRes = await app.request("/api/internal/sites", {
607
+ method: "POST",
608
+ headers: {
609
+ Authorization: "Bearer internal-secret",
610
+ "Content-Type": "application/json",
611
+ },
612
+ body: JSON.stringify({
613
+ key: "redirect-demo",
614
+ primaryHost: "redirect-demo.jant.blog",
615
+ siteName: "Redirect Demo",
616
+ }),
617
+ });
618
+
619
+ expect(createRes.status).toBe(201);
620
+ const created = (await createRes.json()) as { siteId: string };
621
+
622
+ const addRes = await app.request(
623
+ `/api/internal/sites/${created.siteId}/domains`,
624
+ {
625
+ method: "POST",
626
+ headers: {
627
+ Authorization: "Bearer internal-secret",
628
+ "Content-Type": "application/json",
629
+ },
630
+ body: JSON.stringify({
631
+ host: "blog.example.com",
632
+ makePrimary: true,
633
+ }),
634
+ },
635
+ );
636
+
637
+ expect(addRes.status).toBe(201);
638
+ const addedBody = (await addRes.json()) as {
639
+ domains: Array<{
640
+ host: string;
641
+ id: string;
642
+ kind: string;
643
+ redirectToPrimary: boolean;
644
+ }>;
645
+ };
646
+ const newPrimary = addedBody.domains.find(
647
+ (domain) => domain.host === "blog.example.com",
648
+ );
649
+ const demotedAlias = addedBody.domains.find(
650
+ (domain) => domain.host === "redirect-demo.jant.blog",
651
+ );
652
+ expect(newPrimary?.kind).toBe("primary");
653
+ expect(demotedAlias?.kind).toBe("alias");
654
+ // The demoted managed host must keep serving its own content while the
655
+ // newly-added custom primary's DNS is still propagating.
656
+ expect(demotedAlias?.redirectToPrimary).toBe(false);
657
+
658
+ const flipRes = await app.request(
659
+ `/api/internal/sites/${created.siteId}/domains/${demotedAlias?.id}/redirect`,
660
+ {
661
+ method: "POST",
662
+ headers: {
663
+ Authorization: "Bearer internal-secret",
664
+ "Content-Type": "application/json",
665
+ },
666
+ body: JSON.stringify({ redirectToPrimary: true }),
667
+ },
668
+ );
669
+
670
+ expect(flipRes.status).toBe(200);
671
+ const flipBody = (await flipRes.json()) as {
672
+ domains: Array<{
673
+ host: string;
674
+ id: string;
675
+ redirectToPrimary: boolean;
676
+ }>;
677
+ };
678
+ const aliasAfterFlip = flipBody.domains.find(
679
+ (domain) => domain.id === demotedAlias?.id,
680
+ );
681
+ expect(aliasAfterFlip?.redirectToPrimary).toBe(true);
682
+ });
515
683
  });
@@ -9,17 +9,23 @@ import type { AppVariables } from "../../../types/app-context.js";
9
9
 
10
10
  type Env = { Bindings: Bindings; Variables: AppVariables };
11
11
 
12
+ const ManagedSiteKeySchema = z
13
+ .string()
14
+ .trim()
15
+ .toLowerCase()
16
+ .min(3)
17
+ .max(40)
18
+ .regex(
19
+ /^[a-z0-9](?:[a-z0-9-]{1,38}[a-z0-9])?$/,
20
+ "Site key must use lowercase letters, numbers, or hyphens.",
21
+ );
22
+
23
+ const SiteKeyAvailabilityQuerySchema = z.object({
24
+ key: ManagedSiteKeySchema,
25
+ });
26
+
12
27
  const CreateManagedSiteSchema = z.object({
13
- key: z
14
- .string()
15
- .trim()
16
- .toLowerCase()
17
- .min(3)
18
- .max(40)
19
- .regex(
20
- /^[a-z0-9](?:[a-z0-9-]{1,38}[a-z0-9])?$/,
21
- "Site key must use lowercase letters, numbers, or hyphens.",
22
- ),
28
+ key: ManagedSiteKeySchema,
23
29
  primaryHost: z
24
30
  .string()
25
31
  .trim()
@@ -76,6 +82,23 @@ internalSitesRoutes.post("/", requireInternalAdminApi(), async (c) => {
76
82
  );
77
83
  });
78
84
 
85
+ internalSitesRoutes.get(
86
+ "/availability",
87
+ requireInternalAdminApi(),
88
+ async (c) => {
89
+ assertHostBasedMode(c.env);
90
+
91
+ const query = parseValidated(SiteKeyAvailabilityQuerySchema, {
92
+ key: c.req.query("key") ?? "",
93
+ });
94
+ const result = await c.var.services.siteAdmin.isManagedSiteKeyAvailable(
95
+ query.key,
96
+ );
97
+
98
+ return c.json(result);
99
+ },
100
+ );
101
+
79
102
  internalSitesRoutes.delete("/:siteId", requireInternalAdminApi(), async (c) => {
80
103
  assertHostBasedMode(c.env);
81
104
 
@@ -222,6 +245,36 @@ internalSitesRoutes.post(
222
245
  },
223
246
  );
224
247
 
248
+ const ManagedSiteDomainRedirectSchema = z.object({
249
+ redirectToPrimary: z.boolean(),
250
+ });
251
+
252
+ internalSitesRoutes.post(
253
+ "/:siteId/domains/:domainId/redirect",
254
+ requireInternalAdminApi(),
255
+ async (c) => {
256
+ assertHostBasedMode(c.env);
257
+ const body = parseValidated(
258
+ ManagedSiteDomainRedirectSchema,
259
+ await c.req.json(),
260
+ );
261
+ const domains = await c.var.services.siteAdmin.setManagedSiteDomainRedirect(
262
+ c.req.param("siteId"),
263
+ c.req.param("domainId"),
264
+ body.redirectToPrimary,
265
+ );
266
+
267
+ return c.json({
268
+ domains: domains.map((domain) => ({
269
+ host: domain.host,
270
+ id: domain.id,
271
+ kind: domain.kind,
272
+ redirectToPrimary: domain.redirectToPrimary,
273
+ })),
274
+ });
275
+ },
276
+ );
277
+
225
278
  internalSitesRoutes.delete(
226
279
  "/:siteId/domains/:domainId",
227
280
  requireInternalAdminApi(),
@@ -1,7 +1,7 @@
1
1
  import { describe, expect, it } from "vitest";
2
2
  import { Hono } from "hono";
3
3
  import { errorHandler } from "../../../middleware/error-handler.js";
4
- import { verifyHostedDomainCheckToken } from "../../../lib/hosted-domain-check.js";
4
+ import { computeHostedVerificationToken } from "../../../lib/hosted-domain-check.js";
5
5
  import { hostedDomainCheckRoutes } from "../domain-check.js";
6
6
  import type { Bindings } from "../../../types.js";
7
7
  import type { AppVariables } from "../../../types/app-context.js";
@@ -42,12 +42,12 @@ function createHostedDomainCheckTestApp(options?: {
42
42
  return app;
43
43
  }
44
44
 
45
- describe("hostedDomainCheckRoutes", () => {
45
+ describe("hostedDomainCheckRoutes (jant-verification)", () => {
46
46
  it("returns 404 when the domain check secret is not configured", async () => {
47
47
  const app = createHostedDomainCheckTestApp();
48
48
 
49
49
  const response = await app.request(
50
- "/.well-known/jant-domain-check?nonce=test-nonce",
50
+ "/.well-known/jant-verification?nonce=test-nonce",
51
51
  );
52
52
 
53
53
  expect(response.status).toBe(404);
@@ -59,36 +59,47 @@ describe("hostedDomainCheckRoutes", () => {
59
59
  secret: "cloud-domain-check-secret-cloud-domain-check-secret",
60
60
  });
61
61
 
62
- const response = await app.request("/.well-known/jant-domain-check");
62
+ const response = await app.request("/.well-known/jant-verification");
63
63
 
64
64
  expect(response.status).toBe(400);
65
- await expect(response.json()).resolves.toEqual({
66
- error: "Missing nonce.",
67
- });
65
+ await expect(response.text()).resolves.toBe("Missing nonce.");
68
66
  });
69
67
 
70
- it("returns a signed token for the current hosted domain", async () => {
68
+ it("returns the plaintext HMAC token for the current hosted domain", async () => {
71
69
  const secret = "cloud-domain-check-secret-cloud-domain-check-secret";
70
+ const host = "blog.example.com";
71
+ const nonce = "test-nonce";
72
72
  const app = createHostedDomainCheckTestApp({
73
73
  domainId: "sdom_custom",
74
- host: "blog.example.com",
74
+ host,
75
75
  secret,
76
76
  });
77
77
 
78
78
  const response = await app.request(
79
- "/.well-known/jant-domain-check?nonce=test-nonce",
79
+ `/.well-known/jant-verification?nonce=${nonce}`,
80
80
  );
81
- const body = (await response.json()) as { token: string };
82
- const claims = await verifyHostedDomainCheckToken(secret, body.token);
81
+ const body = (await response.text()).trim();
82
+ const expected = await computeHostedVerificationToken(secret, host, nonce);
83
83
 
84
84
  expect(response.status).toBe(200);
85
85
  expect(response.headers.get("cache-control")).toBe("no-store");
86
- expect(claims).toMatchObject({
87
- aud: "jant-cloud",
88
- domainId: "sdom_custom",
89
- host: "blog.example.com",
90
- iss: "jant-core",
91
- nonce: "test-nonce",
92
- });
86
+ expect(response.headers.get("content-type")).toMatch(/^text\/plain/);
87
+ expect(body).toBe(`jant-verification=${expected}`);
88
+ });
89
+
90
+ it("normalizes host casing when computing the token", async () => {
91
+ const secret = "cloud-domain-check-secret-cloud-domain-check-secret";
92
+ const nonce = "abc";
93
+ const lower = await computeHostedVerificationToken(
94
+ secret,
95
+ "blog.example.com",
96
+ nonce,
97
+ );
98
+ const mixed = await computeHostedVerificationToken(
99
+ secret,
100
+ "Blog.Example.COM",
101
+ nonce,
102
+ );
103
+ expect(lower).toBe(mixed);
93
104
  });
94
105
  });
@@ -1,6 +1,6 @@
1
1
  import { Hono } from "hono";
2
2
  import { getHostedControlPlaneDomainCheckSecret } from "../../lib/env.js";
3
- import { signHostedDomainCheckToken } from "../../lib/hosted-domain-check.js";
3
+ import { computeHostedVerificationToken } from "../../lib/hosted-domain-check.js";
4
4
  import { NotFoundError } from "../../lib/errors.js";
5
5
  import type { Bindings } from "../../types.js";
6
6
  import type { AppVariables } from "../../types/app-context.js";
@@ -9,31 +9,26 @@ type Env = { Bindings: Bindings; Variables: AppVariables };
9
9
 
10
10
  export const hostedDomainCheckRoutes = new Hono<Env>();
11
11
 
12
- hostedDomainCheckRoutes.get("/.well-known/jant-domain-check", async (c) => {
12
+ hostedDomainCheckRoutes.get("/.well-known/jant-verification", async (c) => {
13
13
  const secret = getHostedControlPlaneDomainCheckSecret(c.env);
14
14
  if (!secret) {
15
- throw new NotFoundError("Hosted domain check endpoint");
15
+ throw new NotFoundError("Hosted domain verification endpoint");
16
16
  }
17
17
 
18
18
  if (!c.var.currentSiteDomain) {
19
- throw new NotFoundError("Hosted domain check endpoint");
19
+ throw new NotFoundError("Hosted domain verification endpoint");
20
20
  }
21
21
 
22
22
  const nonce = c.req.query("nonce")?.trim();
23
23
  if (!nonce) {
24
- return c.json({ error: "Missing nonce." }, 400);
24
+ return c.text("Missing nonce.", 400);
25
25
  }
26
26
 
27
- const token = await signHostedDomainCheckToken(secret, {
28
- aud: "jant-cloud",
29
- domainId: c.var.currentSiteDomain.id,
30
- host: c.var.currentSiteDomain.host.trim().toLowerCase(),
31
- iat: Math.floor(Date.now() / 1000),
32
- iss: "jant-core",
33
- nonce,
34
- });
27
+ const host = c.var.currentSiteDomain.host.trim().toLowerCase();
28
+ const token = await computeHostedVerificationToken(secret, host, nonce);
35
29
 
36
- return c.json({ token }, 200, {
30
+ return c.text(`jant-verification=${token}\n`, 200, {
37
31
  "Cache-Control": "no-store",
32
+ "Content-Type": "text/plain; charset=utf-8",
38
33
  });
39
34
  });
@@ -87,12 +87,26 @@ export interface ManagedSiteMediaUsageResult {
87
87
  siteId: string;
88
88
  }
89
89
 
90
+ export interface ManagedSiteKeyAvailabilityResult {
91
+ available: boolean;
92
+ key: string;
93
+ }
94
+
90
95
  export interface SiteAdminService {
91
96
  addManagedSiteDomain(
92
97
  siteId: string,
93
98
  input: ManageManagedSiteDomainInput,
94
99
  ): Promise<SiteDomain[]>;
95
100
  createManagedSite(input: CreateManagedSiteInput): Promise<ManagedSiteResult>;
101
+ /**
102
+ * Lookup whether the given site key is free in `site`. Returns the
103
+ * normalized key so the caller can confirm what was checked. Used by the
104
+ * control plane before reserving a cloud_site row, so the user sees a
105
+ * conflict on the form instead of after provisioning.
106
+ */
107
+ isManagedSiteKeyAvailable(
108
+ key: string,
109
+ ): Promise<ManagedSiteKeyAvailabilityResult>;
96
110
  exportManagedSite(
97
111
  siteId: string,
98
112
  deps: ExportManagedSiteDeps,
@@ -115,6 +129,11 @@ export interface SiteAdminService {
115
129
  siteId: string,
116
130
  domainId: string,
117
131
  ): Promise<SiteDomain[]>;
132
+ setManagedSiteDomainRedirect(
133
+ siteId: string,
134
+ domainId: string,
135
+ redirectToPrimary: boolean,
136
+ ): Promise<SiteDomain[]>;
118
137
  }
119
138
 
120
139
  export interface SiteAdminServiceConfig {
@@ -604,6 +623,16 @@ export function createSiteAdminService(
604
623
 
605
624
  return createWithDatabase(db, input);
606
625
  },
626
+ async isManagedSiteKeyAvailable(key) {
627
+ assertManagedSiteOperationsEnabled();
628
+ const normalizedKey = key.trim();
629
+ const existing = await db
630
+ .select({ id: sites.id })
631
+ .from(sites)
632
+ .where(eq(sites.key, normalizedKey))
633
+ .limit(1);
634
+ return { available: !existing[0], key: normalizedKey };
635
+ },
607
636
  async getManagedSiteMediaUsage(siteId) {
608
637
  assertManagedSiteOperationsEnabled();
609
638
  return getManagedSiteMediaUsage(siteId);
@@ -819,11 +848,16 @@ export function createSiteAdminService(
819
848
 
820
849
  const timestamp = now();
821
850
  if (input.makePrimary) {
851
+ // Newly-added primaries (e.g. custom domains) are unverified at this
852
+ // point. Leave any demoted alias serving directly so the site stays
853
+ // reachable while the new primary's DNS propagates. The caller is
854
+ // expected to flip redirectToPrimary back on once the new primary is
855
+ // confirmed to work.
822
856
  await targetDb
823
857
  .update(siteDomains)
824
858
  .set({
825
859
  kind: "alias",
826
- redirectToPrimary: true,
860
+ redirectToPrimary: false,
827
861
  updatedAt: timestamp,
828
862
  })
829
863
  .where(eq(siteDomains.siteId, normalizedSiteId));
@@ -841,6 +875,33 @@ export function createSiteAdminService(
841
875
  });
842
876
  });
843
877
  },
878
+ async setManagedSiteDomainRedirect(siteId, domainId, redirectToPrimary) {
879
+ assertManagedSiteOperationsEnabled();
880
+ return mutateSiteDomains(siteId, async (targetDb, normalizedSiteId) => {
881
+ await requireSite(targetDb, normalizedSiteId);
882
+
883
+ const normalizedDomainId = domainId.trim();
884
+ const current = await targetDb
885
+ .select({ id: siteDomains.id })
886
+ .from(siteDomains)
887
+ .where(
888
+ sql`${siteDomains.id} = ${normalizedDomainId} AND ${siteDomains.siteId} = ${normalizedSiteId}`,
889
+ )
890
+ .limit(1);
891
+ if (!current[0]) {
892
+ throw new NotFoundError("Site domain");
893
+ }
894
+
895
+ const timestamp = now();
896
+ await targetDb
897
+ .update(siteDomains)
898
+ .set({
899
+ redirectToPrimary,
900
+ updatedAt: timestamp,
901
+ })
902
+ .where(eq(siteDomains.id, normalizedDomainId));
903
+ });
904
+ },
844
905
  async setManagedSitePrimaryDomain(siteId, domainId) {
845
906
  assertManagedSiteOperationsEnabled();
846
907
  return mutateSiteDomains(siteId, async (targetDb, normalizedSiteId) => {
@@ -64,7 +64,7 @@ export function getMediaPlaceholderDataUrl(
64
64
  }
65
65
 
66
66
  function getSingleVisualWidth(ratio: number): string {
67
- return `min(100%, calc(24rem * ${ratio}))`;
67
+ return `min(100%, calc(24rem * ${ratio}), var(--layout-content-width))`;
68
68
  }
69
69
 
70
70
  /**
@@ -50,7 +50,9 @@ describe("MediaGallery", () => {
50
50
  );
51
51
 
52
52
  expect(html).toContain("aspect-ratio:900/1600");
53
- expect(html).toMatch(/width:min\(100%, ?calc\(24rem ?\* ?0\.5625\)\)/);
53
+ expect(html).toMatch(
54
+ /width:min\(100%, ?calc\(24rem ?\* ?0\.5625\), ?var\(--layout-content-width\)\)/,
55
+ );
54
56
  expect(html).not.toContain("object-contain");
55
57
  });
56
58