1/**2 * Opt-in parser for a narrow custom-protocol collaborative-list deep link.3 *4 * @module5 */67/** A validated collaborative-list item reference, never an arbitrary redirect. */8export type CollaborativeListProtocolTarget = {9 readonly listId: string;10 readonly itemId: string;11};1213/** Stable rejection that never contains the received protocol URL. */14export type CollaborativeListProtocolIssue =15 | "missing-target"16 | "duplicate-target"17 | "unexpected-parameter"18 | "target-too-long"19 | "invalid-target"20 | "wrong-protocol"21 | "unsupported-shape"22 | "invalid-list-id"23 | "invalid-item-id";2425/** Accepted item reference or value-free rejection. */26export type CollaborativeListProtocolResult =27 | { ok: true; target: CollaborativeListProtocolTarget }28 | { ok: false; issue: CollaborativeListProtocolIssue };2930/** Options matching one `protocol_handlers` manifest entry. */31export type ParseCollaborativeListProtocolOptions = {32 /** Exact custom scheme, such as `web+lofi`. */33 protocol: string;34 /** Handler-route query parameter receiving `%s`. Defaults to `url`. */35 parameter?: string;36 /** Maximum decoded protocol URL length. Defaults to 512. */37 maxLength?: number;38};3940function configuredOptions(options: ParseCollaborativeListProtocolOptions): {41 protocol: string;42 parameter: string;43 maxLength: number;44} {45 if (!/^web\+[a-z]+$/.test(options.protocol)) {46 throw new TypeError("protocol must be a custom web+ scheme with lowercase ASCII letters");47 }48 const parameter = options.parameter ?? "url";49 if (!/^[a-z][a-z0-9_-]{0,31}$/.test(parameter)) {50 throw new TypeError("protocol query parameter must be a short lowercase name");51 }52 const maxLength = options.maxLength ?? 512;53 if (!Number.isSafeInteger(maxLength) || maxLength < 1 || maxLength > 4096) {54 throw new TypeError("protocol target length must be an integer from 1 to 4096");55 }56 return { protocol: options.protocol, parameter, maxLength };57}5859/**60 * Decode the handler query once and allow-list one collaborative-list item.61 *62 * Expected payload: `web+scheme:collaborative-list/LIST_ID/item/ITEM_ID`.63 * IDs are restricted to portable unreserved characters, and the result contains64 * identifiers only. The received URL is never returned or used as a redirect.65 */66export function parseCollaborativeListProtocolTarget(67 search: string | URLSearchParams,68 options: ParseCollaborativeListProtocolOptions,69): CollaborativeListProtocolResult {70 const configured = configuredOptions(options);71 const params = typeof search === "string" ? new URLSearchParams(search) : search;72 const unexpected = [...params.keys()].some((name) => name !== configured.parameter);73 if (unexpected) return { ok: false, issue: "unexpected-parameter" };74 const values = params.getAll(configured.parameter);75 if (values.length === 0) return { ok: false, issue: "missing-target" };76 if (values.length !== 1) return { ok: false, issue: "duplicate-target" };77 const value = values[0];78 if (value.length > configured.maxLength) return { ok: false, issue: "target-too-long" };79 // URLSearchParams has already decoded the query value. Reject remaining escapes80 // instead of applying a second decode with ambiguous semantics.81 if (value.includes("%")) return { ok: false, issue: "invalid-target" };82 let target: URL;83 try {84 target = new URL(value);85 } catch {86 return { ok: false, issue: "invalid-target" };87 }88 if (target.protocol !== `${configured.protocol}:`) {89 return { ok: false, issue: "wrong-protocol" };90 }91 if (target.search || target.hash || target.host || target.username || target.password) {92 return { ok: false, issue: "unsupported-shape" };93 }94 const segments = target.pathname.split("/");95 if (96 segments.length !== 4 || segments[0] !== "collaborative-list" || segments[2] !== "item"97 ) {98 return { ok: false, issue: "unsupported-shape" };99 }100 const identifier = /^[A-Za-z0-9_-]{1,64}$/;101 if (!identifier.test(segments[1])) return { ok: false, issue: "invalid-list-id" };102 if (!identifier.test(segments[3])) return { ok: false, issue: "invalid-item-id" };103 return { ok: true, target: { listId: segments[1], itemId: segments[3] } };104}