@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.
- package/components/ContentCard.vue +13 -3
- package/components/CpubMarkdown.vue +46 -0
- package/components/NotificationItem.vue +45 -14
- package/components/contest/ContestEntries.vue +6 -3
- package/components/contest/ContestHero.vue +28 -2
- package/components/contest/ContestPrizes.vue +7 -3
- package/components/contest/ContestRules.vue +9 -9
- package/composables/useFeatures.ts +8 -0
- package/nuxt.config.ts +1 -0
- package/package.json +9 -9
- package/pages/contests/[slug]/edit.vue +88 -15
- package/pages/contests/[slug]/index.vue +4 -3
- package/pages/contests/[slug]/results.vue +20 -5
- package/pages/contests/create.vue +31 -13
- package/pages/contests/index.vue +30 -2
- package/pages/events/[slug]/index.vue +1 -1
- package/pages/notifications.vue +9 -0
- package/server/api/admin/api-keys/[id]/usage.get.ts +1 -1
- package/server/api/admin/api-keys/[id].delete.ts +1 -1
- package/server/api/admin/api-keys/index.get.ts +1 -1
- package/server/api/admin/api-keys/index.post.ts +1 -1
- package/server/api/admin/audit.get.ts +1 -1
- package/server/api/admin/categories/[id].delete.ts +1 -1
- package/server/api/admin/categories/[id].patch.ts +1 -1
- package/server/api/admin/categories/index.get.ts +1 -1
- package/server/api/admin/categories/index.post.ts +1 -1
- package/server/api/admin/content/[id].delete.ts +1 -1
- package/server/api/admin/content/[id].patch.ts +1 -1
- package/server/api/admin/content/bulk-editorial.post.ts +1 -1
- package/server/api/admin/features/index.get.ts +1 -1
- package/server/api/admin/features/index.put.ts +1 -1
- package/server/api/admin/federation/activity.get.ts +1 -1
- package/server/api/admin/federation/clients.get.ts +1 -1
- package/server/api/admin/federation/clients.post.ts +1 -1
- package/server/api/admin/federation/hub-mirrors/[id]/backfill.post.ts +1 -1
- package/server/api/admin/federation/hub-mirrors/index.get.ts +1 -1
- package/server/api/admin/federation/hub-mirrors/index.post.ts +1 -1
- package/server/api/admin/federation/mirrors/[id]/backfill.post.ts +1 -1
- package/server/api/admin/federation/mirrors/[id].delete.ts +1 -1
- package/server/api/admin/federation/mirrors/[id].get.ts +1 -1
- package/server/api/admin/federation/mirrors/[id].put.ts +1 -1
- package/server/api/admin/federation/mirrors/index.get.ts +1 -1
- package/server/api/admin/federation/mirrors/index.post.ts +1 -1
- package/server/api/admin/federation/pending.get.ts +1 -1
- package/server/api/admin/federation/refederate.post.ts +1 -1
- package/server/api/admin/federation/repair-types.post.ts +1 -1
- package/server/api/admin/federation/retry.post.ts +1 -1
- package/server/api/admin/federation/stats.get.ts +1 -1
- package/server/api/admin/federation/trusted-instances.delete.ts +1 -1
- package/server/api/admin/federation/trusted-instances.get.ts +1 -1
- package/server/api/admin/federation/trusted-instances.post.ts +1 -1
- package/server/api/admin/homepage/sections.get.ts +1 -1
- package/server/api/admin/homepage/sections.put.ts +1 -1
- package/server/api/admin/layouts/[id]/publish.post.ts +1 -1
- package/server/api/admin/layouts/[id]/versions/[versionId]/revert.post.ts +1 -1
- package/server/api/admin/layouts/[id]/versions/index.get.ts +1 -1
- package/server/api/admin/layouts/[id].delete.ts +1 -1
- package/server/api/admin/layouts/[id].get.ts +1 -1
- package/server/api/admin/layouts/[id].put.ts +1 -1
- package/server/api/admin/layouts/index.get.ts +1 -1
- package/server/api/admin/layouts/index.post.ts +1 -1
- package/server/api/admin/layouts/migrate-homepage.post.ts +1 -1
- package/server/api/admin/layouts/seed-homepage.post.ts +1 -1
- package/server/api/admin/navigation/items.get.ts +1 -1
- package/server/api/admin/navigation/items.put.ts +1 -1
- package/server/api/admin/reports/[id]/resolve.post.ts +1 -1
- package/server/api/admin/reports.get.ts +1 -1
- package/server/api/admin/search/reindex.post.ts +1 -1
- package/server/api/admin/settings.get.ts +1 -1
- package/server/api/admin/settings.put.ts +1 -1
- package/server/api/admin/stats.get.ts +1 -1
- package/server/api/admin/storage/backfill-cdn-urls.post.ts +1 -1
- package/server/api/admin/themes/[id].delete.ts +1 -1
- package/server/api/admin/themes/[id].get.ts +1 -1
- package/server/api/admin/themes/[id].put.ts +1 -1
- package/server/api/admin/themes/discover.get.ts +1 -1
- package/server/api/admin/themes/index.get.ts +1 -1
- package/server/api/admin/themes/index.post.ts +1 -1
- package/server/api/admin/users/[id]/role.put.ts +1 -1
- package/server/api/admin/users/[id]/status.put.ts +1 -1
- package/server/api/admin/users/[id].delete.ts +1 -1
- package/server/api/admin/users.get.ts +1 -1
- package/server/api/contests/[slug]/entries.get.ts +3 -1
- package/server/api/contests/[slug]/index.delete.ts +4 -1
- package/server/api/contests/[slug]/judges/[userId].delete.ts +1 -1
- package/server/api/contests/[slug]/judges/index.post.ts +1 -1
- package/server/api/contests/[slug]/stakeholders/[userId].delete.ts +1 -1
- package/server/api/contests/[slug]/stakeholders/index.get.ts +1 -1
- package/server/api/contests/[slug]/stakeholders/index.post.ts +1 -1
- package/server/api/docs/migrate-content.post.ts +1 -1
- package/server/api/events/[slug].delete.ts +1 -1
- package/server/api/events/[slug].put.ts +1 -1
- package/server/api/layouts/by-route.get.ts +1 -1
- package/server/api/products/[id].delete.ts +1 -1
- package/server/api/videos/categories/[id].delete.ts +1 -1
- package/server/api/videos/categories/[id].put.ts +1 -1
- package/server/api/videos/categories.post.ts +1 -1
- package/server/middleware/auth.ts +22 -0
- package/server/utils/auth.ts +12 -5
- package/server/utils/permissions.ts +97 -0
- 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
|
+
}
|