@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.
- package/dist/{app-C7CtIQM-.js → app-DQgkp6yV.js} +155 -130
- package/dist/app-DYJLFZaM.js +6 -0
- package/dist/client/.vite/manifest.json +3 -3
- package/dist/client/_assets/client-BbJ0FhON.css +2 -0
- package/dist/client/_assets/client-DqsPJKiP.js +272 -0
- package/dist/client/_assets/{client-auth-Ce5WEAVS.js → client-auth-N6fiJcOg.js} +82 -82
- package/dist/{export-ZBlfKSKm.js → export-DwH3ga3Y.js} +2 -2
- package/dist/{github-sync-C593r22F.js → github-sync-D2FO19Re.js} +2 -2
- package/dist/{github-sync-bL1hnx3Q.js → github-sync-eHOTYZGO.js} +1 -1
- package/dist/index.js +3 -3
- package/dist/node.js +4 -4
- package/package.json +1 -1
- package/src/client/__tests__/compose-shortcuts.test.ts +1 -4
- package/src/client/components/__tests__/jant-compose-dialog.test.ts +1 -1
- package/src/client/components/__tests__/jant-media-lightbox.test.ts +89 -0
- package/src/client/components/compose-types.ts +6 -1
- package/src/client/components/jant-compose-dialog.ts +2 -0
- package/src/client/components/jant-compose-editor.ts +2 -1
- package/src/client/components/jant-media-lightbox.ts +33 -10
- package/src/client/compose-bridge.ts +88 -25
- package/src/client/compose-launch.ts +0 -13
- package/src/client/palette-search-trigger.ts +35 -0
- package/src/client/thread-context.ts +1 -140
- package/src/client/upload-session.ts +77 -31
- package/src/client-auth.ts +1 -0
- package/src/i18n/locales/public/en.po +0 -4
- package/src/i18n/locales/public/zh-Hans.po +0 -4
- package/src/i18n/locales/public/zh-Hant.po +0 -4
- package/src/lib/__tests__/hosted-domain.test.ts +1 -1
- package/src/lib/hosted-domain-check.ts +21 -80
- package/src/lib/hosted-domain.ts +1 -1
- package/src/routes/api/internal/__tests__/sites.test.ts +168 -0
- package/src/routes/api/internal/sites.ts +63 -10
- package/src/routes/hosted/__tests__/domain-check.test.ts +30 -19
- package/src/routes/hosted/domain-check.ts +9 -14
- package/src/services/export-theme/assets/client-site.js +1 -1
- package/src/services/site-admin.ts +62 -1
- package/src/styles/tokens.css +0 -1
- package/src/styles/ui.css +0 -71
- package/src/ui/feed/ThreadPreview.tsx +34 -65
- package/src/ui/feed/__tests__/thread-preview.test.ts +64 -58
- package/src/ui/feed/thread-preview-state.ts +0 -48
- package/dist/app-CIx9SSOi.js +0 -6
- package/dist/client/_assets/client-BoUn7xBo.css +0 -2
- 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
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
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
|
|
239
|
+
const partBytes = end - start;
|
|
240
|
+
const response = await xhrPut(
|
|
200
241
|
publicPath(`${transport.url}?partNumber=${partNumber}`),
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
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 =
|
|
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 +=
|
|
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
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
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
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
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 =
|
|
249
|
-
throw new Error(
|
|
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
|
}
|
package/src/client-auth.ts
CHANGED
|
@@ -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-
|
|
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
|
-
|
|
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
|
|
16
|
-
let
|
|
4
|
+
function bytesToHex(bytes: Uint8Array): string {
|
|
5
|
+
let out = "";
|
|
17
6
|
for (const byte of bytes) {
|
|
18
|
-
|
|
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
|
-
|
|
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
|
-
|
|
44
|
-
|
|
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
|
-
|
|
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
|
}
|
package/src/lib/hosted-domain.ts
CHANGED
|
@@ -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:
|
|
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(),
|