From 6e8d77b11afbb4fdc83454a92b94e1060e29b699 Mon Sep 17 00:00:00 2001 From: Shibo Lyu Date: Wed, 30 Apr 2025 22:33:34 +0800 Subject: [PATCH] feat: Add identity description file URL utilities Add utilities to work with identity description file URLs, including validation of ID URL format and path construction. Also export these functions in the public API. --- identity/identityDescription.test.ts | 16 ++++++++++++++ identity/identityDescription.ts | 27 ++++++++++++++++++++++- identity/mod.ts | 12 +++++++++-- identity/profile.test.ts | 32 +++++++++++++++++++++++++++- identity/profile.ts | 17 ++++++++++++++- 5 files changed, 99 insertions(+), 5 deletions(-) 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; +}