@jant/core 0.3.50 → 0.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. package/dist/{app-C7CtIQM-.js → app-DQgkp6yV.js} +155 -130
  2. package/dist/app-DYJLFZaM.js +6 -0
  3. package/dist/client/.vite/manifest.json +3 -3
  4. package/dist/client/_assets/client-BbJ0FhON.css +2 -0
  5. package/dist/client/_assets/client-DqsPJKiP.js +272 -0
  6. package/dist/client/_assets/{client-auth-Ce5WEAVS.js → client-auth-N6fiJcOg.js} +82 -82
  7. package/dist/{export-ZBlfKSKm.js → export-DwH3ga3Y.js} +2 -2
  8. package/dist/{github-sync-C593r22F.js → github-sync-D2FO19Re.js} +2 -2
  9. package/dist/{github-sync-bL1hnx3Q.js → github-sync-eHOTYZGO.js} +1 -1
  10. package/dist/index.js +3 -3
  11. package/dist/node.js +4 -4
  12. package/package.json +1 -1
  13. package/src/client/__tests__/compose-shortcuts.test.ts +1 -4
  14. package/src/client/components/__tests__/jant-compose-dialog.test.ts +1 -1
  15. package/src/client/components/__tests__/jant-media-lightbox.test.ts +89 -0
  16. package/src/client/components/compose-types.ts +6 -1
  17. package/src/client/components/jant-compose-dialog.ts +2 -0
  18. package/src/client/components/jant-compose-editor.ts +2 -1
  19. package/src/client/components/jant-media-lightbox.ts +33 -10
  20. package/src/client/compose-bridge.ts +88 -25
  21. package/src/client/compose-launch.ts +0 -13
  22. package/src/client/palette-search-trigger.ts +35 -0
  23. package/src/client/thread-context.ts +1 -140
  24. package/src/client/upload-session.ts +77 -31
  25. package/src/client-auth.ts +1 -0
  26. package/src/i18n/locales/public/en.po +0 -4
  27. package/src/i18n/locales/public/zh-Hans.po +0 -4
  28. package/src/i18n/locales/public/zh-Hant.po +0 -4
  29. package/src/lib/__tests__/hosted-domain.test.ts +1 -1
  30. package/src/lib/hosted-domain-check.ts +21 -80
  31. package/src/lib/hosted-domain.ts +1 -1
  32. package/src/routes/api/internal/__tests__/sites.test.ts +168 -0
  33. package/src/routes/api/internal/sites.ts +63 -10
  34. package/src/routes/hosted/__tests__/domain-check.test.ts +30 -19
  35. package/src/routes/hosted/domain-check.ts +9 -14
  36. package/src/services/export-theme/assets/client-site.js +1 -1
  37. package/src/services/site-admin.ts +62 -1
  38. package/src/styles/tokens.css +0 -1
  39. package/src/styles/ui.css +0 -71
  40. package/src/ui/feed/ThreadPreview.tsx +34 -65
  41. package/src/ui/feed/__tests__/thread-preview.test.ts +64 -58
  42. package/src/ui/feed/thread-preview-state.ts +0 -48
  43. package/dist/app-CIx9SSOi.js +0 -6
  44. package/dist/client/_assets/client-BoUn7xBo.css +0 -2
  45. package/dist/client/_assets/client-dSfWfMe9.js +0 -272
@@ -89,20 +89,61 @@ function parseTransport(value: unknown): UploadTransportResponse | null {
89
89
  return null;
90
90
  }
91
91
 
