@commonpub/layer 0.3.20 → 0.3.22

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.
@@ -0,0 +1,53 @@
1
+ <script setup lang="ts">
2
+ const props = defineProps<{
3
+ text: string;
4
+ }>();
5
+
6
+ interface TextSegment {
7
+ type: 'text' | 'mention';
8
+ value: string;
9
+ }
10
+
11
+ const MENTION_RE = /(?:^|(?<=[\s(]))@([a-zA-Z0-9_-]{1,39})(?=[\s,.!?;:)\]|]|$)/g;
12
+
13
+ const segments = computed((): TextSegment[] => {
14
+ const result: TextSegment[] = [];
15
+ let lastIndex = 0;
16
+
17
+ for (const match of props.text.matchAll(MENTION_RE)) {
18
+ const start = match.index!;
19
+ if (start > lastIndex) {
20
+ result.push({ type: 'text', value: props.text.slice(lastIndex, start) });
21
+ }
22
+ result.push({ type: 'mention', value: match[1]! });
23
+ lastIndex = start + match[0].length;
24
+ }
25
+
26
+ if (lastIndex < props.text.length) {
27
+ result.push({ type: 'text', value: props.text.slice(lastIndex) });
28
+ }
29
+
30
+ return result.length > 0 ? result : [{ type: 'text', value: props.text }];
31
+ });
32
+ </script>
33
+
34
+ <template>
35
+ <span class="cpub-mention-text">
36
+ <template v-for="(seg, idx) in segments" :key="idx">
37
+ <NuxtLink v-if="seg.type === 'mention'" :to="`/u/${seg.value}`" class="cpub-mention">@{{ seg.value }}</NuxtLink>
38
+ <template v-else>{{ seg.value }}</template>
39
+ </template>
40
+ </span>
41
+ </template>
42
+
43
+ <style scoped>
44
+ .cpub-mention {
45
+ color: var(--accent);
46
+ text-decoration: none;
47
+ font-weight: 500;
48
+ }
49
+
50
+ .cpub-mention:hover {
51
+ text-decoration: underline;
52
+ }
53
+ </style>
@@ -1,57 +1,10 @@
1
- /** Composable for real-time unread message count via SSE */
1
+ /** Composable for real-time unread message count. Delegates to unified SSE stream. */
2
2
  export function useMessages() {
3
- const count = useState<number>('message-count', () => 0);
4
- const connected = useState<boolean>('message-connected', () => false);
5
-
6
- let eventSource: EventSource | null = null;
7
- let retryDelay = 5000;
8
- let retryTimer: ReturnType<typeof setTimeout> | null = null;
9
- const MAX_RETRY_DELAY = 60_000;
10
-
11
- function connect(): void {
12
- if (import.meta.server || eventSource) return;
13
-
14
- eventSource = new EventSource('/api/messages/stream');
15
- connected.value = true;
16
-
17
- eventSource.onopen = () => {
18
- retryDelay = 5000;
19
- };
20
-
21
- eventSource.onmessage = (event) => {
22
- try {
23
- const data = JSON.parse(event.data) as { type: string; count?: number };
24
- if (data.type === 'count' && typeof data.count === 'number') {
25
- count.value = data.count;
26
- }
27
- } catch { /* ignore */ }
28
- };
29
-
30
- eventSource.onerror = () => {
31
- connected.value = false;
32
- const wasClosed = eventSource?.readyState === 2;
33
- eventSource?.close();
34
- eventSource = null;
35
- if (wasClosed) return;
36
- retryTimer = setTimeout(connect, retryDelay);
37
- retryDelay = Math.min(retryDelay * 2, MAX_RETRY_DELAY);
38
- };
39
- }
40
-
41
- function disconnect(): void {
42
- if (retryTimer) {
43
- clearTimeout(retryTimer);
44
- retryTimer = null;
45
- }
46
- eventSource?.close();
47
- eventSource = null;
48
- connected.value = false;
49
- retryDelay = 5000;
50
- }
3
+ const { messageCount, connected, connect, disconnect } = useRealtimeCounts();
51
4
 
52
5
  return {
53
- count: readonly(count),
54
- connected: readonly(connected),
6
+ count: messageCount,
7
+ connected,
55
8
  connect,
56
9
  disconnect,
57
10
  };
@@ -1,73 +1,13 @@
1
- /** Composable for real-time notification count via SSE */
1
+ /** Composable for real-time notification count. Delegates to unified SSE stream. */
2
2
  export function useNotifications() {
3
- const count = useState<number>('notification-count', () => 0);
4
- const connected = useState<boolean>('notification-connected', () => false);
5
-
6
- let eventSource: EventSource | null = null;
7
- let retryDelay = 5000;
8
- let retryTimer: ReturnType<typeof setTimeout> | null = null;
9
- const MAX_RETRY_DELAY = 60_000;
10
-
11
- function connect(): void {
12
- if (import.meta.server || eventSource) return;
13
-
14
- eventSource = new EventSource('/api/notifications/stream');
15
- connected.value = true;
16
-
17
- eventSource.onopen = () => {
18
- retryDelay = 5000; // Reset backoff on successful connection
19
- };
20
-
21
- eventSource.onmessage = (event) => {
22
- try {
23
- const data = JSON.parse(event.data) as { type: string; count?: number };
24
- if (data.type === 'count' && typeof data.count === 'number') {
25
- count.value = data.count;
26
- }
27
- } catch {
28
- // Ignore malformed messages
29
- }
30
- };
31
-
32
- eventSource.onerror = () => {
33
- connected.value = false;
34
- // EventSource readyState 2 = CLOSED (server rejected, e.g. 401)
35
- const wasClosed = eventSource?.readyState === 2;
36
- eventSource?.close();
37
- eventSource = null;
38
- // If the connection was fully closed (auth error, 401, etc.), don't retry
39
- if (wasClosed) return;
40
- // Exponential backoff: 5s → 10s → 20s → 40s → 60s cap
41
- retryTimer = setTimeout(connect, retryDelay);
42
- retryDelay = Math.min(retryDelay * 2, MAX_RETRY_DELAY);
43
- };
44
- }
45
-
46
- function disconnect(): void {
47
- if (retryTimer) {
48
- clearTimeout(retryTimer);
49
- retryTimer = null;
50
- }
51
- eventSource?.close();
52
- eventSource = null;
53
- connected.value = false;
54
- retryDelay = 5000;
55
- }
56
-
57
- function decrement(): void {
58
- if (count.value > 0) count.value--;
59
- }
60
-
61
- function reset(): void {
62
- count.value = 0;
63
- }
3
+ const { notificationCount, connected, connect, disconnect, decrementNotifications, resetNotifications } = useRealtimeCounts();
64
4
 
65
5
  return {
66
- count: readonly(count),
67
- connected: readonly(connected),
6
+ count: notificationCount,
7
+ connected,
68
8
  connect,
69
9
  disconnect,
70
- decrement,
71
- reset,
10
+ decrement: decrementNotifications,
11
+ reset: resetNotifications,
72
12
  };
73
13
  }
@@ -0,0 +1,74 @@
1
+ /**
2
+ * Unified composable for real-time notification and message counts via a single SSE stream.
3
+ * Replaces the separate useNotifications() and useMessages() EventSource connections.
4
+ */
5
+ export function useRealtimeCounts() {
6
+ const notificationCount = useState<number>('notification-count', () => 0);
7
+ const messageCount = useState<number>('message-count', () => 0);
8
+ const connected = useState<boolean>('realtime-connected', () => false);
9
+
10
+ let eventSource: EventSource | null = null;
11
+ let retryDelay = 5000;
12
+ let retryTimer: ReturnType<typeof setTimeout> | null = null;
13
+ const MAX_RETRY_DELAY = 60_000;
14
+
15
+ function connect(): void {
16
+ if (import.meta.server || eventSource) return;
17
+
18
+ eventSource = new EventSource('/api/realtime/stream');
19
+ connected.value = true;
20
+
21
+ eventSource.onopen = () => {
22
+ retryDelay = 5000;
23
+ };
24
+
25
+ eventSource.onmessage = (event) => {
26
+ try {
27
+ const data = JSON.parse(event.data) as { type: string; notifications?: number; messages?: number };
28
+ if (data.type === 'counts') {
29
+ if (typeof data.notifications === 'number') notificationCount.value = data.notifications;
30
+ if (typeof data.messages === 'number') messageCount.value = data.messages;
31
+ }
32
+ } catch { /* ignore */ }
33
+ };
34
+
35
+ eventSource.onerror = () => {
36
+ connected.value = false;
37
+ const wasClosed = eventSource?.readyState === 2;
38
+ eventSource?.close();
39
+ eventSource = null;
40
+ if (wasClosed) return;
41
+ retryTimer = setTimeout(connect, retryDelay);
42
+ retryDelay = Math.min(retryDelay * 2, MAX_RETRY_DELAY);
43
+ };
44
+ }
45
+
46
+ function disconnect(): void {
47
+ if (retryTimer) {
48
+ clearTimeout(retryTimer);
49
+ retryTimer = null;
50
+ }
51
+ eventSource?.close();
52
+ eventSource = null;
53
+ connected.value = false;
54
+ retryDelay = 5000;
55
+ }
56
+
57
+ function decrementNotifications(): void {
58
+ if (notificationCount.value > 0) notificationCount.value--;
59
+ }
60
+
61
+ function resetNotifications(): void {
62
+ notificationCount.value = 0;
63
+ }
64
+
65
+ return {
66
+ notificationCount: readonly(notificationCount),
67
+ messageCount: readonly(messageCount),
68
+ connected: readonly(connected),
69
+ connect,
70
+ disconnect,
71
+ decrementNotifications,
72
+ resetNotifications,
73
+ };
74
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@commonpub/layer",
3
- "version": "0.3.20",
3
+ "version": "0.3.22",
4
4
  "type": "module",
5
5
  "main": "./nuxt.config.ts",
6
6
  "files": [
@@ -44,15 +44,15 @@
44
44
  "vue": "^3.4.0",
45
45
  "vue-router": "^4.3.0",
46
46
  "zod": "^4.3.6",
47
+ "@commonpub/auth": "0.5.0",
47
48
  "@commonpub/config": "0.7.0",
48
49
  "@commonpub/docs": "0.5.2",
49
- "@commonpub/auth": "0.5.0",
50
50
  "@commonpub/editor": "0.5.0",
51
- "@commonpub/protocol": "0.9.4",
52
51
  "@commonpub/learning": "0.5.0",
52
+ "@commonpub/protocol": "0.9.4",
53
53
  "@commonpub/schema": "0.8.12",
54
- "@commonpub/ui": "0.7.1",
55
- "@commonpub/server": "2.13.0"
54
+ "@commonpub/server": "2.14.0",
55
+ "@commonpub/ui": "0.7.1"
56
56
  },
57
57
  "scripts": {}
58
58
  }
@@ -2,7 +2,7 @@
2
2
  definePageMeta({ layout: 'admin', middleware: 'auth' });
3
3
  useSeoMeta({ title: `Federation — Admin — ${useSiteName()}` });
4
4
 
5
- const activeTab = ref<'activity' | 'mirrors' | 'clients' | 'tools'>('activity');
5
+ const activeTab = ref<'activity' | 'mirrors' | 'clients' | 'trusted' | 'tools'>('activity');
6
6
 
7
7
  const { data: statsData } = await useFetch('/api/admin/federation/stats', {
8
8
  default: () => ({ inbound: 0, outbound: 0, pending: 0, failed: 0, followers: 0, following: 0 }),
@@ -21,6 +21,38 @@ const { data: clientsData } = await useFetch<any[]>('/api/admin/federation/clien
21
21
  default: () => [],
22
22
  });
23
23
 
24
+ // Trusted instances
25
+ const { data: trustedData, refresh: refreshTrusted } = await useFetch<{ configDomains: string[]; storedDomains: string[] }>('/api/admin/federation/trusted-instances', {
26
+ default: () => ({ configDomains: [], storedDomains: [] }),
27
+ });
28
+
29
+ const newTrustedDomain = ref('');
30
+ const trustedAdding = ref(false);
31
+
32
+ async function addTrusted(): Promise<void> {
33
+ const domain = newTrustedDomain.value.trim().toLowerCase();
34
+ if (!domain) return;
35
+ trustedAdding.value = true;
36
+ try {
37
+ await $fetch('/api/admin/federation/trusted-instances', {
38
+ method: 'POST',
39
+ body: { domain },
40
+ });
41
+ newTrustedDomain.value = '';
42
+ await refreshTrusted();
43
+ } finally {
44
+ trustedAdding.value = false;
45
+ }
46
+ }
47
+
48
+ async function removeTrusted(domain: string): Promise<void> {
49
+ await $fetch('/api/admin/federation/trusted-instances', {
50
+ method: 'DELETE',
51
+ body: { domain },
52
+ });
53
+ await refreshTrusted();
54
+ }
55
+
24
56
  // Mirror creation
25
57
  const newMirrorDomain = ref('');
26
58
  const newMirrorActorUri = ref('');
@@ -179,6 +211,7 @@ async function refederate(): Promise<void> {
179
211
  <button :class="{ active: activeTab === 'activity' }" @click="activeTab = 'activity'">Activity</button>
180
212
  <button :class="{ active: activeTab === 'mirrors' }" @click="activeTab = 'mirrors'">Mirrors</button>
181
213
  <button :class="{ active: activeTab === 'clients' }" @click="activeTab = 'clients'">OAuth Clients</button>
214
+ <button :class="{ active: activeTab === 'trusted' }" @click="activeTab = 'trusted'">Trusted Instances</button>
182
215
  <button :class="{ active: activeTab === 'tools' }" @click="activeTab = 'tools'">Tools</button>
183
216
  </div>
184
217
 
@@ -282,6 +315,36 @@ async function refederate(): Promise<void> {
282
315
  </p>
283
316
  </div>
284
317
 
318
+ <!-- Trusted Instances Tab -->
319
+ <div v-if="activeTab === 'trusted'">
320
+ <p class="cpub-fed-info-text" style="margin-bottom: 12px;">
321
+ Trusted instances can use cross-instance SSO to authenticate users on this instance.
322
+ Domains from the config file cannot be removed here.
323
+ </p>
324
+
325
+ <div class="cpub-fed-form">
326
+ <input v-model="newTrustedDomain" placeholder="instance.example.com" class="cpub-fed-input" @keydown.enter.prevent="addTrusted" />
327
+ <button :disabled="trustedAdding || !newTrustedDomain.trim()" class="cpub-fed-btn" @click="addTrusted">
328
+ {{ trustedAdding ? 'Adding...' : 'Add Instance' }}
329
+ </button>
330
+ </div>
331
+
332
+ <div class="cpub-fed-activity-list">
333
+ <div v-if="!trustedData.configDomains.length && !trustedData.storedDomains.length" class="cpub-fed-empty">No trusted instances configured.</div>
334
+
335
+ <div v-for="domain in trustedData.configDomains" :key="'config-' + domain" class="cpub-fed-activity-row">
336
+ <span class="cpub-fed-type">{{ domain }}</span>
337
+ <span class="cpub-fed-status processed">config</span>
338
+ </div>
339
+
340
+ <div v-for="domain in trustedData.storedDomains" :key="'stored-' + domain" class="cpub-fed-activity-row">
341
+ <span class="cpub-fed-type">{{ domain }}</span>
342
+ <span class="cpub-fed-status pending">admin</span>
343
+ <button class="cpub-fed-btn-sm cpub-fed-btn-danger" @click="removeTrusted(domain)">Remove</button>
344
+ </div>
345
+ </div>
346
+ </div>
347
+
285
348
  <!-- Tools Tab -->
286
349
  <div v-if="activeTab === 'tools'">
287
350
  <div class="cpub-fed-tools">
@@ -0,0 +1,17 @@
1
+ import { removeTrustedInstance } from '@commonpub/server';
2
+ import { z } from 'zod';
3
+
4
+ const removeSchema = z.object({
5
+ domain: z.string().min(3).max(255),
6
+ });
7
+
8
+ export default defineEventHandler(async (event) => {
9
+ requireFeature('admin');
10
+ requireAdmin(event);
11
+ const db = useDB();
12
+ const { domain } = await parseBody(event, removeSchema);
13
+
14
+ await removeTrustedInstance(db, domain.toLowerCase());
15
+
16
+ return { success: true };
17
+ });
@@ -0,0 +1,16 @@
1
+ import { getStoredTrustedInstances } from '@commonpub/server';
2
+
3
+ export default defineEventHandler(async (event) => {
4
+ requireFeature('admin');
5
+ requireAdmin(event);
6
+ const db = useDB();
7
+ const config = useConfig();
8
+
9
+ const stored = await getStoredTrustedInstances(db);
10
+ const configDomains = config.auth.trustedInstances ?? [];
11
+
12
+ return {
13
+ configDomains,
14
+ storedDomains: stored,
15
+ };
16
+ });
@@ -0,0 +1,17 @@
1
+ import { addTrustedInstance } from '@commonpub/server';
2
+ import { z } from 'zod';
3
+
4
+ const addSchema = z.object({
5
+ domain: z.string().min(3).max(255).regex(/^[a-zA-Z0-9][a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/, 'Invalid domain format'),
6
+ });
7
+
8
+ export default defineEventHandler(async (event) => {
9
+ requireFeature('admin');
10
+ requireAdmin(event);
11
+ const db = useDB();
12
+ const { domain } = await parseBody(event, addSchema);
13
+
14
+ await addTrustedInstance(db, domain.toLowerCase());
15
+
16
+ return { success: true, domain: domain.toLowerCase() };
17
+ });
@@ -1,5 +1,5 @@
1
- import { discoverOAuthEndpoint, isTrustedInstance } from '@commonpub/auth';
2
- import { storeOAuthState } from '@commonpub/server';
1
+ import { discoverOAuthEndpoint } from '@commonpub/auth';
2
+ import { storeOAuthState, isDomainTrusted } from '@commonpub/server';
3
3
  import { z } from 'zod';
4
4
 
5
5
  const loginSchema = z.object({
@@ -19,7 +19,8 @@ export default defineEventHandler(async (event) => {
19
19
  const db = useDB();
20
20
  const { instanceDomain, clientId, clientSecret } = await parseBody(event, loginSchema);
21
21
 
22
- if (!isTrustedInstance(config, instanceDomain)) {
22
+ const trusted = await isDomainTrusted(db, config, instanceDomain);
23
+ if (!trusted) {
23
24
  throw createError({
24
25
  statusCode: 403,
25
26
  statusMessage: `Instance ${instanceDomain} is not in the trusted instances list`,
@@ -1,6 +1,8 @@
1
1
  import { getUnreadMessageCount } from '@commonpub/server';
2
2
 
3
+ /** @deprecated Use /api/realtime/stream instead */
3
4
  export default defineEventHandler(async (event) => {
5
+ console.warn('[deprecated] /api/messages/stream — use /api/realtime/stream instead');
4
6
  const user = requireAuth(event);
5
7
  const userId = user.id;
6
8
  const db = useDB();
@@ -1,6 +1,8 @@
1
1
  import { getUnreadCount } from '@commonpub/server';
2
2
 
3
+ /** @deprecated Use /api/realtime/stream instead */
3
4
  export default defineEventHandler(async (event) => {
5
+ console.warn('[deprecated] /api/notifications/stream — use /api/realtime/stream instead');
4
6
  const user = requireAuth(event);
5
7
  const userId = user.id;
6
8
  const db = useDB();
@@ -0,0 +1,65 @@
1
+ import { getUnreadCount, getUnreadMessageCount } from '@commonpub/server';
2
+
3
+ /**
4
+ * Unified SSE stream for notification and message counts.
5
+ * Replaces the separate /api/notifications/stream and /api/messages/stream endpoints.
6
+ * Sends both counts in a single event, halving DB polls and open connections.
7
+ */
8
+ export default defineEventHandler(async (event) => {
9
+ const user = requireAuth(event);
10
+ const userId = user.id;
11
+ const db = useDB();
12
+
13
+ const encoder = new TextEncoder();
14
+ const stream = new ReadableStream({
15
+ async start(controller) {
16
+ let closed = false;
17
+ function cleanup(): void {
18
+ if (closed) return;
19
+ closed = true;
20
+ clearInterval(interval);
21
+ clearInterval(keepalive);
22
+ try { controller.close(); } catch { /* already closed */ }
23
+ }
24
+
25
+ async function sendCounts(): Promise<void> {
26
+ const [notifications, messages] = await Promise.all([
27
+ getUnreadCount(db, userId),
28
+ getUnreadMessageCount(db, userId),
29
+ ]);
30
+ controller.enqueue(encoder.encode(`data: ${JSON.stringify({ type: 'counts', notifications, messages })}\n\n`));
31
+ }
32
+
33
+ // Send initial counts
34
+ await sendCounts();
35
+
36
+ // Poll every 10 seconds
37
+ const interval = setInterval(async () => {
38
+ try {
39
+ await sendCounts();
40
+ } catch {
41
+ cleanup();
42
+ }
43
+ }, 10000);
44
+
45
+ // Keepalive every 30 seconds
46
+ const keepalive = setInterval(() => {
47
+ try {
48
+ controller.enqueue(encoder.encode(': keepalive\n\n'));
49
+ } catch {
50
+ cleanup();
51
+ }
52
+ }, 30000);
53
+
54
+ event.node.req.on('close', cleanup);
55
+ },
56
+ });
57
+
58
+ return new Response(stream, {
59
+ headers: {
60
+ 'Content-Type': 'text/event-stream',
61
+ 'Cache-Control': 'no-cache',
62
+ 'Connection': 'keep-alive',
63
+ },
64
+ });
65
+ });