@commonpub/layer 0.43.3 → 0.45.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,15 +1,27 @@
1
1
  import { contentItems, hubs, hubPosts } from '@commonpub/schema';
2
2
  import { federateContent, federateHubPost, federateHubActor } from '@commonpub/server';
3
- import { eq, isNull } from 'drizzle-orm';
3
+ import { eq, and, gte, desc, isNull } from 'drizzle-orm';
4
4
  import { z } from 'zod';
5
5
  import { extractDomain } from '../../../utils/inbox';
6
6
 
7
+ /** Default re-federation window when neither `all` nor `since` is given — avoids a delivery storm. */
8
+ const DEFAULT_SINCE_DAYS = 30;
9
+ /** Hard cap on a bounded (non-`all`) re-federation run. */
10
+ const DEFAULT_LIMIT = 1000;
11
+
7
12
  /**
8
13
  * POST /api/admin/federation/refederate
9
- * Queue all published content AND hub posts for federation delivery.
14
+ * Re-queue published content (Create) + hub posts (Announce) for delivery to current followers.
10
15
  * Useful after establishing new mirrors or enabling federation.
11
16
  *
12
- * Body: { contentId?: string, hubsOnly?: boolean } if omitted, re-federates ALL
17
+ * BOUNDED BY DEFAULT to avoid blasting every follower with thousands of activities:
18
+ * - `contentId` — re-federate a single item.
19
+ * - `all: true` — re-federate everything (explicit opt-in; no date/limit bound).
20
+ * - `sinceDays` — only items published within the last N days.
21
+ * - `limit` — cap the number of items.
22
+ * With none of these, defaults to the last 30 days, capped at 1000 items, newest-first.
23
+ * `federateContent` is idempotent (skips an already-pending Create for the same object), so
24
+ * repeated runs don't duplicate the queue.
13
25
  */
