feat: add WebFinger resource discovery (RFC 7033)

Implemented complete WebFinger support for resource discovery with dual-mode configuration:

## Core Implementation

### Types (src/types.ts)
- WebFingerConfig: Main configuration with static resources + content collections
- WebFingerResource: JRD (JSON Resource Descriptor) structure
- WebFingerLink: RFC 7033 compliant link relations
- Content collection integration with builder functions

### Generator (src/generators/webfinger.ts)
- RFC 7033 compliant JRD generation
- Static resource lookup from config
- Async content collection integration
- Template variable replacement: {slug}, {id}, {data.fieldName}, {siteURL}
- Optional rel filtering for link relations

### Route Handler (src/routes/webfinger.ts)
- Dynamic route at /.well-known/webfinger
- Required 'resource' query parameter validation
- Optional 'rel' parameter filtering (can be multiple)
- Proper RFC 7033 compliance:
  - CORS headers (Access-Control-Allow-Origin: *)
  - Media type: application/jrd+json
  - Error responses (400, 404, 500)
- Configurable caching (default: 1 hour)

### Integration Updates
- Added webfinger to DiscoveryConfig
- Route injection when enabled (prerender: false for dynamic queries)
- Build output notification: "/.well-known/webfinger (dynamic)"
- Caching config: webfinger: 3600 (1 hour)

## Features

**Static Resources Mode:**
Configure resources directly in astro.config.mjs for simple use cases.

**Content Collection Mode:**
Automatically expose content collections (team, authors, etc.) via WebFinger
with customizable template URIs and link builders.

**RFC 7033 Compliance:**
- HTTPS required (dev mode allows HTTP)
- Proper JRD format with subject, aliases, properties, links
- CORS support for cross-origin discovery
- Rel filtering for targeted link queries

Opt-in by default (enabled: false) - requires explicit configuration.
This commit is contained in:
Ryan Malloy 2025-11-03 09:05:17 -07:00
parent a686b22417
commit a485092767
5 changed files with 332 additions and 7 deletions

153
src/generators/webfinger.ts Normal file
View File

@ -0,0 +1,153 @@
import type { WebFingerConfig, WebFingerResource } from '../types.js';
/**
* Generate WebFinger JRD (JSON Resource Descriptor) response
* RFC 7033: https://datatracker.ietf.org/doc/html/rfc7033
*
* @param config - WebFinger configuration
* @param requestedResource - The resource being queried (from ?resource= param)
* @param requestedRels - Optional rel filters (from ?rel= params)
* @param siteURL - Site base URL
* @param getCollectionData - Optional function to fetch collection data
* @returns JRD JSON string or null if resource not found
*/
export async function generateWebFingerJRD(
config: WebFingerConfig,
requestedResource: string,
requestedRels: string[] | undefined,
siteURL: URL,
getCollectionData?: (collectionName: string) => Promise<any[]>
): Promise<string | null> {
// First, try to find in static resources
const staticResource = config.resources?.find(
(r) => r.resource === requestedResource
);
if (staticResource) {
return buildJRD(staticResource, requestedRels);
}
// Then, try content collections
if (config.collections && getCollectionData) {
for (const collectionConfig of config.collections) {
const entries = await getCollectionData(collectionConfig.name);
for (const entry of entries) {
// Replace template variables (e.g., {slug}, {id}, {data.email})
const resourceUri = replaceTemplate(
collectionConfig.resourceTemplate,
entry,
siteURL
);
if (resourceUri === requestedResource) {
// Found matching entry - build JRD from collection entry
const resource: WebFingerResource = {
resource: requestedResource,
subject: collectionConfig.subjectTemplate
? replaceTemplate(collectionConfig.subjectTemplate, entry, siteURL)
: requestedResource,
};
// Build aliases if configured
if (collectionConfig.aliasesBuilder) {
resource.aliases = collectionConfig.aliasesBuilder(entry);
}
// Build properties if configured
if (collectionConfig.propertiesBuilder) {
resource.properties = collectionConfig.propertiesBuilder(entry);
}
// Build links if configured
if (collectionConfig.linksBuilder) {
resource.links = collectionConfig.linksBuilder(entry);
}
return buildJRD(resource, requestedRels);
}
}
}
}
// Resource not found
return null;
}
/**
* Build JRD JSON from a WebFingerResource
*/
function buildJRD(
resource: WebFingerResource,
requestedRels?: string[]
): string {
const jrd: any = {
subject: resource.subject || resource.resource,
};
// Add aliases if present
if (resource.aliases && resource.aliases.length > 0) {
jrd.aliases = resource.aliases;
}
// Add properties if present
if (resource.properties && Object.keys(resource.properties).length > 0) {
jrd.properties = resource.properties;
}
// Add links with optional rel filtering
if (resource.links && resource.links.length > 0) {
let links = resource.links;
// Filter by rel if requested
if (requestedRels && requestedRels.length > 0) {
links = links.filter((link) => requestedRels.includes(link.rel));
}
if (links.length > 0) {
jrd.links = links;
}
}
return JSON.stringify(jrd, null, 2);
}
/**
* Replace template variables in a string
* Supports: {slug}, {id}, {data.fieldName}
*/
function replaceTemplate(
template: string,
entry: any,
siteURL: URL
): string {
return template.replace(/\{([^}]+)\}/g, (match, path) => {
// Handle {siteURL}
if (path === 'siteURL') {
return siteURL.hostname;
}
// Handle {slug}, {id}
if (path === 'slug' || path === 'id') {
return entry[path] || match;
}
// Handle {data.fieldName}
if (path.startsWith('data.')) {
const fieldPath = path.substring(5).split('.');
let value: any = entry.data;
for (const field of fieldPath) {
if (value && typeof value === 'object' && field in value) {
value = value[field];
} else {
return match; // Field not found, return original
}
}
return String(value);
}
return match;
});
}

