1// Connection shortcuts: the owner's list of apps on the Connect fold. Each2// is a connection template from connection-templates/ (the library, folded3// into gen/connections.ts) with a visibility and the template's inputs4// filled in, kept in settings as the ordered list the fold shows.5//6// GET /connect.json the shortcuts the asker may see, links resolved7//8// A template writes its links once, with placeholders: {relay:url},9// {owner:nprofile}, {user:npub}, {input:repo} and so on. The relay fills10// them in here, for whoever is looking, so one good template works on any11// relay and every client sees the same handoff. The door takes an optional12// NIP-98 signature: it decides which shortcuts the asker sees (public,13// signed in, members, owner) and fills the {user:*} placeholders. A link14// that needs a user is left out for a visitor, and the shortcut says so.15import { naddrEncode, npubEncode, nprofileEncode } from "nostr-tools/nip19";16import { CONNECTION_LIBRARY } from "./gen/connections.ts";17import { KIND_GROUP_METADATA } from "./kinds.ts";18import { FEATURE_NAMES, VISIBILITIES, featureOn, type Connection, type Feature, type Settings, type Visibility } from "./settings.ts";19import { whoAsks } from "./auth.ts";20import { QR_MAX_BYTES } from "./card.ts";21import type { Relay } from "./relay.ts";2223export const FORMAT = "bind.ws/connection-template/1";24// The console draws these; a template names one (console.js, IC).25export const ICONS = ["notes", "blog", "bookmark", "photo", "site", "git", "chat", "person", "files", "key", "search", "feed", "lock", "app"] as const;26export type Icon = (typeof ICONS)[number];27export const MAX_CONNECTIONS = 24;28const MAX_LINKS = 6;29const MAX_INPUTS = 4;30const MAX_TEXT = 2000;3132export interface ConnectionLink {33 label: string;34 href?: string; // a link to open: https://, or nostr: for the viewer's own app35 copy?: string; // text for the clipboard instead36}37export interface ConnectionInput {38 name: string; // fills {input:name}39 label: string;40 placeholder: string;41 default: string; // the value when the owner leaves it blank42 // A regular expression the owner's value must match, or "" for any text.43 // A value that lands in a URL path or a shell command names one, so what44 // the owner types cannot change the link or the command around it.45 pattern: string;46}47export interface ConnectionTemplate {48 name: string;49 title: string;50 about: string;51 app: string;52 where: string;53 icon: Icon;54 feature?: Feature; // shown only while this feature is on55 visibility: Visibility; // what a shortcut starts with56 links: ConnectionLink[];57 qr: string; // what the QR carries; "" for none58 inputs: ConnectionInput[];59}6061// What a placeholder may name, by source. {input:*} names one of the62// template's own inputs instead.63export const PLACEHOLDER_FIELDS: Record<"relay" | "owner" | "user", string[]> = {64 relay: ["url", "host", "web", "name", "domain", "hex", "npub", "nprofile", "naddr"],65 owner: ["hex", "npub", "nprofile"],66 user: ["hex", "npub", "nprofile"],67};68const PLACEHOLDER = /\{(relay|owner|user|input):([a-z][a-z0-9_-]*)(\|enc)?\}/g;69const INPUT_NAME = /^[a-z][a-z0-9_-]{0,23}$/;70const str = (v: unknown, max: number) => (typeof v === "string" ? v.slice(0, max) : "");7172// placeholders lists what a text names, as source:field pairs.73export function placeholders(text: string): { source: string; field: string }[] {74 return [...text.matchAll(PLACEHOLDER)].map((m) => ({ source: m[1], field: m[2] }));75}7677// checkPlaceholders is the reason a text names something the relay cannot78// fill, or "" when every placeholder is known.79function checkPlaceholders(text: string, inputs: string[]): string {80 for (const { source, field } of placeholders(text)) {81 if (source === "input") {82 if (!inputs.includes(field)) return `names {input:${field}} but declares no such input`;83 } else if (!PLACEHOLDER_FIELDS[source as keyof typeof PLACEHOLDER_FIELDS].includes(field)) return `names {${source}:${field}}, which is not one of ${PLACEHOLDER_FIELDS[source as keyof typeof PLACEHOLDER_FIELDS].map((f) => source + ":" + f).join(", ")}`;84 }85 return "";86}8788// safeHref says whether a resolved link may be shown: the web, or a nostr:89// URI for the viewer's own app. An owner's input cannot smuggle another90// scheme in. Plain http is admitted here and not in a template, since91// {relay:web} resolves to it on a local dev host.92export const safeHref = (href: string) => /^(https?:\/\/|nostr:)/i.test(href);93const templateHref = (href: string) => /^(https:\/\/|nostr:)/i.test(href);9495// parseConnectionTemplate reads one library document and returns the96// template, or the reason it is not one. The build check runs the same97// function, so a bad file fails typecheck instead of the fold.98export function parseConnectionTemplate(name: string, raw: unknown): ConnectionTemplate | string {99 const d = raw as Record<string, unknown>;100 if (!d || typeof d !== "object" || Array.isArray(d)) return "not a connection template";101 if (d.format !== FORMAT) return "format must be " + FORMAT;102 const title = str(d.title, 40).trim();103 const about = str(d.about, 200).trim();104 const app = str(d.app, 40).trim();105 if (!title || !about || !app) return "needs a title, an about and an app";106 const icon = d.icon === undefined ? "app" : d.icon;107 if (!(ICONS as readonly unknown[]).includes(icon)) return `icon must be one of ${ICONS.join(", ")}`;108 if (d.feature !== undefined && !(FEATURE_NAMES as readonly unknown[]).includes(d.feature)) return `feature must be one of ${FEATURE_NAMES.join(", ")}`;109 const visibility = d.visibility === undefined ? "public" : d.visibility;110 if (!(VISIBILITIES as readonly unknown[]).includes(visibility)) return `visibility must be one of ${VISIBILITIES.join(", ")}`;111 const inputs: ConnectionInput[] = [];112 if (d.inputs !== undefined) {113 if (!Array.isArray(d.inputs) || d.inputs.length > MAX_INPUTS) return `inputs must be a list of at most ${MAX_INPUTS}`;114 for (const x of d.inputs as unknown[]) {115 const i = (x ?? {}) as Record<string, unknown>;116 const iname = str(i.name, 24);117 if (!INPUT_NAME.test(iname)) return "an input name is lowercase letters, digits, dash and underscore";118 if (inputs.some((o) => o.name === iname)) return `input ${iname} is declared twice`;119 const label = str(i.label, 60).trim();120 if (!label) return `input ${iname} needs a label`;121 const pattern = str(i.pattern, 200);122 if (pattern) {123 try {124 new RegExp(pattern);125 } catch {126 return `input ${iname} has a pattern that does not compile`;127 }128 }129 inputs.push({ name: iname, label, placeholder: str(i.placeholder, 200), default: str(i.default, 500), pattern });130 }131 }132 const names = inputs.map((i) => i.name);133 if (!Array.isArray(d.links) || d.links.length === 0 || d.links.length > MAX_LINKS) return `links must be a list of 1 to ${MAX_LINKS}`;134 const links: ConnectionLink[] = [];135 for (const x of d.links as unknown[]) {136 const l = (x ?? {}) as Record<string, unknown>;137 const label = str(l.label, 40).trim();138 if (!label) return "a link needs a label";139 const href = typeof l.href === "string" ? l.href.trim() : "";140 const copy = typeof l.copy === "string" ? l.copy : "";141 if ((href === "") === (copy === "")) return `link ${label} needs an href or a copy text, not both`;142 if (href.length > MAX_TEXT || copy.length > MAX_TEXT) return `link ${label} is longer than ${MAX_TEXT} characters`;143 if (href && !templateHref(href)) return `link ${label} must be https:// or nostr:`;144 const bad = checkPlaceholders(href || copy, names);145 if (bad) return `link ${label} ${bad}`;146 links.push(href ? { label, href } : { label, copy });147 }148 const qr = d.qr === undefined ? (links.find((l) => l.href)?.href ?? links[0].copy ?? "") : str(d.qr, MAX_TEXT).trim();149 if (qr) {150 const bad = checkPlaceholders(qr, names);151 if (bad) return `qr ${bad}`;152 }153 const out: ConnectionTemplate = { name, title, about, app, where: str(d.where, 40).trim(), icon: icon as Icon, visibility: visibility as Visibility, links, qr, inputs };154 if (d.feature !== undefined) out.feature = d.feature as Feature;155 return out;156}157158export const CONNECTION_TEMPLATES: ConnectionTemplate[] = CONNECTION_LIBRARY.map(({ name, document }) => {159 const t = parseConnectionTemplate(name, document);160 if (typeof t === "string") throw new Error(`connection template ${name}: ${t}`);161 return t;162});163164export function findConnectionTemplate(name: string): ConnectionTemplate | undefined {165 return CONNECTION_TEMPLATES.find((t) => t.name === name);166}167168// parseConnections reads an owner's list, from setconnections or a169// configuration document: each entry names a template, and may set a170// visibility, a title, an about and the template's inputs. What no relay171// would take is dropped, one warning each. Not a list at all is the reason.172export function parseConnections(raw: unknown, label = "connections"): { list: Connection[]; warnings: string[] } | string {173 if (!Array.isArray(raw)) return `invalid: ${label} must be a list`;174 const warnings: string[] = [];175 const list: Connection[] = [];176 for (const [i, x] of raw.entries()) {177 const c = (x ?? {}) as Record<string, unknown>;178 const t = typeof c.template === "string" ? findConnectionTemplate(c.template) : undefined;179 if (!t) {180 warnings.push(`${label}[${i}]: no connection template named ${typeof c.template === "string" ? c.template : "(none)"}`);181 continue;182 }183 if (list.length === MAX_CONNECTIONS) {184 warnings.push(`${label}[${i}]: at most ${MAX_CONNECTIONS} connections`);185 break;186 }187 const out: Connection = { template: t.name, visibility: t.visibility };188 if (c.visibility !== undefined) {189 if ((VISIBILITIES as readonly unknown[]).includes(c.visibility)) out.visibility = c.visibility as Visibility;190 else warnings.push(`${label}[${i}].visibility: must be one of ${VISIBILITIES.join(", ")}`);191 }192 const title = str(c.title, 40).trim();193 if (title) out.title = title;194 const about = str(c.about, 200).trim();195 if (about) out.about = about;196 if (c.inputs !== undefined) {197 if (!c.inputs || typeof c.inputs !== "object" || Array.isArray(c.inputs)) warnings.push(`${label}[${i}].inputs: must be an object of input name to value`);198 else {199 const inputs: Record<string, string> = {};200 for (const [k, v] of Object.entries(c.inputs as Record<string, unknown>)) {201 const declared = t.inputs.find((d) => d.name === k);202 if (!declared) warnings.push(`${label}[${i}].inputs.${k}: ${t.name} has no such input`);203 else if (typeof v !== "string") warnings.push(`${label}[${i}].inputs.${k}: must be a string`);204 else if (v.trim() === "") continue;205 else if (declared.pattern && !new RegExp(declared.pattern).test(v.trim().slice(0, 500))) warnings.push(`${label}[${i}].inputs.${k}: must match ${declared.pattern}`);206 else inputs[k] = v.trim().slice(0, 500);207 }208 if (Object.keys(inputs).length) out.inputs = inputs;209 }210 }211 list.push(out);212 }213 return { list, warnings };214}215216// The values a placeholder may take, by source; user is null for a visitor.217export interface Values {218 relay: Record<string, string>;219 owner: Record<string, string>;220 user: Record<string, string> | null;221}222223// connectionValues is what this relay fills in for a given Host header and224// viewer. A value the relay does not have yet (no identity, no owner) is225// "", and a link that names it is left out rather than shown broken.226export function connectionValues(relay: Relay, host: string, user: string | null): Values {227 const p = relay.settings.policy;228 const url = relay.relayURL(host);229 const self = relay.identity.pubkey;230 const profile = (pk: string) => (pk ? nprofileEncode({ pubkey: pk, relays: [url] }) : "");231 const npub = (pk: string) => (pk ? npubEncode(pk) : "");232 return {233 relay: { url, host, web: relay.webURL(host), name: relay.slug, domain: relay.domain, hex: self, npub: npub(self), nprofile: profile(self), naddr: self ? naddrEncode({ kind: KIND_GROUP_METADATA, pubkey: self, identifier: relay.slug, relays: [url] }) : "" },234 owner: { hex: p.owner, npub: npub(p.owner), nprofile: profile(p.owner) },235 user: user ? { hex: user, npub: npub(user), nprofile: profile(user) } : null,236 };237}238239// fill resolves a text's placeholders and lists the ones it could not, as240// source:field, so the caller can tell a visitor's missing user from a241// relay that has no owner yet.242export function fill(text: string, values: Values, inputs: Record<string, string>): { text: string; missing: string[] } {243 const missing: string[] = [];244 const out = text.replace(PLACEHOLDER, (_, source: string, field: string, enc: string | undefined) => {245 const v = source === "input" ? (inputs[field] ?? "") : source === "user" ? (values.user?.[field] ?? "") : (values[source as "relay" | "owner"][field] ?? "");246 if (v === "") {247 missing.push(source + ":" + field);248 return "";249 }250 return enc ? encodeURIComponent(v) : v;251 });252 return { text: out, missing };253}254255// visibleTo applies a shortcut's visibility to the pubkeys the asker proved.256export function visibleTo(v: Visibility, s: Settings, pubkeys: string[]): boolean {257 switch (v) {258 case "public":259 return true;260 case "auth":261 return pubkeys.length > 0;262 case "members":263 return pubkeys.some((pk) => s.isAllowed(pk));264 case "owner":265 return pubkeys.some((pk) => s.isOwner(pk));266 }267}268269// A shortcut as the fold shows it: the template's words unless the owner270// changed them, and the links that resolved for this viewer.271export interface ResolvedConnection {272 template: string;273 title: string;274 about: string;275 app: string;276 where: string;277 icon: Icon;278 visibility: Visibility;279 links: ConnectionLink[];280 qr: string; // "" when nothing fits in a QR281 needsUser: boolean; // a link was left out because nobody is signed in282}283284// resolveConnections is the owner's list as this viewer sees it: what285// their visibility admits, what the features on allow, links filled in.286export function resolveConnections(relay: Relay, host: string, pubkeys: string[]): ResolvedConnection[] {287 const s = relay.settings;288 const values = connectionValues(relay, host, pubkeys[0] ?? null);289 const out: ResolvedConnection[] = [];290 for (const c of s.connections()) {291 const t = findConnectionTemplate(c.template);292 if (!t) continue;293 if (t.feature && !featureOn(s.policy, t.feature)) continue;294 if (!visibleTo(c.visibility, s, pubkeys)) continue;295 const inputs: Record<string, string> = {};296 for (const i of t.inputs) inputs[i.name] = c.inputs?.[i.name] || i.default;297 let needsUser = false;298 const links: ConnectionLink[] = [];299 for (const l of t.links) {300 const r = fill(l.href ?? l.copy ?? "", values, inputs);301 if (r.missing.length) {302 if (r.missing.some((m) => m.startsWith("user:"))) needsUser = true;303 continue;304 }305 if (l.href) {306 if (safeHref(r.text)) links.push({ label: l.label, href: r.text });307 } else links.push({ label: l.label, copy: r.text });308 }309 if (links.length === 0 && !needsUser) continue;310 const qr = t.qr ? fill(t.qr, values, inputs) : { text: "", missing: [] };311 const qrText = qr.missing.length === 0 && new TextEncoder().encode(qr.text).length <= QR_MAX_BYTES ? qr.text : "";312 out.push({ template: t.name, title: c.title || t.title, about: c.about || t.about, app: t.app, where: t.where, icon: t.icon, visibility: c.visibility, links, qr: qrText, needsUser });313 }314 return out;315}316317// connectDoor answers GET /connect.json for whoever asks.318export function connectDoor(relay: Relay, req: Request, url: URL): Response {319 const who = whoAsks(req, "", null);320 const cors = { "access-control-allow-origin": "*" };321 if (typeof who === "string") return Response.json({ error: who }, { status: 401, headers: cors });322 const values = connectionValues(relay, url.host, null);323 const signed = who.pubkeys.length > 0;324 return Response.json(325 { relay: { url: values.relay.url, host: values.relay.host, web: values.relay.web, name: values.relay.name }, viewer: who.pubkeys[0] ?? null, connections: resolveConnections(relay, url.host, who.pubkeys) },326 { headers: { ...cors, "cache-control": signed ? "no-store" : "public, max-age=60" } },327 );328}