92
- async function sha256Base64(file: File): Promise<string> {
93
- const digest = await crypto.subtle.digest(
94
- "SHA-256",
95
- await file.arrayBuffer(),
96
- );
97
- const bytes = new Uint8Array(digest);
98
- let ascii = "";
99
- for (const byte of bytes) {
100
- ascii += String.fromCharCode(byte);
92
+ interface XhrPutResult {
93
+ status: number;
94
+ ok: boolean;
95
+ text: string;
96
+ }
97
+
98
+ /**
99
+ * PUT a request body via XMLHttpRequest so we can observe upload progress.
100
+ * `fetch()` does not expose `upload.progress`, so byte-level progress for
101
+ * single-PUT and per-part requests requires XHR.
102
+ */
103
+ function xhrPut(
104
+ url: string,
105
+ body: Blob | File,
106
+ headers: Record<string, string>,
107
+ onProgress?: (progress: number) => void,
108
+ ): Promise<XhrPutResult> {
109
+ return new Promise((resolve, reject) => {
110
+ const xhr = new globalThis.XMLHttpRequest();
111
+ xhr.open("PUT", url);
112
+ for (const [name, value] of Object.entries(headers)) {
113
+ xhr.setRequestHeader(name, value);
114
+ }
115
+ xhr.upload.addEventListener("progress", (event) => {
116
+ if (event.lengthComputable && onProgress) {
117
+ onProgress(event.loaded / event.total);
118
+ }
119
+ });
120
+ xhr.addEventListener("load", () => {
121
+ resolve({
122
+ status: xhr.status,
123
+ ok: xhr.status >= 200 && xhr.status < 300,
124
+ text: xhr.responseText,
125
+ });
126
+ });
127
+ xhr.addEventListener("error", () => reject(new Error("Network error")));
128
+ xhr.addEventListener("abort", () => reject(new Error("Upload aborted")));
129
+ xhr.send(body);
130
+ });
131
+ }
132
+
133
+ function parseJsonObjectFromText(text: string): Record<string, unknown> | null {
134
+ try {
135
+ const parsed = JSON.parse(text);
136
+ return isJsonObject(parsed) ? parsed : null;
137
+ } catch {
138
+ return null;
101
139
  }
102
- return btoa(ascii);
103
140
  }
104
141
 
105
142
  async function initiateUpload(file: File): Promise<InitiateResponse> {
143
+ // Note: no client-side SHA-256 here. Hashing the whole file would force a
144
+ // full File.arrayBuffer() read, which on mobile Safari pushes peak memory
145
+ // past the per-tab cap for large videos and gets the page killed.
146
+ // Server-side size + storage ETag are sufficient integrity checks.
106
147
  const res = await fetch(publicPath("/api/uploads/init"), {
107
148
  method: "POST",
108
149
  headers: { "Content-Type": "application/json" },
@@ -110,7 +151,6 @@ async function initiateUpload(file: File): Promise<InitiateResponse> {
110
151
  filename: file.name,
111
152
  contentType: file.type || "application/octet-stream",
112
153
  size: file.size,
113
- checksumSha256: await sha256Base64(file),
114
154
  }),
115
155
  });
116
156
 
@@ -196,24 +236,26 @@ async function uploadMultipartRelay(
196
236
  const end = Math.min(start + transport.partSize, file.size);
197
237
  const partNumber = i + 1;
198
238
  const chunk = file.slice(start, end);
199
- const response = await fetch(
239
+ const partBytes = end - start;
240
+ const response = await xhrPut(
200
241
  publicPath(`${transport.url}?partNumber=${partNumber}`),
201
- {
202
- method: "PUT",
203
- body: chunk,
242
+ chunk,
243
+ {},
244
+ (partProgress) => {
245
+ onProgress?.((uploadedBytes + partBytes * partProgress) / file.size);
204
246
  },
205
247
  );
206
248
  if (!response.ok) {
207
249
  throw new Error(`Failed to upload part ${partNumber}`);
208
250
  }
209
- const data = await readJsonObject(response);
210
- const uploadedPart = getJsonNumber(data, "partNumber");
211
- const etag = getJsonString(data, "etag");
251
+ const data = parseJsonObjectFromText(response.text);
252
+ const uploadedPart = data ? getJsonNumber(data, "partNumber") : null;
253
+ const etag = data ? getJsonString(data, "etag") : null;
212
254
  if (!uploadedPart || !etag) {
213
255
  throw new Error(`Failed to upload part ${partNumber}`);
214
256
  }
215
257
  parts.push({ partNumber: uploadedPart, etag });
216
- uploadedBytes += end - start;
258
+ uploadedBytes += partBytes;
217
259
  onProgress?.(uploadedBytes / file.size);
218
260
  }
219
261
 
@@ -229,24 +271,28 @@ export async function uploadViaSession(
229
271
 
230
272
  try {
231
273
  if (transport.kind === "put") {
232
- const response = await fetch(transport.url, {
233
- method: "PUT",
234
- headers: transport.headers,
235
- body: file,
236
- });
274
+ const response = await xhrPut(
275
+ transport.url,
276
+ file,
277
+ transport.headers,
278
+ onProgress,
279
+ );
237
280
  if (!response.ok) {
238
281
  throw new Error("Upload failed");
239
282
  }
240
283
  onProgress?.(1);
241
284
  } else if (transport.kind === "relay") {
242
- const response = await fetch(publicPath(transport.url), {
243
- method: "PUT",
244
- headers: { "Content-Type": file.type },
245
- body: file,
246
- });
285
+ const response = await xhrPut(
286
+ publicPath(transport.url),
287
+ file,
288
+ { "Content-Type": file.type },
289
+ onProgress,
290
+ );
247
291
  if (!response.ok) {
248
- const data = await readJsonObject(response);
249
- throw new Error(getJsonString(data, "error") ?? "Upload failed");
292
+ const data = parseJsonObjectFromText(response.text);
293
+ throw new Error(
294
+ (data && getJsonString(data, "error")) ?? "Upload failed",
295
+ );
250
296
  }
251
297
  onProgress?.(1);
252
298
  }
@@ -31,6 +31,7 @@ import "./client/collection-page-actions.js";
31
31
  import "./client/custom-url-menu.js";
32
32
  import "./client/components/jant-command-palette.js";
33
33
  import "./client/palette-shortcuts.js";
34
+ import "./client/palette-search-trigger.js";
34
35
 
35
36
  // Mount fullscreen overlay at body level to escape the dialog's containing
36
37
  // block (dialog animation creates a containing block that traps fixed children).
@@ -1773,17 +1773,13 @@ msgstr "Settings"
1773
1773
  msgid "Shared links"
1774
1774
  msgstr "Shared links"
1775
1775
 
1776
- #. @context: Button to collapse expanded thread context
1777
1776
  #. @context: Collapse reply context
1778
1777
  #: src/ui/compose/ComposeDialog.tsx
1779
- #: src/ui/feed/ThreadPreview.tsx
1780
1778
  msgid "Show less"
1781
1779
  msgstr "Show less"
1782
1780
 
1783
- #. @context: Button to expand faded thread context
1784
1781
  #. @context: Expand reply context
1785
1782
  #: src/ui/compose/ComposeDialog.tsx
1786
- #: src/ui/feed/ThreadPreview.tsx
1787
1783
  msgid "Show more"
1788
1784
  msgstr "Show more"
1789
1785
 
@@ -1770,17 +1770,13 @@ msgstr ""
1770
1770
  msgid "Shared links"
1771
1771
  msgstr ""
1772
1772
 
1773
- #. @context: Button to collapse expanded thread context
1774
1773
  #. @context: Collapse reply context
1775
1774
  #: src/ui/compose/ComposeDialog.tsx
1776
- #: src/ui/feed/ThreadPreview.tsx
1777
1775
  msgid "Show less"
1778
1776
  msgstr ""
1779
1777
 
1780
- #. @context: Button to expand faded thread context
1781
1778
  #. @context: Expand reply context
1782
1779
  #: src/ui/compose/ComposeDialog.tsx
1783
- #: src/ui/feed/ThreadPreview.tsx
1784
1780
  msgid "Show more"
1785
1781
  msgstr ""
1786
1782
 
@@ -1770,17 +1770,13 @@ msgstr ""
1770
1770
  msgid "Shared links"
1771
1771
  msgstr ""
1772
1772
 
1773
- #. @context: Button to collapse expanded thread context
1774
1773
  #. @context: Collapse reply context
1775
1774
  #: src/ui/compose/ComposeDialog.tsx
1776
- #: src/ui/feed/ThreadPreview.tsx
1777
1775
  msgid "Show less"
1778
1776
  msgstr ""
1779
1777
 
1780
- #. @context: Button to expand faded thread context
1781
1778
  #. @context: Expand reply context
1782
1779
  #: src/ui/compose/ComposeDialog.tsx
1783
- #: src/ui/feed/ThreadPreview.tsx
1784
1780
  msgid "Show more"
1785
1781
  msgstr ""
1786
1782
 
@@ -27,7 +27,7 @@ const aliasDomain: SiteDomain = {
27
27
  describe("hosted canonical redirects", () => {
28
28
  it("bypasses hosted redirects for admin and auth paths", () => {
29
29
  expect(
30
- shouldBypassHostedCanonicalRedirect("/.well-known/jant-domain-check"),
30
+ shouldBypassHostedCanonicalRedirect("/.well-known/jant-verification"),
31
31
  ).toBe(true);
32
32
  expect(shouldBypassHostedCanonicalRedirect("/signin")).toBe(true);
33
33
  expect(shouldBypassHostedCanonicalRedirect("/settings/account")).toBe(true);
@@ -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(),