diff --git a/identity/identityDescription.test.ts b/identity/identityDescription.test.ts index 3b2c544..8823c32 100644 --- a/identity/identityDescription.test.ts +++ b/identity/identityDescription.test.ts @@ -1,11 +1,27 @@ import { type BlahIdentityDescription, blahIdentityDescriptionSchema, + getIdentityDescriptionFileURL, + identityDescriptionFilePath, } from "./identityDescription.ts"; import { assertTypeMatchesZodSchema } from "../test/utils.ts"; +import { expect } from "@std/expect"; Deno.test("type BlahIdentityDescription is accurate", () => { assertTypeMatchesZodSchema( blahIdentityDescriptionSchema, ); }); + +Deno.test("getIdentityDescriptionFileURL", () => { + expect(getIdentityDescriptionFileURL("https://lao.sb")).toBe( + "https://lao.sb" + identityDescriptionFilePath, + ); + + expect(getIdentityDescriptionFileURL("https://test.lao.sb")).toBe( + "https://test.lao.sb" + identityDescriptionFilePath, + ); + + expect(() => getIdentityDescriptionFileURL("https://trailing-slash.lao.sb/")) + .toThrow(); +}); diff --git a/identity/identityDescription.ts b/identity/identityDescription.ts index 5d4cc5b..40840bd 100644 --- a/identity/identityDescription.ts +++ b/identity/identityDescription.ts @@ -1,17 +1,42 @@ import { z } from "zod"; import { blahSignedPayloadSchemaOf } from "../crypto/signedPayload.ts"; import { type BlahActKeyRecord, blahActKeyRecordSchema } from "./actKey.ts"; -import { type BlahProfile, blahProfileSchema } from "./profile.ts"; +import { + type BlahProfile, + blahProfileSchema, + validateIDURLFormat, +} from "./profile.ts"; import type { BlahSignedPayload } from "../crypto/mod.ts"; +/** Schema for Blah identity description. */ export const blahIdentityDescriptionSchema = z.object({ id_key: z.string(), act_keys: z.array(blahSignedPayloadSchemaOf(blahActKeyRecordSchema)).min(1), profile: blahSignedPayloadSchemaOf(blahProfileSchema), }); +/** Type for Blah identity description. */ export type BlahIdentityDescription = { id_key: string; act_keys: Array>; profile: BlahSignedPayload; }; + +/** Path to the identity description file under a given ID URL. */ +export const identityDescriptionFilePath = "/.well-known/blah/identity.json"; + +/** + * Get the full URL to the identity description file for a given ID URL. + * + * @param idURL - The ID URL to get the identity description file URL for. + * @returns The full URL to the identity description file. + * @throws Error if the ID URL format is invalid. + */ +export function getIdentityDescriptionFileURL( + idURL: string, +): string { + if (!validateIDURLFormat(idURL)) throw new Error("Invalid ID URL format"); + const url = new URL(idURL); + url.pathname = identityDescriptionFilePath; + return url.toString(); +} diff --git a/identity/mod.ts b/identity/mod.ts index 337c8fb..39270cd 100644 --- a/identity/mod.ts +++ b/identity/mod.ts @@ -5,17 +5,25 @@ export * from "./identity.ts"; import { type BlahProfile, blahProfileSchema as internalBlahProfileSchema, + validateIDURLFormat, } from "./profile.ts"; const blahProfileSchema: z.ZodType = internalBlahProfileSchema; -export { type BlahProfile, blahProfileSchema }; +export { type BlahProfile, blahProfileSchema, validateIDURLFormat }; import { type BlahIdentityDescription, blahIdentityDescriptionSchema as internalBlahIdentityDescriptionSchema, + getIdentityDescriptionFileURL, + identityDescriptionFilePath, } from "./identityDescription.ts"; const blahIdentityDescriptionSchema: z.ZodType = internalBlahIdentityDescriptionSchema; -export { type BlahIdentityDescription, blahIdentityDescriptionSchema }; +export { + type BlahIdentityDescription, + blahIdentityDescriptionSchema, + getIdentityDescriptionFileURL, + identityDescriptionFilePath, +}; import { type BlahActKeyRecord, diff --git a/identity/profile.test.ts b/identity/profile.test.ts index ad0dda5..68b827f 100644 --- a/identity/profile.test.ts +++ b/identity/profile.test.ts @@ -1,6 +1,36 @@ -import { type BlahProfile, blahProfileSchema } from "./profile.ts"; +import { + type BlahProfile, + blahProfileSchema, + validateIDURLFormat, +} from "./profile.ts"; import { assertTypeMatchesZodSchema } from "../test/utils.ts"; +import { expect } from "@std/expect"; Deno.test("type BlahProfile is accurate", () => { assertTypeMatchesZodSchema(blahProfileSchema); }); + +Deno.test("ID URL format - valid", () => { + expect(validateIDURLFormat("https://lao.sb")).toBe(true); + expect(validateIDURLFormat("https://test.lao.sb")).toBe(true); + expect(validateIDURLFormat("https://🧧.lao.sb")).toBe(true); +}); + +Deno.test("ID URL format - invalid", () => { + // Must be valid URL + expect(validateIDURLFormat("lao.sb")).toBe(false); + // No trailing slash + expect(validateIDURLFormat("https://lao.sb/")).toBe(false); + // No search params + expect(validateIDURLFormat("https://lao.sb?query=1")).toBe(false); + // No fragment + expect(validateIDURLFormat("https://lao.sb#fragment")).toBe(false); + // No path + expect(validateIDURLFormat("https://lao.sb/path")).toBe(false); + // No username + expect(validateIDURLFormat("https://user@lao.sb")).toBe(false); + // No password + expect(validateIDURLFormat("https://user:123@lao.sb")).toBe(false); + // No non-HTTPS protocol + expect(validateIDURLFormat("http://lao.sb")).toBe(false); +}); diff --git a/identity/profile.ts b/identity/profile.ts index 07cf40d..f86fda4 100644 --- a/identity/profile.ts +++ b/identity/profile.ts @@ -1,13 +1,15 @@ import z from "zod"; +/** Schema for Blah user profile. */ export const blahProfileSchema = z.object({ typ: z.literal("profile"), preferred_chat_server_urls: z.array(z.string().url()), - id_urls: z.array(z.string().url()).min(1), + id_urls: z.array(z.string().refine(validateIDURLFormat)).min(1), name: z.string(), bio: z.string().optional(), }); +/** Type for Blah user profile. */ export type BlahProfile = { typ: "profile"; preferred_chat_server_urls: string[]; @@ -15,3 +17,16 @@ export type BlahProfile = { name: string; bio?: string; }; + +/** Validate the format of an ID URL. */ +export function validateIDURLFormat(url: string): boolean { + const idURL = URL.parse(url); + return !!idURL && + idURL.protocol === "https:" && + idURL.pathname === "/" && + !url.endsWith("/") && + !idURL.search && + !idURL.hash && + !idURL.username && + !idURL.password; +}