View File

@ -119,6 +119,14 @@ export default function discovery(
prerender: true
});
}
if (config.webfinger && config.webfinger.enabled !== false) {
injectRoute({
pattern: '/.well-known/webfinger',
entrypoint: '@astrojs/discovery/routes/webfinger',
prerender: false // Dynamic - requires query params
});
}
},
'astro:build:done': () => {
@ -140,6 +148,9 @@ export default function discovery(
if (config.canary && config.canary.enabled !== false) {
console.log(' ✅ /.well-known/canary.txt');
}
if (config.webfinger && config.webfinger.enabled !== false) {
console.log(' ✅ /.well-known/webfinger (dynamic)');
}
console.log(' ✅ /sitemap-index.xml');
console.log('');
@ -157,6 +168,9 @@ export type {
SecurityConfig,
CanaryConfig,
CanaryStatement,
WebFingerConfig,
WebFingerResource,
WebFingerLink,
SitemapConfig,
CachingConfig,
TemplateConfig,

95
src/routes/webfinger.ts Normal file
View File

@ -0,0 +1,95 @@
import type { APIRoute } from 'astro';
import { generateWebFingerJRD } from '../generators/webfinger.js';
// @ts-ignore - Virtual module
import config from 'virtual:@astrojs/discovery/config';
/**
* API route for /.well-known/webfinger (RFC 7033)
*
* Query parameters:
* - resource (required): The URI being queried
* - rel (optional, can be multiple): Link relation type filter
*/
export const GET: APIRoute = async ({ url, site }) => {
const webfingerConfig = config.webfinger || {};
const siteURL = site || new URL('http://localhost:4321');
// RFC 7033 requires the 'resource' parameter
const resource = url.searchParams.get('resource');
if (!resource) {
return new Response(
JSON.stringify({
error: 'Bad Request',
message: 'Missing required "resource" query parameter',
}),
{
status: 400,
headers: {
'Content-Type': 'application/json',
'Access-Control-Allow-Origin': '*', // RFC 7033 recommends CORS
},
}
);
}
// Parse optional 'rel' parameters (can be multiple)
const rels = url.searchParams.getAll('rel');
const requestedRels = rels.length > 0 ? rels : undefined;
try {
// Generate JRD (with potential content collection integration)
const jrd = await generateWebFingerJRD(
webfingerConfig,
resource,
requestedRels,
siteURL,
// TODO: Implement content collection fetcher
undefined
);
if (!jrd) {
return new Response(
JSON.stringify({
error: 'Not Found',
message: `Resource "${resource}" not found`,
}),
{
status: 404,
headers: {
'Content-Type': 'application/json',
'Access-Control-Allow-Origin': '*',
},
}
);
}
// Get cache duration (default: 1 hour)
const cacheSeconds = config.caching?.webfinger ?? 3600;
return new Response(jrd, {
status: 200,
headers: {
'Content-Type': 'application/jrd+json', // RFC 7033 media type
'Cache-Control': `public, max-age=${cacheSeconds}`,
'Access-Control-Allow-Origin': '*', // RFC 7033 recommends CORS
},
});
} catch (error) {
console.error('[webfinger] Error generating JRD:', error);
return new Response(
JSON.stringify({
error: 'Internal Server Error',
message: 'Failed to generate WebFinger response',
}),
{
status: 500,
headers: {
'Content-Type': 'application/json',
'Access-Control-Allow-Origin': '*',
},
}
);
}
};
export const prerender = false; // Dynamic route - needs query params

View File

@ -12,6 +12,8 @@ export interface DiscoveryConfig {
security?: SecurityConfig;
/** Configuration for canary.txt generation (warrant canary) */
canary?: CanaryConfig;
/** Configuration for WebFinger resource discovery (RFC 7033) */
webfinger?: WebFingerConfig;
/** Configuration passed to @astrojs/sitemap */
sitemap?: SitemapConfig;
/** HTTP cache control configuration */
@ -134,6 +136,8 @@ export interface CachingConfig {
security?: number;
/** Cache duration for canary.txt (default: 3600) */
canary?: number;
/** Cache duration for webfinger (default: 3600) */
webfinger?: number;
/** Cache duration for sitemap (default: 3600) */
sitemap?: number;
}
@ -301,6 +305,63 @@ export interface CanaryStatement {
received: boolean;
}
/**
* Configuration for WebFinger resource discovery (RFC 7033)
*/
export interface WebFingerConfig {
/** Enable/disable WebFinger generation (default: false - opt-in) */
enabled?: boolean;
/** Static resources to expose via WebFinger */
resources?: WebFingerResource[];
/** Content collection integration */
collections?: {
/** Collection name (e.g., 'team', 'authors') */
name: string;
/** Resource URI template (e.g., 'acct:{slug}@example.com') */
resourceTemplate: string;
/** Subject URI template (defaults to resourceTemplate) */
subjectTemplate?: string;
/** Function to generate links for a collection entry */
linksBuilder?: (entry: any) => WebFingerLink[];
/** Function to generate aliases for a collection entry */
aliasesBuilder?: (entry: any) => string[];
/** Function to generate properties for a collection entry */
propertiesBuilder?: (entry: any) => Record<string, string | null>;
}[];
}
/**
* WebFinger resource (JRD - JSON Resource Descriptor)
*/
export interface WebFingerResource {
/** Resource URI (e.g., 'acct:alice@example.com', 'https://example.com/user/alice') */
resource: string;
/** Subject URI (defaults to resource if not provided) */
subject?: string;
/** Alternative URIs for the same resource */
aliases?: string[];
/** Properties as name-value pairs (names must be URIs) */
properties?: Record<string, string | null>;
/** Links to related resources */
links?: WebFingerLink[];
}
/**
* WebFinger link relation
*/
export interface WebFingerLink {
/** Link relation type (URI or IANA-registered relation) */
rel: string;
/** Target URI for the link */
href?: string;
/** Media type of the target resource */
type?: string;
/** Human-readable titles with language tags */
titles?: Record<string, string>;
/** Additional link-specific properties */
properties?: Record<string, string | null>;
}
/**
* Sitemap item interface (from @astrojs/sitemap)
*/

View File

@ -3,7 +3,7 @@ import type { DiscoveryConfig } from '../types.js';
/**
* Default configuration values
*/
const DEFAULT_CONFIG: Required<Omit<DiscoveryConfig, 'templates' | 'security' | 'canary'>> & { templates?: DiscoveryConfig['templates']; security?: DiscoveryConfig['security']; canary?: DiscoveryConfig['canary'] } = {
const DEFAULT_CONFIG: Required<Omit<DiscoveryConfig, 'templates' | 'security' | 'canary' | 'webfinger'>> & { templates?: DiscoveryConfig['templates']; security?: DiscoveryConfig['security']; canary?: DiscoveryConfig['canary']; webfinger?: DiscoveryConfig['webfinger'] } = {
robots: {
enabled: true,
crawlDelay: 1,
@ -25,6 +25,7 @@ const DEFAULT_CONFIG: Required<Omit<DiscoveryConfig, 'templates' | 'security' |
humans: 86400, // 24 hours
security: 86400, // 24 hours
canary: 3600, // 1 hour
webfinger: 3600, // 1 hour
sitemap: 3600, // 1 hour
},
};
@ -55,6 +56,7 @@ export function validateConfig(userConfig: DiscoveryConfig = {}): DiscoveryConfi
},
security: userConfig.security,
canary: userConfig.canary,
webfinger: userConfig.webfinger,
sitemap: {
...DEFAULT_CONFIG.sitemap,
...userConfig.sitemap,