@commonpub/layer 0.59.0 → 0.61.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.
@@ -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
@@ -112,6 +112,7 @@ export default defineNuxtConfig({
112
112
  rbac: false,
113
113
  actAsRegistry: false,
114
114
  announceToRegistry: true,
115
+ publicApiMetricsFederation: false,
115
116
  },
116
117
  contentTypes: 'project,blog,explainer',
117
118
  contestCreation: 'admin',
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@commonpub/layer",
3
- "version": "0.59.0",
3
+ "version": "0.61.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.18.0",
58
- "@commonpub/docs": "0.6.3",
59
57
  "@commonpub/editor": "0.7.11",
60
- "@commonpub/explainer": "0.7.15",
61
- "@commonpub/schema": "0.33.0",
62
- "@commonpub/server": "2.80.0",
63
- "@commonpub/ui": "0.9.2",
58
+ "@commonpub/config": "0.19.0",
64
59
  "@commonpub/protocol": "0.13.0",
65
- "@commonpub/learning": "0.5.2"
60
+ "@commonpub/learning": "0.5.2",
61
+ "@commonpub/server": "2.81.0",
62
+ "@commonpub/ui": "0.9.2",
63
+ "@commonpub/explainer": "0.7.15",
64
+ "@commonpub/schema": "0.34.0",
65
+ "@commonpub/docs": "0.6.3"
66
66
  },
67
67
  "devDependencies": {
68
68
  "@testing-library/jest-dom": "^6.9.1",
@@ -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 for="key-origins">Allowed CORS origins (comma or whitespace separated, optional)</label>
231
- <input id="key-origins" v-model="form.allowedOrigins" class="cpub-input" placeholder="https://app.example.com" />
232
- <small>Leave blank for server-to-server only (default, recommended).</small>
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;
@@ -65,9 +65,17 @@ const currentStageIdRef = ref<string | null>(null);
65
65
  const advancing = ref<string | null>(null);
66
66
  const advanceN = ref<Record<string, number>>({});
67
67
 
68
+ // Dirty tracking: any edit after the contest loads flips this so the save bar
69
+ // shows "unsaved changes" — feedback that a change (e.g. checking an eligible
70
+ // type) registered. `hydratingForm` suppresses the watcher while the loader
71
+ // populates the fields from the fetched contest.
72
+ const formDirty = ref(false);
73
+ let hydratingForm = false;
74
+
68
75
  // Load current data
69
76
  watch(contest, (c) => {
70
77
  if (!c) return;
78
+ hydratingForm = true;
71
79
  title.value = c.title ?? '';
72
80
  slugInput.value = c.slug ?? '';
73
81
  subheading.value = c.subheading ?? '';
@@ -104,8 +112,20 @@ watch(contest, (c) => {
104
112
  weight: cr.weight ?? null,
105
113
  description: cr.description ?? '',
106
114
  }));
115
+ // Let the field watchers settle from this hydration, then re-arm dirty tracking.
116
+ void nextTick(() => { hydratingForm = false; });
107
117
  }, { immediate: true });
108
118
 
119
+ // Mark the form dirty on any post-hydration edit (gives the save bar its
120
+ // "unsaved changes" cue). Worst case (timing) is a harmless early "dirty".
121
+ watch(
122
+ [title, slugInput, subheading, description, rules, bannerUrl, coverImageUrl, startDate, endDate, judgingEndDate,
123
+ communityVotingEnabled, judgingVisibility, eligibleContentTypes, maxEntriesPerUser, visibility, visibleToRoles,
124
+ showPrizes, stages, currentStageIdRef, prizesDescription, prizes, criteria],
125
+ () => { if (!hydratingForm) formDirty.value = true; },
126
+ { deep: true },
127
+ );
128
+
109
129
  function addPrize(): void {
110
130
  prizes.value.push({ place: null, category: '', title: '', description: '', value: '' });
111
131
  }
@@ -190,6 +210,7 @@ async function handleSave(): Promise<void> {
190
210
  },
191
211
  });
192
212
  toast.success('Contest updated');
