@commonpub/layer 0.43.3 → 0.44.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.
@@ -2,7 +2,11 @@
2
2
  definePageMeta({ layout: 'admin', middleware: 'auth' });
3
3
  useSeoMeta({ title: `Federation — Admin — ${useSiteName()}` });
4
4
 
5
- const activeTab = ref<'activity' | 'mirrors' | 'clients' | 'trusted' | 'tools'>('activity');
5
+ const activeTab = ref<'activity' | 'mirrors' | 'registry' | 'clients' | 'trusted' | 'tools'>('activity');
6
+
7
+ const featureFlags = useFeatures();
8
+ const actAsRegistry = computed(() => featureFlags.features.value.actAsRegistry);
9
+ const announceToRegistry = computed(() => featureFlags.features.value.announceToRegistry);
6
10
 
7
11
  const { data: statsData, pending } = await useFetch('/api/admin/federation/stats', {
8
12
  default: () => ({ inbound: 0, outbound: 0, pending: 0, failed: 0, followers: 0, following: 0 }),
@@ -57,31 +61,150 @@ async function removeTrusted(domain: string): Promise<void> {
57
61
  }
58
62
  }
59
63
 
64
+ const toast = useToast();
65
+
66
+ // Instances mirroring US (followers of our instance actor).
67
+ const { data: followersData } = await useFetch<Array<{ actorUri: string; domain: string; followedAt: string | null }>>(
68
+ '/api/admin/federation/followers',
69
+ { default: () => [] },
70
+ );
71
+
72
+ // Consent-based mirror requests (Phase 3): incoming = others asking to mirror us; outgoing = us asking them.
73
+ type MirrorRequest = { id: string; direction: string; remoteDomain: string; remoteActorUri: string; status: string; createdAt: string; decidedAt: string | null };
74
+ const { data: requestsData, refresh: refreshRequests } = await useFetch<{ incoming: MirrorRequest[]; outgoing: MirrorRequest[] }>(
75
+ '/api/admin/federation/mirror-requests',
76
+ { default: () => ({ incoming: [], outgoing: [] }) },
77
+ );
78
+ const pendingIncoming = computed(() => (requestsData.value?.incoming ?? []).filter((r) => r.status === 'pending'));
79
+ const decidedIncoming = computed(() => (requestsData.value?.incoming ?? []).filter((r) => r.status !== 'pending'));
80
+ const approvingRequest = ref<MirrorRequest | null>(null);
81
+
82
+ async function onRequestChanged(): Promise<void> {
83
+ await Promise.all([refreshRequests(), refreshMirrors()]);
84
+ }
85
+
86
+ async function rejectRequest(id: string): Promise<void> {
87
+ const url: string = `/api/admin/federation/mirror-requests/${id}/reject`;
88
+ try {
89
+ await $fetch(url, { method: 'POST' });
90
+ toast.success('Request rejected');
91
+ await refreshRequests();
92
+ } catch {
93
+ toast.error('Failed to reject request');
94
+ }
95
+ }
96
+
97
+ // Registry directory (Phase 4) — only fetched when this instance acts as a registry.
98
+ type RegistryRow = { id: string; domain: string; actorUri: string; name: string | null; description: string | null; userCount: number; activeMonthCount: number; localPostCount: number; softwareName: string | null; softwareVersion: string | null; status: string; lastPingAt: string | null; online: boolean };
99
+ const registrySearch = ref('');
100
+ const { data: registryData, refresh: refreshRegistry } = await useFetch<{ instances: RegistryRow[]; total: number }>(
101
+ '/api/admin/registry/instances',
102
+ {
103
+ query: computed(() => ({ search: registrySearch.value || undefined, limit: 50 })),
104
+ default: () => ({ instances: [], total: 0 }),
105
+ immediate: actAsRegistry.value,
106
+ },
107
+ );
108
+
109
+ function onRegistrySearch(value: string): void {
110
+ registrySearch.value = value;
111
+ void refreshRegistry();
112
+ }
113
+
60
114
  // Mirror creation
115
+ const FEDERATABLE_TYPES = ['project', 'blog', 'explainer'] as const;
116
+ // Bounded "how far back" choices for the optional history import on create.
117
+ const DEPTH_OPTIONS = [
118
+ { label: 'None — forward only (default)', body: null as Record<string, number> | null },
119
+ { label: 'Last 7 days', body: { sinceDays: 7 } },
120
+ { label: 'Last 30 days', body: { sinceDays: 30 } },
121
+ { label: 'Last 90 days', body: { sinceDays: 90 } },
122
+ { label: 'Last 200 items', body: { maxItems: 200 } },
123
+ { label: 'Everything (up to limit)', body: {} },
124
+ ];
125
+
61
126
  const newMirrorDomain = ref('');
62
127
  const newMirrorActorUri = ref('');
128
+ const newMirrorDirection = ref<'pull' | 'push'>('pull');
129
+ const newMirrorTypes = ref<string[]>([]);
130
+ const newMirrorTags = ref('');
131
+ const newMirrorDepth = ref(0);
132
+ const showAdvanced = ref(false);
63
133
  const mirrorCreating = ref(false);
64
134
 
135
+ function resetMirrorForm(): void {
136
+ newMirrorDomain.value = '';
137
+ newMirrorActorUri.value = '';
138
+ newMirrorTypes.value = [];
139
+ newMirrorTags.value = '';
140
+ newMirrorDepth.value = 0;
141
+ showAdvanced.value = false;
142
+ }
143
+
65
144
  async function createMirror(): Promise<void> {
66
- if (!newMirrorDomain.value) return;
145
+ const domain = newMirrorDomain.value.trim().toLowerCase();
146
+ if (!domain) return;
67
147
  mirrorCreating.value = true;
68
148
  try {
69
- await $fetch('/api/admin/federation/mirrors', {
149
+ // Push = consent-based request: ask them to mirror us. No filters/depth here — the approver
150
+ // chooses their own. The request flows to their admin; we track it under "Requests you've sent".
151
+ if (newMirrorDirection.value === 'push') {
152
+ await $fetch('/api/admin/federation/mirrors', {
153
+ method: 'POST',
154
+ body: {
155
+ remoteDomain: domain,
156
+ remoteActorUri: newMirrorActorUri.value.trim() || `https://${domain}/actor`,
157
+ direction: 'push',
158
+ },
159
+ });
160
+ toast.success(`Request sent to ${domain} — they must approve before they mirror you`);
161
+ resetMirrorForm();
162
+ newMirrorDirection.value = 'pull';
163
+ await refreshRequests();
164
+ return;
165
+ }
166
+
167
+ const tags = newMirrorTags.value.split(',').map((t) => t.trim().replace(/^#/, '')).filter(Boolean);
168
+ const created = await $fetch<{ id: string }>('/api/admin/federation/mirrors', {
70
169
  method: 'POST',
71
170
  body: {
72
- remoteDomain: newMirrorDomain.value,
73
- remoteActorUri: newMirrorActorUri.value || `https://${newMirrorDomain.value}/actor`,
171
+ remoteDomain: domain,
172
+ remoteActorUri: newMirrorActorUri.value.trim() || `https://${domain}/actor`,
74
173
  direction: 'pull',
174
+ filterContentTypes: newMirrorTypes.value.length ? newMirrorTypes.value : null,
175
+ filterTags: tags.length ? tags : null,
75
176
  },
76
177
  });
77
- newMirrorDomain.value = '';
78
- newMirrorActorUri.value = '';
178
+ // Optional bounded history import — forward-only unless a depth is chosen. The mirror is
179
+ // already created at this point, so a backfill failure must NOT masquerade as create-failure.
180
+ const depth = DEPTH_OPTIONS[newMirrorDepth.value]!.body;
181
+ if (depth && created?.id) {
182
+ // string-typed URL avoids the typed-routes $fetch recursion (TS2321) on dynamic paths.
183
+ const backfillUrl: string = `/api/admin/federation/mirrors/${created.id}/backfill`;
184
+ try {
185
+ const r = await $fetch<{ processed: number }>(backfillUrl, { method: 'POST', body: depth });
186
+ toast.success(`Mirror added — imported ${r?.processed ?? 0} item(s)`);
187
+ } catch {
188
+ toast.error('Mirror added, but history import failed — use Backfill in its details to retry.');
189
+ }
190
+ } else {
191
+ toast.success('Mirror added — new posts will arrive as they publish');
192
+ }
193
+ resetMirrorForm();
79
194
  await refreshMirrors();
195
+ } catch {
196
+ toast.error(newMirrorDirection.value === 'push' ? 'Failed to send request' : 'Failed to add mirror');
80
197
  } finally {
81
198
  mirrorCreating.value = false;
82
199
  }
83
200
  }
84
201
 
202
+ function toggleType(t: string): void {
203
+ const i = newMirrorTypes.value.indexOf(t);
204
+ if (i === -1) newMirrorTypes.value.push(t);
205
+ else newMirrorTypes.value.splice(i, 1);
206
+ }
207
+
85
208
  async function toggleMirror(id: string, currentStatus: string): Promise<void> {
86
209
  try {
87
210
  await $fetch(`/api/admin/federation/mirrors/${id}`, {
@@ -90,32 +213,18 @@ async function toggleMirror(id: string, currentStatus: string): Promise<void> {
90
213
  });
91
214
  await refreshMirrors();
92
215
  } catch {
93
- alert('Failed to update mirror');
94
- }
95
- }
96
-
97
- async function deleteMirror(id: string): Promise<void> {
98
- try {
99
- await $fetch(`/api/admin/federation/mirrors/${id}`, { method: 'DELETE' });
100
- await refreshMirrors();
101
- } catch {
102
- alert('Failed to delete mirror');
216
+ toast.error('Failed to update mirror');
103
217
  }
104
218
  }
105
219
 
106
- // Backfill
107
- const backfilling = ref<string | null>(null);
108
- const backfillResult = ref<{ processed: number; errors: number; pages: number } | null>(null);
220
+ // Mirror detail modal — per-mirror info + bounded re-backfill + delete.
221
+ type MirrorRow = { id: string; status: string; direction: string; remoteDomain: string; remoteActorUri: string; filterContentTypes: string[] | null; filterTags: string[] | null; contentCount: number; errorCount: number; lastError: string | null; lastSyncAt: string | null; backfillCursor?: string | null };
222
+ const selectedMirror = ref<MirrorRow | null>(null);
109
223
 
110
- async function backfillMirror(id: string): Promise<void> {
111
- backfilling.value = id;
112
- backfillResult.value = null;
113
- try {
114
- const result = await $fetch<{ processed: number; errors: number; pages: number }>(`/api/admin/federation/mirrors/${id}/backfill`, { method: 'POST' });
115
- backfillResult.value = result;
116
- await refreshMirrors();
117
- } finally {
118
- backfilling.value = null;
224
+ async function onMirrorChanged(): Promise<void> {
225
+ await refreshMirrors();
226
+ if (selectedMirror.value) {
227
+ selectedMirror.value = (mirrorsData.value ?? []).find((m) => m.id === selectedMirror.value!.id) ?? null;
119
228
  }
120
229
  }
121
230
 
@@ -171,15 +280,19 @@ async function repairTypes(): Promise<void> {
171
280
  }
172
281
  }
173
282
 
174
- // Tools: re-federate
283
+ // Tools: re-federate (bounded by default to avoid blasting every follower with thousands).
175
284
  const refederating = ref(false);
285
+ const refederateScope = ref<'7' | '30' | 'all'>('30');
176
286
  const refederateResult = ref<{ queued: number; content?: number; hubs?: number; hubsFound?: number; hubPosts?: number } | null>(null);
177
287
 
178
288
  async function refederate(): Promise<void> {
179
289
  refederating.value = true;
180
290
  refederateResult.value = null;
181
291
  try {
182
- refederateResult.value = await ($fetch as Function)('/api/admin/federation/refederate', { method: 'POST' });
292
+ const body = refederateScope.value === 'all'
293
+ ? { all: true }
294
+ : { sinceDays: Number(refederateScope.value) };
295
+ refederateResult.value = await ($fetch as Function)('/api/admin/federation/refederate', { method: 'POST', body });
183
296
  } finally {
184
297
  refederating.value = false;
185
298
  }
@@ -228,6 +341,7 @@ async function refederate(): Promise<void> {
228
341
  <div class="cpub-fed-tabs">
229
342
  <button :class="{ active: activeTab === 'activity' }" @click="activeTab = 'activity'">Activity</button>
230
343
  <button :class="{ active: activeTab === 'mirrors' }" @click="activeTab = 'mirrors'">Mirrors</button>
344
+ <button v-if="actAsRegistry" :class="{ active: activeTab === 'registry' }" @click="activeTab = 'registry'">Registry</button>
231
345
  <button :class="{ active: activeTab === 'clients' }" @click="activeTab = 'clients'">OAuth Clients</button>
232
346
  <button :class="{ active: activeTab === 'trusted' }" @click="activeTab = 'trusted'">Trusted Instances</button>
233
347
  <button :class="{ active: activeTab === 'tools' }" @click="activeTab = 'tools'">Tools</button>
@@ -284,40 +398,131 @@ async function refederate(): Promise<void> {
284
398
 
285
399
  <!-- Mirrors Tab -->
286
400
  <div v-if="activeTab === 'mirrors'">
287
- <div class="cpub-fed-form">
288
- <input v-model="newMirrorDomain" placeholder="remote-instance.com" class="cpub-fed-input" />
289
- <button :disabled="mirrorCreating || !newMirrorDomain" class="cpub-fed-btn" @click="createMirror">
290
- {{ mirrorCreating ? 'Creating...' : 'Add Mirror' }}
401
+ <p class="cpub-fed-explain">
402
+ A <strong>mirror</strong> pulls another instance's public content into your federated feed.
403
+ It's <strong>one-directional</strong> you receive their posts; they receive nothing from
404
+ you and need do nothing. New posts arrive automatically once added; use <strong>Import
405
+ history</strong> to also pull older posts (bounded, so you don't ingest an entire large
406
+ instance at once).
407
+ </p>
408
+
409
+ <!-- Create form -->
410
+ <div class="cpub-fed-create">
411
+ <div class="cpub-fed-form" style="margin-bottom: 8px;">
412
+ <select v-model="newMirrorDirection" class="cpub-fed-input" style="flex:0 0 auto;width:auto;" aria-label="Direction">
413
+ <option value="pull">Mirror them (pull)</option>
414
+ <option value="push">Request they mirror you</option>
415
+ </select>
416
+ <input v-model="newMirrorDomain" placeholder="remote-instance.com" class="cpub-fed-input" @keydown.enter.prevent="createMirror" />
417
+ <select v-if="newMirrorDirection === 'pull'" v-model.number="newMirrorDepth" class="cpub-fed-input" style="flex:0 0 auto;width:auto;" aria-label="Import history depth">
418
+ <option v-for="(opt, i) in DEPTH_OPTIONS" :key="i" :value="i">{{ opt.label }}</option>
419
+ </select>
420
+ <button :disabled="mirrorCreating || !newMirrorDomain.trim()" class="cpub-fed-btn" @click="createMirror">
421
+ {{ mirrorCreating ? (newMirrorDirection === 'push' ? 'Sending…' : 'Adding…') : (newMirrorDirection === 'push' ? 'Send Request' : 'Add Mirror') }}
422
+ </button>
423
+ </div>
424
+ <p v-if="newMirrorDirection === 'push'" class="cpub-fed-info-text" style="margin: 0 0 8px;">
425
+ Sends a request asking <strong>{{ newMirrorDomain.trim() || 'the remote instance' }}</strong> to pull-mirror you.
426
+ Their admin must approve (CommonPub instances only). You'll see the status under <strong>Requests you've sent</strong>.
427
+ </p>
428
+ <button v-if="newMirrorDirection === 'pull'" type="button" class="cpub-fed-disclosure" :aria-expanded="showAdvanced" @click="showAdvanced = !showAdvanced">
429
+ <i class="fa-solid" :class="showAdvanced ? 'fa-chevron-down' : 'fa-chevron-right'"></i> Filters &amp; advanced
291
430
  </button>
431
+ <div v-if="showAdvanced" class="cpub-fed-advanced">
432
+ <span class="cpub-fed-adv-label">Content types <span class="cpub-fed-adv-faint">(none = all)</span></span>
433
+ <div class="cpub-fed-checks">
434
+ <label v-for="t in FEDERATABLE_TYPES" :key="t" class="cpub-fed-check">
435
+ <input type="checkbox" :checked="newMirrorTypes.includes(t)" @change="toggleType(t)" /> {{ t }}
436
+ </label>
437
+ </div>
438
+ <label class="cpub-fed-adv-label" for="cpub-fed-tags">Tags <span class="cpub-fed-adv-faint">(comma-separated, none = all)</span></label>
439
+ <input id="cpub-fed-tags" v-model="newMirrorTags" placeholder="arduino, 3dprinting" class="cpub-fed-input" style="width:100%;" />
440
+ <label class="cpub-fed-adv-label" for="cpub-fed-actor">Actor URI <span class="cpub-fed-adv-faint">(defaults to https://domain/actor)</span></label>
441
+ <input id="cpub-fed-actor" v-model="newMirrorActorUri" placeholder="https://remote-instance.com/actor" class="cpub-fed-input" style="width:100%;" />
442
+ </div>
292
443
  </div>
293
444
 
445
+ <!-- Status legend -->
446
+ <div class="cpub-fed-legend">
447
+ <span><span class="cpub-fed-status active">active</span> receiving</span>
448
+ <span><span class="cpub-fed-status paused">paused</span> stopped, kept</span>
449
+ <span><span class="cpub-fed-status pending">pending</span> follow not yet accepted</span>
450
+ <span><span class="cpub-fed-status failed">failed</span> last sync errored</span>
451
+ </div>
452
+
453
+ <!-- Mirror list -->
294
454
  <div class="cpub-fed-activity-list">
295
455
  <div v-if="!mirrorsData?.length" class="cpub-fed-empty">No mirrors configured.</div>
296
456
  <div v-for="m in mirrorsData" :key="m.id" class="cpub-fed-activity-row">
297
457
  <span class="cpub-fed-status" :class="m.status">{{ m.status }}</span>
298
- <span class="cpub-fed-type">{{ m.remoteDomain }}</span>
299
- <span class="cpub-fed-actor">{{ m.contentCount }} items</span>
300
- <span v-if="m.lastError" class="cpub-fed-error" :title="m.lastError">err</span>
301
- <button class="cpub-fed-btn-sm" @click="toggleMirror(m.id, m.status)">
302
- {{ m.status === 'active' ? 'Pause' : 'Resume' }}
303
- </button>
304
- <button
305
- class="cpub-fed-btn-sm"
306
- :disabled="backfilling === m.id"
307
- @click="backfillMirror(m.id)"
308
- >
309
- {{ backfilling === m.id ? 'Backfilling...' : 'Backfill' }}
310
- </button>
311
- <button class="cpub-fed-btn-sm cpub-fed-btn-danger" @click="deleteMirror(m.id)">Delete</button>
458
+ <span class="cpub-fed-dir-arrow" title="pull (you receive their content)">↓</span>
459
+ <button class="cpub-fed-mirror-name" @click="selectedMirror = m">{{ m.remoteDomain }}</button>
460
+ <span class="cpub-fed-actor">{{ m.contentCount }} items<template v-if="m.filterContentTypes?.length"> · {{ m.filterContentTypes.join(', ') }}</template><template v-if="m.filterTags?.length"> · #{{ m.filterTags.join(' #') }}</template></span>
461
+ <span v-if="m.errorCount > 0" class="cpub-fed-error" :title="m.lastError || ''">{{ m.errorCount }} err</span>
462
+ <time v-if="m.lastSyncAt" class="cpub-fed-time">{{ new Date(m.lastSyncAt).toLocaleDateString() }}</time>
463
+ <button class="cpub-fed-btn-sm" @click="toggleMirror(m.id, m.status)">{{ m.status === 'active' ? 'Pause' : 'Resume' }}</button>
464
+ <button class="cpub-fed-btn-sm" @click="selectedMirror = m">Details</button>
312
465
  </div>
313
466
  </div>
314
467
 
315
- <!-- Backfill result -->
316
- <div v-if="backfillResult" class="cpub-fed-result">
317
- Backfill complete: {{ backfillResult.processed }} items, {{ backfillResult.errors }} errors, {{ backfillResult.pages }} pages.
468
+ <!-- Instances mirroring you -->
469
+ <h3 class="cpub-fed-subhead">Instances mirroring you</h3>
470
+ <p class="cpub-fed-info-text" style="margin-bottom: 8px;">Remote instances following your instance actor they pull your public content. (One-directional: you don't pull them unless you add a mirror above.)</p>
471
+ <div class="cpub-fed-activity-list">
472
+ <div v-if="!followersData?.length" class="cpub-fed-empty">No instances are mirroring you yet.</div>
473
+ <div v-for="f in followersData" :key="f.actorUri" class="cpub-fed-activity-row">
474
+ <span class="cpub-fed-dir-arrow" title="they pull from you">↗</span>
475
+ <span class="cpub-fed-type">{{ f.domain }}</span>
476
+ <span class="cpub-fed-actor">{{ f.actorUri }}</span>
477
+ <time v-if="f.followedAt" class="cpub-fed-time">{{ new Date(f.followedAt).toLocaleDateString() }}</time>
478
+ </div>
479
+ </div>
480
+
481
+ <!-- Requests to mirror you (incoming) -->
482
+ <h3 class="cpub-fed-subhead">Requests to mirror you</h3>
483
+ <p class="cpub-fed-info-text" style="margin-bottom: 8px;">Other CommonPub instances asking you to let them pull-mirror your content. Approve to start mirroring them back (you choose depth + filters), or reject.</p>
484
+ <div class="cpub-fed-activity-list">
485
+ <div v-if="!pendingIncoming.length && !decidedIncoming.length" class="cpub-fed-empty">No incoming mirror requests.</div>
486
+ <div v-for="r in pendingIncoming" :key="r.id" class="cpub-fed-activity-row">
487
+ <span class="cpub-fed-status pending">pending</span>
488
+ <span class="cpub-fed-type">{{ r.remoteDomain }}</span>
489
+ <span class="cpub-fed-actor">{{ r.remoteActorUri }}</span>
490
+ <time class="cpub-fed-time">{{ new Date(r.createdAt).toLocaleDateString() }}</time>
491
+ <button class="cpub-fed-btn-sm" @click="approvingRequest = r">Review</button>
492
+ <button class="cpub-fed-btn-sm cpub-fed-btn-danger" @click="rejectRequest(r.id)">Reject</button>
493
+ </div>
494
+ <div v-for="r in decidedIncoming" :key="r.id" class="cpub-fed-activity-row">
495
+ <span class="cpub-fed-status" :class="r.status === 'approved' ? 'active' : 'failed'">{{ r.status }}</span>
496
+ <span class="cpub-fed-type">{{ r.remoteDomain }}</span>
497
+ <span class="cpub-fed-actor">{{ r.remoteActorUri }}</span>
498
+ <time v-if="r.decidedAt" class="cpub-fed-time">{{ new Date(r.decidedAt).toLocaleDateString() }}</time>
499
+ </div>
500
+ </div>
501
+
502
+ <!-- Requests you've sent (outgoing) -->
503
+ <h3 class="cpub-fed-subhead">Requests you've sent</h3>
504
+ <p class="cpub-fed-info-text" style="margin-bottom: 8px;">Instances you've asked to mirror you ("Request they mirror you" above). They start mirroring once their admin approves.</p>
505
+ <div class="cpub-fed-activity-list">
506
+ <div v-if="!requestsData?.outgoing?.length" class="cpub-fed-empty">No outgoing requests.</div>
507
+ <div v-for="r in requestsData?.outgoing ?? []" :key="r.id" class="cpub-fed-activity-row">
508
+ <span class="cpub-fed-status" :class="r.status === 'approved' ? 'active' : r.status === 'rejected' ? 'failed' : 'pending'">{{ r.status }}</span>
509
+ <span class="cpub-fed-dir-arrow" title="you asked them to mirror you">↑</span>
510
+ <span class="cpub-fed-type">{{ r.remoteDomain }}</span>
511
+ <time class="cpub-fed-time">{{ new Date(r.createdAt).toLocaleDateString() }}</time>
512
+ </div>
318
513
  </div>
319
514
  </div>
320
515
 
516
+ <!-- Registry Tab -->
517
+ <div v-if="activeTab === 'registry' && actAsRegistry">
518
+ <RegistryDirectory
519
+ :instances="registryData?.instances ?? []"
520
+ :announcing-to="announceToRegistry ? 'your configured registry' : null"
521
+ @changed="refreshMirrors"
522
+ @search="onRegistrySearch"
523
+ />
524
+ </div>
525
+
321
526
  <!-- OAuth Clients Tab -->
322
527
  <div v-if="activeTab === 'clients'">
323
528
  <div class="cpub-fed-activity-list">
@@ -397,13 +602,20 @@ async function refederate(): Promise<void> {
397
602
  </div>
398
603
  </div>
399
604
 
400
- <!-- Re-federate All Content + Hub Posts -->
605
+ <!-- Re-federate Content + Hub Posts -->
401
606
  <div class="cpub-fed-tool-card">
402
- <h3 class="cpub-fed-tool-title"><i class="fa-solid fa-rotate"></i> Re-federate All</h3>
403
- <p class="cpub-fed-tool-desc">Queue all published content (Create) and hub posts (Announce) for re-delivery. Safe to run multiple times.</p>
404
- <button class="cpub-fed-btn" :disabled="refederating" @click="refederate">
405
- {{ refederating ? 'Queuing...' : 'Re-federate All' }}
406
- </button>
607
+ <h3 class="cpub-fed-tool-title"><i class="fa-solid fa-rotate"></i> Re-federate</h3>
608
+ <p class="cpub-fed-tool-desc">Re-queue your published content (Create) and hub posts (Announce) for delivery to your current followers. Idempotent. <strong>Bounded by default</strong> so you don't blast every follower with thousands of activities — choose how far back.</p>
609
+ <div class="cpub-fed-form">
610
+ <select v-model="refederateScope" class="cpub-fed-input" style="flex:0 0 auto;width:auto;" aria-label="Re-federate scope">
611
+ <option value="7">Last 7 days</option>
612
+ <option value="30">Last 30 days</option>
613
+ <option value="all">Everything</option>
614
+ </select>
615
+ <button class="cpub-fed-btn" :disabled="refederating" @click="refederate">
616
+ {{ refederating ? 'Queuing…' : 'Re-federate' }}
617
+ </button>
618
+ </div>
407
619
  <div v-if="refederateResult" class="cpub-fed-tool-result">
408
620
  Queued {{ refederateResult.queued }} items for delivery.
409
621
  <span v-if="refederateResult.content !== undefined" style="display: block; font-size: 12px; color: var(--text-faint); margin-top: 4px">
@@ -414,6 +626,20 @@ async function refederate(): Promise<void> {
414
626
  </div>
415
627
  </div>
416
628
  </template>
629
+
630
+ <MirrorDetailModal
631
+ v-if="selectedMirror"
632
+ :mirror="selectedMirror"
633
+ @close="selectedMirror = null"
634
+ @changed="onMirrorChanged"
635
+ />
636
+
637
+ <MirrorRequestApproveModal
638
+ v-if="approvingRequest"
639
+ :request="approvingRequest"
640
+ @close="approvingRequest = null"
641
+ @changed="onRequestChanged"
642
+ />
417
643
  </div>
418
644
  </template>
419
645
 
@@ -501,6 +727,36 @@ async function refederate(): Promise<void> {
501
727
  .cpub-fed-info-text { font-size: 0.75rem; color: var(--text-dim); margin-top: 12px; }
502
728
  .cpub-fed-info-text code { font-family: var(--font-mono); background: var(--surface2); padding: 1px 4px; }
503
729
 
730
+ /* Mirrors tab — explainer, create form, legend, list extras */
731
+ .cpub-fed-explain { font-size: 0.8125rem; color: var(--text-dim); line-height: 1.6; margin-bottom: 16px; }
732
+ .cpub-fed-create { margin-bottom: 16px; }
733
+ .cpub-fed-disclosure {
734
+ background: none; border: none; cursor: pointer; padding: 2px 0;
735
+ font-family: var(--font-mono); font-size: 11px; font-weight: 600; text-transform: uppercase;
736
+ letter-spacing: 0.06em; color: var(--text-dim); display: flex; align-items: center; gap: 6px;
737
+ }
738
+ .cpub-fed-disclosure:hover { color: var(--accent); }
739
+ .cpub-fed-advanced {
740
+ margin-top: 10px; padding: 12px; border: var(--border-width-default) solid var(--border);
741
+ background: var(--surface2); display: flex; flex-direction: column; gap: 6px;
742
+ }
743
+ .cpub-fed-adv-label { font-family: var(--font-mono); font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.04em; color: var(--text-dim); margin-top: 6px; }
744
+ .cpub-fed-adv-faint { color: var(--text-faint); font-weight: 400; text-transform: none; letter-spacing: 0; }
745
+ .cpub-fed-checks { display: flex; gap: 12px; flex-wrap: wrap; }
746
+ .cpub-fed-check { display: flex; align-items: center; gap: 5px; font-size: 0.8125rem; font-family: var(--font-mono); cursor: pointer; }
747
+ .cpub-fed-legend { display: flex; gap: 16px; flex-wrap: wrap; margin-bottom: 12px; font-size: 0.75rem; color: var(--text-dim); align-items: center; }
748
+ .cpub-fed-legend > span { display: flex; align-items: center; gap: 6px; }
749
+ .cpub-fed-dir-arrow { font-weight: 700; color: var(--accent); font-family: var(--font-mono); min-width: 12px; text-align: center; }
750
+ .cpub-fed-mirror-name {
751
+ background: none; border: none; cursor: pointer; padding: 0; text-align: left;
752
+ font-weight: 600; color: var(--text); min-width: 60px; font-family: var(--font-mono); font-size: 0.75rem;
753
+ }
754
+ .cpub-fed-mirror-name:hover { color: var(--accent); text-decoration: underline; }
755
+ .cpub-fed-subhead {
756
+ font-family: var(--font-mono); font-size: 0.8125rem; font-weight: 700; text-transform: uppercase;
757
+ letter-spacing: 0.04em; margin: 24px 0 4px;
758
+ }
759
+
504
760
  .cpub-fed-result {
505
761
  margin-top: 8px; padding: 10px 14px; font-size: 0.8125rem; font-family: var(--font-mono);
506
762
  background: var(--accent-bg); border: var(--border-width-default) solid var(--accent-border); color: var(--text);
@@ -0,0 +1,18 @@
1
+ import { listInstanceFollowers } from '@commonpub/server';
2
+ import { extractDomain } from '../../../utils/inbox';
3
+
4
+ /**
5
+ * GET /api/admin/federation/followers
6
+ * Instances mirroring US — remote actors that follow our instance Service actor.
7
+ * Answers "who is mirroring me". Admin only.
8
+ */
9
+ export default defineEventHandler(async (event) => {
10
+ requireFeature('federation');
11
+ requirePermission(event, 'federation.manage');
12
+
13
+ const config = useConfig();
14
+ const runtimeConfig = useRuntimeConfig();
15
+ const domain = extractDomain((runtimeConfig.public?.siteUrl as string) || `https://${config.instance.domain}`);
16
+
17
+ return listInstanceFollowers(useDB(), domain);
18
+ });
@@ -0,0 +1,20 @@
1
+ import { approveMirrorRequest } from '@commonpub/server';
2
+ import { approveMirrorRequestSchema } from '@commonpub/schema';
3
+
4
+ /**
5
+ * POST /api/admin/federation/mirror-requests/[id]/approve
6
+ * Approve an incoming mirror request: create a pull mirror of the requester using the approver's
7
+ * own bounded depth + filters, then Accept the request. Body (all optional):
8
+ * { sinceDays?, maxItems?, filterContentTypes?, filterTags? } — absent depth = forward-only.
9
+ * Admin only.
10
+ */
11
+ export default defineEventHandler(async (event) => {
12
+ requireFeature('federation');
13
+ requirePermission(event, 'federation.manage');
14
+
15
+ const { id } = parseParams(event, { id: 'uuid' });
16
+ const body = await parseBody(event, approveMirrorRequestSchema.optional()).catch(() => ({}));
17
+ const config = useConfig();
18
+
19
+ return approveMirrorRequest(useDB(), id, config.instance.domain, body ?? {});
20
+ });
@@ -0,0 +1,16 @@
1
+ import { rejectMirrorRequest } from '@commonpub/server';
2
+
3
+ /**
4
+ * POST /api/admin/federation/mirror-requests/[id]/reject
5
+ * Reject an incoming mirror request: send Reject(Offer) to the requester; create no mirror.
6
+ * Admin only.
7
+ */
8
+ export default defineEventHandler(async (event) => {
9
+ requireFeature('federation');
10
+ requirePermission(event, 'federation.manage');
11
+
12
+ const { id } = parseParams(event, { id: 'uuid' });
13
+ const config = useConfig();
14
+
15
+ return rejectMirrorRequest(useDB(), id, config.instance.domain);
16
+ });
@@ -0,0 +1,21 @@
1
+ import { listMirrorRequests } from '@commonpub/server';
2
+
3
+ /**
4
+ * GET /api/admin/federation/mirror-requests
5
+ * Consent-based mirror requests (Phase 3), grouped by direction:
6
+ * - `incoming` — instances asking US to mirror them (approve/reject in the admin UI)
7
+ * - `outgoing` — instances WE asked to mirror us (track approval status)
8
+ * Admin only.
9
+ */
10
+ export default defineEventHandler(async (event) => {
11
+ requireFeature('federation');
12
+ requirePermission(event, 'federation.manage');
13
+ const db = useDB();
14
+
15
+ const [incoming, outgoing] = await Promise.all([
16
+ listMirrorRequests(db, 'incoming'),
17
+ listMirrorRequests(db, 'outgoing'),
18
+ ]);
19
+
20
+ return { incoming, outgoing };
21
+ });
@@ -1,4 +1,5 @@
1
1
  import { getMirror, backfillFromOutbox } from '@commonpub/server';
2
+ import { z } from 'zod';
2
3
  /** Extract clean domain from URL */
3
4
  function extractDomain(url: string): string {
4
5
  try { return new URL(url).hostname; }
@@ -8,7 +9,11 @@ function extractDomain(url: string): string {
8
9
  /**
9
10
  * POST /api/admin/federation/mirrors/[id]/backfill
10
11
  * Crawl the remote instance's outbox to import historical content.
11
- * Admin only.
12
+ *
13
+ * Bounded by operator choice so a mirror of a large instance can't pull thousands at once:
14
+ * - `sinceDays` — only import items published within the last N days (maps to backfill `since`)
15
+ * - `maxItems` — hard cap on items pulled this run
16
+ * Both optional; `mirrorMaxItems` from federation config remains the ceiling. Admin only.
12
17
  */
13
18
  export default defineEventHandler(async (event) => {
14
19
  requirePermission(event, 'federation.manage');
@@ -19,6 +24,10 @@ export default defineEventHandler(async (event) => {
19
24
  }
20
25
 
21
26
  const { id: mirrorId } = parseParams(event, { id: 'uuid' });
27
+ const body = await parseBody(event, z.object({
28
+ sinceDays: z.number().int().positive().max(3650).optional(),
29
+ maxItems: z.number().int().positive().max(10000).optional(),
30
+ }).optional()).catch(() => ({} as { sinceDays?: number; maxItems?: number }));
22
31
  const db = useDB();
23
32
 
24
33
  const mirror = await getMirror(db, mirrorId);
@@ -29,7 +38,22 @@ export default defineEventHandler(async (event) => {
29
38
  const runtimeConfig = useRuntimeConfig();
30
39
  const domain = extractDomain((runtimeConfig.public?.siteUrl as string) || `https://${config.instance.domain}`);
31
40
 
32
- const result = await backfillFromOutbox(db, mirror.remoteActorUri, domain);
41
+ const ceiling = config.federation?.mirrorMaxItems;
42
+ const requested = body?.maxItems;
43
+ const maxItems = ceiling != null
44
+ ? Math.min(requested ?? ceiling, ceiling)
45
+ : requested;
46
+ const since = body?.sinceDays != null
47
+ ? new Date(Date.now() - body.sinceDays * 24 * 60 * 60 * 1000)
48
+ : undefined;
49
+
50
+ // Manual admin backfill crawls fresh from the top each run (no cursor resume) so a
51
+ // depth-picked "last N days" run isn't skewed by a stale cursor from a prior full crawl.
52
+ // processInboxActivity upserts by objectUri, so re-crawling is idempotent.
53
+ const result = await backfillFromOutbox(db, mirror.remoteActorUri, domain, {
54
+ ...(maxItems != null ? { maxItems } : {}),
55
+ ...(since ? { since } : {}),
56
+ });
33
57
 
34
58
  return {
35
59
  mirrorId: mirror.id,
@@ -1,4 +1,4 @@
1
- import { createMirror } from '@commonpub/server';
1
+ import { createMirror, requestMirror } from '@commonpub/server';
2
2
  import { z } from 'zod';
3
3
 
4
4
  const createMirrorSchema = z.object({
@@ -14,13 +14,19 @@ export default defineEventHandler(async (event) => {
14
14
  requirePermission(event, 'federation.manage');
15
15
  const db = useDB();
16
16
  const input = await parseBody(event, createMirrorSchema);
17
-
18
17
  const config = useConfig();
18
+
19
+ // Push = consent-based mirror request (Phase 3): ask them to pull-mirror us. No filters here —
20
+ // the approver chooses their own depth/filters. Pull = a normal subscription to their content.
21
+ if (input.direction === 'push') {
22
+ return requestMirror(db, input.remoteDomain, input.remoteActorUri, config.instance.domain);
23
+ }
24
+
19
25
  return createMirror(
20
26
  db,
21
27
  input.remoteDomain,
22
28
  input.remoteActorUri,
23
- input.direction,
29
+ 'pull',
24
30
  config.instance.domain,
25
31
  {
26
32
  contentTypes: input.filterContentTypes ?? undefined,