@commonpub/layer 0.29.0 → 0.31.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.
Files changed (101) hide show
  1. package/components/ContentCard.vue +13 -3
  2. package/components/CpubMarkdown.vue +46 -0
  3. package/components/NotificationItem.vue +45 -14
  4. package/components/contest/ContestEntries.vue +6 -3
  5. package/components/contest/ContestHero.vue +28 -2
  6. package/components/contest/ContestPrizes.vue +7 -3
  7. package/components/contest/ContestRules.vue +9 -9
  8. package/composables/useFeatures.ts +8 -0
  9. package/nuxt.config.ts +1 -0
  10. package/package.json +9 -9
  11. package/pages/contests/[slug]/edit.vue +88 -15
  12. package/pages/contests/[slug]/index.vue +4 -3
  13. package/pages/contests/[slug]/results.vue +20 -5
  14. package/pages/contests/create.vue +31 -13
  15. package/pages/contests/index.vue +30 -2
  16. package/pages/events/[slug]/index.vue +1 -1
  17. package/pages/notifications.vue +9 -0
  18. package/server/api/admin/api-keys/[id]/usage.get.ts +1 -1
  19. package/server/api/admin/api-keys/[id].delete.ts +1 -1
  20. package/server/api/admin/api-keys/index.get.ts +1 -1
  21. package/server/api/admin/api-keys/index.post.ts +1 -1
  22. package/server/api/admin/audit.get.ts +1 -1
  23. package/server/api/admin/categories/[id].delete.ts +1 -1
  24. package/server/api/admin/categories/[id].patch.ts +1 -1
  25. package/server/api/admin/categories/index.get.ts +1 -1
  26. package/server/api/admin/categories/index.post.ts +1 -1
  27. package/server/api/admin/content/[id].delete.ts +1 -1
  28. package/server/api/admin/content/[id].patch.ts +1 -1
  29. package/server/api/admin/content/bulk-editorial.post.ts +1 -1
  30. package/server/api/admin/features/index.get.ts +1 -1
  31. package/server/api/admin/features/index.put.ts +1 -1
  32. package/server/api/admin/federation/activity.get.ts +1 -1
  33. package/server/api/admin/federation/clients.get.ts +1 -1
  34. package/server/api/admin/federation/clients.post.ts +1 -1
  35. package/server/api/admin/federation/hub-mirrors/[id]/backfill.post.ts +1 -1
  36. package/server/api/admin/federation/hub-mirrors/index.get.ts +1 -1
  37. package/server/api/admin/federation/hub-mirrors/index.post.ts +1 -1
  38. package/server/api/admin/federation/mirrors/[id]/backfill.post.ts +1 -1
  39. package/server/api/admin/federation/mirrors/[id].delete.ts +1 -1
  40. package/server/api/admin/federation/mirrors/[id].get.ts +1 -1
  41. package/server/api/admin/federation/mirrors/[id].put.ts +1 -1
  42. package/server/api/admin/federation/mirrors/index.get.ts +1 -1
  43. package/server/api/admin/federation/mirrors/index.post.ts +1 -1
  44. package/server/api/admin/federation/pending.get.ts +1 -1
  45. package/server/api/admin/federation/refederate.post.ts +1 -1
  46. package/server/api/admin/federation/repair-types.post.ts +1 -1
  47. package/server/api/admin/federation/retry.post.ts +1 -1
  48. package/server/api/admin/federation/stats.get.ts +1 -1
  49. package/server/api/admin/federation/trusted-instances.delete.ts +1 -1
  50. package/server/api/admin/federation/trusted-instances.get.ts +1 -1
  51. package/server/api/admin/federation/trusted-instances.post.ts +1 -1
  52. package/server/api/admin/homepage/sections.get.ts +1 -1
  53. package/server/api/admin/homepage/sections.put.ts +1 -1
  54. package/server/api/admin/layouts/[id]/publish.post.ts +1 -1
  55. package/server/api/admin/layouts/[id]/versions/[versionId]/revert.post.ts +1 -1
  56. package/server/api/admin/layouts/[id]/versions/index.get.ts +1 -1
  57. package/server/api/admin/layouts/[id].delete.ts +1 -1
  58. package/server/api/admin/layouts/[id].get.ts +1 -1
  59. package/server/api/admin/layouts/[id].put.ts +1 -1
  60. package/server/api/admin/layouts/index.get.ts +1 -1
  61. package/server/api/admin/layouts/index.post.ts +1 -1
  62. package/server/api/admin/layouts/migrate-homepage.post.ts +1 -1
  63. package/server/api/admin/layouts/seed-homepage.post.ts +1 -1
  64. package/server/api/admin/navigation/items.get.ts +1 -1
  65. package/server/api/admin/navigation/items.put.ts +1 -1
  66. package/server/api/admin/reports/[id]/resolve.post.ts +1 -1
  67. package/server/api/admin/reports.get.ts +1 -1
  68. package/server/api/admin/search/reindex.post.ts +1 -1
  69. package/server/api/admin/settings.get.ts +1 -1
  70. package/server/api/admin/settings.put.ts +1 -1
  71. package/server/api/admin/stats.get.ts +1 -1
  72. package/server/api/admin/storage/backfill-cdn-urls.post.ts +1 -1
  73. package/server/api/admin/themes/[id].delete.ts +1 -1
  74. package/server/api/admin/themes/[id].get.ts +1 -1
  75. package/server/api/admin/themes/[id].put.ts +1 -1
  76. package/server/api/admin/themes/discover.get.ts +1 -1
  77. package/server/api/admin/themes/index.get.ts +1 -1
  78. package/server/api/admin/themes/index.post.ts +1 -1
  79. package/server/api/admin/users/[id]/role.put.ts +1 -1
  80. package/server/api/admin/users/[id]/status.put.ts +1 -1
  81. package/server/api/admin/users/[id].delete.ts +1 -1
  82. package/server/api/admin/users.get.ts +1 -1
  83. package/server/api/contests/[slug]/entries.get.ts +3 -1
  84. package/server/api/contests/[slug]/index.delete.ts +4 -1
  85. package/server/api/contests/[slug]/judges/[userId].delete.ts +1 -1
  86. package/server/api/contests/[slug]/judges/index.post.ts +1 -1
  87. package/server/api/contests/[slug]/stakeholders/[userId].delete.ts +1 -1
  88. package/server/api/contests/[slug]/stakeholders/index.get.ts +1 -1
  89. package/server/api/contests/[slug]/stakeholders/index.post.ts +1 -1
  90. package/server/api/docs/migrate-content.post.ts +1 -1
  91. package/server/api/events/[slug].delete.ts +1 -1
  92. package/server/api/events/[slug].put.ts +1 -1
  93. package/server/api/layouts/by-route.get.ts +1 -1
  94. package/server/api/products/[id].delete.ts +1 -1
  95. package/server/api/videos/categories/[id].delete.ts +1 -1
  96. package/server/api/videos/categories/[id].put.ts +1 -1
  97. package/server/api/videos/categories.post.ts +1 -1
  98. package/server/middleware/auth.ts +22 -0
  99. package/server/utils/auth.ts +12 -5
  100. package/server/utils/permissions.ts +97 -0
  101. package/server/utils/requirePermission.ts +102 -0