213
+ formDirty.value = false;
193
214
  // Slug changed → the old URL no longer resolves. Navigate to the renamed
194
215
  // contest's page — a different route component, so it loads fresh. (Navigating
195
216
  // to the new /edit URL would reuse THIS component with its stale fetch key.)
@@ -594,11 +615,12 @@ async function transitionStatus(newStatus: string): Promise<void> {
594
615
  <div class="cpub-edit-actionbar">
595
616
  <span class="cpub-edit-actionbar-status">
596
617
  Status <span class="cpub-status-badge" :class="`cpub-status-${contest.status}`">{{ contest.status }}</span>
618
+ <span v-if="formDirty" class="cpub-edit-dirty"><i class="fa-solid fa-circle"></i> Unsaved changes</span>
597
619
  </span>
598
620
  <div class="cpub-edit-actionbar-btns">
599
621
  <NuxtLink :to="`/contests/${slug}`" class="cpub-btn cpub-edit-cancel">Cancel</NuxtLink>
600
- <button type="submit" class="cpub-btn cpub-btn-primary" :disabled="saving || !title.trim() || !!dateError">
601
- <i class="fa-solid fa-floppy-disk"></i> {{ saving ? 'Saving…' : 'Save Changes' }}
622
+ <button type="submit" class="cpub-btn cpub-btn-primary" :disabled="saving || !title.trim() || !!dateError || !formDirty">
623
+ <i class="fa-solid fa-floppy-disk"></i> {{ saving ? 'Saving…' : formDirty ? 'Save Changes' : 'Saved' }}
602
624
  </button>
603
625
  </div>
604
626
  </div>
@@ -703,7 +725,9 @@ async function transitionStatus(newStatus: string): Promise<void> {
703
725
  .cpub-advance-pick-title { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
704
726
  .cpub-advance-pick-score { font-family: var(--font-mono); font-size: 11px; color: var(--accent); flex-shrink: 0; }
705
727
  .cpub-advance-manual .cpub-btn { align-self: flex-start; margin-top: 6px; }
706
- .cpub-edit-actionbar-status { font-size: 11px; font-family: var(--font-mono); text-transform: uppercase; letter-spacing: .06em; color: var(--text-faint); display: flex; align-items: center; gap: 8px; }
728
+ .cpub-edit-actionbar-status { font-size: 11px; font-family: var(--font-mono); text-transform: uppercase; letter-spacing: .06em; color: var(--text-faint); display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
729
+ .cpub-edit-dirty { color: var(--accent); display: inline-flex; align-items: center; gap: 5px; }
730
+ .cpub-edit-dirty i { font-size: 6px; }
707
731
  .cpub-edit-actionbar-btns { display: flex; align-items: center; gap: 8px; }
708
732
 
709
733
  /* Collapse the meta rail under the main column on narrower viewports. */
@@ -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
+ });
@@ -364,6 +364,24 @@ 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/federation': {
383
+ 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' } } } } } },
384
+ },
367
385
  '/openapi.json': {
368
386
  get: { summary: 'This OpenAPI spec', responses: { '200': { description: 'OK' } } },
369
387
  },
@@ -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
- if (origin) {
49
- // Echo origin only on preflight real requests get the per-key
50
- // allow-list check below. Browsers that don't trust this echo because
51
- // credentials aren't involved will simply fall back to the no-CORS path.
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. `null` means server-to-server only (no CORS
89
- // headers, so browser cross-origin calls are blocked by the browser).
90
- if (key.allowedOrigins && key.allowedOrigins.length > 0) {
91
- const origin = getRequestHeader(event, 'origin');
92
- if (origin && key.allowedOrigins.includes(origin)) {
93
- setResponseHeader(event, 'Access-Control-Allow-Origin', origin);
94
- setResponseHeader(event, 'Access-Control-Allow-Methods', 'GET, OPTIONS');
95
- setResponseHeader(event, 'Access-Control-Allow-Headers', 'Authorization, Content-Type');
96
- appendResponseHeader(event, 'Vary', 'Origin');
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;