diff --git a/src/ap/index.js b/src/ap/index.js
index d868691..9cc51ec 100644
--- a/src/ap/index.js
+++ b/src/ap/index.js
@@ -4,13 +4,15 @@
*/
import { webfinger } from 'microfed'
-import { loadOrCreateKeypair, getKeyId } from './keys.js'
+import websocket from '@fastify/websocket'
+import { getWebIdFromRequestAsync } from '../auth/token.js'
+import { loadOrCreateKeypair, getKeyId, getDefaultKeyPath } from './keys.js'
import { initStore } from './store.js'
import { createInboxHandler } from './routes/inbox.js'
-import { createOutboxHandler, createOutboxPostHandler } from './routes/outbox.js'
+import { createOutboxHandler, createOutboxPostHandler, createPostObjectHandler } from './routes/outbox.js'
import { createCollectionsHandler } from './routes/collections.js'
import { createActorHandler } from './routes/actor.js'
-import { createAppsHandler, createVerifyCredentialsHandler, createInstanceHandler } from './routes/mastodon.js'
+import { createAppsHandler, createVerifyCredentialsHandler, createUpdateCredentialsHandler, createAccountLookupHandler, createAccountsSearchHandler, createPreferencesHandler, createListsHandler, createAccountListsHandler, createRelationshipsHandler, createInstanceHandler, createInstanceV2Handler, createSearchHandler, createTimelinesHomeHandler, createPostStatusHandler, createGetStatusHandler, createFavouriteStatusHandler, createUpdateStatusHandler, createGetAccountHandler, createGetAccountStatusesHandler, createFollowAccountHandler, createGetNotificationsHandler, getProfileMediaBuffer } from './routes/mastodon.js'
import { createAuthorizeHandler, createAuthorizePostHandler, createTokenHandler } from './routes/oauth.js'
// Shared state for actor handler (accessed by server.js)
@@ -27,21 +29,104 @@ export function getActorHandler() { return sharedActorHandler }
* @param {string} options.nostrPubkey - Nostr public key (hex) for identity linking
*/
export async function activityPubPlugin(fastify, options = {}) {
- // Initialize storage and keypair
- const keypair = loadOrCreateKeypair()
- await initStore()
-
- // Store config for handlers
- const config = {
- keypair,
- username: options.username || 'me',
- displayName: options.displayName || options.username || 'Anonymous',
+ const defaultUsername = options.username || 'me'
+ await initStore(undefined, defaultUsername)
+
+ // Register WebSocket support for Mastodon streaming API if not already present.
+ if (!fastify.websocketServer) {
+ await fastify.register(websocket)
+ }
+
+ const subdomains = options.subdomains || false
+ const baseDomain = options.baseDomain || null
+
+ // Single-user fallback config (used when not in subdomain mode)
+ const defaultConfig = {
+ username: defaultUsername,
+ displayName: options.displayName || defaultUsername,
summary: options.summary || '',
- nostrPubkey: options.nostrPubkey || null
+ nostrPubkey: options.nostrPubkey || null,
+ subdomains,
+ baseDomain
+ }
+
+ // Keypair cache: username → keypair (loaded on demand)
+ const keypairCache = new Map()
+
+ const getKeypairForUser = (username) => {
+ if (!keypairCache.has(username)) {
+ keypairCache.set(username, loadOrCreateKeypair(getDefaultKeyPath(username)))
+ }
+ return keypairCache.get(username)
+ }
+
+ // In single-user mode (no subdomains) pre-load the keypair now.
+ // In subdomain mode, keypairs are created on first request per user — no eager load.
+ if (!subdomains) {
+ defaultConfig.keypair = getKeypairForUser(defaultUsername)
+ }
+
+ const getRequestHost = (request) => request.headers['x-forwarded-host'] || request.hostname
+
+ const getUsernameFromWebId = (webId) => {
+ if (!webId) return null
+ try {
+ const url = new URL(webId)
+ const hostname = url.hostname
+ if (hostname === 'localhost' || /^(127\.|192\.168\.|10\.|::1)/.test(hostname)) {
+ const seg = url.pathname.split('/').filter(Boolean)[0]
+ return seg || null
+ }
+ const parts = hostname.split('.')
+ return parts.length >= 2 ? parts[0] : (parts[0] || null)
+ } catch {
+ return null
+ }
+ }
+
+ /**
+ * In subdomain mode, derive the active user from the request subdomain.
+ * e.g. alice.pivot-test.local → username "alice"
+ * Any subdomain user is automatically a valid AP actor.
+ * Falls back to the default (single-user) config.
+ */
+ const getUserConfig = (request) => {
+ if (subdomains && baseDomain) {
+ const host = getRequestHost(request)
+ const baseDomainHost = baseDomain.includes(':') ? baseDomain.split(':')[0] : baseDomain
+ const hostNoPort = host.includes(':') ? host.split(':')[0] : host
+ const subdomain = hostNoPort.endsWith('.' + baseDomainHost)
+ ? hostNoPort.slice(0, -(baseDomainHost.length + 1))
+ : null
+ if (subdomain) {
+ const keypair = getKeypairForUser(subdomain)
+ return {
+ keypair,
+ username: subdomain,
+ displayName: subdomain,
+ summary: '',
+ nostrPubkey: null,
+ subdomains,
+ baseDomain
+ }
+ }
+ }
+ return defaultConfig
}
- // Decorate fastify with AP config
- fastify.decorate('apConfig', config)
+ // Decorate fastify with AP config (default user, for compat)
+ fastify.decorate('apConfig', defaultConfig)
+
+ // In subdomain mode, the canonical AP actor host is the AP username subdomain.
+ // Example: --ap-username alice + --base-domain pivot-test.local:4443
+ // -> actor/profile URLs use alice.pivot-test.local:4443.
+ const getActorHost = (request, userConfig) => {
+ const uc = userConfig || getUserConfig(request)
+ if (uc.subdomains && uc.baseDomain) {
+ return `${uc.username}.${uc.baseDomain}`
+ }
+ return getRequestHost(request)
+ }
// Helper to detect protocol from proxy headers
const getProtocol = (request) => {
@@ -58,26 +143,74 @@ export async function activityPubPlugin(fastify, options = {}) {
}
}
// If still no protocol and hostname looks like a public domain, assume https
- if (!protocol && request.hostname && !request.hostname.match(/^(localhost|127\.|192\.168\.|10\.)/)) {
+ const host = getRequestHost(request)
+ const hostNoPort = host.includes(':') ? host.split(':')[0] : host
+ if (!protocol && hostNoPort && !hostNoPort.match(/^(localhost|127\.|192\.168\.|10\.)/)) {
protocol = 'https'
}
return protocol || request.protocol
}
- // Helper to build actor ID from request
- const getActorId = (request) => {
+ // Helper to build actor ID from request (optionally for a specific user config)
+ const getActorId = (request, userConfig) => {
const protocol = getProtocol(request)
- const host = request.headers['x-forwarded-host'] || request.hostname
+ const host = getActorHost(request, userConfig)
return `${protocol}://${host}/profile/card.jsonld#me`
}
// Helper to get base URL
- const getBaseUrl = (request) => {
+ const getBaseUrl = (request, actor = false, userConfig) => {
const protocol = getProtocol(request)
- const host = request.headers['x-forwarded-host'] || request.hostname
+ const host = actor ? getActorHost(request, userConfig) : getRequestHost(request)
return `${protocol}://${host}`
}
+ // host-meta discovery (used by Mastodon clients like Phanpy)
+ fastify.get('/.well-known/host-meta', async (request, reply) => {
+ const baseUrl = getBaseUrl(request)
+ const xml = `\n\n \n`
+
+ return reply
+ .header('Content-Type', 'application/xrd+xml; charset=utf-8')
+ .header('Access-Control-Allow-Origin', '*')
+ .send(xml)
+ })
+
+ fastify.get('/.well-known/host-meta.json', async (request, reply) => {
+ const baseUrl = getBaseUrl(request)
+ return reply
+ .header('Content-Type', 'application/json')
+ .header('Access-Control-Allow-Origin', '*')
+ .send({
+ links: [
+ {
+ rel: 'lrdd',
+ type: 'application/jrd+json',
+ template: `${baseUrl}/.well-known/webfinger?resource={uri}`
+ }
+ ]
+ })
+ })
+
+ // OAuth 2.0 Authorization Server Metadata (RFC 8414)
+ // Used by Mastodon-compatible clients during discovery.
+ fastify.get('/.well-known/oauth-authorization-server', async (request, reply) => {
+ const baseUrl = getBaseUrl(request)
+ return reply
+ .header('Content-Type', 'application/json')
+ .header('Access-Control-Allow-Origin', '*')
+ .send({
+ issuer: baseUrl,
+ authorization_endpoint: `${baseUrl}/oauth/authorize`,
+ token_endpoint: `${baseUrl}/oauth/token`,
+ registration_endpoint: `${baseUrl}/api/v1/apps`,
+ response_types_supported: ['code'],
+ grant_types_supported: ['authorization_code', 'refresh_token'],
+ token_endpoint_auth_methods_supported: ['none', 'client_secret_post'],
+ code_challenge_methods_supported: ['S256']
+ })
+ })
+
// WebFinger endpoint
fastify.get('/.well-known/webfinger', async (request, reply) => {
const resource = request.query.resource
@@ -90,20 +223,31 @@ export async function activityPubPlugin(fastify, options = {}) {
return reply.code(400).send({ error: 'Invalid resource format' })
}
- // Check if this is our user
- const host = request.headers['x-forwarded-host'] || request.hostname
+ // Check if this is our domain
+ const host = getRequestHost(request)
if (parsed.domain !== host) {
return reply.code(404).send({ error: 'Not found' })
}
- // For now, accept any username and map to /profile/card.jsonld#me
- // In multi-user mode, we'd look up the user
+ // In subdomain mode, any username on this domain is a valid AP actor.
+ // In single-user mode, only the configured username is accepted.
+ const username = parsed.username
+ if (!subdomains && username !== defaultConfig.username) {
+ return reply.code(404).send({ error: 'User not found' })
+ }
+
+ // Build a minimal config for this user to resolve their actor host
+ const matchedUser = subdomains
+ ? { username, subdomains, baseDomain }
+ : defaultConfig
+
const baseUrl = getBaseUrl(request)
- const actorUrl = `${baseUrl}/profile/card.jsonld#me`
- const profileUrl = `${baseUrl}/profile/card.jsonld`
+ const actorBaseUrl = getBaseUrl(request, true, matchedUser)
+ const actorUrl = `${actorBaseUrl}/profile/card.jsonld#me`
+ const profileUrl = `${actorBaseUrl}/profile/card.jsonld`
const response = webfinger.createResponse(
- `${parsed.username}@${parsed.domain}`,
+ `${username}@${parsed.domain}`,
actorUrl,
{ profileUrl }
)
@@ -111,7 +255,7 @@ export async function activityPubPlugin(fastify, options = {}) {
// Add remoteStorage link relation
response.links.push({
rel: 'http://tools.ietf.org/id/draft-dejong-remotestorage',
- href: `${baseUrl}/storage/${config.username}/`,
+ href: `${baseUrl}/storage/${username}/`,
properties: {
'http://remotestorage.io/spec/version': 'draft-dejong-remotestorage-22',
'http://tools.ietf.org/html/rfc6749#section-4.2': `${baseUrl}/oauth/authorize`,
@@ -155,7 +299,7 @@ export async function activityPubPlugin(fastify, options = {}) {
services: { inbound: [], outbound: [] },
usage: {
users: { total: 1, activeMonth: 1, activeHalfyear: 1 },
- localPosts: getPostCount()
+ localPosts: getPostCount(defaultConfig.username)
},
openRegistrations: true,
metadata: {
@@ -165,32 +309,124 @@ export async function activityPubPlugin(fastify, options = {}) {
})
})
- // Actor endpoint - expose handler for profile/card AP requests
- const actorHandler = createActorHandler(config, keypair)
+ // Actor endpoint - build a dispatcher that resolves the right user config per request
+ // In subdomain mode each user's subdomain routes here; in single-host mode use primary.
+ const actorHandlerDispatch = (request, reply) => {
+ const uc = getUserConfig(request)
+ return createActorHandler(uc, uc.keypair)(request, reply)
+ }
- // Store actorHandler in shared state for use by server-level hook
- sharedActorHandler = actorHandler
+ // Store actorHandler dispatcher in shared state for use by server-level hook
+ sharedActorHandler = actorHandlerDispatch
- // Inbox endpoint
- const inboxHandler = createInboxHandler(config, keypair)
- fastify.post('/inbox', inboxHandler)
- fastify.post('/profile/card.jsonld/inbox', inboxHandler)
+ // Shared inbox (base domain) — use default config
+ fastify.post('/inbox', createInboxHandler(defaultConfig, getKeypairForUser(defaultUsername)))
- // Outbox endpoint
- const outboxHandler = createOutboxHandler(config, keypair)
- const outboxPostHandler = createOutboxPostHandler(config, keypair)
- fastify.get('/profile/card.jsonld/outbox', outboxHandler)
- fastify.post('/profile/card.jsonld/outbox', outboxPostHandler)
+ // Per-user inbox/outbox/collections — same route path, resolved by subdomain
+ const inboxDispatch = (request, reply) => {
+ const uc = getUserConfig(request)
+ return createInboxHandler(uc, uc.keypair)(request, reply)
+ }
+ const outboxDispatch = (request, reply) => {
+ const uc = getUserConfig(request)
+ return createOutboxHandler(uc, uc.keypair)(request, reply)
+ }
+ const outboxPostDispatch = (request, reply) => {
+ const uc = getUserConfig(request)
+ return createOutboxPostHandler(uc, uc.keypair)(request, reply)
+ }
+ const postObjectDispatch = (request, reply) => {
+ const uc = getUserConfig(request)
+ return createPostObjectHandler(uc)(request, reply)
+ }
+ const collectionsDispatch = (request, reply, type) => {
+ const uc = getUserConfig(request)
+ return createCollectionsHandler(uc)(request, reply, type)
+ }
- // Followers/Following collections
- const collectionsHandler = createCollectionsHandler(config)
- fastify.get('/profile/card.jsonld/followers', (req, reply) => collectionsHandler(req, reply, 'followers'))
- fastify.get('/profile/card.jsonld/following', (req, reply) => collectionsHandler(req, reply, 'following'))
+ fastify.post('/profile/card.jsonld/inbox', inboxDispatch)
+ fastify.get('/profile/card.jsonld/outbox', outboxDispatch)
+ fastify.post('/profile/card.jsonld/outbox', outboxPostDispatch)
+ fastify.get('/posts/:id', postObjectDispatch)
+ fastify.get('/profile/avatar.png', async (request, reply) => {
+ const uc = getUserConfig(request)
+ let mediaUsername = uc.username
+ if (!mediaUsername || mediaUsername === 'me') {
+ const auth = await getWebIdFromRequestAsync(request)
+ mediaUsername = getUsernameFromWebId(auth.webId) || mediaUsername
+ }
+ const media = getProfileMediaBuffer(mediaUsername, 'avatar')
+ if (media) {
+ return reply.header('Content-Type', media.contentType).send(media.buffer)
+ }
+ const png = Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8Xw8AAoMBgNfQh8EAAAAASUVORK5CYII=', 'base64')
+ return reply.header('Content-Type', 'image/png').send(png)
+ })
+ fastify.get('/profile/header.png', async (request, reply) => {
+ const uc = getUserConfig(request)
+ let mediaUsername = uc.username
+ if (!mediaUsername || mediaUsername === 'me') {
+ const auth = await getWebIdFromRequestAsync(request)
+ mediaUsername = getUsernameFromWebId(auth.webId) || mediaUsername
+ }
+ const media = getProfileMediaBuffer(mediaUsername, 'header')
+ if (media) {
+ return reply.header('Content-Type', media.contentType).send(media.buffer)
+ }
+ const png = Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8Xw8AAoMBgNfQh8EAAAAASUVORK5CYII=', 'base64')
+ return reply.header('Content-Type', 'image/png').send(png)
+ })
+ fastify.get('/profile/card.jsonld/followers', (req, reply) => collectionsDispatch(req, reply, 'followers'))
+ fastify.get('/profile/card.jsonld/following', (req, reply) => collectionsDispatch(req, reply, 'following'))
// Mastodon-compatible API endpoints
+ const streamingDispatch = (connection, request) => {
+ // Minimal compatibility endpoint for Mastodon clients (Phanpy/Elk):
+ // accept connection and keep it alive even when no events are emitted yet.
+ const ping = setInterval(() => {
+ if (connection.socket.readyState === 1) {
+ try {
+ connection.socket.ping()
+ } catch {
+ clearInterval(ping)
+ }
+ }
+ }, 30000)
+
+ connection.socket.on('close', () => clearInterval(ping))
+ connection.socket.on('error', () => clearInterval(ping))
+ }
+
+ fastify.get('/api/v1/streaming', { websocket: true }, streamingDispatch)
+ fastify.get('/api/v1/streaming/', { websocket: true }, streamingDispatch)
+
fastify.post('/api/v1/apps', createAppsHandler())
- fastify.get('/api/v1/accounts/verify_credentials', createVerifyCredentialsHandler(config))
- fastify.get('/api/v1/instance', createInstanceHandler(config))
+ fastify.get('/api/v1/accounts/verify_credentials', createVerifyCredentialsHandler(getUserConfig))
+ fastify.patch('/api/v1/accounts/update_credentials', createUpdateCredentialsHandler())
+ fastify.post('/api/v1/accounts/update_credentials', createUpdateCredentialsHandler())
+ fastify.get('/api/v1/accounts/lookup', createAccountLookupHandler())
+ fastify.get('/api/v1/accounts/search', createAccountsSearchHandler())
+ fastify.get('/api/v1/preferences', createPreferencesHandler())
+ fastify.get('/api/v1/lists', createListsHandler())
+ fastify.get('/api/v1/accounts/relationships', createRelationshipsHandler())
+ fastify.get('/api/v1/instance', createInstanceHandler())
+ fastify.get('/api/v2/instance', createInstanceV2Handler())
+ fastify.get('/api/v2/search', createSearchHandler())
+ fastify.get('/api/v1/timelines/home', createTimelinesHomeHandler())
+ fastify.post('/api/v1/statuses', createPostStatusHandler())
+ fastify.get('/api/v1/statuses/:id', createGetStatusHandler())
+ fastify.get('/api/v1/statuses/:id/source', createGetStatusHandler())
+ fastify.get('/api/v1/statuses/:id/history', createGetStatusHandler())
+ fastify.get('/api/v1/statuses/*', createGetStatusHandler())
+ fastify.post('/api/v1/statuses/:id/favourite', createFavouriteStatusHandler(getUserConfig))
+ fastify.post('/api/v1/statuses/*', createFavouriteStatusHandler(getUserConfig))
+ fastify.put('/api/v1/statuses/:id', createUpdateStatusHandler())
+ fastify.put('/api/v1/statuses/*', createUpdateStatusHandler())
+ fastify.get('/api/v1/accounts/:id', createGetAccountHandler())
+ fastify.get('/api/v1/accounts/:id/lists', createAccountListsHandler())
+ fastify.get('/api/v1/accounts/:id/statuses', createGetAccountStatusesHandler())
+ fastify.post('/api/v1/accounts/:id/follow', createFollowAccountHandler(getUserConfig))
+ fastify.get('/api/v1/notifications', createGetNotificationsHandler(getUserConfig))
// OAuth 2.0 authorize/token flow (Mastodon clients, remoteStorage, third-party panes)
fastify.get('/oauth/authorize', createAuthorizeHandler())
diff --git a/src/ap/keys.js b/src/ap/keys.js
index 2a71d6c..6242f53 100644
--- a/src/ap/keys.js
+++ b/src/ap/keys.js
@@ -7,7 +7,14 @@ import { generateKeyPairSync } from 'crypto'
import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'fs'
import { dirname, join } from 'path'
-const DEFAULT_KEY_PATH = 'data/ap-keys.json'
+/**
+ * Get default key path under DATA_ROOT/.idp/ap/
+ * @param {string} username
+ */
+export function getDefaultKeyPath(username = 'me') {
+ const dataRoot = process.env.DATA_ROOT || './data'
+ return join(dataRoot, '.idp', 'ap', 'keys.json')
+}
/**
* Generate RSA keypair
@@ -25,27 +32,27 @@ export function generateKeypair(modulusLength = 2048) {
/**
* Load keypair from disk, generate if not exists
- * @param {string} path - Path to keys file
+ * @param {string} [path] - Path to keys file (defaults to DATA_ROOT/.idp/ap/keys.json)
* @returns {{ publicKey: string, privateKey: string }}
*/
-export function loadOrCreateKeypair(path = DEFAULT_KEY_PATH) {
- if (existsSync(path)) {
- const data = JSON.parse(readFileSync(path, 'utf8'))
- return data
+export function loadOrCreateKeypair(path) {
+ const resolvedPath = path || getDefaultKeyPath('me')
+ if (existsSync(resolvedPath)) {
+ return JSON.parse(readFileSync(resolvedPath, 'utf8'))
}
// Generate new keypair
const keypair = generateKeypair()
// Ensure directory exists
- const dir = dirname(path)
+ const dir = dirname(resolvedPath)
if (!existsSync(dir)) {
mkdirSync(dir, { recursive: true })
}
// Save to disk
- writeFileSync(path, JSON.stringify(keypair, null, 2))
- console.log(`Generated new ActivityPub keypair: ${path}`)
+ writeFileSync(resolvedPath, JSON.stringify(keypair, null, 2))
+ console.log(`Generated new ActivityPub keypair: ${resolvedPath}`)
return keypair
}
diff --git a/src/ap/routes/actor.js b/src/ap/routes/actor.js
index be6b7b2..a71a425 100644
--- a/src/ap/routes/actor.js
+++ b/src/ap/routes/actor.js
@@ -23,9 +23,12 @@ export function createActorHandler(config, keypair) {
} catch { /* ignore */ }
}
}
- // If still no protocol and hostname looks like a public domain, assume https
- const host = request.headers['x-forwarded-host'] || request.hostname
- if (!protocol && host && !host.match(/^(localhost|127\.|192\.168\.|10\.)/)) {
+ const requestHost = request.headers['x-forwarded-host'] || request.hostname
+ const host = (config.subdomains && config.baseDomain)
+ ? `${config.username}.${config.baseDomain}`
+ : requestHost
+ const hostNoPort = host.includes(':') ? host.split(':')[0] : host
+ if (!protocol && hostNoPort && !hostNoPort.match(/^(localhost|127\.|192\.168\.|10\.)/)) {
protocol = 'https'
}
protocol = protocol || request.protocol
diff --git a/src/ap/routes/collections.js b/src/ap/routes/collections.js
index c51bf63..2d8cc43 100644
--- a/src/ap/routes/collections.js
+++ b/src/ap/routes/collections.js
@@ -20,13 +20,13 @@ export function createCollectionsHandler(config) {
let items, totalItems
if (collectionType === 'followers') {
- const followers = getFollowers()
+ const followers = getFollowers(config.username)
items = followers.map(f => f.actor)
- totalItems = getFollowerCount()
+ totalItems = getFollowerCount(config.username)
} else {
- const following = getFollowing()
+ const following = getFollowing(config.username)
items = following.map(f => f.actor)
- totalItems = getFollowingCount()
+ totalItems = getFollowingCount(config.username)
}
const collection = {
diff --git a/src/ap/routes/inbox.js b/src/ap/routes/inbox.js
index 9a27660..1c2352f 100644
--- a/src/ap/routes/inbox.js
+++ b/src/ap/routes/inbox.js
@@ -4,6 +4,8 @@
*/
import { auth, outbox } from 'microfed'
+import { Agent } from 'undici'
+import { createSign } from 'crypto'
import {
saveActivity,
addFollower,
@@ -14,6 +16,9 @@ import {
} from '../store.js'
import { getKeyId } from '../keys.js'
+// Allow self-signed certs when fetching local actors (e.g. server fetching itself)
+const insecureAgent = new Agent({ connect: { rejectUnauthorized: false } })
+
/**
* Fetch remote actor (with caching)
* @param {string} id - Actor URL
@@ -28,6 +33,7 @@ async function fetchActor(id, log) {
try {
const response = await fetch(fetchUrl, {
+ dispatcher: insecureAgent,
headers: {
'Accept': 'application/activity+json',
'User-Agent': 'JSS/1.0 (+https://github.com/JavaScriptSolidServer/JavaScriptSolidServer)'
@@ -143,7 +149,7 @@ export function createInboxHandler(config, keypair) {
// Save activity
if (activity.id) {
- saveActivity(activity)
+ saveActivity(config.username, activity)
}
// Handle activity by type
@@ -157,15 +163,15 @@ export function createInboxHandler(config, keypair) {
switch (activity.type) {
case 'Follow':
- await handleFollow(activity, actorId, profileUrl, keypair, request.log)
+ await handleFollow(config.username, activity, actorId, profileUrl, keypair, request.log)
break
case 'Undo':
- await handleUndo(activity, request.log)
+ await handleUndo(config.username, activity, request.log)
break
case 'Accept':
- handleAccept(activity, request.log)
+ handleAccept(config.username, activity, request.log)
break
case 'Create':
@@ -192,7 +198,7 @@ export function createInboxHandler(config, keypair) {
/**
* Handle Follow activity
*/
-async function handleFollow(activity, actorId, profileUrl, keypair, log) {
+async function handleFollow(username, activity, actorId, profileUrl, keypair, log) {
const followerActor = await fetchActor(activity.actor, log)
if (!followerActor) {
log.warn('Could not fetch follower actor')
@@ -200,20 +206,38 @@ async function handleFollow(activity, actorId, profileUrl, keypair, log) {
}
// Add to followers
- addFollower(activity.actor, followerActor.inbox)
+ addFollower(username, activity.actor, followerActor.inbox)
log.info(`New follower: ${followerActor.preferredUsername || activity.actor}`)
- // Send Accept
+ // Send Accept — use direct signed fetch to support self-signed certs
const accept = outbox.createAccept(actorId, activity)
+ const body = JSON.stringify(accept)
+ const inboxUrl = new URL(followerActor.inbox)
+ const date = new Date().toUTCString()
+ const digest = `SHA-256=${Buffer.from(
+ (await crypto.subtle.digest('SHA-256', new TextEncoder().encode(body)))
+ ).toString('base64')}`
+ const signingString = `(request-target): post ${inboxUrl.pathname}\nhost: ${inboxUrl.host}\ndate: ${date}\ndigest: ${digest}`
+ const signer = createSign('RSA-SHA256')
+ signer.update(signingString)
+ const signature = signer.sign(keypair.privateKey, 'base64')
+ const keyId = `${profileUrl}#main-key`
+ const signatureHeader = `keyId="${keyId}",algorithm="rsa-sha256",headers="(request-target) host date digest",signature="${signature}"`
try {
- await outbox.send({
- activity: accept,
- inbox: followerActor.inbox,
- privateKey: keypair.privateKey,
- keyId: `${profileUrl}#main-key`
+ const res = await fetch(followerActor.inbox, {
+ method: 'POST',
+ dispatcher: insecureAgent,
+ headers: {
+ 'Content-Type': 'application/activity+json',
+ 'Accept': 'application/activity+json',
+ 'Date': date,
+ 'Digest': digest,
+ 'Signature': signatureHeader
+ },
+ body
})
- log.info(`Sent Accept to ${followerActor.inbox}`)
+ log.info(`Sent Accept to ${followerActor.inbox} — ${res.status}`)
} catch (err) {
log.error(`Failed to send Accept: ${err.message}`)
}
@@ -222,9 +246,9 @@ async function handleFollow(activity, actorId, profileUrl, keypair, log) {
/**
* Handle Undo activity
*/
-async function handleUndo(activity, log) {
+async function handleUndo(username, activity, log) {
if (activity.object?.type === 'Follow') {
- removeFollower(activity.actor)
+ removeFollower(username, activity.actor)
log.info(`Unfollowed by ${activity.actor}`)
}
}
@@ -232,13 +256,13 @@ async function handleUndo(activity, log) {
/**
* Handle Accept activity (our follow was accepted)
*/
-function handleAccept(activity, log) {
+function handleAccept(username, activity, log) {
if (activity.object?.type === 'Follow') {
const target = typeof activity.object.object === 'string'
? activity.object.object
: activity.object.object?.id
if (target) {
- acceptFollowing(target)
+ acceptFollowing(username, target)
log.info('Follow accepted!')
}
}
diff --git a/src/ap/routes/mastodon.js b/src/ap/routes/mastodon.js
index 1141c2b..67408d5 100644
--- a/src/ap/routes/mastodon.js
+++ b/src/ap/routes/mastodon.js
@@ -7,8 +7,368 @@
* https://docs.joinmastodon.org/methods/accounts/#verify_credentials
*/
+import { createSign, randomUUID } from 'crypto'
+import { existsSync, mkdirSync, readFileSync, readdirSync, unlinkSync, writeFileSync } from 'fs'
+import { dirname, join } from 'path'
+import { Agent } from 'undici'
+import { getWebIdFromRequestAsync } from '../../auth/token.js'
+import { safeFetch } from '../../utils/ssrf.js'
+import {
+ getPosts,
+ getPost,
+ getPostById,
+ updatePost,
+ getFollowers,
+ getFollowing,
+ getFollowerCount,
+ getFollowingCount,
+ addFollowing,
+ addFollower,
+ acceptFollowing,
+ cacheActor,
+ getCachedActor
+} from '../store.js'
+
+// Allow self-signed certs when delivering activities to local/dev servers
+const insecureAgent = new Agent({ connect: { rejectUnauthorized: false } })
+
+/**
+ * Send a signed ActivityPub activity to a remote inbox
+ */
+async function sendSignedActivity (activity, inboxUrl, actorId, keypair, log) {
+ const body = JSON.stringify(activity)
+ const url = new URL(inboxUrl)
+ const date = new Date().toUTCString()
+ const digest = `SHA-256=${Buffer.from(
+ await crypto.subtle.digest('SHA-256', new TextEncoder().encode(body))
+ ).toString('base64')}`
+ const signingString = `(request-target): post ${url.pathname}\nhost: ${url.host}\ndate: ${date}\ndigest: ${digest}`
+ const signer = createSign('RSA-SHA256')
+ signer.update(signingString)
+ const signature = signer.sign(keypair.privateKey, 'base64')
+ const keyId = `${actorId}#main-key`
+ const signatureHeader = `keyId="${keyId}",algorithm="rsa-sha256",headers="(request-target) host date digest",signature="${signature}"`
+
+ try {
+ const res = await fetch(inboxUrl, {
+ method: 'POST',
+ dispatcher: insecureAgent,
+ headers: {
+ 'Content-Type': 'application/activity+json',
+ 'Accept': 'application/activity+json',
+ 'Date': date,
+ 'Digest': digest,
+ 'Signature': signatureHeader
+ },
+ body
+ })
+ if (log) log.info(`Sent ${activity.type} to ${inboxUrl} — ${res.status}`)
+ return res.status
+ } catch (err) {
+ if (log) log.error(`Failed to send ${activity.type} to ${inboxUrl}: ${err.message}`)
+ return null
+ }
+}
+
// In-memory client store (replace with persistent storage later)
const clients = new Map()
+let clientsLoaded = false
+
+// Per-account profile overrides (display_name, note) persisted to disk
+const profileOverrides = new Map()
+const loadedProfiles = new Set()
+
+function getClientsFilePath () {
+ const root = process.env.DATA_ROOT || './data'
+ return join(root, '.idp', 'ap', 'oauth-clients.json')
+}
+
+function ensureClientsLoaded () {
+ if (clientsLoaded) return
+ clientsLoaded = true
+
+ const filePath = getClientsFilePath()
+ try {
+ if (!existsSync(filePath)) return
+ const raw = readFileSync(filePath, 'utf8')
+ const list = JSON.parse(raw)
+ if (Array.isArray(list)) {
+ for (const c of list) {
+ if (c?.client_id) clients.set(c.client_id, c)
+ }
+ }
+ } catch {
+ // Ignore malformed/missing file and continue with empty registry
+ }
+}
+
+function persistClients () {
+ const filePath = getClientsFilePath()
+ try {
+ mkdirSync(dirname(filePath), { recursive: true })
+ writeFileSync(filePath, JSON.stringify(Array.from(clients.values()), null, 2), 'utf8')
+ } catch {
+ // If persistence fails, keep in-memory behavior rather than failing requests
+ }
+}
+
+function getPodApDir (username) {
+ const root = process.env.DATA_ROOT || './data'
+ return join(root, username, '.ap')
+}
+
+function getProfilesFilePath (username) {
+ return join(getPodApDir(username), 'profile-overrides.json')
+}
+
+function ensureProfileLoaded (username) {
+ if (loadedProfiles.has(username)) return
+ loadedProfiles.add(username)
+
+ const filePath = getProfilesFilePath(username)
+ try {
+ if (!existsSync(filePath)) return
+ const raw = readFileSync(filePath, 'utf8')
+ const data = JSON.parse(raw)
+ if (data && typeof data === 'object') profileOverrides.set(username, data)
+ } catch {
+ // Ignore malformed/missing file and continue with defaults
+ }
+}
+
+function persistProfile (username) {
+ const filePath = getProfilesFilePath(username)
+ try {
+ mkdirSync(dirname(filePath), { recursive: true })
+ writeFileSync(filePath, JSON.stringify(profileOverrides.get(username) || {}, null, 2), 'utf8')
+ } catch {
+ // Keep server functional even if persistence fails
+ }
+}
+
+function getProfileOverride (username) {
+ ensureProfileLoaded(username)
+ return profileOverrides.get(username) || null
+}
+
+function getProfileMediaDir (username) {
+ return join(getPodApDir(username), 'profile-media')
+}
+
+function getProfileMediaPath (username, kind, ext) {
+ return join(getProfileMediaDir(username), `${kind}.${ext}`)
+}
+
+function cleanupProfileMedia (username, kind) {
+ const dir = getProfileMediaDir(username)
+ if (!existsSync(dir)) return
+ for (const file of readdirSync(dir)) {
+ if (file.startsWith(`${kind}.`)) {
+ try { unlinkSync(join(dir, file)) } catch {}
+ }
+ }
+}
+
+function getProfileMediaPart (request, fieldName) {
+ const ct = request.headers['content-type'] || ''
+ const boundaryMatch = ct.match(/boundary=(?:"([^"]+)"|([^;]+))/i)
+ const boundary = boundaryMatch?.[1] || boundaryMatch?.[2]
+ if (!boundary) return null
+
+ const bodyBuffer = Buffer.isBuffer(request.body)
+ ? request.body
+ : Buffer.from(String(request.body || ''), 'utf8')
+
+ // latin1 keeps 1-byte index mapping between string and buffer offsets.
+ const raw = bodyBuffer.toString('latin1')
+ const boundaryToken = `--${boundary}`
+ let pos = raw.indexOf(boundaryToken)
+
+ while (pos !== -1) {
+ let partStart = pos + boundaryToken.length
+ if (raw.slice(partStart, partStart + 2) === '--') break
+ if (raw.slice(partStart, partStart + 2) === '\r\n') partStart += 2
+
+ const headerEnd = raw.indexOf('\r\n\r\n', partStart)
+ if (headerEnd === -1) break
+
+ const headers = raw.slice(partStart, headerEnd)
+ const contentStart = headerEnd + 4
+ const nextBoundary = raw.indexOf(`\r\n${boundaryToken}`, contentStart)
+ if (nextBoundary === -1) break
+
+ const disposition = headers.split('\r\n').find(h => /^content-disposition:/i.test(h)) || ''
+ const nameMatch = disposition.match(/name="([^"]+)"/i)
+ const filenameMatch = disposition.match(/filename="([^"]*)"/i)
+ const contentTypeMatch = headers.match(/content-type:\s*([^\r\n]+)/i)
+
+ const name = nameMatch?.[1]
+ const filename = filenameMatch?.[1] || ''
+ const contentType = (contentTypeMatch?.[1] || '').trim().toLowerCase()
+
+ if (name === fieldName && filename) {
+ const buffer = bodyBuffer.subarray(contentStart, nextBoundary)
+ return { filename, contentType, buffer }
+ }
+
+ pos = nextBoundary + 2
+ }
+
+ // Fallback parser path for client variations in multipart layout.
+ try {
+ const escapedBoundary = boundary.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
+ const re = new RegExp(
+ `name="${fieldName}"; filename="([^"]*)"[\\s\\S]*?Content-Type:\\s*([^\\r\\n]+)\\r\\n\\r\\n([\\s\\S]*?)\\r\\n--${escapedBoundary}`,
+ 'i'
+ )
+ const match = raw.match(re)
+ if (match && match[1]) {
+ return {
+ filename: match[1],
+ contentType: (match[2] || '').trim().toLowerCase(),
+ buffer: Buffer.from(match[3] || '', 'latin1')
+ }
+ }
+ } catch {
+ // Ignore fallback parse errors
+ }
+
+ return null
+}
+
+function getAnyProfileMediaPart (request, preferredFieldNames = []) {
+ for (const name of preferredFieldNames) {
+ const part = getProfileMediaPart(request, name)
+ if (part) return part
+ }
+
+ // Last-resort: pick the first file-like multipart section.
+ const ct = request.headers['content-type'] || ''
+ const boundaryMatch = ct.match(/boundary=(?:"([^"]+)"|([^;]+))/i)
+ const boundary = boundaryMatch?.[1] || boundaryMatch?.[2]
+ if (!boundary) return null
+
+ const bodyBuffer = Buffer.isBuffer(request.body)
+ ? request.body
+ : Buffer.from(String(request.body || ''), 'utf8')
+ const raw = bodyBuffer.toString('latin1')
+ const escapedBoundary = boundary.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
+
+ try {
+ const re = new RegExp(
+ `name="([^"]+)"; filename="([^"]*)"[\\s\\S]*?Content-Type:\\s*([^\\r\\n]+)\\r\\n\\r\\n([\\s\\S]*?)\\r\\n--${escapedBoundary}`,
+ 'i'
+ )
+ const match = raw.match(re)
+ if (match && match[2]) {
+ return {
+ filename: match[2],
+ contentType: (match[3] || '').trim().toLowerCase(),
+ buffer: Buffer.from(match[4] || '', 'latin1')
+ }
+ }
+ } catch {
+ // ignore parse fallback errors
+ }
+
+ return null
+}
+
+function saveProfileMedia (username, kind, filePart) {
+ if (!filePart || !filePart.buffer || filePart.buffer.length === 0) return null
+
+ const typeToExt = {
+ 'image/jpeg': 'jpg',
+ 'image/jpg': 'jpg',
+ 'image/png': 'png',
+ 'image/webp': 'webp',
+ 'image/gif': 'gif'
+ }
+
+ let ext = typeToExt[filePart.contentType]
+ if (!ext && filePart.filename.includes('.')) {
+ ext = filePart.filename.split('.').pop().toLowerCase()
+ }
+
+ if (!ext || !['jpg', 'jpeg', 'png', 'webp', 'gif'].includes(ext)) {
+ return null
+ }
+ if (ext === 'jpeg') ext = 'jpg'
+
+ const mediaDir = getProfileMediaDir(username)
+ mkdirSync(mediaDir, { recursive: true })
+ cleanupProfileMedia(username, kind)
+
+ const filePath = getProfileMediaPath(username, kind, ext)
+ writeFileSync(filePath, filePart.buffer)
+ return `${kind}.${ext}`
+}
+
+function findProfileMediaPath (username, kind) {
+ const dir = getProfileMediaDir(username)
+ if (!existsSync(dir)) return null
+ for (const file of readdirSync(dir)) {
+ if (file.startsWith(`${kind}.`)) {
+ return join(dir, file)
+ }
+ }
+
+ return null
+}
+
+export function getProfileMediaBuffer (username, kind) {
+ const path = findProfileMediaPath(username, kind)
+ if (!path || !existsSync(path)) return null
+
+ const ext = path.split('.').pop().toLowerCase()
+ const contentType = ext === 'jpg' || ext === 'jpeg'
+ ? 'image/jpeg'
+ : ext === 'png'
+ ? 'image/png'
+ : ext === 'webp'
+ ? 'image/webp'
+ : ext === 'gif'
+ ? 'image/gif'
+ : 'application/octet-stream'
+
+ return { buffer: readFileSync(path), contentType }
+}
+
+function parseMultipartTextFields (request) {
+ const ct = request.headers['content-type'] || ''
+ const boundaryMatch = ct.match(/boundary=(?:"([^"]+)"|([^;]+))/i)
+ const boundary = boundaryMatch?.[1] || boundaryMatch?.[2]
+ if (!boundary) return {}
+
+ const raw = Buffer.isBuffer(request.body) ? request.body.toString() : String(request.body || '')
+ const parts = raw.split(`--${boundary}`)
+ const out = {}
+
+ for (const part of parts) {
+ if (!part || part === '--' || part.trim() === '') continue
+ const splitAt = part.indexOf('\r\n\r\n')
+ if (splitAt === -1) continue
+
+ const headerBlock = part.slice(0, splitAt)
+ const contentDisposition = headerBlock
+ .split('\r\n')
+ .find(h => /^content-disposition:/i.test(h))
+ if (!contentDisposition) continue
+
+ const nameMatch = contentDisposition.match(/name="([^"]+)"/i)
+ if (!nameMatch) continue
+ const field = nameMatch[1]
+
+ // Ignore binary file parts for now.
+ if (/filename="/i.test(contentDisposition)) continue
+
+ let value = part.slice(splitAt + 4)
+ value = value.replace(/\r\n--$/, '').replace(/\r\n$/, '')
+ out[field] = value
+ }
+
+ return out
+}
// Stable instance start time (used for created_at)
const startedAt = new Date().toISOString()
@@ -23,6 +383,9 @@ function parseBody (request) {
}
const raw = Buffer.isBuffer(request.body) ? request.body.toString() : String(request.body || '')
const ct = request.headers['content-type'] || ''
+ if (ct.includes('multipart/form-data')) {
+ return parseMultipartTextFields(request)
+ }
if (ct.includes('application/json')) {
try { return JSON.parse(raw) } catch { return {} }
}
@@ -36,6 +399,7 @@ function parseBody (request) {
*/
export function createAppsHandler () {
return async (request, reply) => {
+ ensureClientsLoaded()
const body = parseBody(request)
const { client_name, redirect_uris, scopes, website } = body
@@ -43,8 +407,8 @@ export function createAppsHandler () {
return reply.code(422).send({ error: 'client_name and redirect_uris are required' })
}
- const clientId = crypto.randomUUID()
- const clientSecret = crypto.randomUUID()
+ const clientId = randomUUID()
+ const clientSecret = randomUUID()
const client = {
id: clientId,
@@ -57,6 +421,7 @@ export function createAppsHandler () {
}
clients.set(clientId, client)
+ persistClients()
return reply.send(client)
}
@@ -66,33 +431,100 @@ export function createAppsHandler () {
* GET /api/v1/accounts/verify_credentials — Who am I?
* Returns the authenticated user's profile as a Mastodon Account object
*/
-export function createVerifyCredentialsHandler (config) {
+export function createVerifyCredentialsHandler (getUserConfig) {
return async (request, reply) => {
+ const auth = await getWebIdFromRequestAsync(request)
+ if (!auth.webId) {
+ return reply.code(401).send({ error: 'Unauthorized' })
+ }
+
const protocol = request.headers['x-forwarded-proto'] || request.protocol
const host = request.headers['x-forwarded-host'] || request.hostname
const baseUrl = `${protocol}://${host}`
+ const uc = typeof getUserConfig === 'function' ? getUserConfig(request) : getUserConfig
+ // In single-host mode uc.username may be the default 'me' — override with token webId
+ const username = (uc.username && uc.username !== 'me')
+ ? uc.username
+ : (getUsernameFromWebId(auth.webId) || uc.username)
+
+ const profile = getProfileOverride(username)
+ const displayName = profile?.display_name || uc.displayName || username
+ const notePlain = profile?.note || uc.summary || ''
const account = {
- id: '1',
- username: config.username,
- acct: config.username,
- display_name: config.displayName,
- note: config.summary ? `
${escapeHtml(config.summary)}
` : '',
- url: `${baseUrl}/profile/card.jsonld`,
- uri: `${baseUrl}/profile/card.jsonld#me`,
+ ...buildAccount(username, baseUrl),
+ display_name: displayName,
+ note: notePlain ? `${escapeHtml(notePlain)}
` : '',
avatar: `${baseUrl}/profile/avatar.png`,
- header: '',
- locked: false,
- bot: false,
- created_at: startedAt,
- followers_count: 0,
- following_count: 0,
- statuses_count: 0,
+ avatar_static: `${baseUrl}/profile/avatar.png`,
+ header: `${baseUrl}/profile/header.png`,
+ header_static: `${baseUrl}/profile/header.png`,
source: {
privacy: 'public',
sensitive: false,
language: 'en',
- note: config.summary || ''
+ note: notePlain,
+ fields: []
+ }
+ }
+
+ return reply.send(account)
+ }
+}
+
+/**
+ * PATCH /api/v1/accounts/update_credentials
+ * Update current user's profile fields for Mastodon clients.
+ */
+export function createUpdateCredentialsHandler () {
+ return async (request, reply) => {
+ const auth = await getWebIdFromRequestAsync(request)
+ if (!auth.webId) {
+ return reply.code(401).send({ error: 'Unauthorized' })
+ }
+
+ const username = getUsernameFromWebId(auth.webId)
+ if (!username) {
+ return reply.code(400).send({ error: 'Invalid WebID' })
+ }
+
+ const protocol = request.headers['x-forwarded-proto'] || request.protocol
+ const host = request.headers['x-forwarded-host'] || request.hostname
+ const baseUrl = `${protocol}://${host}`
+
+ const body = parseBody(request)
+ const avatarPart = getAnyProfileMediaPart(request, ['avatar', 'avatar[]', 'avatar_file'])
+ const headerPart = getAnyProfileMediaPart(request, ['header', 'header[]', 'header_file'])
+ const avatarFile = saveProfileMedia(username, 'avatar', avatarPart)
+ const headerFile = saveProfileMedia(username, 'header', headerPart)
+
+ const prev = getProfileOverride(username) || {}
+ const next = {
+ ...prev,
+ ...(typeof body.display_name === 'string' ? { display_name: body.display_name } : {}),
+ ...(typeof body.note === 'string' ? { note: body.note } : {}),
+ ...(avatarFile ? { avatar_file: avatarFile } : {}),
+ ...(headerFile ? { header_file: headerFile } : {}),
+ updated_at: new Date().toISOString()
+ }
+
+ profileOverrides.set(username, next)
+ persistProfile(username)
+
+ const account = {
+ ...buildAccount(username, baseUrl),
+ display_name: next.display_name || username,
+ note: next.note ? `${escapeHtml(next.note)}
` : '',
+ avatar: `${baseUrl}/profile/avatar.png`,
+ avatar_static: `${baseUrl}/profile/avatar.png`,
+ header: `${baseUrl}/profile/header.png`,
+ header_static: `${baseUrl}/profile/header.png`,
+ source: {
+ privacy: 'public',
+ sensitive: false,
+ language: body.language || 'en',
+ note: next.note || '',
+ fields: []
}
}
@@ -100,38 +532,1117 @@ export function createVerifyCredentialsHandler (config) {
}
}
+/**
+ * GET /api/v1/accounts/lookup?acct=alice or alice@example.org
+ */
+export function createAccountLookupHandler () {
+ return async (request, reply) => {
+ const acct = (request.query?.acct || '').trim()
+ if (!acct) {
+ return reply.code(422).send({ error: 'acct is required' })
+ }
+
+ const protocol = request.headers['x-forwarded-proto'] || request.protocol
+ const host = request.headers['x-forwarded-host'] || request.hostname
+ const parsed = parseAccountIdentifier(acct)
+ if (!parsed) {
+ return reply.code(404).send({ error: 'Account not found' })
+ }
+
+ if (parsed.domain && !isLocalAccountDomain(parsed.domain, host)) {
+ const remote = await resolveRemoteAccount(parsed)
+ if (!remote) {
+ return reply.code(404).send({ error: 'Remote account not found' })
+ }
+ return reply.send(remote)
+ }
+
+ const baseUrl = `${protocol}://${host}`
+
+ return reply.send(buildAccount(parsed.username, baseUrl))
+ }
+}
+
+/**
+ * Shared instance data builder
+ */
+function buildInstanceData (host, wsProtocol) {
+ return {
+ uri: host,
+ domain: host,
+ title: 'JSS',
+ description: 'SAND Stack: Solid + ActivityPub + Nostr + DID',
+ short_description: 'Solid pod with Mastodon-compatible API',
+ version: '4.0.0 (compatible; JSS 0.0.99)',
+ urls: {
+ streaming_api: `${wsProtocol}://${host}`
+ },
+ stats: {
+ user_count: 1,
+ status_count: 0,
+ domain_count: 1
+ },
+ languages: ['en'],
+ registrations: false,
+ approval_required: false,
+ configuration: {
+ statuses: { max_characters: 5000 },
+ media_attachments: { supported_mime_types: [] },
+ polls: { max_options: 4, max_characters_per_option: 50, min_expiration: 300, max_expiration: 2629746 }
+ },
+ contact: { email: 'admin@example.com', account: null },
+ rules: []
+ }
+}
+
/**
* GET /api/v1/instance — Instance information
* Required by most Mastodon clients before login
*/
-export function createInstanceHandler (config) {
+export function createInstanceHandler () {
+ return async (request, reply) => {
+ const protocol = request.headers['x-forwarded-proto'] || request.protocol
+ const host = request.headers['x-forwarded-host'] || request.hostname
+ const wsProtocol = protocol === 'https' ? 'wss' : 'ws'
+ return reply.send(buildInstanceData(host, wsProtocol))
+ }
+}
+
+/**
+ * GET /api/v2/instance — Instance information (v2 format for Elk/Phanpy)
+ */
+export function createInstanceV2Handler () {
return async (request, reply) => {
const protocol = request.headers['x-forwarded-proto'] || request.protocol
const host = request.headers['x-forwarded-host'] || request.hostname
const wsProtocol = protocol === 'https' ? 'wss' : 'ws'
+ const data = buildInstanceData(host, wsProtocol)
+ // v2 moves stats → usage, adds thumbnail
+ const v2 = {
+ ...data,
+ usage: { users: { active_month: data.stats.user_count } },
+ thumbnail: { url: null },
+ registrations: { enabled: false, approval_required: false, message: null }
+ }
+ delete v2.stats
+ return reply.send(v2)
+ }
+}
- return reply.send({
- uri: host,
- title: config.displayName || 'JSS',
- description: 'SAND Stack: Solid + ActivityPub + Nostr + DID',
- short_description: 'Solid pod with Mastodon-compatible API',
- version: '4.0.0 (compatible; JSS 0.0.99)',
- urls: {
- streaming_api: `${wsProtocol}://${host}`
- },
- stats: {
- user_count: 1,
- status_count: 0,
- domain_count: 1
- },
- languages: ['en'],
- registrations: false,
- approval_required: false,
- configuration: {
- statuses: { max_characters: 5000 },
- media_attachments: { supported_mime_types: [] }
+/**
+ * Extract username from webId
+ * Subdomain mode: https://alice.pivot-test.local:4443/profile/card#me → alice
+ * Path mode (test/single-host): http://127.0.0.1:PORT/alice/profile/card#me → alice
+ */
+function getUsernameFromWebId (webId) {
+ if (!webId) return null
+ try {
+ const url = new URL(webId)
+ const hostname = url.hostname
+ // IP address or localhost — extract from first path segment
+ if (hostname === 'localhost' || /^(127\.|192\.168\.|10\.|::1)/.test(hostname)) {
+ const seg = url.pathname.split('/').filter(Boolean)[0]
+ return seg || null
+ }
+ // Subdomain mode — first hostname segment
+ const parts = hostname.split('.')
+ if (parts.length >= 2) {
+ return parts[0] !== 'www' ? parts[0] : null
+ }
+ return parts[0] || null
+ } catch {
+ return null
+ }
+}
+
+/**
+ * Build a Mastodon-compatible Account object
+ * baseUrl is used to derive the domain/port; username replaces the subdomain.
+ */
+function buildAccount (username, baseUrl) {
+ // Build the account's own base URL using its subdomain, not the requester's
+ let accountBaseUrl = baseUrl
+ let accountHost = null
+ try {
+ const u = new URL(baseUrl)
+ const hostParts = u.hostname.split('.')
+ // Replace first segment (subdomain) with this account's username
+ if (hostParts.length >= 2) {
+ hostParts[0] = username
+ u.hostname = hostParts.join('.')
+ accountBaseUrl = u.origin
+ }
+ accountHost = u.host
+ } catch { /* keep baseUrl as-is */ }
+
+ if (!accountHost) {
+ try {
+ accountHost = new URL(accountBaseUrl).host
+ } catch {
+ accountHost = null
+ }
+ }
+
+ const postCount = getPosts(username, 1000).length
+ const account = {
+ id: username,
+ username,
+ acct: accountHost ? `${username}@${accountHost}` : username,
+ display_name: username,
+ locked: false,
+ bot: false,
+ discoverable: true,
+ group: false,
+ created_at: startedAt,
+ note: '',
+ url: `${accountBaseUrl}/profile/card.jsonld`,
+ avatar: `${accountBaseUrl}/profile/avatar.png`,
+ avatar_static: `${accountBaseUrl}/profile/avatar.png`,
+ header: `${accountBaseUrl}/profile/header.png`,
+ header_static: `${accountBaseUrl}/profile/header.png`,
+ followers_count: getFollowerCount(username),
+ following_count: getFollowingCount(username),
+ statuses_count: postCount,
+ last_status_at: postCount > 0 ? new Date().toISOString().split('T')[0] : null,
+ emojis: [],
+ fields: []
+ }
+
+ const profile = getProfileOverride(username)
+ if (profile) {
+ if (typeof profile.display_name === 'string') account.display_name = profile.display_name
+ if (typeof profile.note === 'string') account.note = profile.note
+ }
+
+ return account
+}
+
+/**
+ * Build a Mastodon-compatible Status object
+ */
+function buildStatus (post, username, baseUrl) {
+ return {
+ id: post.id,
+ created_at: post.published,
+ in_reply_to_id: post.in_reply_to || null,
+ in_reply_to_account_id: null,
+ sensitive: false,
+ spoiler_text: '',
+ visibility: 'public',
+ language: 'en',
+ uri: post.id,
+ url: post.id,
+ replies_count: 0,
+ reblogs_count: 0,
+ favourites_count: 0,
+ edited_at: null,
+ content: post.content,
+ reblog: null,
+ application: null,
+ account: buildAccount(username, baseUrl),
+ media_attachments: [],
+ mentions: [],
+ tags: [],
+ emojis: [],
+ card: null,
+ poll: null
+ }
+}
+
+function parseAccountIdentifier (idOrAcct) {
+ if (!idOrAcct) return null
+
+ const raw = String(idOrAcct).trim()
+ if (!raw) return null
+
+ if (raw.startsWith('http://') || raw.startsWith('https://')) {
+ try {
+ const url = new URL(raw)
+ const username = getUsernameFromWebId(raw)
+ if (!username) return null
+ return { username, domain: url.host }
+ } catch {
+ return null
+ }
+ }
+
+ let value = raw
+ if (value.startsWith('@')) value = value.slice(1)
+
+ if (!value) return null
+
+ const at = value.indexOf('@')
+ if (at === -1) {
+ return { username: value, domain: null }
+ }
+
+ const username = value.slice(0, at)
+ const domain = value.slice(at + 1)
+ if (!username || !domain) return null
+ return { username, domain }
+}
+
+function isLocalAccountDomain (domain, requestHost) {
+ if (!domain) return true
+
+ const norm = String(domain).toLowerCase()
+ const host = String(requestHost || '').toLowerCase()
+ if (!host) return false
+
+ const hostNoPort = host.includes(':') ? host.split(':')[0] : host
+ if (hostNoPort === 'localhost' || /^(127\.|10\.|192\.168\.|::1)/.test(hostNoPort)) {
+ // In local/dev mode (IP or localhost host headers), allow acct domains.
+ return true
+ }
+
+ if (norm === host) return true
+
+ // In subdomain mode allow sibling pod hosts under the same base domain.
+ const firstDot = host.indexOf('.')
+ if (firstDot === -1 || firstDot === host.length - 1) return false
+ const base = host.slice(firstDot + 1)
+ return norm.endsWith(`.${base}`)
+}
+
+function normalizeAccountIdentifier (idOrAcct) {
+ const parsed = parseAccountIdentifier(idOrAcct)
+ return parsed?.username || null
+}
+
+function getActorUrlFromWebfinger (resource) {
+ const links = Array.isArray(resource?.links) ? resource.links : []
+ const self = links.find((l) => {
+ if (l?.rel !== 'self' || !l?.href) return false
+ const t = String(l.type || '').toLowerCase()
+ return t.includes('activity+json') || t.includes('application/ld+json')
+ })
+ return self?.href || null
+}
+
+function buildRemoteAccountFromActor (actor, parsed) {
+ const actorId = actor?.id || actor?.url || ''
+ const profileUrl = typeof actor?.url === 'string'
+ ? actor.url
+ : String(actorId || '').replace(/#.*$/, '')
+
+ const username = actor?.preferredUsername || parsed.username
+ const acct = `${username}@${parsed.domain}`
+ const iconUrl = actor?.icon?.url || null
+ const imageUrl = actor?.image?.url || null
+
+ return {
+ id: acct,
+ username,
+ acct,
+ display_name: actor?.name || username,
+ locked: false,
+ bot: false,
+ discoverable: true,
+ group: false,
+ created_at: startedAt,
+ note: actor?.summary || '',
+ url: profileUrl || actorId || null,
+ avatar: iconUrl,
+ avatar_static: iconUrl,
+ header: imageUrl,
+ header_static: imageUrl,
+ followers_count: 0,
+ following_count: 0,
+ statuses_count: 0,
+ last_status_at: null,
+ emojis: [],
+ fields: [],
+ _remote: {
+ actorId,
+ inbox: actor?.inbox || actor?.endpoints?.sharedInbox || null
+ }
+ }
+}
+
+async function resolveRemoteAccount (parsed) {
+ const resource = `acct:${parsed.username}@${parsed.domain}`
+ const wfUrl = `https://${parsed.domain}/.well-known/webfinger?resource=${encodeURIComponent(resource)}`
+
+ let wfJson = null
+ try {
+ const wf = await safeFetch(wfUrl, {
+ headers: { Accept: 'application/jrd+json, application/json' }
+ }, { requireHttps: true, blockPrivateIPs: true, resolveDNS: true })
+ if (!wf.ok) return null
+ wfJson = await wf.json()
+ } catch {
+ return null
+ }
+
+ const actorUrl = getActorUrlFromWebfinger(wfJson)
+ if (!actorUrl) return null
+
+ let actor = getCachedActor(actorUrl)
+ if (!actor) {
+ try {
+ const actorRes = await safeFetch(actorUrl, {
+ headers: {
+ Accept: 'application/activity+json, application/ld+json; profile="https://www.w3.org/ns/activitystreams", application/json'
+ }
+ }, { requireHttps: true, blockPrivateIPs: true, resolveDNS: true })
+ if (!actorRes.ok) return null
+ actor = await actorRes.json()
+ if (actor?.id) cacheActor(actor)
+ } catch {
+ return null
+ }
+ }
+
+ if (!actor || typeof actor !== 'object') return null
+ return buildRemoteAccountFromActor(actor, parsed)
+}
+
+/**
+ * GET /api/v1/timelines/home
+ * Return authenticated user's federated timeline
+ */
+export function createTimelinesHomeHandler () {
+ return async (request, reply) => {
+ const auth = await getWebIdFromRequestAsync(request)
+ if (!auth.webId) {
+ return reply.code(401).send({ error: 'Unauthorized' })
+ }
+
+ const username = getUsernameFromWebId(auth.webId)
+ if (!username) {
+ return reply.code(400).send({ error: 'Invalid WebID' })
+ }
+
+ const protocol = request.headers['x-forwarded-proto'] || request.protocol
+ const host = request.headers['x-forwarded-host'] || request.hostname
+ const baseUrl = `${protocol}://${host}`
+
+ // Get posts from accounts this user follows
+ const following = getFollowing(username)
+ const statuses = []
+
+ for (const follow of following) {
+ const actor = follow.actor
+ // Extract username from actor URL
+ try {
+ const actorUrl = new URL(actor)
+ const followedUsername = actorUrl.hostname.split('.')[0]
+ const posts = getPosts(followedUsername, 50)
+ statuses.push(...posts.map(p => buildStatus(p, followedUsername, baseUrl)))
+ } catch {
+ // Skip invalid actor URLs
}
- })
+ }
+
+ // Also include own posts
+ const ownPosts = getPosts(username, 50)
+ statuses.push(...ownPosts.map(p => buildStatus(p, username, baseUrl)))
+
+ // Sort by date descending
+ statuses.sort((a, b) => new Date(b.created_at) - new Date(a.created_at))
+
+ // Limit to 20 latest
+ return reply.send(statuses.slice(0, 20))
+ }
+}
+
+/**
+ * POST /api/v1/statuses
+ * Create a new status (post/note)
+ */
+export function createPostStatusHandler () {
+ return async (request, reply) => {
+ const auth = await getWebIdFromRequestAsync(request)
+ if (!auth.webId) {
+ return reply.code(401).send({ error: 'Unauthorized' })
+ }
+
+ const username = getUsernameFromWebId(auth.webId)
+ if (!username) {
+ return reply.code(400).send({ error: 'Invalid WebID' })
+ }
+
+ const protocol = request.headers['x-forwarded-proto'] || request.protocol
+ const host = request.headers['x-forwarded-host'] || request.hostname
+ const baseUrl = `${protocol}://${host}`
+
+ const body = parseBody(request)
+ const { status, in_reply_to_id } = body
+ if (!status) {
+ return reply.code(422).send({ error: 'status is required' })
+ }
+
+ const postId = `${baseUrl}/posts/${randomUUID()}`
+ const now = new Date().toISOString()
+
+ // Save to AP store
+ const { savePost } = await import('../store.js')
+ savePost(username, postId, status, in_reply_to_id || null)
+
+ // Return Mastodon status object
+ const post = {
+ id: postId,
+ content: status,
+ published: now,
+ in_reply_to: in_reply_to_id || null
+ }
+
+ return reply.code(200).send(buildStatus(post, username, baseUrl))
+ }
+}
+
+/**
+ * Resolve status identifier from request params.
+ * Supports both /api/v1/statuses/:id and wildcard /api/v1/statuses/* forms.
+ */
+function getStatusIdParam (request) {
+ return request.params?.id || request.params?.['*'] || null
+}
+
+function resolvePostByStatusId (rawId, baseUrl) {
+ if (!rawId) return null
+
+ // /source and /history suffixes are endpoint modifiers
+ let normalized = rawId
+ if (normalized.endsWith('/source')) normalized = normalized.slice(0, -('/source'.length))
+ if (normalized.endsWith('/history')) normalized = normalized.slice(0, -('/history'.length))
+
+ // Full URL IDs are canonical in our store
+ if (normalized.startsWith('http://') || normalized.startsWith('https://')) {
+ return getPostById(normalized)
+ }
+
+ // Bare ID fallback
+ const canonical = `${baseUrl}/posts/${normalized}`
+ return getPostById(canonical) || getPostById(normalized)
+}
+
+function deriveActorFromPostUrl (statusId) {
+ try {
+ const parsed = new URL(statusId)
+ const pathParts = parsed.pathname.split('/').filter(Boolean)
+ if (pathParts.length < 2 || pathParts[0] !== 'posts') return null
+
+ // In subdomain mode: bob.example.org/posts/uuid -> actor bob.example.org/profile/card.jsonld#me
+ const hostParts = parsed.hostname.split('.')
+ if (hostParts.length < 2) return null
+ const actorBase = `${parsed.protocol}//${parsed.host}`
+ return {
+ actorId: `${actorBase}/profile/card.jsonld#me`,
+ inbox: `${actorBase}/profile/card.jsonld/inbox`
+ }
+ } catch {
+ return null
+ }
+}
+
+/**
+ * GET /api/v1/statuses/:id and /api/v1/statuses/:id/source
+ * Returns a status object, or source payload for editing.
+ */
+export function createGetStatusHandler () {
+ return async (request, reply) => {
+ const protocol = request.headers['x-forwarded-proto'] || request.protocol
+ const host = request.headers['x-forwarded-host'] || request.hostname
+ const baseUrl = `${protocol}://${host}`
+
+ const rawId = getStatusIdParam(request)
+ if (!rawId) {
+ return reply.code(400).send({ error: 'Status ID is required' })
+ }
+
+ const sourceMode = rawId.endsWith('/source')
+ const historyMode = rawId.endsWith('/history')
+ const post = resolvePostByStatusId(rawId, baseUrl)
+ if (!post) {
+ return reply.code(404).send({ error: 'Status not found' })
+ }
+
+ if (historyMode) {
+ return reply.send([
+ {
+ content: post.content,
+ spoiler_text: '',
+ sensitive: false,
+ created_at: post.published || new Date().toISOString(),
+ account: buildAccount(post.username, baseUrl)
+ }
+ ])
+ }
+
+ if (!sourceMode) {
+ return reply.send(buildStatus(post, post.username, baseUrl))
+ }
+
+ // /source is for editing own posts: require auth and ownership.
+ const auth = await getWebIdFromRequestAsync(request)
+ if (!auth.webId) {
+ return reply.code(401).send({ error: 'Unauthorized' })
+ }
+ const username = getUsernameFromWebId(auth.webId)
+ if (!username || username !== post.username) {
+ return reply.code(404).send({ error: 'Status not found' })
+ }
+
+ return reply.send({
+ id: post.id,
+ text: post.content,
+ spoiler_text: '',
+ sensitive: false
+ })
+ }
+}
+
+
+/**
+ * PUT /api/v1/statuses/:id
+ * Edit a status content.
+ */
+export function createUpdateStatusHandler () {
+ return async (request, reply) => {
+ const auth = await getWebIdFromRequestAsync(request)
+ if (!auth.webId) {
+ return reply.code(401).send({ error: 'Unauthorized' })
+ }
+
+ const username = getUsernameFromWebId(auth.webId)
+ if (!username) {
+ return reply.code(400).send({ error: 'Invalid WebID' })
+ }
+
+ const protocol = request.headers['x-forwarded-proto'] || request.protocol
+ const host = request.headers['x-forwarded-host'] || request.hostname
+ const baseUrl = `${protocol}://${host}`
+
+ const rawId = getStatusIdParam(request)
+ if (!rawId) {
+ return reply.code(400).send({ error: 'Status ID is required' })
+ }
+
+ const body = parseBody(request)
+ const { status } = body
+ if (!status) {
+ return reply.code(422).send({ error: 'status is required' })
+ }
+
+ // Accept both canonical URL IDs and bare IDs.
+ let postId = rawId
+ if (!postId.startsWith('http://') && !postId.startsWith('https://')) {
+ postId = `${baseUrl}/posts/${postId}`
+ }
+
+ let post = getPost(username, postId)
+ if (!post) {
+ // Fallback for edge cases where host/protocol differs from request host.
+ post = getPostById(rawId) || getPostById(postId)
+ if (!post || post.username !== username) {
+ return reply.code(404).send({ error: 'Status not found' })
+ }
+ postId = post.id
+ }
+
+ updatePost(username, postId, status)
+ const updated = getPost(username, postId)
+
+ return reply.send(buildStatus(updated || { ...post, content: status }, username, baseUrl))
+ }
+}
+
+/**
+ * POST /api/v1/statuses/:id/favourite
+ * Favourite (Like) a status.
+ */
+export function createFavouriteStatusHandler (getUserConfig) {
+ return async (request, reply) => {
+ const auth = await getWebIdFromRequestAsync(request)
+ if (!auth.webId) {
+ return reply.code(401).send({ error: 'Unauthorized' })
+ }
+
+ const username = getUsernameFromWebId(auth.webId)
+ if (!username) {
+ return reply.code(400).send({ error: 'Invalid WebID' })
+ }
+
+ const protocol = request.headers['x-forwarded-proto'] || request.protocol
+ const host = request.headers['x-forwarded-host'] || request.hostname
+ const baseUrl = `${protocol}://${host}`
+
+ let rawId = getStatusIdParam(request)
+ if (!rawId) {
+ return reply.code(400).send({ error: 'Status ID is required' })
+ }
+ if (rawId.endsWith('/favourite')) {
+ rawId = rawId.slice(0, -('/favourite'.length))
+ }
+
+ const targetStatusId = (rawId.startsWith('http://') || rawId.startsWith('https://'))
+ ? rawId
+ : `${baseUrl}/posts/${rawId}`
+
+ const localPost = resolvePostByStatusId(rawId, baseUrl)
+
+ // Best-effort AP Like delivery for remote posts.
+ const requesterActorId = `${baseUrl}/profile/card.jsonld#me`
+ const requesterConfig = typeof getUserConfig === 'function' ? getUserConfig(request) : {}
+ const requesterKeypair = requesterConfig?.keypair
+ const remoteTarget = deriveActorFromPostUrl(targetStatusId)
+ if (requesterKeypair && remoteTarget?.inbox && !targetStatusId.startsWith(`${baseUrl}/`)) {
+ const likeActivity = {
+ '@context': 'https://www.w3.org/ns/activitystreams',
+ id: `${requesterActorId}/activities/${randomUUID()}`,
+ type: 'Like',
+ actor: requesterActorId,
+ object: targetStatusId
+ }
+ sendSignedActivity(likeActivity, remoteTarget.inbox, requesterActorId, requesterKeypair, request.log)
+ }
+
+ if (localPost) {
+ const status = buildStatus(localPost, localPost.username, baseUrl)
+ return reply.send({
+ ...status,
+ favourited: true,
+ favourites_count: Number(status.favourites_count || 0) + 1
+ })
+ }
+
+ const accountId = targetStatusId.startsWith('http')
+ ? (() => {
+ try {
+ return new URL(targetStatusId).hostname.split('.')[0] || 'unknown'
+ } catch {
+ return 'unknown'
+ }
+ })()
+ : 'unknown'
+
+ return reply.send({
+ id: targetStatusId,
+ uri: targetStatusId,
+ url: targetStatusId,
+ created_at: new Date().toISOString(),
+ account: buildAccount(accountId, baseUrl),
+ content: '',
+ visibility: 'public',
+ reblogs_count: 0,
+ favourites_count: 1,
+ replies_count: 0,
+ favourited: true,
+ reblogged: false,
+ muted: false,
+ bookmarked: false,
+ pinned: false,
+ language: 'en',
+ text: '',
+ edited_at: null,
+ poll: null,
+ card: null
+ })
+ }
+}
+
+/**
+ * GET /api/v1/accounts/:id
+ * Get account info by username or ID
+ */
+export function createGetAccountHandler () {
+ return async (request, reply) => {
+ const { id } = request.params
+ const accountId = normalizeAccountIdentifier(id)
+ if (!accountId) {
+ return reply.code(400).send({ error: 'ID is required' })
+ }
+
+ const protocol = request.headers['x-forwarded-proto'] || request.protocol
+ const host = request.headers['x-forwarded-host'] || request.hostname
+ const baseUrl = `${protocol}://${host}`
+
+ return reply.send(buildAccount(accountId, baseUrl))
+ }
+}
+
+/**
+ * GET /api/v1/accounts/:id/statuses
+ * Get statuses for a user
+ */
+export function createGetAccountStatusesHandler () {
+ return async (request, reply) => {
+ const { id } = request.params
+ const accountId = normalizeAccountIdentifier(id)
+ if (!accountId) {
+ return reply.code(400).send({ error: 'ID is required' })
+ }
+
+ const protocol = request.headers['x-forwarded-proto'] || request.protocol
+ const host = request.headers['x-forwarded-host'] || request.hostname
+ const baseUrl = `${protocol}://${host}`
+
+ const posts = getPosts(accountId, 20)
+ const statuses = posts.map(p => buildStatus(p, accountId, baseUrl))
+
+ return reply.send(statuses)
+ }
+}
+
+/**
+ * GET /api/v1/preferences
+ * Minimal preference payload for Mastodon clients.
+ */
+export function createPreferencesHandler () {
+ return async (request, reply) => {
+ const auth = await getWebIdFromRequestAsync(request)
+ if (!auth.webId) {
+ return reply.code(401).send({ error: 'Unauthorized' })
+ }
+
+ return reply.send({
+ 'posting:default:visibility': 'public',
+ 'posting:default:sensitive': false,
+ 'posting:default:language': 'en',
+ 'reading:expand:media': 'default',
+ 'reading:expand:spoilers': false
+ })
+ }
+}
+
+/**
+ * GET /api/v1/accounts/relationships?id[]=...
+ */
+export function createRelationshipsHandler () {
+ return async (request, reply) => {
+ const auth = await getWebIdFromRequestAsync(request)
+ if (!auth.webId) {
+ return reply.code(401).send({ error: 'Unauthorized' })
+ }
+
+ const username = getUsernameFromWebId(auth.webId)
+ if (!username) {
+ return reply.code(400).send({ error: 'Invalid WebID' })
+ }
+
+ const raw = request.query?.id ?? request.query?.['id[]']
+ const ids = Array.isArray(raw) ? raw : (raw ? [raw] : [])
+
+ const following = new Set(
+ getFollowing(username)
+ .map(f => normalizeAccountIdentifier(f.actor))
+ .filter(Boolean)
+ )
+
+ const followers = new Set(
+ getFollowers(username)
+ .map(f => normalizeAccountIdentifier(f.actor))
+ .filter(Boolean)
+ )
+
+ const relationships = ids.map((id) => {
+ const normalized = normalizeAccountIdentifier(id)
+ return {
+ id,
+ following: normalized ? following.has(normalized) : false,
+ showing_reblogs: true,
+ notifying: false,
+ followed_by: normalized ? followers.has(normalized) : false,
+ blocking: false,
+ blocked_by: false,
+ muting: false,
+ muting_notifications: false,
+ requested: false,
+ domain_blocking: false,
+ endorsed: false,
+ note: ''
+ }
+ })
+
+ return reply.send(relationships)
+ }
+}
+
+/**
+ * GET /api/v1/lists
+ * Minimal compatibility: return no lists.
+ */
+export function createListsHandler () {
+ return async (request, reply) => {
+ const auth = await getWebIdFromRequestAsync(request)
+ if (!auth.webId) {
+ return reply.code(401).send({ error: 'Unauthorized' })
+ }
+ return reply.send([])
+ }
+}
+
+/**
+ * GET /api/v1/accounts/:id/lists
+ * Minimal compatibility: account is not in any lists.
+ */
+export function createAccountListsHandler () {
+ return async (request, reply) => {
+ const auth = await getWebIdFromRequestAsync(request)
+ if (!auth.webId) {
+ return reply.code(401).send({ error: 'Unauthorized' })
+ }
+ return reply.send([])
+ }
+}
+
+/**
+ * Extract a canonical URL from Mastodon search query text.
+ * Supports raw URLs and Phanpy-style wrapped links like:
+ * alice.host/s/https://alice.host/posts/
+ */
+function extractSearchUrl (query) {
+ if (!query || typeof query !== 'string') return null
+
+ // If query embeds "https://" in the middle (Phanpy /s/ form), keep the URL part.
+ const embeddedHttps = query.indexOf('https://')
+ if (embeddedHttps > 0) {
+ return query.slice(embeddedHttps)
+ }
+
+ // Regular URL query
+ if (query.startsWith('http://') || query.startsWith('https://')) {
+ return query
+ }
+
+ return null
+}
+
+/**
+ * GET /api/v2/search
+ * Minimal Mastodon search implementation for clients resolving status URLs and accounts.
+ */
+export function createSearchHandler () {
+ return async (request, reply) => {
+ const q = request.query?.q || ''
+ const protocol = request.headers['x-forwarded-proto'] || request.protocol
+ const host = request.headers['x-forwarded-host'] || request.hostname
+ const baseUrl = `${protocol}://${host}`
+
+ const result = {
+ accounts: [],
+ statuses: [],
+ hashtags: []
+ }
+
+ // Account lookup fallback for plain handles/acct strings (bob, @bob, bob@example.org)
+ const parsed = parseAccountIdentifier(String(q).trim())
+ if (parsed) {
+ if (isLocalAccountDomain(parsed.domain, host)) {
+ result.accounts.push(buildAccount(parsed.username, baseUrl))
+ } else {
+ const remote = await resolveRemoteAccount(parsed)
+ if (remote) result.accounts.push(remote)
+ }
+ }
+
+ const targetUrl = extractSearchUrl(q)
+ if (!targetUrl) {
+ return reply.send(result)
+ }
+
+ // Try direct ID match first (posts are stored as full URL IDs)
+ let post = getPostById(targetUrl)
+
+ // Fallback: if URL path is /posts/:id, reconstruct canonical ID with origin
+ if (!post) {
+ try {
+ const u = new URL(targetUrl)
+ const match = u.pathname.match(/^\/posts\/([^/?#]+)$/)
+ if (match) {
+ const canonical = `${u.origin}/posts/${match[1]}`
+ post = getPostById(canonical)
+ }
+ } catch {
+ // Ignore invalid URLs and return empty result below
+ }
+ }
+
+ if (!post) {
+ return reply.send(result)
+ }
+
+ result.statuses.push(buildStatus(post, post.username, baseUrl))
+ return reply.send(result)
+ }
+}
+
+/**
+ * GET /api/v1/accounts/search
+ * Minimal account search used by some Mastodon clients.
+ */
+export function createAccountsSearchHandler () {
+ return async (request, reply) => {
+ const q = String(request.query?.q || '').trim()
+ if (!q) return reply.send([])
+
+ const protocol = request.headers['x-forwarded-proto'] || request.protocol
+ const host = request.headers['x-forwarded-host'] || request.hostname
+ const parsed = parseAccountIdentifier(q)
+ if (!parsed) return reply.send([])
+
+ const baseUrl = `${protocol}://${host}`
+ if (!isLocalAccountDomain(parsed.domain, host)) {
+ const remote = await resolveRemoteAccount(parsed)
+ return reply.send(remote ? [remote] : [])
+ }
+
+ return reply.send([buildAccount(parsed.username, baseUrl)])
+ }
+}
+
+/**
+ * POST /api/v1/accounts/:id/follow
+ * Follow a user
+ */
+export function createFollowAccountHandler (getUserConfig) {
+ return async (request, reply) => {
+ const auth = await getWebIdFromRequestAsync(request)
+ if (!auth.webId) {
+ return reply.code(401).send({ error: 'Unauthorized' })
+ }
+
+ const { id: rawTargetId } = request.params
+ if (!rawTargetId) {
+ return reply.code(400).send({ error: 'Target ID is required' })
+ }
+
+ // Derive requester identity from request subdomain (reliable in subdomain mode)
+ // This is more reliable than extracting from the token webId which may vary
+ const protocol = request.headers['x-forwarded-proto'] || request.protocol
+ const uc = typeof getUserConfig === 'function' ? getUserConfig(request) : {}
+ // In single-host mode uc.username may be 'me' — override with token webId
+ const username = (uc.username && uc.username !== 'me')
+ ? uc.username
+ : (getUsernameFromWebId(auth.webId) || uc.username)
+ if (!username) {
+ return reply.code(400).send({ error: 'Cannot determine requester identity' })
+ }
+ const baseDomain = uc.baseDomain || request.headers['x-forwarded-host'] || request.hostname
+ const subdomains = uc.subdomains || false
+ const host = request.headers['x-forwarded-host'] || request.hostname
+
+ const targetParsed = parseAccountIdentifier(rawTargetId)
+ if (!targetParsed) {
+ return reply.code(400).send({ error: 'Invalid target account identifier' })
+ }
+
+ const isLocalTarget = isLocalAccountDomain(targetParsed.domain, host)
+
+ // Build actor URLs — in subdomain mode use subdomain-style, otherwise path-based
+ let targetActorId, requesterActorId, requesterInbox
+ if (subdomains && baseDomain) {
+ requesterActorId = `${protocol}://${username}.${baseDomain}/profile/card.jsonld#me`
+ requesterInbox = `${protocol}://${username}.${baseDomain}/profile/card.jsonld/inbox`
+ } else {
+ requesterActorId = `${protocol}://${host}/${username}/profile/card.jsonld#me`
+ requesterInbox = `${protocol}://${host}/${username}/profile/card.jsonld/inbox`
+ }
+
+ let remoteInbox = null
+ if (isLocalTarget) {
+ const targetUsername = targetParsed.username
+ if (subdomains && baseDomain) {
+ targetActorId = `${protocol}://${targetUsername}.${baseDomain}/profile/card.jsonld#me`
+ } else {
+ targetActorId = `${protocol}://${host}/${targetUsername}/profile/card.jsonld#me`
+ }
+ } else {
+ const remote = await resolveRemoteAccount(targetParsed)
+ if (!remote?._remote?.actorId) {
+ return reply.code(404).send({ error: 'Remote account not found' })
+ }
+ targetActorId = remote._remote.actorId
+ remoteInbox = remote._remote.inbox || null
+ }
+
+ // Add to requester's following list (pending until Accept received for remote)
+ addFollowing(username, targetActorId, isLocalTarget)
+
+ // Add to target's followers list only for local targets.
+ if (isLocalTarget) {
+ addFollower(targetParsed.username, requesterActorId, requesterInbox)
+ } else if (remoteInbox) {
+ // Deliver outbound Follow activity to remote actor's inbox
+ const uc = typeof getUserConfig === 'function' ? getUserConfig(request) : {}
+ const keypair = uc.keypair
+ if (keypair) {
+ const followActivity = {
+ '@context': 'https://www.w3.org/ns/activitystreams',
+ id: `${requesterActorId}/activities/${randomUUID()}`,
+ type: 'Follow',
+ actor: requesterActorId,
+ object: targetActorId
+ }
+ sendSignedActivity(followActivity, remoteInbox, requesterActorId, keypair, request.log)
+ }
+ }
+
+ // Return relationship
+ return reply.send({
+ id: rawTargetId,
+ following: true,
+ showing_reblogs: true,
+ notifying: false,
+ languages: ['en'],
+ blocked: false,
+ blocking: false,
+ muting: false,
+ muting_notifications: false,
+ requested: false,
+ domain_blocking: false
+ })
+ }
+}
+
+/**
+ * GET /api/v1/notifications
+ * Get notifications (follows, likes, etc)
+ * For now, return empty - would need extended AP model
+ */
+export function createGetNotificationsHandler (getUserConfig) {
+ return async (request, reply) => {
+ const auth = await getWebIdFromRequestAsync(request)
+ if (!auth.webId) {
+ return reply.code(401).send({ error: 'Unauthorized' })
+ }
+
+ const protocol = request.headers['x-forwarded-proto'] || request.protocol
+ const host = request.headers['x-forwarded-host'] || request.hostname
+ const baseUrl = `${protocol}://${host}`
+ const uc = typeof getUserConfig === 'function' ? getUserConfig(request) : getUserConfig
+ const currentUsername = (uc.username && uc.username !== 'me')
+ ? uc.username
+ : (getUsernameFromWebId(auth.webId) || uc.username)
+
+ // Build follow notifications from the followers table
+ const followers = getFollowers(currentUsername)
+ const notifications = followers
+ .map((f, i) => {
+ // actor may be "https://bob.host/profile/card.jsonld#me" — strip fragment first
+ let actorUrl = f.actor || ''
+ try { actorUrl = actorUrl.split('#')[0] } catch { /* keep */ }
+ // Use getUsernameFromWebId which handles both subdomain and path-based WebIDs
+ const followerUsername = getUsernameFromWebId(actorUrl + '#me') || getUsernameFromWebId(actorUrl)
+ // Skip if we can't determine follower or it's the user themselves
+ if (!followerUsername || followerUsername === currentUsername) return null
+ const followerBase = (() => {
+ try {
+ const u = new URL(actorUrl)
+ return `${u.protocol}//${u.host}`
+ } catch { return baseUrl }
+ })()
+ return {
+ id: String(i + 1),
+ type: 'follow',
+ created_at: f.created_at ? new Date(f.created_at).toISOString() : new Date().toISOString(),
+ account: buildAccount(followerUsername, followerBase)
+ }
+ })
+ .filter(Boolean)
+
+ return reply.send(notifications)
}
}
@@ -139,6 +1650,7 @@ export function createInstanceHandler (config) {
* Look up a registered client
*/
export function getClient (clientId) {
+ ensureClientsLoaded()
return clients.get(clientId) || null
}
@@ -149,6 +1661,16 @@ function escapeHtml (str) {
export default {
createAppsHandler,
createVerifyCredentialsHandler,
+ createUpdateCredentialsHandler,
+ createAccountLookupHandler,
+ createAccountsSearchHandler,
+ createPreferencesHandler,
+ createListsHandler,
+ createAccountListsHandler,
+ createRelationshipsHandler,
createInstanceHandler,
+ createSearchHandler,
+ createFavouriteStatusHandler,
+ createUpdateStatusHandler,
getClient
}
diff --git a/src/ap/routes/outbox.js b/src/ap/routes/outbox.js
index f063465..069877c 100644
--- a/src/ap/routes/outbox.js
+++ b/src/ap/routes/outbox.js
@@ -5,8 +5,42 @@
*/
import { outbox } from 'microfed'
-import { getPosts, savePost, getFollowerInboxes } from '../store.js'
-import { randomUUID } from 'crypto'
+import { Agent } from 'undici'
+import { createSign, randomUUID } from 'crypto'
+import { getPosts, getPost, getPostById, savePost, getFollowerInboxes } from '../store.js'
+
+// Allow self-signed certs when delivering to local inboxes
+const insecureAgent = new Agent({ connect: { rejectUnauthorized: false } })
+
+/**
+ * Send a signed ActivityPub POST to an inbox, tolerating self-signed TLS
+ */
+async function sendSigned(inbox, activity, privateKey, keyId) {
+ const body = JSON.stringify(activity)
+ const inboxUrl = new URL(inbox)
+ const date = new Date().toUTCString()
+ const digest = `SHA-256=${Buffer.from(
+ await crypto.subtle.digest('SHA-256', new TextEncoder().encode(body))
+ ).toString('base64')}`
+ const signingString = `(request-target): post ${inboxUrl.pathname}\nhost: ${inboxUrl.host}\ndate: ${date}\ndigest: ${digest}`
+ const signer = createSign('RSA-SHA256')
+ signer.update(signingString)
+ const signature = signer.sign(privateKey, 'base64')
+ const signatureHeader = `keyId="${keyId}",algorithm="rsa-sha256",headers="(request-target) host date digest",signature="${signature}"`
+
+ return fetch(inbox, {
+ method: 'POST',
+ dispatcher: insecureAgent,
+ headers: {
+ 'Content-Type': 'application/activity+json',
+ 'Accept': 'application/activity+json',
+ 'Date': date,
+ 'Digest': digest,
+ 'Signature': signatureHeader
+ },
+ body
+ })
+}
/**
* Create outbox handler
@@ -22,7 +56,7 @@ export function createOutboxHandler(config, keypair) {
const profileUrl = `${baseUrl}/profile/card.jsonld`
const actorId = `${profileUrl}#me`
- const posts = getPosts(20)
+ const posts = getPosts(config.username, 20)
const collection = {
'@context': 'https://www.w3.org/ns/activitystreams',
@@ -69,9 +103,10 @@ export function createOutboxPostHandler(config, keypair) {
// Parse body
let activity
try {
- activity = typeof request.body === 'string'
- ? JSON.parse(request.body)
- : request.body
+ const raw = typeof request.body === 'string'
+ ? request.body
+ : request.body.toString()
+ activity = JSON.parse(raw)
} catch {
return reply.code(400).send({ error: 'Invalid JSON' })
}
@@ -108,6 +143,7 @@ export function createOutboxPostHandler(config, keypair) {
// Save post
if (activity.type === 'Create' && activity.object?.type === 'Note') {
savePost(
+ config.username,
activity.object.id,
activity.object.content,
activity.object.inReplyTo || null
@@ -115,19 +151,12 @@ export function createOutboxPostHandler(config, keypair) {
}
// Deliver to followers
- const inboxes = getFollowerInboxes()
+ const inboxes = getFollowerInboxes(config.username)
request.log.info(`Delivering to ${inboxes.length} follower(s)`)
const keyId = `${profileUrl}#main-key`
const deliveryResults = await Promise.allSettled(
- inboxes.map(inbox =>
- outbox.send({
- activity,
- inbox,
- privateKey: keypair.privateKey,
- keyId
- })
- )
+ inboxes.map(inbox => sendSigned(inbox, activity, keypair.privateKey, keyId))
)
const succeeded = deliveryResults.filter(r => r.status === 'fulfilled').length
@@ -146,4 +175,49 @@ export function createOutboxPostHandler(config, keypair) {
}
}
-export default { createOutboxHandler, createOutboxPostHandler }
+/**
+ * Create post object handler
+ * GET /posts/:id returns ActivityStreams Note object for permalink fetches.
+ *
+ * @param {object} config - AP configuration
+ * @returns {Function} Fastify handler
+ */
+export function createPostObjectHandler(config) {
+ return async (request, reply) => {
+ const postId = `${request.params.id}`
+ const protocol = request.headers['x-forwarded-proto'] || request.protocol
+ const host = request.headers['x-forwarded-host'] || request.hostname
+ const baseUrl = `${protocol}://${host}`
+ const profileUrl = `${baseUrl}/profile/card.jsonld`
+ const actorId = `${profileUrl}#me`
+
+ // Stored post IDs are full URLs. Try direct URL match first, then fallback.
+ const fullId = `${baseUrl}/posts/${postId}`
+ let post = getPost(config.username, fullId)
+ if (!post) post = getPost(config.username, postId)
+ if (!post) post = getPostById(fullId)
+ if (!post) post = getPostById(postId)
+
+ if (!post) {
+ return reply.code(404).send({ error: 'Post not found' })
+ }
+
+ const note = {
+ '@context': 'https://www.w3.org/ns/activitystreams',
+ type: 'Note',
+ id: post.id,
+ content: post.content,
+ published: post.published,
+ attributedTo: actorId,
+ to: ['https://www.w3.org/ns/activitystreams#Public'],
+ cc: [`${profileUrl}/followers`],
+ ...(post.in_reply_to ? { inReplyTo: post.in_reply_to } : {})
+ }
+
+ return reply
+ .header('Content-Type', 'application/activity+json')
+ .send(note)
+ }
+}
+
+export default { createOutboxHandler, createOutboxPostHandler, createPostObjectHandler }
diff --git a/src/ap/store.js b/src/ap/store.js
index 7116c03..c58b6f8 100644
--- a/src/ap/store.js
+++ b/src/ap/store.js
@@ -7,48 +7,60 @@
*/
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs'
-import { dirname } from 'path'
+import { dirname, join } from 'path'
let db = null
let dbPath = null
-// SQL schema
+// SQL schema — username column on all user-scoped tables
const SCHEMA = `
-- Followers (people following us)
CREATE TABLE IF NOT EXISTS followers (
- id TEXT PRIMARY KEY,
+ id TEXT NOT NULL,
+ username TEXT NOT NULL,
actor TEXT NOT NULL,
inbox TEXT,
- created_at TEXT DEFAULT CURRENT_TIMESTAMP
+ created_at TEXT DEFAULT CURRENT_TIMESTAMP,
+ PRIMARY KEY (id, username)
);
+ CREATE INDEX IF NOT EXISTS idx_followers_username ON followers(username);
-- Following (people we follow)
CREATE TABLE IF NOT EXISTS following (
- id TEXT PRIMARY KEY,
+ id TEXT NOT NULL,
+ username TEXT NOT NULL,
actor TEXT NOT NULL,
accepted INTEGER DEFAULT 0,
- created_at TEXT DEFAULT CURRENT_TIMESTAMP
+ created_at TEXT DEFAULT CURRENT_TIMESTAMP,
+ PRIMARY KEY (id, username)
);
+ CREATE INDEX IF NOT EXISTS idx_following_username ON following(username);
-- Activities (inbox)
CREATE TABLE IF NOT EXISTS activities (
- id TEXT PRIMARY KEY,
+ id TEXT NOT NULL,
+ username TEXT NOT NULL,
type TEXT NOT NULL,
actor TEXT,
object TEXT,
raw TEXT NOT NULL,
- created_at TEXT DEFAULT CURRENT_TIMESTAMP
+ created_at TEXT DEFAULT CURRENT_TIMESTAMP,
+ PRIMARY KEY (id, username)
);
+ CREATE INDEX IF NOT EXISTS idx_activities_username ON activities(username);
-- Posts (our outbox)
CREATE TABLE IF NOT EXISTS posts (
- id TEXT PRIMARY KEY,
+ id TEXT NOT NULL,
+ username TEXT NOT NULL,
content TEXT NOT NULL,
in_reply_to TEXT,
- published TEXT DEFAULT CURRENT_TIMESTAMP
+ published TEXT DEFAULT CURRENT_TIMESTAMP,
+ PRIMARY KEY (id, username)
);
+ CREATE INDEX IF NOT EXISTS idx_posts_username ON posts(username);
- -- Known actors (cache)
+ -- Known actors (cache — global, not per-user)
CREATE TABLE IF NOT EXISTS actors (
id TEXT PRIMARY KEY,
data TEXT NOT NULL,
@@ -56,27 +68,37 @@ const SCHEMA = `
);
`
+/**
+ * Get the default DB path under DATA_ROOT/{username}/.ap/
+ */
+function getDefaultDbPath(username = 'me') {
+ const dataRoot = process.env.DATA_ROOT || './data'
+ return join(dataRoot, username, '.ap', 'activitypub.db')
+}
+
/**
* Initialize the database
- * Uses sql.js (WASM) for cross-platform compatibility
- * @param {string} path - Path to SQLite file
+ * @param {string} [path] - Path to SQLite file (defaults to {DATA_ROOT}/{username}/.ap/activitypub.db)
+ * @param {string} [username] - Username used when defaulting path (default: me)
*/
-export async function initStore(path = 'data/activitypub.db') {
+export async function initStore(path, username = 'me') {
+ const resolvedPath = path || getDefaultDbPath(username)
+
// Ensure directory exists
- const dir = dirname(path)
+ const dir = dirname(resolvedPath)
if (!existsSync(dir)) {
mkdirSync(dir, { recursive: true })
}
- dbPath = path
+ dbPath = resolvedPath
// Use sql.js (WASM, works everywhere)
const initSqlJs = (await import('sql.js')).default
const SQL = await initSqlJs()
// Load existing database if it exists
- if (existsSync(path)) {
- const buffer = readFileSync(path)
+ if (existsSync(resolvedPath)) {
+ const buffer = readFileSync(resolvedPath)
db = new SQL.Database(buffer)
} else {
db = new SQL.Database()
@@ -142,64 +164,72 @@ function getAll(sql, params = []) {
// Followers
-export function addFollower(actorId, inbox) {
+export function addFollower(username, actorId, inbox) {
runStmt(
- 'INSERT OR REPLACE INTO followers (id, actor, inbox) VALUES (?, ?, ?)',
- [actorId, actorId, inbox]
+ 'INSERT OR REPLACE INTO followers (id, username, actor, inbox) VALUES (?, ?, ?, ?)',
+ [actorId, username, actorId, inbox]
)
}
-export function removeFollower(actorId) {
- runStmt('DELETE FROM followers WHERE id = ?', [actorId])
+export function removeFollower(username, actorId) {
+ runStmt('DELETE FROM followers WHERE id = ? AND username = ?', [actorId, username])
}
-export function getFollowers() {
- return getAll('SELECT * FROM followers ORDER BY created_at DESC')
+export function getFollowers(username) {
+ return getAll('SELECT * FROM followers WHERE username = ? ORDER BY created_at DESC', [username])
}
-export function getFollowerCount() {
- const row = getOne('SELECT COUNT(*) as count FROM followers')
+export function getFollowerCount(username) {
+ const row = getOne('SELECT COUNT(*) as count FROM followers WHERE username = ?', [username])
return row ? row.count : 0
}
-export function getFollowerInboxes() {
- return getAll('SELECT DISTINCT inbox FROM followers WHERE inbox IS NOT NULL')
+export function getFollowerInboxes(username) {
+ return getAll('SELECT DISTINCT inbox FROM followers WHERE username = ? AND inbox IS NOT NULL', [username])
.map(row => row.inbox)
}
// Following
-export function addFollowing(actorId, accepted = false) {
+export function addFollowing(username, actorId, accepted = false) {
runStmt(
- 'INSERT OR REPLACE INTO following (id, actor, accepted) VALUES (?, ?, ?)',
- [actorId, actorId, accepted ? 1 : 0]
+ 'INSERT OR REPLACE INTO following (id, username, actor, accepted) VALUES (?, ?, ?, ?)',
+ [actorId, username, actorId, accepted ? 1 : 0]
)
}
-export function acceptFollowing(actorId) {
- runStmt('UPDATE following SET accepted = 1 WHERE id = ?', [actorId])
+export function acceptFollowing(username, actorId) {
+ runStmt('UPDATE following SET accepted = 1 WHERE actor = ? AND username = ?', [actorId, username])
+ const row = getOne('SELECT id FROM following WHERE actor = ? AND username = ?', [actorId, username])
+ if (!row) {
+ runStmt(
+ 'INSERT INTO following (id, username, actor, accepted) VALUES (?, ?, ?, 1)',
+ [actorId, username, actorId]
+ )
+ }
}
-export function removeFollowing(actorId) {
- runStmt('DELETE FROM following WHERE id = ?', [actorId])
+export function removeFollowing(username, actorId) {
+ runStmt('DELETE FROM following WHERE id = ? AND username = ?', [actorId, username])
}
-export function getFollowing() {
- return getAll('SELECT * FROM following WHERE accepted = 1 ORDER BY created_at DESC')
+export function getFollowing(username) {
+ return getAll('SELECT * FROM following WHERE username = ? AND accepted = 1 ORDER BY created_at DESC', [username])
}
-export function getFollowingCount() {
- const row = getOne('SELECT COUNT(*) as count FROM following WHERE accepted = 1')
+export function getFollowingCount(username) {
+ const row = getOne('SELECT COUNT(*) as count FROM following WHERE username = ? AND accepted = 1', [username])
return row ? row.count : 0
}
// Activities
-export function saveActivity(activity) {
+export function saveActivity(username, activity) {
runStmt(
- 'INSERT OR REPLACE INTO activities (id, type, actor, object, raw) VALUES (?, ?, ?, ?, ?)',
+ 'INSERT OR REPLACE INTO activities (id, username, type, actor, object, raw) VALUES (?, ?, ?, ?, ?, ?)',
[
activity.id,
+ username,
activity.type,
typeof activity.actor === 'string' ? activity.actor : activity.actor?.id,
typeof activity.object === 'string' ? activity.object : JSON.stringify(activity.object),
@@ -208,8 +238,8 @@ export function saveActivity(activity) {
)
}
-export function getActivities(limit = 20) {
- return getAll('SELECT * FROM activities ORDER BY created_at DESC LIMIT ?', [limit])
+export function getActivities(username, limit = 20) {
+ return getAll('SELECT * FROM activities WHERE username = ? ORDER BY created_at DESC LIMIT ?', [username, limit])
.map(row => ({
...row,
raw: JSON.parse(row.raw)
@@ -218,27 +248,35 @@ export function getActivities(limit = 20) {
// Posts
-export function savePost(id, content, inReplyTo = null) {
+export function savePost(username, id, content, inReplyTo = null) {
runStmt(
- 'INSERT INTO posts (id, content, in_reply_to) VALUES (?, ?, ?)',
- [id, content, inReplyTo]
+ 'INSERT INTO posts (id, username, content, in_reply_to) VALUES (?, ?, ?, ?)',
+ [id, username, content, inReplyTo]
)
}
-export function getPosts(limit = 20) {
- return getAll('SELECT * FROM posts ORDER BY published DESC LIMIT ?', [limit])
+export function getPosts(username, limit = 20) {
+ return getAll('SELECT * FROM posts WHERE username = ? ORDER BY published DESC LIMIT ?', [username, limit])
}
-export function getPost(id) {
+export function getPost(username, id) {
+ return getOne('SELECT * FROM posts WHERE id = ? AND username = ?', [id, username])
+}
+
+export function getPostById(id) {
return getOne('SELECT * FROM posts WHERE id = ?', [id])
}
-export function getPostCount() {
- const row = getOne('SELECT COUNT(*) as count FROM posts')
+export function updatePost(username, id, content) {
+ runStmt('UPDATE posts SET content = ? WHERE id = ? AND username = ?', [content, id, username])
+}
+
+export function getPostCount(username) {
+ const row = getOne('SELECT COUNT(*) as count FROM posts WHERE username = ?', [username])
return row ? row.count : 0
}
-// Actor cache
+// Actor cache (global — not per-user)
export function cacheActor(actor) {
runStmt(
@@ -270,6 +308,8 @@ export default {
savePost,
getPosts,
getPost,
+ getPostById,
+ updatePost,
getPostCount,
cacheActor,
getCachedActor
diff --git a/src/auth/middleware.js b/src/auth/middleware.js
index 88007dd..611e067 100644
--- a/src/auth/middleware.js
+++ b/src/auth/middleware.js
@@ -7,8 +7,10 @@
import { getWebIdFromRequestAsync } from './token.js';
import { checkAccess, getRequiredMode } from '../wac/checker.js';
import { AccessMode } from '../wac/parser.js';
+import { parseN3Patch } from '../patch/n3-patch.js';
+import { parseSparqlUpdate } from '../patch/sparql-update.js';
import * as storage from '../storage/filesystem.js';
-import { getEffectiveUrlPath } from '../utils/url.js';
+import { getEffectiveUrlPath, getBaseDomainHost } from '../utils/url.js';
import { generateDatabrowserHtml, generateModuleDatabrowserHtml } from '../mashlib/index.js';
/**
@@ -26,8 +28,10 @@ import { generateDatabrowserHtml, generateModuleDatabrowserHtml } from '../mashl
export function buildResourceUrl(request, urlPath) {
// Use request.headers.host (includes port) instead of request.hostname (strips port)
const host = request.headers.host || request.hostname;
+ // request.hostname may include port — strip it for comparison
+ const hostnameOnly = request.hostname.includes(':') ? request.hostname.split(':')[0] : request.hostname;
if (request.subdomainsEnabled && request.baseDomain &&
- request.hostname === request.baseDomain && !request.podName) {
+ hostnameOnly === getBaseDomainHost(request.baseDomain) && !request.podName) {
const pathMatch = urlPath.match(/^\/([^/]+)(\/.*)?$/);
// Treat a path segment as a pod name only if it looks like one:
// - not a dotfile (.well-known, .acl, .meta, ...)
@@ -91,7 +95,13 @@ export async function authorize(request, reply, options = {}) {
const resourceUrl = buildResourceUrl(request, urlPath);
// Get required access mode - use override if provided, otherwise derive from method
- const requiredMode = options.requiredMode || getRequiredMode(method);
+ let requiredMode = options.requiredMode || getRequiredMode(method);
+
+ // PATCH can be authorized as Append when it is insert-only.
+ // Any delete operation (or parse ambiguity) stays Write.
+ if (!options.requiredMode && method === 'PATCH') {
+ requiredMode = getPatchRequiredMode(request, resourceUrl);
+ }
// For write operations on non-existent resources, check parent container
let checkPath = storagePath;
@@ -120,6 +130,36 @@ export async function authorize(request, reply, options = {}) {
return { authorized: allowed, webId, wacAllow, authError, paymentRequired, paid, balance, currency };
}
+/**
+ * Determine PATCH required mode from patch payload semantics.
+ * Insert-only patches require Append; delete-capable patches require Write.
+ */
+function getPatchRequiredMode(request, baseUri) {
+ const contentType = (request.headers['content-type'] || '').toLowerCase();
+ const rawBody = Buffer.isBuffer(request.body) ? request.body.toString() : request.body;
+
+ if (typeof rawBody !== 'string') {
+ return AccessMode.WRITE;
+ }
+
+ try {
+ if (contentType.includes('application/sparql-update')) {
+ const update = parseSparqlUpdate(rawBody, baseUri);
+ return update.deletes.length === 0 ? AccessMode.APPEND : AccessMode.WRITE;
+ }
+
+ if (contentType.includes('text/n3') || contentType.includes('application/n3')) {
+ const patch = parseN3Patch(rawBody, baseUri);
+ return patch.deletes.length === 0 ? AccessMode.APPEND : AccessMode.WRITE;
+ }
+ } catch {
+ // Fail closed to Write when patch parsing is invalid/ambiguous.
+ return AccessMode.WRITE;
+ }
+
+ return AccessMode.WRITE;
+}
+
/**
* Get parent container path
*/
@@ -155,10 +195,16 @@ export function handleUnauthorized(request, reply, isAuthenticated, wacAllow, au
// If mashlib is enabled, serve mashlib instead of static error page
// Mashlib has built-in login functionality via panes.runDataBrowser()
if (request.mashlibEnabled) {
+ // OIDC code-flow callbacks often land back on protected resources with
+ // ?code=...&state=...; return 200 so the browser shell can process the
+ // callback instead of getting stuck on an HTTP 401 page.
+ const isOidcCallback = request.method === 'GET' &&
+ typeof request.query?.code === 'string' &&
+ typeof request.query?.state === 'string';
const html = request.mashlibModule
? generateModuleDatabrowserHtml(request.mashlibModule)
: generateDatabrowserHtml(request.url, request.mashlibCdn ? request.mashlibVersion : null);
- return reply.code(statusCode).type('text/html').send(html);
+ return reply.code(isOidcCallback ? 200 : statusCode).type('text/html').send(html);
}
return reply.code(statusCode).type('text/html').send(getErrorPage(statusCode, isAuthenticated, request));
}
@@ -426,5 +472,60 @@ async function authorizeAclAccess(request, urlPath, method, webId, authError) {
requiredMode: AccessMode.CONTROL
});
+ // Owner fallback: allow ACL read/edit even if acl:Control is missing
+ // or the ACL document is invalid/unparseable.
+ if (!allowed && isAclOwnerAclMethod(method) && isAclOwner(request, protectedUrl, webId)) {
+ return {
+ authorized: true,
+ webId,
+ wacAllow: 'user="read write append control", public=""',
+ authError
+ };
+ }
+
return { authorized: allowed, webId, wacAllow, authError };
}
+
+function isAclMutationMethod(method) {
+ const m = (method || '').toUpperCase();
+ return m === 'PUT' || m === 'PATCH' || m === 'DELETE' || m === 'POST';
+}
+
+function isAclOwnerAclMethod(method) {
+ const m = (method || '').toUpperCase();
+ return m === 'GET' || m === 'HEAD' || isAclMutationMethod(m);
+}
+
+function isAclOwner(request, protectedUrl, webId) {
+ if (!webId) return false;
+
+ const candidates = getOwnerWebIdCandidates(request, protectedUrl);
+ return candidates.includes(webId);
+}
+
+function getOwnerWebIdCandidates(request, protectedUrl) {
+ let parsed;
+ try {
+ parsed = new URL(protectedUrl);
+ } catch {
+ return [];
+ }
+
+ const origin = parsed.origin;
+ const pathSegments = parsed.pathname.split('/').filter(Boolean);
+
+ // Path-based multi-user mode: first path segment is pod name.
+ if (!request.subdomainsEnabled && !request.singleUser && pathSegments.length > 0 && !pathSegments[0].startsWith('.')) {
+ const podName = pathSegments[0];
+ return [
+ `${origin}/${podName}/profile/card.jsonld#me`,
+ `${origin}/${podName}/profile/card#me`
+ ];
+ }
+
+ // Subdomain mode and single-user mode use an origin-scoped profile.
+ return [
+ `${origin}/profile/card.jsonld#me`,
+ `${origin}/profile/card#me`
+ ];
+}
diff --git a/src/auth/solid-oidc.js b/src/auth/solid-oidc.js
index ec7c4a4..18919e8 100644
--- a/src/auth/solid-oidc.js
+++ b/src/auth/solid-oidc.js
@@ -12,6 +12,7 @@
import * as jose from 'jose';
import { validateExternalUrl } from '../utils/ssrf.js';
+import { getPublicJwks } from '../idp/keys.js';
// Cache for OIDC configurations and JWKS
const oidcConfigCache = new Map();
@@ -285,6 +286,26 @@ async function getOidcConfig(issuer) {
}
}
+ // For the server's own trusted issuer, avoid an outbound HTTPS fetch.
+ // This prevents local/self-signed certificate problems during resource-token
+ // verification and guarantees consistency with our served discovery doc.
+ if (isTrusted) {
+ const baseUrl = issuer.replace(/\/$/, '');
+ const config = {
+ issuer: baseUrl + '/',
+ authorization_endpoint: `${baseUrl}/idp/auth`,
+ token_endpoint: `${baseUrl}/idp/token`,
+ userinfo_endpoint: `${baseUrl}/idp/me`,
+ jwks_uri: `${baseUrl}/.well-known/jwks.json`,
+ registration_endpoint: `${baseUrl}/idp/reg`,
+ introspection_endpoint: `${baseUrl}/idp/token/introspection`,
+ revocation_endpoint: `${baseUrl}/idp/token/revocation`,
+ end_session_endpoint: `${baseUrl}/idp/session/end`,
+ };
+ oidcConfigCache.set(issuer, { config, timestamp: Date.now() });
+ return config;
+ }
+
const configUrl = `${issuer.replace(/\/$/, '')}/.well-known/openid-configuration`;
try {
@@ -312,6 +333,15 @@ async function getJwks(issuer) {
return cached.jwks;
}
+ const normalizedIssuer = issuer.replace(/\/$/, '');
+ const isTrusted = trustedIssuers.has(normalizedIssuer) || trustedIssuers.has(normalizedIssuer + '/');
+
+ if (isTrusted) {
+ const localJwks = jose.createLocalJWKSet(await getPublicJwks());
+ jwksCache.set(issuer, { jwks: localJwks, timestamp: Date.now() });
+ return localJwks;
+ }
+
// Get OIDC config to find JWKS URI
const config = await getOidcConfig(issuer);
const jwksUri = config.jwks_uri;
diff --git a/src/handlers/container.js b/src/handlers/container.js
index c143e01..cb2f24b 100644
--- a/src/handlers/container.js
+++ b/src/handlers/container.js
@@ -287,7 +287,8 @@ export async function handleCreatePod(request, reply) {
let baseUri, podUri, webId;
if (subdomainsEnabled && baseDomain) {
- // Subdomain mode: alice.example.com/profile/card.jsonld#me
+ // Subdomain mode: alice.example.com:port/profile/card.jsonld#me
+ // baseDomain may include port (e.g. "example.com:3100")
const podHost = `${name}.${baseDomain}`;
baseUri = `${request.protocol}://${baseDomain}`;
podUri = `${request.protocol}://${podHost}/`;
diff --git a/src/handlers/resource.js b/src/handlers/resource.js
index 1ec3b1b..6dccb09 100644
--- a/src/handlers/resource.js
+++ b/src/handlers/resource.js
@@ -14,7 +14,7 @@ import {
} from '../rdf/conneg.js';
import { emitChange } from '../notifications/events.js';
import { checkIfMatch, checkIfNoneMatchForGet, checkIfNoneMatchForWrite } from '../utils/conditional.js';
-import { generateDatabrowserHtml, generateModuleDatabrowserHtml, shouldServeMashlib, DATA_ISLAND_MAX_BYTES } from '../mashlib/index.js';
+import { generateDatabrowserHtml, generateModuleDatabrowserHtml, getMashlibDecision, DATA_ISLAND_MAX_BYTES } from '../mashlib/index.js';
import { turtleToJsonLd } from '../rdf/turtle.js';
/**
@@ -126,12 +126,26 @@ export async function handleGet(request, reply) {
return reply.code(404).send({ error: 'Not Found' });
}
- // Check If-None-Match for conditional GET (304 Not Modified)
+ // Check If-None-Match for conditional GET (304 Not Modified).
+ // Important: don't short-circuit likely mashlib navigation requests,
+ // otherwise a top-level navigation can reuse a previously cached RDF
+ // variant (e.g., Turtle from mashlib XHR) and display raw text.
const ifNoneMatch = request.headers['if-none-match'];
if (ifNoneMatch) {
- const check = checkIfNoneMatchForGet(ifNoneMatch, stats.etag);
- if (!check.ok && check.notModified) {
- return reply.code(304).send();
+ const decisionContentType = stats.isDirectory
+ ? 'application/ld+json'
+ : getContentType(storagePath);
+ const mashlibDecision = getMashlibDecision(
+ request,
+ request.mashlibEnabled,
+ decisionContentType
+ );
+
+ if (!mashlibDecision.serve) {
+ const check = checkIfNoneMatchForGet(ifNoneMatch, stats.etag);
+ if (!check.ok && check.notModified) {
+ return reply.code(304).send();
+ }
}
}
@@ -238,7 +252,8 @@ export async function handleGet(request, reply) {
const jsonLd = generateContainerJsonLd(resourceUrl, entries || []);
// Check if we should serve Mashlib data browser for containers
- if (shouldServeMashlib(request, request.mashlibEnabled, 'application/ld+json')) {
+ const containerMashlibDecision = getMashlibDecision(request, request.mashlibEnabled, 'application/ld+json');
+ if (containerMashlibDecision.serve) {
// Phase 1 of #7: also embed the container's JSON-LD listing as a
// data island so consumers that look for `