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:
parent
a686b22417
commit
a485092767
153
src/generators/webfinger.ts
Normal file
153
src/generators/webfinger.ts
Normal 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;
|
||||||
|
});
|
||||||
|
}
|
||||||
14
src/index.ts
14
src/index.ts
@ -119,6 +119,14 @@ export default function discovery(
|
|||||||
prerender: true
|
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': () => {
|
'astro:build:done': () => {
|
||||||
@ -140,6 +148,9 @@ export default function discovery(
|
|||||||
if (config.canary && config.canary.enabled !== false) {
|
if (config.canary && config.canary.enabled !== false) {
|
||||||
console.log(' ✅ /.well-known/canary.txt');
|
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(' ✅ /sitemap-index.xml');
|
||||||
console.log('');
|
console.log('');
|
||||||
@ -157,6 +168,9 @@ export type {
|
|||||||
SecurityConfig,
|
SecurityConfig,
|
||||||
CanaryConfig,
|
CanaryConfig,
|
||||||
CanaryStatement,
|
CanaryStatement,
|
||||||
|
WebFingerConfig,
|
||||||
|
WebFingerResource,
|
||||||
|
WebFingerLink,
|
||||||
SitemapConfig,
|
SitemapConfig,
|
||||||
CachingConfig,
|
CachingConfig,
|
||||||
TemplateConfig,
|
TemplateConfig,
|
||||||
|
|||||||
95
src/routes/webfinger.ts
Normal file
95
src/routes/webfinger.ts
Normal 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
|
||||||
61
src/types.ts
61
src/types.ts
@ -12,6 +12,8 @@ export interface DiscoveryConfig {
|
|||||||
security?: SecurityConfig;
|
security?: SecurityConfig;
|
||||||
/** Configuration for canary.txt generation (warrant canary) */
|
/** Configuration for canary.txt generation (warrant canary) */
|
||||||
canary?: CanaryConfig;
|
canary?: CanaryConfig;
|
||||||
|
/** Configuration for WebFinger resource discovery (RFC 7033) */
|
||||||
|
webfinger?: WebFingerConfig;
|
||||||
/** Configuration passed to @astrojs/sitemap */
|
/** Configuration passed to @astrojs/sitemap */
|
||||||
sitemap?: SitemapConfig;
|
sitemap?: SitemapConfig;
|
||||||
/** HTTP cache control configuration */
|
/** HTTP cache control configuration */
|
||||||
@ -134,6 +136,8 @@ export interface CachingConfig {
|
|||||||
security?: number;
|
security?: number;
|
||||||
/** Cache duration for canary.txt (default: 3600) */
|
/** Cache duration for canary.txt (default: 3600) */
|
||||||
canary?: number;
|
canary?: number;
|
||||||
|
/** Cache duration for webfinger (default: 3600) */
|
||||||
|
webfinger?: number;
|
||||||
/** Cache duration for sitemap (default: 3600) */
|
/** Cache duration for sitemap (default: 3600) */
|
||||||
sitemap?: number;
|
sitemap?: number;
|
||||||
}
|
}
|
||||||
@ -301,6 +305,63 @@ export interface CanaryStatement {
|
|||||||
received: boolean;
|
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)
|
* Sitemap item interface (from @astrojs/sitemap)
|
||||||
*/
|
*/
|
||||||
|
|||||||
@ -3,7 +3,7 @@ import type { DiscoveryConfig } from '../types.js';
|
|||||||
/**
|
/**
|
||||||
* Default configuration values
|
* 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: {
|
robots: {
|
||||||
enabled: true,
|
enabled: true,
|
||||||
crawlDelay: 1,
|
crawlDelay: 1,
|
||||||
@ -25,6 +25,7 @@ const DEFAULT_CONFIG: Required<Omit<DiscoveryConfig, 'templates' | 'security' |
|
|||||||
humans: 86400, // 24 hours
|
humans: 86400, // 24 hours
|
||||||
security: 86400, // 24 hours
|
security: 86400, // 24 hours
|
||||||
canary: 3600, // 1 hour
|
canary: 3600, // 1 hour
|
||||||
|
webfinger: 3600, // 1 hour
|
||||||
sitemap: 3600, // 1 hour
|
sitemap: 3600, // 1 hour
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
@ -55,6 +56,7 @@ export function validateConfig(userConfig: DiscoveryConfig = {}): DiscoveryConfi
|
|||||||
},
|
},
|
||||||
security: userConfig.security,
|
security: userConfig.security,
|
||||||
canary: userConfig.canary,
|
canary: userConfig.canary,
|
||||||
|
webfinger: userConfig.webfinger,
|
||||||
sitemap: {
|
sitemap: {
|
||||||
...DEFAULT_CONFIG.sitemap,
|
...DEFAULT_CONFIG.sitemap,
|
||||||
...userConfig.sitemap,
|
...userConfig.sitemap,
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user