@commonpub/layer 0.28.1 → 0.30.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 +23 -2
- package/components/contest/ContestPrizes.vue +2 -2
- package/components/contest/ContestRules.vue +9 -9
- package/components/contest/ContestStakeholderManager.vue +126 -0
- package/composables/useFeatures.ts +8 -0
- package/nuxt.config.ts +1 -0
- package/package.json +8 -8
- package/pages/contests/[slug]/edit.vue +119 -15
- package/pages/contests/[slug]/index.vue +61 -1
- package/pages/contests/[slug]/results.vue +20 -5
- package/pages/contests/create.vue +60 -13
- 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 +8 -2
- package/server/api/contests/[slug]/entries.post.ts +5 -1
- package/server/api/contests/[slug]/index.delete.ts +4 -1
- package/server/api/contests/[slug]/index.get.ts +7 -1
- package/server/api/contests/[slug]/judges/[userId].delete.ts +1 -1
- package/server/api/contests/[slug]/judges/index.get.ts +4 -1
- package/server/api/contests/[slug]/judges/index.post.ts +1 -1
- package/server/api/contests/[slug]/stakeholders/[userId].delete.ts +24 -0
- package/server/api/contests/[slug]/stakeholders/index.get.ts +21 -0
- package/server/api/contests/[slug]/stakeholders/index.post.ts +33 -0
- package/server/api/contests/[slug]/votes.get.ts +4 -1
- package/server/api/contests/index.get.ts +4 -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
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { listContestEntries, getContestBySlug, isContestJudge, shouldRevealScores } from '@commonpub/server';
|
|
1
|
+
import { listContestEntries, getContestBySlug, isContestJudge, shouldRevealScores, canViewContest } from '@commonpub/server';
|
|
2
2
|
import type { ContestEntryItem } from '@commonpub/server';
|
|
3
3
|
import { z } from 'zod';
|
|
4
4
|
|
|
@@ -6,6 +6,7 @@ const entriesQuerySchema = z.object({
|
|
|
6
6
|
limit: z.coerce.number().int().min(1).max(100).optional(),
|
|
7
7
|
offset: z.coerce.number().int().min(0).optional(),
|
|
8
8
|
includeJudgeScores: z.coerce.boolean().optional(),
|
|
9
|
+
order: z.enum(['recent', 'rank']).optional(),
|
|
9
10
|
});
|
|
10
11
|
|
|
11
12
|
export default defineEventHandler(async (event): Promise<{ items: ContestEntryItem[]; total: number }> => {
|
|
@@ -21,17 +22,22 @@ export default defineEventHandler(async (event): Promise<{ items: ContestEntryIt
|
|
|
21
22
|
// Aggregate score visibility additionally honours the contest's
|
|
22
23
|
// judgingVisibility setting (public / judges-only / private).
|
|
23
24
|
const user = getOptionalUser(event);
|
|
25
|
+
// Don't leak a private contest's entries to viewers who can't see the contest.
|
|
26
|
+
if (!(await canViewContest(db, contest, user))) {
|
|
27
|
+
throw createError({ statusCode: 404, statusMessage: 'Contest not found' });
|
|
28
|
+
}
|
|
24
29
|
let privileged = false;
|
|
25
30
|
if (user) {
|
|
26
31
|
privileged =
|
|
27
32
|
user.id === contest.createdById ||
|
|
28
|
-
|
|
33
|
+
hasPermission(event, 'contest.manage') ||
|
|
29
34
|
(await isContestJudge(db, contest.id, user.id));
|
|
30
35
|
}
|
|
31
36
|
|
|
32
37
|
return listContestEntries(db, contest.id, {
|
|
33
38
|
limit: query.limit,
|
|
34
39
|
offset: query.offset,
|
|
40
|
+
orderBy: query.order,
|
|
35
41
|
includeJudgeScores: privileged && query.includeJudgeScores,
|
|
36
42
|
revealScores: shouldRevealScores(contest.judgingVisibility, contest.status, privileged),
|
|
37
43
|
});
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { submitContestEntry, getContestBySlug } from '@commonpub/server';
|
|
1
|
+
import { submitContestEntry, getContestBySlug, canViewContest } from '@commonpub/server';
|
|
2
2
|
import type { ContestEntryItem } from '@commonpub/server';
|
|
3
3
|
import { z } from 'zod';
|
|
4
4
|
|
|
@@ -13,6 +13,10 @@ export default defineEventHandler(async (event): Promise<ContestEntryItem> => {
|
|
|
13
13
|
const { slug } = parseParams(event, { slug: 'string' });
|
|
14
14
|
const contest = await getContestBySlug(db, slug);
|
|
15
15
|
if (!contest) throw createError({ statusCode: 404, statusMessage: 'Contest not found' });
|
|
16
|
+
// Can't enter a contest you can't see.
|
|
17
|
+
if (!(await canViewContest(db, contest, user))) {
|
|
18
|
+
throw createError({ statusCode: 404, statusMessage: 'Contest not found' });
|
|
19
|
+
}
|
|
16
20
|
const input = await parseBody(event, submitEntrySchema);
|
|
17
21
|
|
|
18
22
|
const entry = await submitContestEntry(db, contest.id, input.contentId, user.id);
|
|
@@ -12,7 +12,10 @@ export default defineEventHandler(async (event): Promise<{ deleted: boolean }> =
|
|
|
12
12
|
throw createError({ statusCode: 404, statusMessage: 'Contest not found' });
|
|
13
13
|
}
|
|
14
14
|
|
|
15
|
-
|
|
15
|
+
// Owner OR a contest.manage holder (RBAC Phase 1) may delete. Flag-off this is
|
|
16
|
+
// owner-or-admin, byte-identical to before; flag-on a custom managing role works.
|
|
17
|
+
const canManage = ownerOrPermission(event, contest.createdById, 'contest.manage');
|
|
18
|
+
const deleted = await deleteContest(db, contest.id, user.id, canManage);
|
|
16
19
|
if (!deleted) {
|
|
17
20
|
throw createError({ statusCode: 403, statusMessage: 'Not authorized to delete this contest' });
|
|
18
21
|
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { getContestBySlug } from '@commonpub/server';
|
|
1
|
+
import { getContestBySlug, canViewContest } from '@commonpub/server';
|
|
2
2
|
import type { ContestDetail } from '@commonpub/server';
|
|
3
3
|
|
|
4
4
|
export default defineEventHandler(async (event): Promise<ContestDetail> => {
|
|
@@ -7,5 +7,11 @@ export default defineEventHandler(async (event): Promise<ContestDetail> => {
|
|
|
7
7
|
const { slug } = parseParams(event, { slug: 'string' });
|
|
8
8
|
const contest = await getContestBySlug(db, slug);
|
|
9
9
|
if (!contest) throw createError({ statusCode: 404, statusMessage: 'Contest not found' });
|
|
10
|
+
// Access control: private contests are only visible to owner/admin/stakeholders/
|
|
11
|
+
// judges/allowed-roles. 404 (not 403) so we don't leak that the contest exists.
|
|
12
|
+
const user = getOptionalUser(event);
|
|
13
|
+
if (!(await canViewContest(db, contest, user))) {
|
|
14
|
+
throw createError({ statusCode: 404, statusMessage: 'Contest not found' });
|
|
15
|
+
}
|
|
10
16
|
return contest;
|
|
11
17
|
});
|
|
@@ -15,7 +15,7 @@ export default defineEventHandler(async (event) => {
|
|
|
15
15
|
const contest = await getContestBySlug(db, slug);
|
|
16
16
|
if (!contest) throw createError({ statusCode: 404, statusMessage: 'Contest not found' });
|
|
17
17
|
|
|
18
|
-
if (contest.createdById
|
|
18
|
+
if (!ownerOrPermission(event, contest.createdById, 'contest.manage')) {
|
|
19
19
|
throw createError({ statusCode: 403, statusMessage: 'Only contest owner or admin can manage judges' });
|
|
20
20
|
}
|
|
21
21
|
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { getContestBySlug, listContestJudges } from '@commonpub/server';
|
|
1
|
+
import { getContestBySlug, listContestJudges, canViewContest } from '@commonpub/server';
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
4
|
* GET /api/contests/:slug/judges
|
|
@@ -12,6 +12,9 @@ export default defineEventHandler(async (event) => {
|
|
|
12
12
|
|
|
13
13
|
const contest = await getContestBySlug(db, slug);
|
|
14
14
|
if (!contest) throw createError({ statusCode: 404, statusMessage: 'Contest not found' });
|
|
15
|
+
if (!(await canViewContest(db, contest, getOptionalUser(event)))) {
|
|
16
|
+
throw createError({ statusCode: 404, statusMessage: 'Contest not found' });
|
|
17
|
+
}
|
|
15
18
|
|
|
16
19
|
return listContestJudges(db, contest.id);
|
|
17
20
|
});
|
|
@@ -21,7 +21,7 @@ export default defineEventHandler(async (event) => {
|
|
|
21
21
|
const contest = await getContestBySlug(db, slug);
|
|
22
22
|
if (!contest) throw createError({ statusCode: 404, statusMessage: 'Contest not found' });
|
|
23
23
|
|
|
24
|
-
if (contest.createdById
|
|
24
|
+
if (!ownerOrPermission(event, contest.createdById, 'contest.manage')) {
|
|
25
25
|
throw createError({ statusCode: 403, statusMessage: 'Only contest owner or admin can manage judges' });
|
|
26
26
|
}
|
|
27
27
|
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { getContestBySlug, removeContestStakeholder } from '@commonpub/server';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* DELETE /api/contests/:slug/stakeholders/:userId
|
|
5
|
+
* Revoke a stakeholder's review access (contest owner or admin only).
|
|
6
|
+
*/
|
|
7
|
+
export default defineEventHandler(async (event) => {
|
|
8
|
+
requireFeature('contests');
|
|
9
|
+
const user = requireAuth(event);
|
|
10
|
+
const db = useDB();
|
|
11
|
+
const slug = getRouterParam(event, 'slug');
|
|
12
|
+
const userId = getRouterParam(event, 'userId');
|
|
13
|
+
if (!slug || !userId) throw createError({ statusCode: 400, statusMessage: 'Missing slug or userId' });
|
|
14
|
+
|
|
15
|
+
const contest = await getContestBySlug(db, slug);
|
|
16
|
+
if (!contest) throw createError({ statusCode: 404, statusMessage: 'Contest not found' });
|
|
17
|
+
if (!ownerOrPermission(event, contest.createdById, 'contest.manage')) {
|
|
18
|
+
throw createError({ statusCode: 403, statusMessage: 'Only the contest owner or admin can manage stakeholders' });
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const removed = await removeContestStakeholder(db, contest.id, userId);
|
|
22
|
+
if (!removed) throw createError({ statusCode: 404, statusMessage: 'Stakeholder not found' });
|
|
23
|
+
return { removed: true };
|
|
24
|
+
});
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { getContestBySlug, listContestStakeholders } from '@commonpub/server';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* GET /api/contests/:slug/stakeholders
|
|
5
|
+
* List view-only stakeholders (contest owner or admin only).
|
|
6
|
+
*/
|
|
7
|
+
export default defineEventHandler(async (event) => {
|
|
8
|
+
requireFeature('contests');
|
|
9
|
+
const user = requireAuth(event);
|
|
10
|
+
const db = useDB();
|
|
11
|
+
const slug = getRouterParam(event, 'slug');
|
|
12
|
+
if (!slug) throw createError({ statusCode: 400, statusMessage: 'Missing slug' });
|
|
13
|
+
|
|
14
|
+
const contest = await getContestBySlug(db, slug);
|
|
15
|
+
if (!contest) throw createError({ statusCode: 404, statusMessage: 'Contest not found' });
|
|
16
|
+
if (!ownerOrPermission(event, contest.createdById, 'contest.manage')) {
|
|
17
|
+
throw createError({ statusCode: 403, statusMessage: 'Only the contest owner or admin can view stakeholders' });
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
return listContestStakeholders(db, contest.id);
|
|
21
|
+
});
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { getContestBySlug, addContestStakeholder } from '@commonpub/server';
|
|
2
|
+
import { z } from 'zod';
|
|
3
|
+
|
|
4
|
+
const addStakeholderSchema = z.object({ userId: z.string().uuid() });
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* POST /api/contests/:slug/stakeholders
|
|
8
|
+
* Grant a user view-only review access (contest owner or admin only).
|
|
9
|
+
*/
|
|
10
|
+
export default defineEventHandler(async (event) => {
|
|
11
|
+
requireFeature('contests');
|
|
12
|
+
const user = requireAuth(event);
|
|
13
|
+
const db = useDB();
|
|
14
|
+
const slug = getRouterParam(event, 'slug');
|
|
15
|
+
if (!slug) throw createError({ statusCode: 400, statusMessage: 'Missing slug' });
|
|
16
|
+
|
|
17
|
+
const contest = await getContestBySlug(db, slug);
|
|
18
|
+
if (!contest) throw createError({ statusCode: 404, statusMessage: 'Contest not found' });
|
|
19
|
+
if (!ownerOrPermission(event, contest.createdById, 'contest.manage')) {
|
|
20
|
+
throw createError({ statusCode: 403, statusMessage: 'Only the contest owner or admin can manage stakeholders' });
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const body = await parseBody(event, addStakeholderSchema);
|
|
24
|
+
const result = await addContestStakeholder(db, contest.id, body.userId, {
|
|
25
|
+
contestSlug: slug,
|
|
26
|
+
contestTitle: contest.title,
|
|
27
|
+
invitedBy: user.id,
|
|
28
|
+
});
|
|
29
|
+
if (!result.added) {
|
|
30
|
+
throw createError({ statusCode: 400, statusMessage: result.error ?? 'Failed to add stakeholder' });
|
|
31
|
+
}
|
|
32
|
+
return { added: true };
|
|
33
|
+
});
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { getContestBySlug, getContestEntryVotes } from '@commonpub/server';
|
|
1
|
+
import { getContestBySlug, getContestEntryVotes, canViewContest } from '@commonpub/server';
|
|
2
2
|
import type { ContestEntryVoteInfo } from '@commonpub/server';
|
|
3
3
|
|
|
4
4
|
/**
|
|
@@ -13,6 +13,9 @@ export default defineEventHandler(async (event): Promise<ContestEntryVoteInfo[]>
|
|
|
13
13
|
|
|
14
14
|
const contest = await getContestBySlug(db, slug);
|
|
15
15
|
if (!contest) throw createError({ statusCode: 404, statusMessage: 'Contest not found' });
|
|
16
|
+
if (!(await canViewContest(db, contest, user))) {
|
|
17
|
+
throw createError({ statusCode: 404, statusMessage: 'Contest not found' });
|
|
18
|
+
}
|
|
16
19
|
// Voting disabled, or contest not yet open (no entries) → empty array, not an error.
|
|
17
20
|
if (!contest.communityVotingEnabled || contest.status === 'upcoming') return [];
|
|
18
21
|
|
|
@@ -6,5 +6,8 @@ export default defineEventHandler(async (event): Promise<PaginatedResponse<Conte
|
|
|
6
6
|
requireFeature('contests');
|
|
7
7
|
const db = useDB();
|
|
8
8
|
const filters = parseQueryParams(event, contestFiltersSchema);
|
|
9
|
-
|
|
9
|
+
// Pass the viewer so the list can include their own drafts/hidden contests
|
|
10
|
+
// (and everything for admins) while keeping the public list public-only.
|
|
11
|
+
const user = getOptionalUser(event);
|
|
12
|
+
return listContests(db, filters, user ? { userId: user.id, role: user.role } : null);
|
|
10
13
|
});
|
|
@@ -16,7 +16,7 @@ import { docsPages } from '@commonpub/schema';
|
|
|
16
16
|
import { eq } from 'drizzle-orm';
|
|
17
17
|
|
|
18
18
|
export default defineEventHandler(async (event) => {
|
|
19
|
-
|
|
19
|
+
requirePermission(event, 'content.editorial');
|
|
20
20
|
const db = useDB();
|
|
21
21
|
|
|
22
22
|
// Fetch all pages
|
|
@@ -14,7 +14,7 @@ export default defineEventHandler(async (event) => {
|
|
|
14
14
|
const existing = await getEventBySlug(db, slug);
|
|
15
15
|
if (!existing) throw createError({ statusCode: 404, statusMessage: 'Event not found' });
|
|
16
16
|
|
|
17
|
-
const isAdmin =
|
|
17
|
+
const isAdmin = hasPermission(event, 'event.manage');
|
|
18
18
|
const deleted = await deleteEvent(db, existing.id, user.id, isAdmin);
|
|
19
19
|
if (!deleted) throw createError({ statusCode: 403, statusMessage: 'Unauthorized' });
|
|
20
20
|
|
|
@@ -29,7 +29,7 @@ export default defineEventHandler(async (event) => {
|
|
|
29
29
|
if (!slug) throw createError({ statusCode: 400, statusMessage: 'Missing slug' });
|
|
30
30
|
|
|
31
31
|
const body = await parseBody(event, updateEventSchema);
|
|
32
|
-
const isAdmin =
|
|
32
|
+
const isAdmin = hasPermission(event, 'event.manage');
|
|
33
33
|
|
|
34
34
|
const result = await updateEvent(db, slug, user.id, body, isAdmin);
|
|
35
35
|
if (!result) throw createError({ statusCode: 404, statusMessage: 'Event not found or unauthorized' });
|
|
@@ -71,7 +71,7 @@ export default defineEventHandler(async (event): Promise<PublicLayoutSlice | nul
|
|
|
71
71
|
// requester's access tier so a layout served to a higher tier can't
|
|
72
72
|
// leak via cache to a lower tier on the same path.
|
|
73
73
|
const user = getOptionalUser(event);
|
|
74
|
-
const isAdmin =
|
|
74
|
+
const isAdmin = hasPermission(event, 'layout.manage');
|
|
75
75
|
const tier: 'admin' | 'members' | 'anon' = isAdmin ? 'admin' : user ? 'members' : 'anon';
|
|
76
76
|
const cacheKey = `${tier}:${path}`;
|
|
77
77
|
|
|
@@ -2,7 +2,7 @@ import { deleteProduct } from '@commonpub/server';
|
|
|
2
2
|
|
|
3
3
|
export default defineEventHandler(async (event): Promise<{ deleted: boolean }> => {
|
|
4
4
|
const db = useDB();
|
|
5
|
-
|
|
5
|
+
requirePermission(event, 'content.moderate');
|
|
6
6
|
const { id } = parseParams(event, { id: 'uuid' });
|
|
7
7
|
|
|
8
8
|
const deleted = await deleteProduct(db, id);
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { deleteVideoCategory } from '@commonpub/server';
|
|
2
2
|
|
|
3
3
|
export default defineEventHandler(async (event): Promise<{ success: boolean }> => {
|
|
4
|
-
|
|
4
|
+
requirePermission(event, 'categories.manage');
|
|
5
5
|
|
|
6
6
|
const { id } = parseParams(event, { id: 'uuid' });
|
|
7
7
|
|
|
@@ -3,7 +3,7 @@ import type { VideoCategoryItem } from '@commonpub/server';
|
|
|
3
3
|
import { createVideoCategorySchema } from '@commonpub/schema';
|
|
4
4
|
|
|
5
5
|
export default defineEventHandler(async (event): Promise<VideoCategoryItem> => {
|
|
6
|
-
|
|
6
|
+
requirePermission(event, 'categories.manage');
|
|
7
7
|
const { id } = parseParams(event, { id: 'uuid' });
|
|
8
8
|
const input = await parseBody(event, createVideoCategorySchema.partial());
|
|
9
9
|
|
|
@@ -3,7 +3,7 @@ import type { VideoCategoryItem } from '@commonpub/server';
|
|
|
3
3
|
import { createVideoCategorySchema } from '@commonpub/schema';
|
|
4
4
|
|
|
5
5
|
export default defineEventHandler(async (event): Promise<VideoCategoryItem> => {
|
|
6
|
-
|
|
6
|
+
requirePermission(event, 'categories.manage');
|
|
7
7
|
const db = useDB();
|
|
8
8
|
const input = await parseBody(event, createVideoCategorySchema);
|
|
9
9
|
|
|
@@ -55,6 +55,26 @@ declare module 'h3' {
|
|
|
55
55
|
}
|
|
56
56
|
}
|
|
57
57
|
|
|
58
|
+
/**
|
|
59
|
+
* Attach the user's resolved permissions to the request context (RBAC Phase 0).
|
|
60
|
+
* One cached Map hit on the hot path (30s TTL), like feature-flags-prime. Reads
|
|
61
|
+
* the userId from the already-enriched auth user. Fail-closed: on any error the
|
|
62
|
+
* context stays unset and the guards default-deny — but the admin floor still
|
|
63
|
+
* holds because `requirePermission` falls back to the enriched `user.role`
|
|
64
|
+
* (INV-2). No-op for anon requests. `resolvePermissions` is a Nitro auto-import.
|
|
65
|
+
*/
|
|
66
|
+
async function attachPermissions(event: import('h3').H3Event, auth: AuthLocals): Promise<void> {
|
|
67
|
+
if (!auth?.user?.id) return;
|
|
68
|
+
try {
|
|
69
|
+
// Pass the enriched role so the resolver skips its own users query (admin +
|
|
70
|
+
// flag-off paths do zero extra DB work) and stays consistent with enrichUser.
|
|
71
|
+
const primaryRole = (auth.user as unknown as { role?: string }).role;
|
|
72
|
+
event.context.cpubPermissions = await resolvePermissions(auth.user.id, primaryRole);
|
|
73
|
+
} catch {
|
|
74
|
+
// Leave unset — guards default-deny; admin floor survives via user.role.
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
58
78
|
/**
|
|
59
79
|
* Enrich the session user with custom DB columns (role, username, status)
|
|
60
80
|
* that Better Auth doesn't include by default.
|
|
@@ -89,6 +109,7 @@ export default defineEventHandler(async (event) => {
|
|
|
89
109
|
const webHeaders = new Headers(headers as Record<string, string>);
|
|
90
110
|
event.context.auth = await middleware.resolveSession(webHeaders);
|
|
91
111
|
await enrichUser(event.context.auth);
|
|
112
|
+
await attachPermissions(event, event.context.auth);
|
|
92
113
|
} catch {
|
|
93
114
|
event.context.auth = { user: null, session: null };
|
|
94
115
|
}
|
|
@@ -143,6 +164,7 @@ export default defineEventHandler(async (event) => {
|
|
|
143
164
|
const webHeaders = new Headers(headers as Record<string, string>);
|
|
144
165
|
event.context.auth = await middleware.resolveSession(webHeaders);
|
|
145
166
|
await enrichUser(event.context.auth);
|
|
167
|
+
await attachPermissions(event, event.context.auth);
|
|
146
168
|
} catch (err: unknown) {
|
|
147
169
|
// DB error during session resolution — don't silently eat it for API routes
|
|
148
170
|
if (pathname.startsWith('/api/')) {
|
package/server/utils/auth.ts
CHANGED
|
@@ -22,12 +22,19 @@ export function requireAuth(event: H3Event): AuthUser {
|
|
|
22
22
|
return auth.user as AuthUser;
|
|
23
23
|
}
|
|
24
24
|
|
|
25
|
+
/**
|
|
26
|
+
* Admin gate — the linchpin reimplemented (session 175, RBAC Phase 0) as
|
|
27
|
+
* `requirePermission(event, 'admin.access')`, routing all ~73 call sites through
|
|
28
|
+
* the permission machinery without changing any of them. `admin.access` is
|
|
29
|
+
* seeded ONLY to the admin role, and the resolver's admin-floor + flag-off
|
|
30
|
+
* legacy mapping make this bit-identical to the old `user.role === 'admin'`
|
|
31
|
+
* check (INV-1). The legacy 403 message is preserved verbatim.
|
|
32
|
+
*
|
|
33
|
+
* `requirePermission` is a Nitro auto-import (sibling util) — referenced without
|
|
34
|
+
* a static import so there's no import cycle with requirePermission.ts.
|
|
35
|
+
*/
|
|
25
36
|
export function requireAdmin(event: H3Event): AuthUser {
|
|
26
|
-
|
|
27
|
-
if (user.role !== 'admin') {
|
|
28
|
-
throw createError({ statusCode: 403, statusMessage: 'Admin access required' });
|
|
29
|
-
}
|
|
30
|
-
return user;
|
|
37
|
+
return requirePermission(event, 'admin.access', 'Admin access required');
|
|
31
38
|
}
|
|
32
39
|
|
|
33
40
|
export function getOptionalUser(event: H3Event): AuthUser | null {
|
|
@@ -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
|
+
}
|