@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.
- package/dist/{app-C8bKBHtv.js → app-B9XQDSoB.js} +113 -71
- package/dist/{app-DxnM9H8F.js → app-CHW6VVQt.js} +1 -1
- package/dist/client/.vite/manifest.json +1 -1
- package/dist/client/_assets/{client-auth-Ce5WEAVS.js → client-auth-DFDajqqT.js} +2 -2
- package/dist/index.js +1 -1
- package/dist/node.js +2 -2
- package/package.json +1 -1
- package/src/client/compose-bridge.ts +5 -0
- package/src/client/palette-search-trigger.ts +35 -0
- package/src/client-auth.ts +1 -0
- 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/site-admin.ts +62 -1
- package/src/ui/shared/MediaGallery.tsx +1 -1
- package/src/ui/shared/__tests__/media-gallery.test.ts +3 -1
|
@@ -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(),
|
|
@@ -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 {
|
|
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-
|
|
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-
|
|
62
|
+
const response = await app.request("/.well-known/jant-verification");
|
|
63
63
|
|
|
64
64
|
expect(response.status).toBe(400);
|
|
65
|
-
await expect(response.
|
|
66
|
-
error: "Missing nonce.",
|
|
67
|
-
});
|
|
65
|
+
await expect(response.text()).resolves.toBe("Missing nonce.");
|
|
68
66
|
});
|
|
69
67
|
|
|
70
|
-
it("returns
|
|
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
|
|
74
|
+
host,
|
|
75
75
|
secret,
|
|
76
76
|
});
|
|
77
77
|
|
|
78
78
|
const response = await app.request(
|
|
79
|
-
|
|
79
|
+
`/.well-known/jant-verification?nonce=${nonce}`,
|
|
80
80
|
);
|
|
81
|
-
const body = (await response.
|
|
82
|
-
const
|
|
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(
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
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 {
|
|
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-
|
|
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
|
|
15
|
+
throw new NotFoundError("Hosted domain verification endpoint");
|
|
16
16
|
}
|
|
17
17
|
|
|
18
18
|
if (!c.var.currentSiteDomain) {
|
|
19
|
-
throw new NotFoundError("Hosted domain
|
|
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.
|
|
24
|
+
return c.text("Missing nonce.", 400);
|
|
25
25
|
}
|
|
26
26
|
|
|
27
|
-
const
|
|
28
|
-
|
|
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.
|
|
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:
|
|
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) => {
|
|
@@ -50,7 +50,9 @@ describe("MediaGallery", () => {
|
|
|
50
50
|
);
|
|
51
51
|
|
|
52
52
|
expect(html).toContain("aspect-ratio:900/1600");
|
|
53
|
-
expect(html).toMatch(
|
|
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
|
|