14
26
  export default defineEventHandler(async (event) => {
15
27
  // Allow CLI trigger via AUTH_SECRET header (for server-side automation)
@@ -29,9 +41,13 @@ export default defineEventHandler(async (event) => {
29
41
  const body = await parseBody(event, z.object({
30
42
  contentId: z.string().uuid().optional(),
31
43
  hubsOnly: z.boolean().optional(),
44
+ all: z.boolean().optional(),
45
+ sinceDays: z.number().int().positive().max(3650).optional(),
46
+ limit: z.number().int().positive().max(10000).optional(),
32
47
  }));
33
48
  const contentId = body.contentId;
34
49
  const hubsOnly = body.hubsOnly === true;
50
+ const all = body.all === true;
35
51
 
36
52
  const db = useDB();
37
53
  const domain = extractDomain((runtimeConfig.public?.siteUrl as string) || `https://${config.instance.domain}`);
@@ -45,12 +61,25 @@ export default defineEventHandler(async (event) => {
45
61
  let hubsQueued = 0;
46
62
  let hubPostsQueued = 0;
47
63
 
48
- // Re-federate published content (unless hubsOnly)
64
+ // Re-federate published content (unless hubsOnly), bounded unless `all` is set.
49
65
  if (!hubsOnly) {
50
- const published = await db
66
+ const since = all
67
+ ? null
68
+ : new Date(Date.now() - (body.sinceDays ?? DEFAULT_SINCE_DAYS) * 24 * 60 * 60 * 1000);
69
+ const limit = all ? undefined : (body.limit ?? DEFAULT_LIMIT);
70
+
71
+ let q = db
51
72
  .select({ id: contentItems.id })
52
73
  .from(contentItems)
53
- .where(eq(contentItems.status, 'published'));
74
+ .where(
75
+ since
76
+ ? and(eq(contentItems.status, 'published'), gte(contentItems.publishedAt, since))
77
+ : eq(contentItems.status, 'published'),
78
+ )
79
+ .orderBy(desc(contentItems.publishedAt))
80
+ .$dynamic();
81
+ if (limit != null) q = q.limit(limit);
82
+ const published = await q;
54
83
 
55
84
  for (const item of published) {
56
85
  try {
@@ -0,0 +1,18 @@
1
+ import { setRegistryInstanceStatus } from '@commonpub/server';
2
+ import { setRegistryInstanceStatusSchema } from '@commonpub/schema';
3
+
4
+ /**
5
+ * POST /api/admin/registry/instances/[id]/status (Phase 4)
6
+ * Admin sets a directory entry's status: active (visible) | hidden (tracked, not shown) |
7
+ * blocked (future pings ignored). Admin only.
8
+ */
9
+ export default defineEventHandler(async (event) => {
10
+ requireFeature('actAsRegistry');
11
+ requirePermission(event, 'federation.manage');
12
+ const { id } = parseParams(event, { id: 'uuid' });
13
+ const { status } = await parseBody(event, setRegistryInstanceStatusSchema);
14
+
15
+ const updated = await setRegistryInstanceStatus(useDB(), id, status);
16
+ if (!updated) throw createError({ statusCode: 404, statusMessage: 'Instance not found' });
17
+ return updated;
18
+ });
@@ -0,0 +1,19 @@
1
+ import { listRegistryInstances } from '@commonpub/server';
2
+ import { registryInstanceQuerySchema } from '@commonpub/schema';
3
+
4
+ /**
5
+ * GET /api/admin/registry/instances (Phase 4)
6
+ * Admin view of the registry directory — ALL entries incl. hidden/blocked, with status.
7
+ * Admin only.
8
+ */
9
+ export default defineEventHandler(async (event) => {
10
+ requireFeature('actAsRegistry');
11
+ requirePermission(event, 'federation.manage');
12
+ const q = registryInstanceQuerySchema.parse(getQuery(event));
13
+ return listRegistryInstances(useDB(), {
14
+ search: q.search,
15
+ limit: q.limit,
16
+ offset: q.offset,
17
+ includeNonActive: true,
18
+ });
19
+ });
@@ -0,0 +1,37 @@
1
+ import { listRegistryInstances, type RegistryInstanceView } from '@commonpub/server';
2
+ import { registryInstanceQuerySchema } from '@commonpub/schema';
3
+
4
+ /**
5
+ * GET /api/registry/instances (Phase 4)
6
+ * Public directory of CommonPub instances registered with this registry. Gated on
7
+ * `features.actAsRegistry`. Returns ACTIVE entries only, through an explicit allow-list (no
8
+ * internal id/status). Other instances/clients can consume this to discover peers.
9
+ */
10
+ function toPublicRegistryInstance(v: RegistryInstanceView) {
11
+ return {
12
+ domain: v.domain,
13
+ actorUri: v.actorUri,
14
+ name: v.name,
15
+ description: v.description,
16
+ userCount: v.userCount,
17
+ activeMonthCount: v.activeMonthCount,
18
+ localPostCount: v.localPostCount,
19
+ features: v.features,
20
+ softwareName: v.softwareName,
21
+ softwareVersion: v.softwareVersion,
22
+ online: v.online,
23
+ lastPingAt: v.lastPingAt,
24
+ };
25
+ }
26
+
27
+ export default defineEventHandler(async (event) => {
28
+ requireFeature('actAsRegistry');
29
+ const q = registryInstanceQuerySchema.parse(getQuery(event));
30
+ const { instances, total } = await listRegistryInstances(useDB(), {
31
+ search: q.search,
32
+ limit: q.limit,
33
+ offset: q.offset,
34
+ includeNonActive: false,
35
+ });
36
+ return { instances: instances.map(toPublicRegistryInstance), total, limit: q.limit, offset: q.offset };
37
+ });
@@ -0,0 +1,44 @@
1
+ import { recordRegistryPing, createRateLimitStore, getClientIp } from '@commonpub/server';
2
+ import { verifyInboxRequest, extractDomain } from '../../utils/inbox';
3
+
4
+ /**
5
+ * POST /api/registry/ping (Phase 4)
6
+ * A signed heartbeat from another CommonPub instance announcing itself to this registry.
7
+ * Gated on `features.actAsRegistry`. Identity is proven by the HTTP signature (the keyId domain
8
+ * must match the resolved actor) — so a domain can only register itself. We derive the domain from
9
+ * the verified actor, then `recordRegistryPing` pulls the instance's public NodeInfo for stats.
10
+ *
11
+ * Defence in depth: the global IP rate-limit middleware caps pre-verification floods; here we add a
12
+ * per-source-domain limit so a verified instance can't spam updates.
13
+ */
14
+ const store = createRateLimitStore({ redisUrl: process.env.NUXT_REDIS_URL });
15
+ const PRE_TIER = { limit: 20, windowMs: 60_000 }; // coarse per-IP cap BEFORE signature verification
16
+ const PING_TIER = { limit: 3, windowMs: 5 * 60 * 1000 }; // 3 pings / 5 min per verified domain
17
+
18
+ export default defineEventHandler(async (event) => {
19
+ requireFeature('actAsRegistry');
20
+ if (getMethod(event) !== 'POST') {
21
+ throw createError({ statusCode: 405, statusMessage: 'Method Not Allowed' });
22
+ }
23
+
24
+ // h3 types the well-known `Retry-After` header as a number (seconds).
25
+ const reject429 = (resetAt: number): never => {
26
+ setResponseHeader(event, 'Retry-After', Math.max(1, Math.ceil((resetAt - Date.now()) / 1000)));
27
+ throw createError({ statusCode: 429, statusMessage: 'Too Many Requests' });
28
+ };
29
+
30
+ // Coarse per-IP cap BEFORE verification: `verifyInboxRequest` resolves the (attacker-controlled)
31
+ // keyId actor over the network, so an unauthenticated caller must not fan that out unbounded.
32
+ const preRl = await store.check(`registry:ping:ip:${getClientIp(event)}`, PRE_TIER);
33
+ if (!preRl.allowed) reject429(preRl.resetAt);
34
+
35
+ const { actorUri } = await verifyInboxRequest(event, 'registry-ping');
36
+ const domain = extractDomain(actorUri);
37
+
38
+ // Per-verified-domain cap (the signer is now cryptographically known).
39
+ const rl = await store.check(`registry:ping:${domain}`, PING_TIER);
40
+ if (!rl.allowed) reject429(rl.resetAt);
41
+
42
+ const result = await recordRegistryPing(useDB(), domain, actorUri);
43
+ return { status: result };
44
+ });
@@ -1,7 +1,7 @@
1
1
  // Nitro middleware for authentication using @commonpub/auth
2
2
  import { createAuthMiddleware, type AuthLocals } from '@commonpub/auth';
3
3
  import { createAuth } from '@commonpub/auth';
4
- import { emailTemplates } from '@commonpub/server';
4
+ import { emailTemplates, emitHook } from '@commonpub/server';
5
5
 
6
6
  let authMiddleware: ReturnType<typeof createAuthMiddleware> | null = null;
7
7
 
@@ -43,6 +43,14 @@ function getAuthMiddleware(): ReturnType<typeof createAuthMiddleware> {
43
43
  await emailAdapter.send({ ...template, to: email });
44
44
  },
45
45
  },
46
+ onUserCreated: async (user) => {
47
+ await emitHook('user:registered', {
48
+ db,
49
+ userId: user.id,
50
+ username: user.username ?? '',
51
+ email: user.email,
52
+ });
53
+ },
46
54
  });
47
55
 
48
56
  authMiddleware = createAuthMiddleware({ auth });
@@ -0,0 +1,67 @@
1
+ /**
2
+ * Registry heartbeat worker (Phase 4).
3
+ * When `features.announceToRegistry` is on, periodically sends a signed heartbeat to the configured
4
+ * `federation.registryUrl` so this instance appears in that registry's directory. Opt-in — nothing
5
+ * is sent unless the operator enables the flag.
6
+ */
7
+ import { sendRegistryPing } from '@commonpub/server';
8
+
9
+ export default defineNitroPlugin((nitro) => {
10
+ if (process.env.NODE_ENV === 'test') return;
11
+
12
+ let interval: ReturnType<typeof setInterval> | null = null;
13
+
14
+ const startupTimer = setTimeout(() => {
15
+ try {
16
+ const config = useConfig();
17
+ if (!config.features.announceToRegistry) {
18
+ return; // opt-in — silent when off
19
+ }
20
+ // The registry verifies our ping by resolving `https://{instance.domain}/actor`, which is
21
+ // only served when federation is on. Without it every ping 401s — warn + skip.
22
+ if (!config.features.federation) {
23
+ console.warn('[registry] announceToRegistry is on but federation is off — pings would be unverifiable (our /actor is not served). Skipping.');
24
+ return;
25
+ }
26
+ const registryUrl = config.federation?.registryUrl ?? 'https://commonpub.io';
27
+
28
+ // Use instance.domain — the heartbeat is signed with `https://{instance.domain}/actor#main-key`
29
+ // and MUST match the served actor's id host (verifyInboxRequest enforces keyId==actor.id host).
30
+ const domain = config.instance.domain;
31
+
32
+ // Don't announce a registry to itself.
33
+ const registryDomain = registryUrl.replace(/^https?:\/\//, '').replace(/[:/].*$/, '');
34
+ if (registryDomain === domain) {
35
+ console.log('[registry] Skipping heartbeat — this instance is its own registry');
36
+ return;
37
+ }
38
+
39
+ const intervalMs = config.federation?.registryPingIntervalMs ?? 21_600_000;
40
+ console.log(`[registry] Heartbeat worker started (registry: ${registryUrl}, interval: ${intervalMs}ms)`);
41
+
42
+ runHeartbeat(registryUrl, domain);
43
+ interval = setInterval(() => runHeartbeat(registryUrl, domain), intervalMs);
44
+ } catch (err) {
45
+ console.error('[registry] Heartbeat worker failed to start:', err instanceof Error ? err.message : err);
46
+ }
47
+ }, 10_000); // stagger after the delivery worker (5s)
48
+
49
+ async function runHeartbeat(registryUrl: string, domain: string) {
50
+ try {
51
+ const res = await sendRegistryPing(useDB(), registryUrl, domain);
52
+ if (!res.ok) {
53
+ console.warn(`[registry] Heartbeat to ${registryUrl} returned ${res.status}`);
54
+ }
55
+ } catch (err) {
56
+ console.error('[registry] Heartbeat error:', err instanceof Error ? err.message : err);
57
+ }
58
+ }
59
+
60
+ nitro.hooks.hook('close', () => {
61
+ clearTimeout(startupTimer);
62
+ if (interval) {
63
+ clearInterval(interval);
64
+ interval = null;
65
+ }
66
+ });
67
+ });
@@ -1,6 +1,6 @@
1
1
  import { processInboxActivity } from '@commonpub/protocol';
2
2
  import { createInboxHandlers } from '@commonpub/server';
3
- import { verifyInboxRequest } from '../../../utils/inbox';
3
+ import { verifyInboxRequest, assertActorMatchesSigner } from '../../../utils/inbox';
4
4
 
5
5
  /**
6
6
  * Hub-specific inbox endpoint (FEP-1b12).
@@ -18,7 +18,8 @@ export default defineEventHandler(async (event) => {
18
18
  }
19
19
 
20
20
  // Verify signature, domain, date freshness, body size
21
- const { body } = await verifyInboxRequest(event, 'hub-inbox');
21
+ const { actorUri, body } = await verifyInboxRequest(event, 'hub-inbox');
22
+ assertActorMatchesSigner(actorUri, body, 'hub-inbox');
22
23
 
23
24
  const db = useDB();
24
25
  const domain = config.instance.domain;
@@ -1,6 +1,6 @@
1
1
  import { processInboxActivity } from '@commonpub/protocol';
2
2
  import { createInboxHandlers } from '@commonpub/server';
3
- import { verifyInboxRequest, extractDomain } from '../utils/inbox';
3
+ import { verifyInboxRequest, assertActorMatchesSigner, extractDomain } from '../utils/inbox';
4
4
 
5
5
  export default defineEventHandler(async (event) => {
6
6
  const config = useConfig();
@@ -13,7 +13,9 @@ export default defineEventHandler(async (event) => {
13
13
  }
14
14
 
15
15
  // Verify signature, domain, date freshness, body size
16
- const { body } = await verifyInboxRequest(event, 'shared-inbox');
16
+ const { actorUri, body } = await verifyInboxRequest(event, 'shared-inbox');
17
+ // Bind the activity's actor to the verified signer (anti-spoofing).
18
+ assertActorMatchesSigner(actorUri, body, 'shared-inbox');
17
19
 
18
20
  const db = useDB();
19
21
  const runtimeConfig = useRuntimeConfig();
@@ -1,6 +1,6 @@
1
1
  import { processInboxActivity } from '@commonpub/protocol';
2
2
  import { createInboxHandlers } from '@commonpub/server';
3
- import { verifyInboxRequest, extractDomain } from '../../../utils/inbox';
3
+ import { verifyInboxRequest, assertActorMatchesSigner, extractDomain } from '../../../utils/inbox';
4
4
 
5
5
  export default defineEventHandler(async (event) => {
6
6
  const config = useConfig();
@@ -13,7 +13,8 @@ export default defineEventHandler(async (event) => {
13
13
  }
14
14
 
15
15
  // Verify signature, domain, date freshness, body size
16
- const { body } = await verifyInboxRequest(event, 'user-inbox');
16
+ const { actorUri, body } = await verifyInboxRequest(event, 'user-inbox');
17
+ assertActorMatchesSigner(actorUri, body, 'user-inbox');
17
18
 
18
19
  const db = useDB();
19
20
  const runtimeConfig = useRuntimeConfig();
@@ -31,6 +31,44 @@ interface VerifiedInbox {
31
31
  body: Record<string, unknown>;
32
32
  }
33
33
 
34
+ /**
35
+ * Bind an inbound activity to its HTTP-signature signer: the activity's top-level `actor` MUST be
36
+ * on the same host as the cryptographically-verified signer (from `verifyInboxRequest`). Without
37
+ * this, a validly-signed request from instance X could carry `actor: https://victim/actor` and be
38
+ * processed as if it came from the victim — spoofed mirror requests/Accepts (Phase 3), federated
39
+ * content attributed to others, like/boost tampering, etc. CommonPub and Mastodon sign with the
40
+ * actor's own key (we don't support relays/LD-signature forwarding), so host equality is the
41
+ * correct binding. Throws 401 on mismatch; no-ops when `actor` is absent (processInboxActivity
42
+ * rejects a missing actor itself).
43
+ */
44
+ export function assertActorMatchesSigner(
45
+ signerActorUri: string,
46
+ body: Record<string, unknown>,
47
+ label: string,
48
+ ): void {
49
+ const raw = body.actor;
50
+ const actorUri =
51
+ typeof raw === 'string'
52
+ ? raw
53
+ : raw && typeof raw === 'object'
54
+ ? ((raw as Record<string, unknown>).id as string | undefined)
55
+ : undefined;
56
+ if (!actorUri) return;
57
+
58
+ let signerHost: string;
59
+ let actorHost: string;
60
+ try {
61
+ signerHost = new URL(signerActorUri).hostname;
62
+ actorHost = new URL(actorUri).hostname;
63
+ } catch {
64
+ throw createError({ statusCode: 400, statusMessage: 'Invalid activity actor' });
65
+ }
66
+ if (signerHost !== actorHost) {
67
+ console.warn(`[${label}] actor/signer host mismatch: actor=${actorHost}, signer=${signerHost}`);
68
+ throw createError({ statusCode: 401, statusMessage: 'Activity actor does not match request signer' });
69
+ }
70
+ }
71
+
34
72
  /**
35
73
  * Verify an inbound AP activity request.
36
74
  * Checks: body size, signature presence, actor resolution, domain match,