@@ -0,0 +1,97 @@
1
+ /**
2
+ * Cached per-user permission resolver (Nitro util).
3
+ *
4
+ * Wraps the pure `resolveUserPermissions` core (@commonpub/server) with a short
5
+ * TTL + bounded LRU + explicit invalidation — modeled on `config.ts`'s DB-cache
6
+ * pattern and `layoutCache.ts`'s bounded map. Lives in the LAYER (not the app)
7
+ * so it ships to every consumer via @commonpub/layer: the auth middleware,
8
+ * `requireAdmin`, and `requirePermission` that consume it are all in the layer,
9
+ * and the layer already calls `useDB()`/`useConfig()` from layer code on every
10
+ * instance (see middleware/auth.ts). See docs/plans/rbac.md (Phase 0 location
11
+ * correction).
12
+ *
13
+ * The flag lives ONLY here (in the resolver), never in the guards —
14
+ * `requireFeature('rbac')` would 404 admin endpoints when off. With the flag
15
+ * off, the core returns the legacy mapping (admin→all, else→none) ⇒
16
+ * byte-identical to pre-RBAC (INV-1).
17
+ *
18
+ * Auth-critical → 30s TTL (favor freshness so a demote/revoke takes effect
19
+ * within the window, not on re-login — the set is never baked into the session
20
+ * token). Invalidate AFTER the DB commit; per-process so it clears the local
21
+ * node only (≤30s elsewhere — documented multi-pod staleness, acceptable v1).
22
+ */
23
+ import { resolveUserPermissions, type ResolvedPermissions } from '@commonpub/server';
24
+
25
+ export const PERMISSIONS_CACHE_TTL_MS = 30_000;
26
+
27
+ /** Caps memory across adversarial/large user populations (bounded LRU). */
28
+ export const MAX_PERMISSION_ENTRIES = 5000;
29
+
30
+ interface CacheEntry {
31
+ value: ResolvedPermissions;
32
+ at: number;
33
+ }
34
+
35
+ const cache = new Map<string, CacheEntry>();
36
+
37
+ function readFresh(userId: string, now: number): ResolvedPermissions | null {
38
+ const entry = cache.get(userId);
39
+ if (!entry) return null;
40
+ if (now - entry.at > PERMISSIONS_CACHE_TTL_MS) {
41
+ cache.delete(userId);
42
+ return null;
43
+ }
44
+ // LRU touch — move to newest end so eviction stays O(1) oldest-first.
45
+ cache.delete(userId);
46
+ cache.set(userId, entry);
47
+ return entry.value;
48
+ }
49
+
50
+ function store(userId: string, value: ResolvedPermissions, now: number): void {
51
+ if (cache.has(userId)) cache.delete(userId);
52
+ cache.set(userId, { value, at: now });
53
+ while (cache.size > MAX_PERMISSION_ENTRIES) {
54
+ const oldest = cache.keys().next().value;
55
+ if (oldest === undefined) break;
56
+ cache.delete(oldest);
57
+ }
58
+ }
59
+
60
+ /**
61
+ * Resolve (and cache) a user's effective permissions. Reads the `features.rbac`
62
+ * flag from the merged config; never throws (the core default-denies on error,
63
+ * and the admin floor is enforced downstream via `primaryRole`).
64
+ *
65
+ * @param primaryRole the already-enriched `users.role` (the auth middleware has
66
+ * it). Passing it lets the core skip its own users query — so the admin and
67
+ * flag-off hot paths do ZERO extra DB work — and keeps resolution consistent
68
+ * with the enrich query.
69
+ */
70
+ export async function resolvePermissions(
71
+ userId: string,
72
+ primaryRole?: string,
73
+ ): Promise<ResolvedPermissions> {
74
+ const now = Date.now();
75
+ const cached = readFresh(userId, now);
76
+ if (cached) return cached;
77
+
78
+ const rbacEnabled = useConfig().features.rbac === true;
79
+ const resolved = await resolveUserPermissions(useDB(), userId, { rbacEnabled, primaryRole });
80
+ store(userId, resolved, now);
81
+ return resolved;
82
+ }
83
+
84
+ /** Drop one user's cached permissions. Call AFTER a role/grant DB commit. */
85
+ export function invalidatePermissions(userId: string): void {
86
+ cache.delete(userId);
87
+ }
88
+
89
+ /** Drop the whole cache — the RBAC kill-switch companion to flipping the flag off. */
90
+ export function invalidateAllPermissions(): void {
91
+ cache.clear();
92
+ }
93
+
94
+ /** Cache size — test-only inspection. */
95
+ export function _permissionsCacheSize(): number {
96
+ return cache.size;
97
+ }
@@ -0,0 +1,102 @@
1
+ import { hasPermissionPure } from '@commonpub/auth';
2
+ import type { PermissionKey } from '@commonpub/schema';
3
+ import type { ResolvedPermissions } from '@commonpub/server';
4
+ import type { H3Event } from 'h3';
5
+ import type { AuthUser } from './auth';
6
+
7
+ // `requireAuth`, `getOptionalUser`, and `createError` are Nitro/h3 auto-imports
8
+ // (same as the rest of the layer's server utils) — referenced without a static
9
+ // import so there's no import cycle with auth.ts (which calls requirePermission
10
+ // to reimplement requireAdmin).
11
+
12
+ declare module 'h3' {
13
+ interface H3EventContext {
14
+ /**
15
+ * Effective permissions for the request's user, attached by the auth
16
+ * middleware via `resolvePermissions()`. Absent for anon / unenriched
17
+ * requests — guards default-deny when missing (INV-3).
18
+ */
19
+ cpubPermissions?: ResolvedPermissions;
20
+ }
21
+ }
22
+
23
+ /**
24
+ * Server-side permission gate — the single choke-point all instance-wide
25
+ * authorization routes through. Mirrors `requireScope.ts`: reads the resolved
26
+ * set the middleware attached (`event.context.cpubPermissions`) and the primary
27
+ * role (admin floor), then defers the decision to the pure `hasPermissionPure`.
28
+ *
29
+ * 401 if anon, 403 if the user lacks `needed`. NOT wrapped in
30
+ * `requireFeature('rbac')` — the flag lives only in the resolver, so admin
31
+ * endpoints keep working with the flag off (a flag-gated guard would 404 them).
32
+ * With the flag off the resolver yields the legacy mapping ⇒ this is
33
+ * byte-identical to the old `requireAdmin` for `admin.access` (INV-1).
34
+ *
35
+ * @param statusMessage Optional 403 message override (lets `requireAdmin`
36
+ * preserve its exact legacy "Admin access required" wording).
37
+ */
38
+ export function requirePermission(
39
+ event: H3Event,
40
+ needed: PermissionKey,
41
+ statusMessage?: string,
42
+ ): AuthUser {
43
+ const user = requireAuth(event);
44
+ const resolved = event.context.cpubPermissions;
45
+ const granted = resolved?.permissions ?? new Set<string>();
46
+ // Admin floor (INV-2) reads the AUTHORITATIVE enriched `user.role` from
47
+ // requireAuth — never `resolved.primaryRole`. If the resolver default-denied
48
+ // on a DB error it returns an empty set with `primaryRole: ''`, and `?? `
49
+ // would NOT fall back from a defined empty string — locking out admins for a
50
+ // TTL window. `user.role` comes from the same enrich query the old
51
+ // requireAdmin trusted, so no DB state can lock out admin.
52
+ const primaryRole = user.role || resolved?.primaryRole;
53
+
54
+ if (!hasPermissionPure(granted, needed, primaryRole)) {
55
+ throw createError({
56
+ statusCode: 403,
57
+ statusMessage: statusMessage ?? `Missing permission: ${needed}`,
58
+ });
59
+ }
60
+ return user;
61
+ }
62
+
63
+ /**
64
+ * Non-throwing permission check — for owner-OR-permission cases (the ad-hoc
65
+ * `user.role === 'x'` sites migrated in Phase 1) and client-driving endpoints.
66
+ * Returns false for anon. Reads the same attached context as requirePermission.
67
+ */
68
+ export function hasPermission(event: H3Event, needed: PermissionKey): boolean {
69
+ const user = getOptionalUser(event);
70
+ if (!user) return false;
71
+ const resolved = event.context.cpubPermissions;
72
+ const granted = resolved?.permissions ?? new Set<string>();
73
+ // Authoritative enriched role for the admin floor — see requirePermission.
74
+ const primaryRole = user.role || resolved?.primaryRole;
75
+ return hasPermissionPure(granted, needed, primaryRole);
76
+ }
77
+
78
+ /**
79
+ * Owner-OR-permission gate — the Phase 1 replacement for the ad-hoc
80
+ * `resource.ownerId === user.id || user.role === 'admin'` idiom on resource
81
+ * routes (contest judges/stakeholders, etc). Returns true if the caller owns the
82
+ * resource OR holds `needed`. Non-throwing (returns false for anon / neither).
83
+ *
84
+ * Flag-off this is byte-identical to the old idiom: `hasPermission` floors on the
85
+ * admin role, so `owner || hasPermission(…)` ≡ `owner || role==='admin'`. Flag-on,
86
+ * a custom/staff role granted `needed` also passes — the intended broadening.
87
+ *
88
+ * The plan filed this under `packages/server/src/rbac/`, but — like the resolver
89
+ * (session-175 location correction) — it operates on an H3 `event` + the
90
+ * middleware-attached context, so it lives in the layer beside its siblings
91
+ * `requirePermission`/`hasPermission`. The pure core is `hasPermissionPure`.
92
+ */
93
+ export function ownerOrPermission(
94
+ event: H3Event,
95
+ ownerId: string | null | undefined,
96
+ needed: PermissionKey,
97
+ ): boolean {
98
+ const user = getOptionalUser(event);
99
+ if (!user) return false;
100
+ if (ownerId && user.id === ownerId) return true;
101
+ return hasPermission(event, needed);
102
+ }