@commonpub/layer 0.60.0 → 0.62.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.
- package/composables/useFeatures.ts +3 -0
- package/nuxt.config.ts +1 -0
- package/package.json +7 -7
- package/pages/admin/api-keys.vue +66 -8
- package/server/api/public/v1/metrics/content/top.get.ts +30 -0
- package/server/api/public/v1/metrics/contributors/top.get.ts +25 -0
- package/server/api/public/v1/metrics/engagement.get.ts +19 -0
- package/server/api/public/v1/metrics/federation.get.ts +30 -0
- package/server/api/public/v1/metrics/overview.get.ts +15 -0
- package/server/api/public/v1/metrics/tags/trending.get.ts +24 -0
- package/server/api/public/v1/metrics/timeseries.get.ts +41 -0
- package/server/api/public/v1/openapi.json.get.ts +21 -0
- package/server/middleware/public-api-auth.ts +20 -14
- package/server/plugins/metrics-rollup.ts +54 -0
|
@@ -44,6 +44,8 @@ export interface FeatureFlags {
|
|
|
44
44
|
actAsRegistry: boolean;
|
|
45
45
|
/** Announce this instance to a registry (Phase 4). Default ON (discoverable). */
|
|
46
46
|
announceToRegistry: boolean;
|
|
47
|
+
/** Expose federation reach metrics on the public API. Default OFF (server-gated). */
|
|
48
|
+
publicApiMetricsFederation: boolean;
|
|
47
49
|
/**
|
|
48
50
|
* Cross-instance delegated authorization. All sub-flags default false.
|
|
49
51
|
* Mirrors `@commonpub/config`'s `IdentityFeatures`. Phase 1b+ — see
|
|
@@ -68,6 +70,7 @@ export const DEFAULT_FLAGS: FeatureFlags = {
|
|
|
68
70
|
rbac: false,
|
|
69
71
|
actAsRegistry: false,
|
|
70
72
|
announceToRegistry: true,
|
|
73
|
+
publicApiMetricsFederation: false,
|
|
71
74
|
identity: {
|
|
72
75
|
linkRemoteAccounts: false,
|
|
73
76
|
signInWithRemote: false,
|
package/nuxt.config.ts
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@commonpub/layer",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.62.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"main": "./nuxt.config.ts",
|
|
6
6
|
"files": [
|
|
@@ -54,15 +54,15 @@
|
|
|
54
54
|
"vue-router": "^4.3.0",
|
|
55
55
|
"zod": "^4.3.6",
|
|
56
56
|
"@commonpub/auth": "0.8.0",
|
|
57
|
-
"@commonpub/config": "0.
|
|
58
|
-
"@commonpub/
|
|
57
|
+
"@commonpub/config": "0.19.0",
|
|
58
|
+
"@commonpub/explainer": "0.7.15",
|
|
59
59
|
"@commonpub/docs": "0.6.3",
|
|
60
60
|
"@commonpub/protocol": "0.13.0",
|
|
61
|
-
"@commonpub/server": "2.
|
|
61
|
+
"@commonpub/server": "2.82.0",
|
|
62
|
+
"@commonpub/editor": "0.7.11",
|
|
63
|
+
"@commonpub/learning": "0.5.2",
|
|
62
64
|
"@commonpub/ui": "0.9.2",
|
|
63
|
-
"@commonpub/
|
|
64
|
-
"@commonpub/schema": "0.33.0",
|
|
65
|
-
"@commonpub/learning": "0.5.2"
|
|
65
|
+
"@commonpub/schema": "0.35.0"
|
|
66
66
|
},
|
|
67
67
|
"devDependencies": {
|
|
68
68
|
"@testing-library/jest-dom": "^6.9.1",
|
package/pages/admin/api-keys.vue
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
<script setup lang="ts">
|
|
2
2
|
import type { AdminApiKeyView, ApiKeyUsageStats } from '@commonpub/server';
|
|
3
|
-
import { PUBLIC_API_SCOPES } from '@commonpub/schema';
|
|
3
|
+
import { PUBLIC_API_SCOPES, originPatternSchema } from '@commonpub/schema';
|
|
4
4
|
|
|
5
5
|
definePageMeta({ layout: 'admin', middleware: 'auth' });
|
|
6
6
|
|
|
@@ -30,6 +30,12 @@ const form = reactive({
|
|
|
30
30
|
rateLimitPerMinute: 60,
|
|
31
31
|
allowedOrigins: '',
|
|
32
32
|
});
|
|
33
|
+
// CORS preset: 'none' (server-to-server, default), 'any' (*), 'localhost', or
|
|
34
|
+
// 'custom' (free-text origin patterns). Keeps the common cases one click away
|
|
35
|
+
// while still allowing wildcard/subdomain/port patterns via Custom.
|
|
36
|
+
type CorsPreset = 'none' | 'any' | 'localhost' | 'custom';
|
|
37
|
+
const corsPreset = ref<CorsPreset>('none');
|
|
38
|
+
|
|
33
39
|
const creating = ref(false);
|
|
34
40
|
const createError = ref('');
|
|
35
41
|
const createdKey = ref<CreateResponse | null>(null);
|
|
@@ -50,9 +56,31 @@ function resetForm(): void {
|
|
|
50
56
|
form.expiresAt = '';
|
|
51
57
|
form.rateLimitPerMinute = 60;
|
|
52
58
|
form.allowedOrigins = '';
|
|
59
|
+
corsPreset.value = 'none';
|
|
53
60
|
createError.value = '';
|
|
54
61
|
}
|
|
55
62
|
|
|
63
|
+
/**
|
|
64
|
+
* Resolve the CORS preset into the origin list the API expects. Returns null
|
|
65
|
+
* (and sets createError) when a custom pattern is invalid, so the caller can
|
|
66
|
+
* abort before submitting. Mirrors the server-side `originPatternSchema`.
|
|
67
|
+
*/
|
|
68
|
+
function resolveOrigins(): string[] | null {
|
|
69
|
+
if (corsPreset.value === 'none') return [];
|
|
70
|
+
if (corsPreset.value === 'any') return ['*'];
|
|
71
|
+
if (corsPreset.value === 'localhost') return ['localhost'];
|
|
72
|
+
const list = form.allowedOrigins
|
|
73
|
+
.split(/[\s,]+/)
|
|
74
|
+
.map((o) => o.trim())
|
|
75
|
+
.filter(Boolean);
|
|
76
|
+
const bad = list.find((o) => !originPatternSchema.safeParse(o).success);
|
|
77
|
+
if (bad) {
|
|
78
|
+
createError.value = `Invalid CORS origin: ${bad}`;
|
|
79
|
+
return null;
|
|
80
|
+
}
|
|
81
|
+
return list;
|
|
82
|
+
}
|
|
83
|
+
|
|
56
84
|
async function submitCreate(): Promise<void> {
|
|
57
85
|
createError.value = '';
|
|
58
86
|
if (!form.name.trim()) {
|
|
@@ -63,12 +91,11 @@ async function submitCreate(): Promise<void> {
|
|
|
63
91
|
createError.value = 'Select at least one scope';
|
|
64
92
|
return;
|
|
65
93
|
}
|
|
94
|
+
const origins = resolveOrigins();
|
|
95
|
+
if (origins === null) return; // invalid custom pattern; error already set
|
|
96
|
+
|
|
66
97
|
creating.value = true;
|
|
67
98
|
try {
|
|
68
|
-
const origins = form.allowedOrigins
|
|
69
|
-
.split(/[\s,]+/)
|
|
70
|
-
.map((o) => o.trim())
|
|
71
|
-
.filter(Boolean);
|
|
72
99
|
const body = {
|
|
73
100
|
name: form.name.trim(),
|
|
74
101
|
description: form.description.trim() || null,
|
|
@@ -227,9 +254,38 @@ function fmtErrorRate(rate: number): string {
|
|
|
227
254
|
</div>
|
|
228
255
|
</div>
|
|
229
256
|
<div class="cpub-form-row">
|
|
230
|
-
<label
|
|
231
|
-
<
|
|
232
|
-
|
|
257
|
+
<label id="key-cors-label">CORS access</label>
|
|
258
|
+
<div class="cpub-scope-grid" role="radiogroup" aria-labelledby="key-cors-label">
|
|
259
|
+
<label class="cpub-scope-chip">
|
|
260
|
+
<input type="radio" name="cors-preset" value="none" v-model="corsPreset" />
|
|
261
|
+
<span>Server-to-server only</span>
|
|
262
|
+
</label>
|
|
263
|
+
<label class="cpub-scope-chip">
|
|
264
|
+
<input type="radio" name="cors-preset" value="any" v-model="corsPreset" />
|
|
265
|
+
<span>Allow any origin (*)</span>
|
|
266
|
+
</label>
|
|
267
|
+
<label class="cpub-scope-chip">
|
|
268
|
+
<input type="radio" name="cors-preset" value="localhost" v-model="corsPreset" />
|
|
269
|
+
<span>Localhost (dev)</span>
|
|
270
|
+
</label>
|
|
271
|
+
<label class="cpub-scope-chip">
|
|
272
|
+
<input type="radio" name="cors-preset" value="custom" v-model="corsPreset" />
|
|
273
|
+
<span>Custom origins</span>
|
|
274
|
+
</label>
|
|
275
|
+
</div>
|
|
276
|
+
<div v-if="corsPreset === 'custom'" class="cpub-cors-custom">
|
|
277
|
+
<label for="key-origins">Allowed origins (comma or whitespace separated)</label>
|
|
278
|
+
<input
|
|
279
|
+
id="key-origins"
|
|
280
|
+
v-model="form.allowedOrigins"
|
|
281
|
+
class="cpub-input"
|
|
282
|
+
placeholder="https://app.example.com, https://*.example.com, http://localhost:*"
|
|
283
|
+
/>
|
|
284
|
+
</div>
|
|
285
|
+
<small v-if="corsPreset === 'none'">Default. Browser cross-origin calls are blocked. Use this key from a server.</small>
|
|
286
|
+
<small v-else-if="corsPreset === 'any'">Any website can call this key from a browser. The Bearer token still controls access.</small>
|
|
287
|
+
<small v-else-if="corsPreset === 'localhost'">Allows http and https on localhost at any port, for local development.</small>
|
|
288
|
+
<small v-else>Patterns: *, localhost, https://app.example.com, https://*.example.com, http://localhost:*</small>
|
|
233
289
|
</div>
|
|
234
290
|
<p v-if="createError" class="cpub-form-error" role="alert">{{ createError }}</p>
|
|
235
291
|
<div class="cpub-form-actions">
|
|
@@ -393,6 +449,8 @@ function fmtErrorRate(rate: number): string {
|
|
|
393
449
|
font-size: 12px; cursor: pointer;
|
|
394
450
|
}
|
|
395
451
|
.cpub-scope-chip code { font-family: var(--font-mono); font-size: 11px; color: var(--text); }
|
|
452
|
+
.cpub-scope-chip span { font-size: 12px; color: var(--text); }
|
|
453
|
+
.cpub-cors-custom { margin-top: 8px; }
|
|
396
454
|
|
|
397
455
|
.cpub-btn {
|
|
398
456
|
padding: 6px 14px; font-size: 12px; font-weight: 500;
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { getTopContent } from '@commonpub/server';
|
|
2
|
+
import { z } from 'zod';
|
|
3
|
+
|
|
4
|
+
const querySchema = z.object({
|
|
5
|
+
metric: z.enum(['views', 'likes', 'comments']).default('views'),
|
|
6
|
+
type: z.enum(['project', 'blog', 'explainer']).optional(),
|
|
7
|
+
limit: z.coerce.number().int().min(1).max(100).default(20),
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* GET /api/public/v1/metrics/content/top
|
|
12
|
+
*
|
|
13
|
+
* Scope: read:analytics. Leaderboard of published, public content by the chosen
|
|
14
|
+
* engagement metric. Author attribution is intentional (the content is public).
|
|
15
|
+
*/
|
|
16
|
+
export default defineEventHandler(async (event) => {
|
|
17
|
+
requireApiScope(event, 'read:analytics');
|
|
18
|
+
const parsed = querySchema.safeParse(getQuery(event));
|
|
19
|
+
if (!parsed.success) {
|
|
20
|
+
throw createError({ statusCode: 400, statusMessage: 'Invalid query parameters', data: parsed.error.flatten() });
|
|
21
|
+
}
|
|
22
|
+
const db = useDB();
|
|
23
|
+
const config = useConfig();
|
|
24
|
+
const items = await getTopContent(db, config.instance.domain, {
|
|
25
|
+
metric: parsed.data.metric,
|
|
26
|
+
type: parsed.data.type,
|
|
27
|
+
limit: parsed.data.limit,
|
|
28
|
+
});
|
|
29
|
+
return { items, metric: parsed.data.metric, limit: parsed.data.limit };
|
|
30
|
+
});
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { getTopContributors } from '@commonpub/server';
|
|
2
|
+
import { z } from 'zod';
|
|
3
|
+
|
|
4
|
+
const querySchema = z.object({
|
|
5
|
+
limit: z.coerce.number().int().min(1).max(100).default(20),
|
|
6
|
+
});
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* GET /api/public/v1/metrics/contributors/top
|
|
10
|
+
*
|
|
11
|
+
* Scope: read:analytics. Ranks public-profile, active users by their published,
|
|
12
|
+
* public content (with engagement received). Private/suspended/deleted profiles
|
|
13
|
+
* are excluded; this aggregates already-public attribution.
|
|
14
|
+
*/
|
|
15
|
+
export default defineEventHandler(async (event) => {
|
|
16
|
+
requireApiScope(event, 'read:analytics');
|
|
17
|
+
const parsed = querySchema.safeParse(getQuery(event));
|
|
18
|
+
if (!parsed.success) {
|
|
19
|
+
throw createError({ statusCode: 400, statusMessage: 'Invalid query parameters', data: parsed.error.flatten() });
|
|
20
|
+
}
|
|
21
|
+
const db = useDB();
|
|
22
|
+
const config = useConfig();
|
|
23
|
+
const items = await getTopContributors(db, config.instance.domain, parsed.data.limit);
|
|
24
|
+
return { items, limit: parsed.data.limit };
|
|
25
|
+
});
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { getEngagementMetrics } from '@commonpub/server';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* GET /api/public/v1/metrics/engagement
|
|
5
|
+
*
|
|
6
|
+
* Scope: read:analytics. Aggregate engagement ratios and funnels: content
|
|
7
|
+
* likes/comments-per-view, learning enroll->complete, event capacity->attendance,
|
|
8
|
+
* contest entries. Feature-gated sections are omitted when the feature is off.
|
|
9
|
+
*/
|
|
10
|
+
export default defineEventHandler(async (event) => {
|
|
11
|
+
requireApiScope(event, 'read:analytics');
|
|
12
|
+
const db = useDB();
|
|
13
|
+
const config = useConfig();
|
|
14
|
+
return await getEngagementMetrics(db, {
|
|
15
|
+
learning: config.features.learning,
|
|
16
|
+
events: config.features.events,
|
|
17
|
+
contests: config.features.contests,
|
|
18
|
+
});
|
|
19
|
+
});
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { getFederationReach } from '@commonpub/server';
|
|
2
|
+
import { z } from 'zod';
|
|
3
|
+
|
|
4
|
+
const querySchema = z.object({
|
|
5
|
+
limit: z.coerce.number().int().min(1).max(100).default(20),
|
|
6
|
+
});
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* GET /api/public/v1/metrics/federation
|
|
10
|
+
*
|
|
11
|
+
* Scope: read:federation. Federation reach: known instances, active mirrors,
|
|
12
|
+
* accepted followers, and inbound content by origin domain (domain-level only,
|
|
13
|
+
* never per-user).
|
|
14
|
+
*
|
|
15
|
+
* Double-gated: requires `features.federation` AND the opt-in
|
|
16
|
+
* `features.publicApiMetricsFederation` (default OFF), because this exposes
|
|
17
|
+
* network-topology data about third-party instances. 404 (not 403) when either
|
|
18
|
+
* is off, so the surface stays invisible.
|
|
19
|
+
*/
|
|
20
|
+
export default defineEventHandler(async (event) => {
|
|
21
|
+
requireFeature('federation');
|
|
22
|
+
requireFeature('publicApiMetricsFederation');
|
|
23
|
+
requireApiScope(event, 'read:federation');
|
|
24
|
+
const parsed = querySchema.safeParse(getQuery(event));
|
|
25
|
+
if (!parsed.success) {
|
|
26
|
+
throw createError({ statusCode: 400, statusMessage: 'Invalid query parameters', data: parsed.error.flatten() });
|
|
27
|
+
}
|
|
28
|
+
const db = useDB();
|
|
29
|
+
return await getFederationReach(db, parsed.data.limit);
|
|
30
|
+
});
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { getMetricsOverview } from '@commonpub/server';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* GET /api/public/v1/metrics/overview
|
|
5
|
+
*
|
|
6
|
+
* Scope: read:analytics. Instance-wide DevRel scorecard: totals (users,
|
|
7
|
+
* contributors, content by type, hubs, tags, engagement) plus 7d/30d growth
|
|
8
|
+
* deltas derived from timestamps. Aggregates only — no per-user data.
|
|
9
|
+
*/
|
|
10
|
+
export default defineEventHandler(async (event) => {
|
|
11
|
+
requireApiScope(event, 'read:analytics');
|
|
12
|
+
const db = useDB();
|
|
13
|
+
const config = useConfig();
|
|
14
|
+
return await getMetricsOverview(db, config.instance.domain);
|
|
15
|
+
});
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { getTrendingTags } from '@commonpub/server';
|
|
2
|
+
import { z } from 'zod';
|
|
3
|
+
|
|
4
|
+
const querySchema = z.object({
|
|
5
|
+
limit: z.coerce.number().int().min(1).max(100).default(20),
|
|
6
|
+
});
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* GET /api/public/v1/metrics/tags/trending
|
|
10
|
+
*
|
|
11
|
+
* Scope: read:analytics. Tags ranked by lifetime usage count (unused tags
|
|
12
|
+
* excluded). Time-windowed trending arrives with Phase 3 rollups.
|
|
13
|
+
*/
|
|
14
|
+
export default defineEventHandler(async (event) => {
|
|
15
|
+
requireApiScope(event, 'read:analytics');
|
|
16
|
+
const parsed = querySchema.safeParse(getQuery(event));
|
|
17
|
+
if (!parsed.success) {
|
|
18
|
+
throw createError({ statusCode: 400, statusMessage: 'Invalid query parameters', data: parsed.error.flatten() });
|
|
19
|
+
}
|
|
20
|
+
const db = useDB();
|
|
21
|
+
const config = useConfig();
|
|
22
|
+
const items = await getTrendingTags(db, config.instance.domain, parsed.data.limit);
|
|
23
|
+
return { items, limit: parsed.data.limit };
|
|
24
|
+
});
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { getMetricsTimeseries, TIMESERIES_METRICS } from '@commonpub/server';
|
|
2
|
+
import { z } from 'zod';
|
|
3
|
+
|
|
4
|
+
const DAY = /^\d{4}-\d{2}-\d{2}$/;
|
|
5
|
+
const DAY_MS = 86_400_000;
|
|
6
|
+
|
|
7
|
+
const querySchema = z.object({
|
|
8
|
+
metric: z.enum(Object.keys(TIMESERIES_METRICS) as [string, ...string[]]),
|
|
9
|
+
interval: z.enum(['day', 'week', 'month']).default('day'),
|
|
10
|
+
from: z.string().regex(DAY).optional(),
|
|
11
|
+
to: z.string().regex(DAY).optional(),
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* GET /api/public/v1/metrics/timeseries
|
|
16
|
+
*
|
|
17
|
+
* Scope: read:analytics. Daily time-series from the `metrics_daily` rollups.
|
|
18
|
+
* `metric` is one of the registered series (users.total/new, content.total/new/
|
|
19
|
+
* views/likes/comments); `interval` buckets day|week|month. Defaults to the last
|
|
20
|
+
* 90 days. Engagement (views/likes/comments) series begin at the first rollup,
|
|
21
|
+
* surfaced via `since`.
|
|
22
|
+
*/
|
|
23
|
+
export default defineEventHandler(async (event) => {
|
|
24
|
+
requireApiScope(event, 'read:analytics');
|
|
25
|
+
const parsed = querySchema.safeParse(getQuery(event));
|
|
26
|
+
if (!parsed.success) {
|
|
27
|
+
throw createError({ statusCode: 400, statusMessage: 'Invalid query parameters', data: parsed.error.flatten() });
|
|
28
|
+
}
|
|
29
|
+
const { metric, interval } = parsed.data;
|
|
30
|
+
|
|
31
|
+
// Default to the last 90 days; clamp the span to 2 years to bound the scan.
|
|
32
|
+
const todayMs = Date.now();
|
|
33
|
+
const to = parsed.data.to ?? new Date(todayMs).toISOString().slice(0, 10);
|
|
34
|
+
let from = parsed.data.from ?? new Date(todayMs - 90 * DAY_MS).toISOString().slice(0, 10);
|
|
35
|
+
if (from > to) from = to;
|
|
36
|
+
const minFrom = new Date(new Date(`${to}T00:00:00Z`).getTime() - 730 * DAY_MS).toISOString().slice(0, 10);
|
|
37
|
+
if (from < minFrom) from = minFrom;
|
|
38
|
+
|
|
39
|
+
const db = useDB();
|
|
40
|
+
return await getMetricsTimeseries(db, { metric, interval, from, to });
|
|
41
|
+
});
|
|
@@ -364,6 +364,27 @@ export default defineEventHandler((event) => {
|
|
|
364
364
|
'/search': {
|
|
365
365
|
get: { summary: 'Search content', security: [{ bearer: ['read:search'] }], parameters: [{ name: 'q', in: 'query', required: true, schema: { type: 'string' } }, { name: 'type', in: 'query', schema: { type: 'string' } }], responses: { '200': { description: 'OK', content: { 'application/json': { schema: paginated('#/components/schemas/PublicContentSummary') } } } } },
|
|
366
366
|
},
|
|
367
|
+
'/metrics/overview': {
|
|
368
|
+
get: { summary: 'Instance analytics scorecard (totals + 7d/30d deltas)', security: [{ bearer: ['read:analytics'] }], responses: { '200': { description: 'OK', content: { 'application/json': { schema: { type: 'object' } } } }, '403': { description: 'Missing scope', content: { 'application/json': { schema: { $ref: '#/components/schemas/Error' } } } } } },
|
|
369
|
+
},
|
|
370
|
+
'/metrics/content/top': {
|
|
371
|
+
get: { summary: 'Top content by engagement metric', security: [{ bearer: ['read:analytics'] }], parameters: [{ name: 'metric', in: 'query', schema: { type: 'string', enum: ['views', 'likes', 'comments'] } }, { name: 'type', in: 'query', schema: { type: 'string', enum: ['project', 'blog', 'explainer'] } }, { name: 'limit', in: 'query', schema: { type: 'integer' } }], responses: { '200': { description: 'OK', content: { 'application/json': { schema: { type: 'object', properties: { items: { type: 'array', items: { $ref: '#/components/schemas/PublicContentSummary' } } } } } } } } },
|
|
372
|
+
},
|
|
373
|
+
'/metrics/tags/trending': {
|
|
374
|
+
get: { summary: 'Tags ranked by usage', security: [{ bearer: ['read:analytics'] }], parameters: [{ name: 'limit', in: 'query', schema: { type: 'integer' } }], responses: { '200': { description: 'OK', content: { 'application/json': { schema: { type: 'object', properties: { items: { type: 'array', items: { $ref: '#/components/schemas/PublicTag' } } } } } } } } },
|
|
375
|
+
},
|
|
376
|
+
'/metrics/contributors/top': {
|
|
377
|
+
get: { summary: 'Top public-profile contributors', security: [{ bearer: ['read:analytics'] }], parameters: [{ name: 'limit', in: 'query', schema: { type: 'integer' } }], responses: { '200': { description: 'OK', content: { 'application/json': { schema: { type: 'object' } } } } } },
|
|
378
|
+
},
|
|
379
|
+
'/metrics/engagement': {
|
|
380
|
+
get: { summary: 'Aggregate engagement ratios and funnels', security: [{ bearer: ['read:analytics'] }], responses: { '200': { description: 'OK', content: { 'application/json': { schema: { type: 'object' } } } } } },
|
|
381
|
+
},
|
|
382
|
+
'/metrics/timeseries': {
|
|
383
|
+
get: { summary: 'Daily time-series from rollups', security: [{ bearer: ['read:analytics'] }], parameters: [{ name: 'metric', in: 'query', required: true, schema: { type: 'string', enum: ['users.total', 'users.new', 'content.total', 'content.new', 'content.views', 'content.likes', 'content.comments'] } }, { name: 'interval', in: 'query', schema: { type: 'string', enum: ['day', 'week', 'month'] } }, { name: 'from', in: 'query', schema: { type: 'string', format: 'date' } }, { name: 'to', in: 'query', schema: { type: 'string', format: 'date' } }], responses: { '200': { description: 'OK', content: { 'application/json': { schema: { type: 'object' } } } } } },
|
|
384
|
+
},
|
|
385
|
+
'/metrics/federation': {
|
|
386
|
+
get: { summary: 'Federation reach (opt-in; read:federation)', security: [{ bearer: ['read:federation'] }], parameters: [{ name: 'limit', in: 'query', schema: { type: 'integer' } }], responses: { '200': { description: 'OK', content: { 'application/json': { schema: { type: 'object' } } } }, '404': { description: 'Federation reach metrics not enabled', content: { 'application/json': { schema: { $ref: '#/components/schemas/Error' } } } } } },
|
|
387
|
+
},
|
|
367
388
|
'/openapi.json': {
|
|
368
389
|
get: { summary: 'This OpenAPI spec', responses: { '200': { description: 'OK' } } },
|
|
369
390
|
},
|
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
import {
|
|
2
2
|
apiKeyRateLimit,
|
|
3
3
|
authenticateApiKey,
|
|
4
|
+
isWellFormedOrigin,
|
|
4
5
|
logApiKeyUsage,
|
|
6
|
+
matchOrigin,
|
|
5
7
|
touchLastUsed,
|
|
6
8
|
type ApiKey,
|
|
7
9
|
} from '@commonpub/server';
|
|
@@ -45,10 +47,12 @@ export default defineEventHandler(async (event) => {
|
|
|
45
47
|
setResponseHeader(event, 'Access-Control-Allow-Headers', 'Authorization, Content-Type');
|
|
46
48
|
setResponseHeader(event, 'Access-Control-Max-Age', 600);
|
|
47
49
|
const origin = getRequestHeader(event, 'origin');
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
50
|
+
// Echo origin only on preflight — real requests get the per-key allow-list
|
|
51
|
+
// check below. Preflight is UNAUTHENTICATED, so the raw header is only
|
|
52
|
+
// reflected after `isWellFormedOrigin` rejects CRLF / control characters
|
|
53
|
+
// (reflecting an unvalidated header is a response-splitting sink). Browsers
|
|
54
|
+
// that don't trust this echo (no credentials) fall back to the no-CORS path.
|
|
55
|
+
if (origin && isWellFormedOrigin(origin)) {
|
|
52
56
|
setResponseHeader(event, 'Access-Control-Allow-Origin', origin);
|
|
53
57
|
appendResponseHeader(event, 'Vary', 'Origin');
|
|
54
58
|
}
|
|
@@ -85,16 +89,18 @@ export default defineEventHandler(async (event) => {
|
|
|
85
89
|
throw createError({ statusCode: 429, statusMessage: 'Rate limit exceeded' });
|
|
86
90
|
}
|
|
87
91
|
|
|
88
|
-
// Per-key CORS allow-list
|
|
89
|
-
//
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
92
|
+
// Per-key CORS allow-list, wildcard-aware (see `matchOrigin`). An empty/null
|
|
93
|
+
// list means server-to-server only (no CORS headers, so the browser blocks
|
|
94
|
+
// cross-origin calls). Patterns support `*` (any origin), `localhost`, and
|
|
95
|
+
// scheme/subdomain/port wildcards. `*` is safe here because auth is a Bearer
|
|
96
|
+
// token, not a cookie, and we never send Access-Control-Allow-Credentials.
|
|
97
|
+
const cors = matchOrigin(key.allowedOrigins, getRequestHeader(event, 'origin'));
|
|
98
|
+
if (cors.allowed && cors.headerValue) {
|
|
99
|
+
setResponseHeader(event, 'Access-Control-Allow-Origin', cors.headerValue);
|
|
100
|
+
setResponseHeader(event, 'Access-Control-Allow-Methods', 'GET, OPTIONS');
|
|
101
|
+
setResponseHeader(event, 'Access-Control-Allow-Headers', 'Authorization, Content-Type');
|
|
102
|
+
// Reflected origins vary by request; the literal `*` does not.
|
|
103
|
+
if (!cors.wildcard) appendResponseHeader(event, 'Vary', 'Origin');
|
|
98
104
|
}
|
|
99
105
|
|
|
100
106
|
event.context.apiKey = key;
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Daily analytics rollup worker (Phase 3).
|
|
3
|
+
*
|
|
4
|
+
* When `features.publicApi` is on, snapshots aggregate metrics into
|
|
5
|
+
* `metrics_daily` so `GET /api/public/v1/metrics/timeseries` has history. On the
|
|
6
|
+
* first ever run (empty table) it backfills the deterministic count-based series
|
|
7
|
+
* from timestamps. Runs are idempotent (upsert per day), so the periodic
|
|
8
|
+
* interval just refreshes today's row. Aggregates only — no per-user data.
|
|
9
|
+
*/
|
|
10
|
+
import { runDailyRollup, backfillMetricsDaily } from '@commonpub/server';
|
|
11
|
+
import { metricsDaily } from '@commonpub/schema';
|
|
12
|
+
|
|
13
|
+
const ROLLUP_INTERVAL_MS = 6 * 60 * 60 * 1000; // every 6h; refreshes today's snapshot
|
|
14
|
+
|
|
15
|
+
export default defineNitroPlugin((nitro) => {
|
|
16
|
+
if (process.env.NODE_ENV === 'test') return;
|
|
17
|
+
|
|
18
|
+
let interval: ReturnType<typeof setInterval> | null = null;
|
|
19
|
+
|
|
20
|
+
const startupTimer = setTimeout(() => {
|
|
21
|
+
try {
|
|
22
|
+
const config = useConfig();
|
|
23
|
+
if (!config.features.publicApi) return; // no public API ⇒ no rollups needed
|
|
24
|
+
console.log(`[metrics-rollup] worker started (interval: ${ROLLUP_INTERVAL_MS}ms)`);
|
|
25
|
+
runRollup();
|
|
26
|
+
interval = setInterval(runRollup, ROLLUP_INTERVAL_MS);
|
|
27
|
+
} catch (err) {
|
|
28
|
+
console.error('[metrics-rollup] failed to start:', err instanceof Error ? err.message : err);
|
|
29
|
+
}
|
|
30
|
+
}, 15_000); // stagger after federation/registry workers
|
|
31
|
+
|
|
32
|
+
async function runRollup(): Promise<void> {
|
|
33
|
+
try {
|
|
34
|
+
const db = useDB();
|
|
35
|
+
const today = new Date().toISOString().slice(0, 10); // YYYY-MM-DD (UTC)
|
|
36
|
+
const [existing] = await db.select({ id: metricsDaily.id }).from(metricsDaily).limit(1);
|
|
37
|
+
if (!existing) {
|
|
38
|
+
const n = await backfillMetricsDaily(db);
|
|
39
|
+
console.log(`[metrics-rollup] backfilled ${n} historical rows`);
|
|
40
|
+
}
|
|
41
|
+
await runDailyRollup(db, today);
|
|
42
|
+
} catch (err) {
|
|
43
|
+
console.error('[metrics-rollup] run error:', err instanceof Error ? err.message : err);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
nitro.hooks.hook('close', () => {
|
|
48
|
+
clearTimeout(startupTimer);
|
|
49
|
+
if (interval) {
|
|
50
|
+
clearInterval(interval);
|
|
51
|
+
interval = null;
|
|
52
|
+
}
|
|
53
|
+
});
|
|
54
|
+
});
|