diff --git a/apps/web/src/components/admin/changelog/changelog-access-control.tsx b/apps/web/src/components/admin/changelog/changelog-access-control.tsx new file mode 100644 index 000000000..0e93bfc3c --- /dev/null +++ b/apps/web/src/components/admin/changelog/changelog-access-control.tsx @@ -0,0 +1,127 @@ +import { useMemo } from 'react' +import { + GlobeAltIcon, + UsersIcon, + TagIcon, + LockClosedIcon, + ArrowPathIcon, +} from '@heroicons/react/24/solid' +import { cn } from '@/lib/shared/utils' +import { useSegments } from '@/lib/client/hooks/use-segments-queries' +import { SegmentMultiSelect } from '@/components/admin/segments/segment-multi-select' +import type { AccessTier, ChangelogAccess } from '@/lib/shared/db-types' + +interface TierOption { + id: AccessTier + label: string + blurb: string + icon: React.ComponentType<{ className?: string }> +} + +// Mirrors the roadmap access tiers (view-only). Labels/icons match +// roadmap-access-control.tsx so the two surfaces read consistently. +const TIERS: readonly TierOption[] = [ + { id: 'anonymous', label: 'Public', blurb: 'Anyone · no sign-in', icon: GlobeAltIcon }, + { id: 'authenticated', label: 'Signed-in', blurb: 'Any logged-in user', icon: UsersIcon }, + { id: 'segments', label: 'Segments', blurb: 'Specific audiences', icon: TagIcon }, + { id: 'team', label: 'Private', blurb: 'Workspace members', icon: LockClosedIcon }, +] as const + +interface ChangelogAccessControlProps { + value: ChangelogAccess + onChange: (next: ChangelogAccess) => void + disabled?: boolean +} + +/** + * Controlled audience-visibility editor for a changelog entry. Picks a tier + * (Public / Signed-in / Segments / Private); when "Segments" is selected, + * reveals a segment allowlist. Independent of the publish-state control — this + * gates *who* sees a live entry. + */ +export function ChangelogAccessControl({ value, onChange, disabled }: ChangelogAccessControlProps) { + const segmentsQuery = useSegments() + const segments = useMemo( + () => + (segmentsQuery.data ?? []).map((s) => ({ + id: String(s.id), + name: s.name, + memberCount: s.memberCount, + })), + [segmentsQuery.data] + ) + + function selectTier(tier: TierOption['id']) { + if (disabled) return + onChange({ ...value, view: tier }) + } + + const showSegmentError = value.view === 'segments' && value.segments.view.length === 0 + + return ( +
+ Visibility +
+ {TIERS.map((tier) => { + const Icon = tier.icon + const active = value.view === tier.id + return ( + + ) + })} +
+ + {value.view === 'segments' && ( +
+ {segmentsQuery.isLoading ? ( +
+ + Loading segments… +
+ ) : segments.length === 0 ? ( +

+ No segments yet — create one in Settings → Segments first. +

+ ) : ( + onChange({ ...value, segments: { view: next } })} + disabled={disabled} + ariaLabel="Changelog segment allowlist" + /> + )} + {showSegmentError && ( +

+ Pick at least one segment — an empty allowlist hides the entry. +

+ )} +
+ )} +
+ ) +} diff --git a/apps/web/src/components/admin/changelog/changelog-metadata-sidebar-content.tsx b/apps/web/src/components/admin/changelog/changelog-metadata-sidebar-content.tsx index 698b3de5b..47f8dee97 100644 --- a/apps/web/src/components/admin/changelog/changelog-metadata-sidebar-content.tsx +++ b/apps/web/src/components/admin/changelog/changelog-metadata-sidebar-content.tsx @@ -25,10 +25,14 @@ import { import { cn } from '@/lib/shared/utils' import type { PostId } from '@quackback/ids' import type { PublishState } from '@/lib/shared/schemas/changelog' +import type { ChangelogAccess } from '@/lib/shared/db-types' +import { ChangelogAccessControl } from './changelog-access-control' interface ChangelogMetadataSidebarContentProps { publishState: PublishState onPublishStateChange: (state: PublishState) => void + access: ChangelogAccess + onAccessChange: (access: ChangelogAccess) => void linkedPostIds: PostId[] onLinkedPostsChange: (postIds: PostId[]) => void authorName?: string | null @@ -43,6 +47,8 @@ const PUBLISH_STATUS_OPTIONS: readonly StatusOption[] = [ export function ChangelogMetadataSidebarContent({ publishState, onPublishStateChange, + access, + onAccessChange, linkedPostIds, onLinkedPostsChange, authorName, @@ -115,6 +121,9 @@ export function ChangelogMetadataSidebarContent({ /> + {/* Audience visibility — independent of publish state */} + + {/* Author */} {authorName && (
diff --git a/apps/web/src/components/admin/changelog/changelog-metadata-sidebar.tsx b/apps/web/src/components/admin/changelog/changelog-metadata-sidebar.tsx index 3769d9fee..4eb17d8bc 100644 --- a/apps/web/src/components/admin/changelog/changelog-metadata-sidebar.tsx +++ b/apps/web/src/components/admin/changelog/changelog-metadata-sidebar.tsx @@ -2,12 +2,15 @@ import { SidebarContainer, SidebarSkeleton } from '@/components/shared/sidebar-p import { ChangelogMetadataSidebarContent } from './changelog-metadata-sidebar-content' import type { PostId } from '@quackback/ids' import type { PublishState } from '@/lib/shared/schemas/changelog' +import type { ChangelogAccess } from '@/lib/shared/db-types' export { SidebarSkeleton as ChangelogMetadataSidebarSkeleton } interface ChangelogMetadataSidebarProps { publishState: PublishState onPublishStateChange: (state: PublishState) => void + access: ChangelogAccess + onAccessChange: (access: ChangelogAccess) => void linkedPostIds: PostId[] onLinkedPostsChange: (postIds: PostId[]) => void authorName?: string | null @@ -16,6 +19,8 @@ interface ChangelogMetadataSidebarProps { export function ChangelogMetadataSidebar({ publishState, onPublishStateChange, + access, + onAccessChange, linkedPostIds, onLinkedPostsChange, authorName, @@ -25,6 +30,8 @@ export function ChangelogMetadataSidebar({ (null) const [linkedPostIds, setLinkedPostIds] = useState([]) const [publishState, setPublishState] = useState({ type: 'draft' }) + const [access, setAccess] = useState(DEFAULT_CHANGELOG_ACCESS) const [mobileSettingsOpen, setMobileSettingsOpen] = useState(false) const [hasInitialized, setHasInitialized] = useState(false) @@ -66,6 +68,7 @@ function ChangelogModalContent({ entryId, onClose }: ChangelogModalContentProps) setContentJson(entry.contentJson as JSONContent | null) setLinkedPostIds(entry.linkedPosts.map((p) => p.id)) setPublishState(toPublishState(entry.status, entry.publishedAt)) + setAccess(entry.access) setHasInitialized(true) } }, [entry, form, hasInitialized]) @@ -78,7 +81,10 @@ function ChangelogModalContent({ entryId, onClose }: ChangelogModalContentProps) [form] ) + const accessInvalid = access.view === 'segments' && access.segments.view.length === 0 + const handleSubmit = form.handleSubmit((data) => { + if (accessInvalid) return updateChangelogMutation.mutate( { id: entryId, @@ -87,6 +93,7 @@ function ChangelogModalContent({ entryId, onClose }: ChangelogModalContentProps) contentJson: contentJson as TiptapContent | null, linkedPostIds, publishState, + access, }, { onSuccess: () => { @@ -149,6 +156,8 @@ function ChangelogModalContent({ entryId, onClose }: ChangelogModalContentProps) {/* Mobile settings button */} @@ -177,6 +187,8 @@ function ChangelogModalContent({ entryId, onClose }: ChangelogModalContentProps) (null) const [linkedPostIds, setLinkedPostIds] = useState([]) const [publishState, setPublishState] = useState({ type: 'draft' }) + const [access, setAccess] = useState(DEFAULT_CHANGELOG_ACCESS) const [mobileSettingsOpen, setMobileSettingsOpen] = useState(false) const createChangelogMutation = useCreateChangelog() @@ -50,7 +52,10 @@ export function CreateChangelogDialog({ onChangelogCreated }: CreateChangelogDia [form] ) + const accessInvalid = access.view === 'segments' && access.segments.view.length === 0 + const handleSubmit = form.handleSubmit((data) => { + if (accessInvalid) return createChangelogMutation.mutate( { title: data.title, @@ -58,6 +63,7 @@ export function CreateChangelogDialog({ onChangelogCreated }: CreateChangelogDia contentJson: contentJson as TiptapContent | null, linkedPostIds, publishState, + access, }, { onSuccess: () => { @@ -66,6 +72,7 @@ export function CreateChangelogDialog({ onChangelogCreated }: CreateChangelogDia setContentJson(null) setLinkedPostIds([]) setPublishState({ type: 'draft' }) + setAccess(DEFAULT_CHANGELOG_ACCESS) onChangelogCreated?.() }, } @@ -79,6 +86,7 @@ export function CreateChangelogDialog({ onChangelogCreated }: CreateChangelogDia setContentJson(null) setLinkedPostIds([]) setPublishState({ type: 'draft' }) + setAccess(DEFAULT_CHANGELOG_ACCESS) createChangelogMutation.reset() } } @@ -136,6 +144,8 @@ export function CreateChangelogDialog({ onChangelogCreated }: CreateChangelogDia @@ -146,6 +156,7 @@ export function CreateChangelogDialog({ onChangelogCreated }: CreateChangelogDia onCancel={() => setOpen(false)} submitLabel={getSubmitButtonText()} isPending={createChangelogMutation.isPending} + submitDisabled={accessInvalid} > {/* Mobile settings button */} @@ -163,6 +174,8 @@ export function CreateChangelogDialog({ onChangelogCreated }: CreateChangelogDia diff --git a/apps/web/src/components/admin/roadmap-sidebar.tsx b/apps/web/src/components/admin/roadmap-sidebar.tsx index 0e95bcd16..288d5e466 100644 --- a/apps/web/src/components/admin/roadmap-sidebar.tsx +++ b/apps/web/src/components/admin/roadmap-sidebar.tsx @@ -11,8 +11,8 @@ import { import { Button } from '@/components/ui/button' import { Input } from '@/components/ui/input' import { Label } from '@/components/ui/label' -import { Switch } from '@/components/ui/switch' import { ScrollArea } from '@/components/ui/scroll-area' +import { RoadmapAccessControl } from '@/components/admin/roadmaps/roadmap-access-control' import { PageHeader } from '@/components/shared/page-header' import { FilterSection } from '@/components/shared/filter-section' import { @@ -35,7 +35,14 @@ import { EmptyState } from '@/components/shared/empty-state' import { cn, slugify } from '@/lib/shared/utils' import { useRoadmaps } from '@/lib/client/hooks/use-roadmaps-query' import { useCreateRoadmap, useUpdateRoadmap, useDeleteRoadmap } from '@/lib/client/mutations' -import type { Roadmap } from '@/lib/shared/db-types' +import type { Roadmap, RoadmapAccess } from '@/lib/shared/db-types' +import { DEFAULT_ROADMAP_ACCESS } from '@/lib/shared/db-types' + +/** A roadmap with the segments allowlist filled but hidden behind a non-segments + * tier is still valid; the only invalid state is segments + empty list. */ +function isAccessValid(access: RoadmapAccess): boolean { + return access.view !== 'segments' || access.segments.view.length > 0 +} interface RoadmapSidebarProps { selectedRoadmapId: string | null @@ -48,6 +55,10 @@ export function RoadmapSidebar({ selectedRoadmapId, onSelectRoadmap }: RoadmapSi const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false) const [editingRoadmap, setEditingRoadmap] = useState(null) const [deletingRoadmap, setDeletingRoadmap] = useState(null) + // Visibility is a controlled value (the segments allowlist can't be captured + // by uncontrolled FormData). Name/description stay on FormData. + const [createAccess, setCreateAccess] = useState(DEFAULT_ROADMAP_ACCESS) + const [editAccess, setEditAccess] = useState(DEFAULT_ROADMAP_ACCESS) const { data: roadmaps, isLoading } = useRoadmaps() const createRoadmap = useCreateRoadmap() @@ -56,17 +67,17 @@ export function RoadmapSidebar({ selectedRoadmapId, onSelectRoadmap }: RoadmapSi const handleCreateSubmit = async (e: React.FormEvent) => { e.preventDefault() + if (!isAccessValid(createAccess)) return const formData = new FormData(e.currentTarget) const name = formData.get('name') as string const description = formData.get('description') as string - const isPublic = formData.get('isPublic') === 'on' try { const newRoadmap = await createRoadmap.mutateAsync({ name, slug: slugify(name), description: description || undefined, - isPublic, + access: createAccess, }) setIsCreateDialogOpen(false) onSelectRoadmap(newRoadmap.id) @@ -78,11 +89,11 @@ export function RoadmapSidebar({ selectedRoadmapId, onSelectRoadmap }: RoadmapSi const handleEditSubmit = async (e: React.FormEvent) => { e.preventDefault() if (!editingRoadmap) return + if (!isAccessValid(editAccess)) return const formData = new FormData(e.currentTarget) const name = formData.get('name') as string const description = formData.get('description') as string - const isPublic = formData.get('isPublic') === 'on' try { await updateRoadmap.mutateAsync({ @@ -90,7 +101,7 @@ export function RoadmapSidebar({ selectedRoadmapId, onSelectRoadmap }: RoadmapSi input: { name, description, - isPublic, + access: editAccess, }, }) setIsEditDialogOpen(false) @@ -117,6 +128,7 @@ export function RoadmapSidebar({ selectedRoadmapId, onSelectRoadmap }: RoadmapSi const openEditDialog = (roadmap: Roadmap) => { setEditingRoadmap(roadmap) + setEditAccess(roadmap.access) setIsEditDialogOpen(true) } @@ -140,7 +152,13 @@ export function RoadmapSidebar({ selectedRoadmapId, onSelectRoadmap }: RoadmapSi title="Roadmaps" collapsible={false} action={ - + { + setIsCreateDialogOpen(open) + if (open) setCreateAccess(DEFAULT_ROADMAP_ACCESS) + }} + >
-
- - -
+
-
-
- - -
+
- + ) + })} +
+ + {value.view === 'segments' && ( +
+ + {segmentsQuery.isLoading ? ( +
+ + Loading segments… +
+ ) : segments.length === 0 ? ( +

+ No segments yet — create one in Settings → Segments first. +

+ ) : ( + + )} + {showSegmentError && ( +

+ Pick at least one segment — an empty allowlist hides the roadmap. +

+ )} +
+ )} + + ) +} diff --git a/apps/web/src/lib/client/hooks/use-roadmaps-query.ts b/apps/web/src/lib/client/hooks/use-roadmaps-query.ts index 03f329381..fa40424aa 100644 --- a/apps/web/src/lib/client/hooks/use-roadmaps-query.ts +++ b/apps/web/src/lib/client/hooks/use-roadmaps-query.ts @@ -6,7 +6,7 @@ */ import { useQuery } from '@tanstack/react-query' -import type { Roadmap } from '@/lib/shared/db-types' +import type { Roadmap, RoadmapAccess } from '@/lib/shared/db-types' import type { RoadmapId } from '@quackback/ids' import { fetchRoadmaps } from '@/lib/server/functions/roadmaps' import { listPublicRoadmapsFn } from '@/lib/server/functions/public-posts' @@ -21,7 +21,7 @@ export interface RoadmapView { name: string description: string | null slug: string - isPublic: boolean + access: RoadmapAccess position: number createdAt: Date | string updatedAt: Date | string diff --git a/apps/web/src/lib/client/mutations/roadmaps.ts b/apps/web/src/lib/client/mutations/roadmaps.ts index e5934de40..56c908b26 100644 --- a/apps/web/src/lib/client/mutations/roadmaps.ts +++ b/apps/web/src/lib/client/mutations/roadmaps.ts @@ -5,7 +5,7 @@ */ import { useMutation, useQueryClient } from '@tanstack/react-query' -import type { Roadmap } from '@/lib/shared/db-types' +import type { Roadmap, RoadmapAccess } from '@/lib/shared/db-types' import type { RoadmapId } from '@quackback/ids' import { createRoadmapFn, @@ -23,13 +23,13 @@ interface CreateRoadmapInput { name: string slug: string description?: string - isPublic?: boolean + access?: RoadmapAccess } interface UpdateRoadmapInput { name?: string description?: string - isPublic?: boolean + access?: RoadmapAccess } // ============================================================================ @@ -49,7 +49,7 @@ export function useCreateRoadmap() { name: input.name, slug: input.slug, description: input.description, - isPublic: input.isPublic, + access: input.access, }, }) as unknown as Promise, onSuccess: () => { @@ -71,7 +71,7 @@ export function useUpdateRoadmap() { id: roadmapId, name: input.name, description: input.description, - isPublic: input.isPublic, + access: input.access, }, }) as unknown as Promise, onSuccess: () => { diff --git a/apps/web/src/lib/server/domains/api/schemas/changelog.ts b/apps/web/src/lib/server/domains/api/schemas/changelog.ts index 671b40f97..e565c00ab 100644 --- a/apps/web/src/lib/server/domains/api/schemas/changelog.ts +++ b/apps/web/src/lib/server/domains/api/schemas/changelog.ts @@ -13,6 +13,23 @@ import { PaginationMetaSchema, } from './common' +// Audience visibility (independent of publish lifecycle), view-only mirror of +// the roadmap access model. +const ChangelogAccessSchema = z + .object({ + view: z.enum(['anonymous', 'authenticated', 'segments', 'team']).meta({ + description: + 'Visibility tier: anonymous (public), authenticated, segments, or team (private)', + }), + segments: z.object({ + view: z.array(z.string()).max(50).meta({ + description: + 'Segment IDs allowed to view (used when view is "segments"). Must be non-empty when view is "segments" — an empty allowlist is rejected (it would hide the entry from everyone).', + }), + }), + }) + .meta({ description: 'Changelog audience visibility' }) + // Changelog entry schema (API response) const ChangelogEntrySchema = z.object({ id: TypeIdSchema.meta({ example: 'changelog_01h455vb4pex5vsknk084sn02q' }), @@ -21,6 +38,7 @@ const ChangelogEntrySchema = z.object({ publishedAt: NullableTimestampSchema.meta({ description: 'When the entry was published (null if draft)', }), + access: ChangelogAccessSchema, createdAt: TimestampSchema, updatedAt: TimestampSchema, }) @@ -39,6 +57,9 @@ const CreateChangelogEntrySchema = z .datetime() .optional() .meta({ description: 'Publish date (omit to save as draft)' }), + access: ChangelogAccessSchema.optional().meta({ + description: 'Audience visibility (defaults to public)', + }), }) .meta({ description: 'Create changelog entry request body' }) @@ -52,6 +73,9 @@ const UpdateChangelogEntrySchema = z .nullable() .optional() .meta({ description: 'Set to null to unpublish' }), + access: ChangelogAccessSchema.optional().meta({ + description: 'Audience visibility', + }), }) .meta({ description: 'Update changelog entry request body' }) diff --git a/apps/web/src/lib/server/domains/api/schemas/roadmaps.ts b/apps/web/src/lib/server/domains/api/schemas/roadmaps.ts index ec78268be..924851818 100644 --- a/apps/web/src/lib/server/domains/api/schemas/roadmaps.ts +++ b/apps/web/src/lib/server/domains/api/schemas/roadmaps.ts @@ -18,13 +18,33 @@ import { ValidationErrorSchema, } from './common' +// Roadmap visibility access (view-only mirror of the board access model) +const RoadmapAccessSchema = z + .object({ + view: z.enum(['anonymous', 'authenticated', 'segments', 'team']).meta({ + description: + 'Visibility tier: anonymous (public), authenticated, segments, or team (private)', + }), + segments: z.object({ + view: z.array(z.string()).max(50).meta({ + description: + 'Segment IDs allowed to view (used when view is "segments"). Must be non-empty when view is "segments" — an empty allowlist is rejected (it would hide the roadmap from everyone).', + }), + }), + }) + .meta({ description: 'Roadmap visibility controls' }) + // Roadmap schema const RoadmapSchema = z.object({ id: TypeIdSchema.meta({ example: 'roadmap_01h455vb4pex5vsknk084sn02q' }), name: z.string().meta({ example: 'Product Roadmap' }), slug: SlugSchema.meta({ example: 'product-roadmap' }), description: z.string().nullable().meta({ example: 'Our product development roadmap' }), - isPublic: z.boolean().meta({ description: 'Whether the roadmap is publicly visible' }), + isPublic: z.boolean().meta({ + description: + 'Whether the roadmap is publicly visible. Derived from access.view === "anonymous".', + }), + access: RoadmapAccessSchema, position: z.number().meta({ description: 'Display order' }), createdAt: TimestampSchema, }) @@ -58,7 +78,9 @@ const CreateRoadmapSchema = z .regex(/^[a-z0-9-]+$/) .meta({ description: 'URL-friendly slug', example: 'product-roadmap' }), description: z.string().max(500).optional().meta({ description: 'Roadmap description' }), - isPublic: z.boolean().optional().meta({ description: 'Make roadmap public', default: true }), + // `isPublic` is legacy (maps to access.view); `access` wins when both given. + isPublic: z.boolean().optional().meta({ description: 'Legacy: make public', default: true }), + access: RoadmapAccessSchema.optional().meta({ description: 'Visibility (overrides isPublic)' }), }) .meta({ description: 'Create roadmap request body' }) @@ -66,7 +88,8 @@ const UpdateRoadmapSchema = z .object({ name: z.string().min(1).max(100).optional(), description: z.string().max(500).nullable().optional(), - isPublic: z.boolean().optional(), + isPublic: z.boolean().optional().meta({ description: 'Legacy: make public' }), + access: RoadmapAccessSchema.optional().meta({ description: 'Visibility (overrides isPublic)' }), }) .meta({ description: 'Update roadmap request body' }) diff --git a/apps/web/src/lib/server/domains/changelog/changelog.public.ts b/apps/web/src/lib/server/domains/changelog/changelog.public.ts index b1d210da9..a9a039919 100644 --- a/apps/web/src/lib/server/domains/changelog/changelog.public.ts +++ b/apps/web/src/lib/server/domains/changelog/changelog.public.ts @@ -18,19 +18,25 @@ import { } from '@/lib/server/db' import type { ChangelogId, StatusId } from '@quackback/ids' import { NotFoundError } from '@/lib/shared/errors' +import { ANONYMOUS_ACTOR, changelogViewFilter, type Actor } from '@/lib/server/policy' import { computeStatus } from './changelog.service' import type { PublicChangelogEntry, PublicChangelogListResult } from './changelog.types' /** - * Predicates that make a changelog entry publicly visible: not soft-deleted - * and published at or before `now`. Shared by every public read path so the - * filter stays consistent. + * Predicates that make a changelog entry publicly visible: not soft-deleted, + * published at or before `now`, AND viewable by the actor's audience tier. + * Shared by every public read path so the filter stays consistent. + * + * The audience gate (`changelogViewFilter`) is independent of publish + * lifecycle: publish state decides whether/when an entry is live, the audience + * gate decides who may see a live entry. Defaults to an anonymous actor. */ -export function publicChangelogConditions(now: Date) { +export function publicChangelogConditions(now: Date, actor: Actor = ANONYMOUS_ACTOR) { return [ isNull(changelogEntries.deletedAt), isNotNull(changelogEntries.publishedAt), lte(changelogEntries.publishedAt, now), + changelogViewFilter(actor), ] } @@ -42,11 +48,12 @@ export function publicChangelogConditions(now: Date) { * Returns null (no throw) when the entry isn't publicly visible. */ export async function getPublicChangelogMetaById( - id: ChangelogId + id: ChangelogId, + actor: Actor = ANONYMOUS_ACTOR ): Promise<{ id: ChangelogId; title: string; publishedAt: Date } | null> { const now = new Date() const entry = await db.query.changelogEntries.findFirst({ - where: and(eq(changelogEntries.id, id), ...publicChangelogConditions(now)), + where: and(eq(changelogEntries.id, id), ...publicChangelogConditions(now, actor)), columns: { id: true, title: true, publishedAt: true }, }) if (!entry || !entry.publishedAt) return null @@ -59,11 +66,14 @@ export async function getPublicChangelogMetaById( * @param id - Changelog entry ID * @returns Public changelog entry */ -export async function getPublicChangelogById(id: ChangelogId): Promise { +export async function getPublicChangelogById( + id: ChangelogId, + actor: Actor = ANONYMOUS_ACTOR +): Promise { const now = new Date() const entry = await db.query.changelogEntries.findFirst({ - where: and(eq(changelogEntries.id, id), ...publicChangelogConditions(now)), + where: and(eq(changelogEntries.id, id), ...publicChangelogConditions(now, actor)), }) if (!entry || !entry.publishedAt) { @@ -151,14 +161,17 @@ export async function getPublicChangelogById(id: ChangelogId): Promise { +export async function listPublicChangelogs( + params: { + cursor?: string + limit?: number + }, + actor: Actor = ANONYMOUS_ACTOR +): Promise { const { cursor, limit = 20 } = params const now = new Date() - const conditions = publicChangelogConditions(now) + const conditions = publicChangelogConditions(now, actor) // Cursor-based pagination. The lookup does NOT filter on deletedAt: // if an admin deleted the cursor row between page load and "Load diff --git a/apps/web/src/lib/server/domains/changelog/changelog.query.ts b/apps/web/src/lib/server/domains/changelog/changelog.query.ts index e8895d096..be4a5009c 100644 --- a/apps/web/src/lib/server/domains/changelog/changelog.query.ts +++ b/apps/web/src/lib/server/domains/changelog/changelog.query.ts @@ -157,6 +157,7 @@ export async function listChangelogs(params: ListChangelogParams): Promise ({ id: lp.post.id, diff --git a/apps/web/src/lib/server/domains/changelog/changelog.service.ts b/apps/web/src/lib/server/domains/changelog/changelog.service.ts index d11a21364..4148f94b7 100644 --- a/apps/web/src/lib/server/domains/changelog/changelog.service.ts +++ b/apps/web/src/lib/server/domains/changelog/changelog.service.ts @@ -19,6 +19,7 @@ import { and, isNull, inArray, + DEFAULT_CHANGELOG_ACCESS, } from '@/lib/server/db' import type { ChangelogId, PrincipalId, PostId } from '@quackback/ids' import { NotFoundError, ValidationError } from '@/lib/shared/errors' @@ -85,6 +86,7 @@ export async function createChangelog( contentJson, principalId: author.principalId, publishedAt, + access: input.access ?? DEFAULT_CHANGELOG_ACCESS, }) .returning() @@ -173,6 +175,11 @@ export async function updateChangelog( updateData.publishedAt = getPublishedAtFromState(input.publishState) } + // Handle audience visibility change + if (input.access !== undefined) { + updateData.access = input.access + } + // Update the entry await db.update(changelogEntries).set(updateData).where(eq(changelogEntries.id, id)) @@ -334,6 +341,7 @@ export async function getChangelogById(id: ChangelogId): Promise { }), }), }) - mockRoadmapFindFirst.mockResolvedValue({ id: 'rm_1' as RoadmapId, isPublic: true }) + mockRoadmapFindFirst.mockResolvedValue({ + id: 'rm_1' as RoadmapId, + access: { view: 'anonymous', segments: { view: [] } }, + }) }) describe('getPublicRoadmapPosts — board audience filter', () => { diff --git a/apps/web/src/lib/server/domains/roadmaps/__tests__/roadmap-public-moderation.test.ts b/apps/web/src/lib/server/domains/roadmaps/__tests__/roadmap-public-moderation.test.ts index 77312d61c..fbf146919 100644 --- a/apps/web/src/lib/server/domains/roadmaps/__tests__/roadmap-public-moderation.test.ts +++ b/apps/web/src/lib/server/domains/roadmaps/__tests__/roadmap-public-moderation.test.ts @@ -78,7 +78,10 @@ beforeEach(() => { }), }), }) - mockRoadmapFindFirst.mockResolvedValue({ id: 'rm_1' as RoadmapId, isPublic: true }) + mockRoadmapFindFirst.mockResolvedValue({ + id: 'rm_1' as RoadmapId, + access: { view: 'anonymous', segments: { view: [] } }, + }) }) describe('getPublicRoadmapPosts — moderation state filter', () => { @@ -111,7 +114,10 @@ describe('getPublicRoadmapPosts — moderation state filter', () => { }), }) mockEq.mockClear() - mockRoadmapFindFirst.mockResolvedValue({ id: 'rm_1' as RoadmapId, isPublic: false }) + mockRoadmapFindFirst.mockResolvedValue({ + id: 'rm_1' as RoadmapId, + access: { view: 'team', segments: { view: [] } }, + }) const { getRoadmapPosts } = await import('../roadmap.query') await getRoadmapPosts('rm_1' as RoadmapId, { limit: 20, offset: 0 }) diff --git a/apps/web/src/lib/server/domains/roadmaps/roadmap.query.ts b/apps/web/src/lib/server/domains/roadmaps/roadmap.query.ts index dfc8aaad3..538650eb6 100644 --- a/apps/web/src/lib/server/domains/roadmaps/roadmap.query.ts +++ b/apps/web/src/lib/server/domains/roadmaps/roadmap.query.ts @@ -17,7 +17,7 @@ import { } from '@/lib/server/db' import { type RoadmapId, type PostId } from '@quackback/ids' import { NotFoundError } from '@/lib/shared/errors' -import { ANONYMOUS_ACTOR, boardViewFilter, type Actor } from '@/lib/server/policy' +import { ANONYMOUS_ACTOR, boardViewFilter, canViewRoadmap, type Actor } from '@/lib/server/policy' import type { SQL } from 'drizzle-orm' import type { RoadmapPostsListResult, RoadmapPostsQueryOptions } from './roadmap.types' @@ -162,7 +162,7 @@ export async function getRoadmapPosts( * Get public roadmap posts. * * Authorization layers: - * - Only `isPublic` roadmaps are reachable here. + * - Only roadmaps the actor may view (canViewRoadmap) are reachable here. * - Only `moderationState='published'` posts surface (admins on the * team-facing getRoadmapPosts see pending; this is the public path). * - `boardViewFilter(actor)` filters posts whose linked board the @@ -175,12 +175,16 @@ export async function getPublicRoadmapPosts( options: RoadmapPostsQueryOptions, actor: Actor = ANONYMOUS_ACTOR ): Promise { - // Verify roadmap exists and is public - const roadmap = await db.query.roadmaps.findFirst({ where: eq(roadmaps.id, roadmapId) }) + // Verify roadmap exists, is not soft-deleted, and the actor may view it. + // A denied (or tombstoned) roadmap returns 404 — never 403 — so callers + // can't probe which roadmaps exist behind a restricted tier. + const roadmap = await db.query.roadmaps.findFirst({ + where: and(eq(roadmaps.id, roadmapId), isNull(roadmaps.deletedAt)), + }) if (!roadmap) { throw new NotFoundError('ROADMAP_NOT_FOUND', `Roadmap with ID ${roadmapId} not found`) } - if (!roadmap.isPublic) { + if (!canViewRoadmap(actor, roadmap).allowed) { throw new NotFoundError('ROADMAP_NOT_FOUND', `Roadmap with ID ${roadmapId} not found`) } diff --git a/apps/web/src/lib/server/domains/roadmaps/roadmap.service.ts b/apps/web/src/lib/server/domains/roadmaps/roadmap.service.ts index 359e4c671..0502dd18c 100644 --- a/apps/web/src/lib/server/domains/roadmaps/roadmap.service.ts +++ b/apps/web/src/lib/server/domains/roadmaps/roadmap.service.ts @@ -20,10 +20,13 @@ import { posts, postRoadmaps, type Roadmap, + type RoadmapAccess, + DEFAULT_ROADMAP_ACCESS, } from '@/lib/server/db' import { toUuid, type RoadmapId, type PostId, type PrincipalId } from '@quackback/ids' import { NotFoundError, ValidationError, ConflictError } from '@/lib/shared/errors' import { createActivity } from '@/lib/server/domains/activity/activity.service' +import { roadmapViewFilter, ANONYMOUS_ACTOR, type Actor } from '@/lib/server/policy' import { logger } from '@/lib/server/logger' const log = logger.child({ component: 'roadmaps' }) @@ -81,7 +84,7 @@ export async function createRoadmap(input: CreateRoadmapInput): Promise name: input.name.trim(), slug: input.slug.trim(), description: input.description?.trim() || null, - isPublic: input.isPublic ?? true, + access: input.access ?? DEFAULT_ROADMAP_ACCESS, position, }) .returning() @@ -106,7 +109,7 @@ export async function updateRoadmap(id: RoadmapId, input: UpdateRoadmapInput): P const updateData: Partial> = {} if (input.name !== undefined) updateData.name = input.name.trim() if (input.description !== undefined) updateData.description = input.description?.trim() || null - if (input.isPublic !== undefined) updateData.isPublic = input.isPublic + if (input.access !== undefined) updateData.access = input.access // Update the roadmap (single update, no transaction needed) const [updated] = await db.update(roadmaps).set(updateData).where(eq(roadmaps.id, id)).returning() @@ -173,15 +176,31 @@ export async function listRoadmaps(): Promise { } /** - * List public roadmaps (for portal view, excludes soft-deleted) + * List roadmaps visible to a portal actor. + * + * Applies `roadmapViewFilter(actor)`, which already excludes soft-deleted + * rows and enforces the per-roadmap access tier (anonymous / authenticated / + * segments / team). Defaults to an anonymous actor — pass the resolved actor + * so authenticated- and segment-restricted roadmaps surface to the right + * viewers. */ -export async function listPublicRoadmaps(): Promise { +export async function listPublicRoadmaps(actor: Actor = ANONYMOUS_ACTOR): Promise { return db.query.roadmaps.findMany({ - where: and(eq(roadmaps.isPublic, true), isNull(roadmaps.deletedAt)), + where: roadmapViewFilter(actor), orderBy: [asc(roadmaps.position)], }) } +/** Derive the legacy `isPublic` boolean from access for the REST API. */ +export function roadmapAccessToIsPublic(access: RoadmapAccess): boolean { + return access.view === 'anonymous' +} + +/** Map the legacy `isPublic` boolean onto an access object for REST writers. */ +export function isPublicToRoadmapAccess(isPublic: boolean): RoadmapAccess { + return { view: isPublic ? 'anonymous' : 'team', segments: { view: [] } } +} + /** * Reorder roadmaps in the sidebar * Uses a single batch UPDATE with CASE WHEN for efficiency @@ -213,10 +232,7 @@ export async function addPostToRoadmap( input: AddPostToRoadmapInput, actorPrincipalId?: PrincipalId ): Promise { - log.debug( - { post_id: input.postId, roadmap_id: input.roadmapId }, - 'add post to roadmap' - ) + log.debug({ post_id: input.postId, roadmap_id: input.roadmapId }, 'add post to roadmap') // Verify roadmap exists const roadmap = await db.query.roadmaps.findFirst({ where: eq(roadmaps.id, input.roadmapId) }) if (!roadmap) { @@ -300,10 +316,7 @@ export async function removePostFromRoadmap( * Uses a single batch UPDATE with CASE WHEN for efficiency */ export async function reorderPostsInColumn(input: ReorderPostsInput): Promise { - log.debug( - { roadmap_id: input.roadmapId, count: input.postIds.length }, - 'reorder posts in column' - ) + log.debug({ roadmap_id: input.roadmapId, count: input.postIds.length }, 'reorder posts in column') // Verify roadmap exists const roadmap = await db.query.roadmaps.findFirst({ where: eq(roadmaps.id, input.roadmapId) }) if (!roadmap) { diff --git a/apps/web/src/lib/server/domains/roadmaps/roadmap.types.ts b/apps/web/src/lib/server/domains/roadmaps/roadmap.types.ts index 3af7b7284..f69024028 100644 --- a/apps/web/src/lib/server/domains/roadmaps/roadmap.types.ts +++ b/apps/web/src/lib/server/domains/roadmaps/roadmap.types.ts @@ -2,7 +2,7 @@ * Input/Output types for RoadmapService operations */ -import type { PostRoadmap } from '@/lib/server/db' +import type { PostRoadmap, RoadmapAccess } from '@/lib/server/db' import type { PostId, RoadmapId, StatusId, BoardId, TagId, SegmentId } from '@quackback/ids' /** @@ -12,7 +12,7 @@ export interface CreateRoadmapInput { name: string slug: string description?: string - isPublic?: boolean + access?: RoadmapAccess } /** @@ -21,7 +21,7 @@ export interface CreateRoadmapInput { export interface UpdateRoadmapInput { name?: string description?: string - isPublic?: boolean + access?: RoadmapAccess } /** diff --git a/apps/web/src/lib/server/functions/__tests__/portal-gate-extended.test.ts b/apps/web/src/lib/server/functions/__tests__/portal-gate-extended.test.ts index 4acae57dc..8f518ccd4 100644 --- a/apps/web/src/lib/server/functions/__tests__/portal-gate-extended.test.ts +++ b/apps/web/src/lib/server/functions/__tests__/portal-gate-extended.test.ts @@ -587,7 +587,7 @@ describe('portal.ts fetchPublicRoadmaps — portal-visibility gate', () => { name: 'Q1', slug: 'q1', description: null, - isPublic: true, + access: { view: 'anonymous', segments: { view: [] } }, position: 0, createdAt: now, updatedAt: now, @@ -608,7 +608,7 @@ describe('portal.ts fetchPublicRoadmaps — portal-visibility gate', () => { name: 'Q2', slug: 'q2', description: null, - isPublic: true, + access: { view: 'anonymous', segments: { view: [] } }, position: 1, createdAt: now, updatedAt: now, diff --git a/apps/web/src/lib/server/functions/__tests__/portal-gate-public-posts.test.ts b/apps/web/src/lib/server/functions/__tests__/portal-gate-public-posts.test.ts index f89a6cfaa..7b88428eb 100644 --- a/apps/web/src/lib/server/functions/__tests__/portal-gate-public-posts.test.ts +++ b/apps/web/src/lib/server/functions/__tests__/portal-gate-public-posts.test.ts @@ -236,7 +236,7 @@ describe('listPublicRoadmapsFn — portal-visibility gate', () => { name: 'Q1', slug: 'q1', description: null, - isPublic: true, + access: { view: 'anonymous', segments: { view: [] } }, position: 0, createdAt: now, updatedAt: now, @@ -261,7 +261,7 @@ describe('listPublicRoadmapsFn — portal-visibility gate', () => { name: 'Q2', slug: 'q2', description: null, - isPublic: true, + access: { view: 'anonymous', segments: { view: [] } }, position: 1, createdAt: now, updatedAt: now, diff --git a/apps/web/src/lib/server/functions/changelog.ts b/apps/web/src/lib/server/functions/changelog.ts index ae279381f..09e06c94c 100644 --- a/apps/web/src/lib/server/functions/changelog.ts +++ b/apps/web/src/lib/server/functions/changelog.ts @@ -9,7 +9,12 @@ import type { BoardId, ChangelogId, PostId } from '@quackback/ids' // Note: BoardId is only used for searchShippedPosts filtering import { sanitizeTiptapContent } from '@/lib/server/sanitize-tiptap' import { NotFoundError } from '@/lib/shared/errors' -import { requireAuth } from './auth-helpers' +import { + requireAuth, + getOptionalAuth, + hasAuthCredentials, + policyActorFromAuth, +} from './auth-helpers' import { resolvePortalAccessForRequest } from './portal-access' import { createChangelog, @@ -61,6 +66,7 @@ export const createChangelogFn = createServerFn({ method: 'POST' }) contentJson: data.contentJson ? sanitizeTiptapContent(data.contentJson) : null, linkedPostIds: (data.linkedPostIds ?? []) as PostId[], publishState: data.publishState as PublishState, + access: data.access, }, { principalId: auth.principal.id, @@ -96,6 +102,7 @@ export const updateChangelogFn = createServerFn({ method: 'POST' }) contentJson: data.contentJson ? sanitizeTiptapContent(data.contentJson) : undefined, linkedPostIds: data.linkedPostIds as PostId[] | undefined, publishState: data.publishState as PublishState | undefined, + access: data.access, }) return { @@ -210,7 +217,12 @@ export const getPublicChangelogFn = createServerFn({ method: 'GET' }) ) } - const entry = await getPublicChangelogById(data.id as ChangelogId) + // Resolve the actor so segment-restricted entries surface only to the + // right viewers (the gate above only decides portal entry). + const auth = hasAuthCredentials() ? await getOptionalAuth() : null + const actor = await policyActorFromAuth(auth) + + const entry = await getPublicChangelogById(data.id as ChangelogId, actor) return { ...entry, @@ -237,10 +249,16 @@ export const listPublicChangelogsFn = createServerFn({ method: 'GET' }) return { items: [], nextCursor: null, hasMore: false } } - const result = await listPublicChangelogs({ - cursor: data.cursor, - limit: data.limit, - }) + const auth = hasAuthCredentials() ? await getOptionalAuth() : null + const actor = await policyActorFromAuth(auth) + + const result = await listPublicChangelogs( + { + cursor: data.cursor, + limit: data.limit, + }, + actor + ) return { ...result, diff --git a/apps/web/src/lib/server/functions/embeds.ts b/apps/web/src/lib/server/functions/embeds.ts index 8ffc2490f..0d3726dea 100644 --- a/apps/web/src/lib/server/functions/embeds.ts +++ b/apps/web/src/lib/server/functions/embeds.ts @@ -216,7 +216,7 @@ export const getEmbedPreviewFn = createServerFn({ method: 'GET' }) { getPostDetail: getPublicPostDetail, listStatuses: listPublicStatuses, - getChangelog: getPublicChangelogMetaById, + getChangelog: (id: ChangelogId) => getPublicChangelogMetaById(id, actor), getArticle: async (slug: string) => { try { return await getPublicArticleBySlug(slug) diff --git a/apps/web/src/lib/server/functions/portal.ts b/apps/web/src/lib/server/functions/portal.ts index 9b0f34861..3b6757921 100644 --- a/apps/web/src/lib/server/functions/portal.ts +++ b/apps/web/src/lib/server/functions/portal.ts @@ -536,13 +536,19 @@ export const fetchPublicRoadmaps = createServerFn({ method: 'GET' }).handler(asy return [] } - const roadmaps = await listPublicRoadmaps() + // Resolve the actor so authenticated- and segment-restricted roadmaps + // surface to the right viewers (the outer gate above only decides portal + // entry; roadmapViewFilter is the finer per-roadmap gate). + const auth = hasAuthCredentials() ? await getOptionalAuth() : null + const actor = await policyActorFromAuth(auth) + + const roadmaps = await listPublicRoadmaps(actor) return roadmaps.map((r) => ({ id: r.id, name: r.name, slug: r.slug, description: r.description, - isPublic: r.isPublic, + access: r.access, position: r.position, createdAt: r.createdAt.toISOString(), updatedAt: r.updatedAt.toISOString(), diff --git a/apps/web/src/lib/server/functions/public-posts.ts b/apps/web/src/lib/server/functions/public-posts.ts index 640b0372a..1ad8b78dd 100644 --- a/apps/web/src/lib/server/functions/public-posts.ts +++ b/apps/web/src/lib/server/functions/public-posts.ts @@ -553,8 +553,11 @@ export const listPublicRoadmapsFn = createServerFn({ method: 'GET' }).handler(as return [] } - // No auth needed - this is public data - const result = await listPublicRoadmaps() + // Resolve the actor so authenticated- and segment-restricted roadmaps + // surface to the right viewers (anonymous callers still see public ones). + const auth = hasAuthCredentials() ? await getOptionalAuth() : null + const actor = await policyActorFromAuth(auth) + const result = await listPublicRoadmaps(actor) log.debug({ count: result.length }, 'list public roadmaps results') // Serialize branded types to plain strings for turbo-stream @@ -563,7 +566,7 @@ export const listPublicRoadmapsFn = createServerFn({ method: 'GET' }).handler(as name: roadmap.name, slug: roadmap.slug, description: roadmap.description, - isPublic: roadmap.isPublic, + access: roadmap.access, position: roadmap.position, createdAt: roadmap.createdAt.toISOString(), updatedAt: roadmap.updatedAt.toISOString(), diff --git a/apps/web/src/lib/server/functions/roadmaps.ts b/apps/web/src/lib/server/functions/roadmaps.ts index 7bc1da009..e63e69f73 100644 --- a/apps/web/src/lib/server/functions/roadmaps.ts +++ b/apps/web/src/lib/server/functions/roadmaps.ts @@ -24,6 +24,7 @@ import { updateRoadmap, } from '@/lib/server/domains/roadmaps/roadmap.service' import { getRoadmapPosts } from '@/lib/server/domains/roadmaps/roadmap.query' +import { roadmapAccessSchema } from '@/lib/shared/schemas/roadmaps' import { logger } from '@/lib/server/logger' const log = logger.child({ component: 'roadmaps' }) @@ -36,7 +37,7 @@ const createRoadmapSchema = z.object({ name: z.string().min(1).max(100), slug: z.string().min(1).max(100), description: z.string().optional(), - isPublic: z.boolean().optional(), + access: roadmapAccessSchema.optional(), }) const getRoadmapSchema = z.object({ @@ -47,7 +48,7 @@ const updateRoadmapSchema = z.object({ id: z.string(), name: z.string().min(1).max(100).optional(), description: z.string().optional(), - isPublic: z.boolean().optional(), + access: roadmapAccessSchema.optional(), }) const deleteRoadmapSchema = z.object({ @@ -112,7 +113,7 @@ export const fetchRoadmaps = createServerFn({ method: 'GET' }).handler(async () name: roadmap.name, slug: roadmap.slug, description: roadmap.description, - isPublic: roadmap.isPublic, + access: roadmap.access, position: roadmap.position, createdAt: roadmap.createdAt.toISOString(), updatedAt: roadmap.updatedAt.toISOString(), @@ -140,7 +141,7 @@ export const fetchRoadmap = createServerFn({ method: 'GET' }) name: roadmap.name, slug: roadmap.slug, description: roadmap.description, - isPublic: roadmap.isPublic, + access: roadmap.access, position: roadmap.position, createdAt: roadmap.createdAt.toISOString(), updatedAt: roadmap.updatedAt.toISOString(), @@ -169,7 +170,7 @@ export const createRoadmapFn = createServerFn({ method: 'POST' }) name: data.name, slug: data.slug, description: data.description, - isPublic: data.isPublic, + access: data.access, }) // Serialize branded types to plain strings for turbo-stream return { @@ -177,7 +178,7 @@ export const createRoadmapFn = createServerFn({ method: 'POST' }) name: roadmap.name, slug: roadmap.slug, description: roadmap.description, - isPublic: roadmap.isPublic, + access: roadmap.access, position: roadmap.position, createdAt: roadmap.createdAt.toISOString(), updatedAt: roadmap.updatedAt.toISOString(), @@ -201,7 +202,7 @@ export const updateRoadmapFn = createServerFn({ method: 'POST' }) const roadmap = await updateRoadmap(data.id as RoadmapId, { name: data.name, description: data.description, - isPublic: data.isPublic, + access: data.access, }) // Serialize branded types to plain strings for turbo-stream return { @@ -209,7 +210,7 @@ export const updateRoadmapFn = createServerFn({ method: 'POST' }) name: roadmap.name, slug: roadmap.slug, description: roadmap.description, - isPublic: roadmap.isPublic, + access: roadmap.access, position: roadmap.position, createdAt: roadmap.createdAt.toISOString(), updatedAt: roadmap.updatedAt.toISOString(), diff --git a/apps/web/src/lib/server/policy/__tests__/changelog-view-filter-parity.test.ts b/apps/web/src/lib/server/policy/__tests__/changelog-view-filter-parity.test.ts new file mode 100644 index 000000000..ba7e50b8f --- /dev/null +++ b/apps/web/src/lib/server/policy/__tests__/changelog-view-filter-parity.test.ts @@ -0,0 +1,198 @@ +/** + * Execution-level parity test: changelogViewFilter (SQL) ↔ canViewChangelog + * (in-memory). For every (actor, ChangelogAccess) pair, the SQL predicate's + * row-membership decision must match the in-memory decision. + * + * Unlike the board/roadmap parity tests, changelogViewFilter does NOT filter + * soft-deleted rows (the public readers compose that separately), so this test + * seeds only live rows and compares the pure audience gate. Entries also need + * a non-null publishedAt only for realism — the filter ignores it. + * + * Connects via DATABASE_URL (falling back to the dev DB). Skips gracefully if + * neither is reachable. + */ +import { describe, it, expect, beforeAll, afterAll } from 'vitest' +import { sql, eq, and } from 'drizzle-orm' +import { changelogEntries, type ChangelogAccess, type Database } from '@/lib/server/db' +// eslint-disable-next-line no-restricted-imports +import { createDb } from '@quackback/db/client' +import { canViewChangelog, changelogViewFilter } from '../changelog' +import { ANONYMOUS_ACTOR, type Actor } from '../types' +import { createId, type SegmentId, type PrincipalId, type ChangelogId } from '@quackback/ids' + +const SEGMENT_ALPHA = createId('segment') as SegmentId +const SEGMENT_BETA = createId('segment') as SegmentId + +function mkAccess(view: ChangelogAccess['view'], segmentIds: string[] = []): ChangelogAccess { + return { view, segments: { view: segmentIds } } +} + +interface AccessCase { + name: string + access: ChangelogAccess +} + +const accessShapes: AccessCase[] = [ + { name: 'anonymous', access: mkAccess('anonymous') }, + { name: 'authenticated', access: mkAccess('authenticated') }, + { name: 'team', access: mkAccess('team') }, + { name: 'segments_alpha', access: mkAccess('segments', [SEGMENT_ALPHA]) }, + { name: 'segments_beta', access: mkAccess('segments', [SEGMENT_BETA]) }, + { name: 'segments_alpha_beta', access: mkAccess('segments', [SEGMENT_ALPHA, SEGMENT_BETA]) }, + { name: 'segments_empty', access: mkAccess('segments', []) }, +] + +function buildActor(overrides: Partial): Actor { + return { + principalId: null, + role: null, + principalType: 'anonymous', + segmentIds: new Set(), + ...overrides, + } +} + +const actors: Record = { + anon: ANONYMOUS_ACTOR, + user: buildActor({ + principalId: createId('principal') as PrincipalId, + role: 'user', + principalType: 'user', + }), + userInAlpha: buildActor({ + principalId: createId('principal') as PrincipalId, + role: 'user', + principalType: 'user', + segmentIds: new Set([SEGMENT_ALPHA]), + }), + userInAlphaBeta: buildActor({ + principalId: createId('principal') as PrincipalId, + role: 'user', + principalType: 'user', + segmentIds: new Set([SEGMENT_ALPHA, SEGMENT_BETA]), + }), + service: buildActor({ + principalId: createId('principal') as PrincipalId, + role: 'user', + principalType: 'service', + }), + serviceInAlpha: buildActor({ + principalId: createId('principal') as PrincipalId, + role: 'user', + principalType: 'service', + segmentIds: new Set([SEGMENT_ALPHA]), + }), + member: buildActor({ + principalId: createId('principal') as PrincipalId, + role: 'member', + principalType: 'user', + }), + admin: buildActor({ + principalId: createId('principal') as PrincipalId, + role: 'admin', + principalType: 'user', + }), +} + +const CANDIDATE_URLS = [ + process.env.DATABASE_URL, + 'postgresql://postgres:password@localhost:5432/quackback', +].filter((u): u is string => !!u) + +async function pickWorkingDb(): Promise<{ db: Database; close: () => Promise } | null> { + for (const url of CANDIDATE_URLS) { + try { + const db = createDb(url, { max: 2, prepare: false }) + await db.execute(sql`select 1`) + await db.execute(sql`select id, access from ${changelogEntries} limit 0`) + return { + db, + close: async () => { + const raw = (db as unknown as { $client?: { end?: () => Promise } }).$client + await raw?.end?.() + }, + } + } catch { + // try next candidate + } + } + return null +} + +interface SeededEntry { + id: ChangelogId + name: string + access: ChangelogAccess +} + +let activeDb: Database | null = null +let closeDb: (() => Promise) | null = null +const seeded: SeededEntry[] = [] +const runSuffix = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}` + +const resolved = await pickWorkingDb() +const dbAvailable = resolved !== null +if (resolved) { + activeDb = resolved.db + closeDb = resolved.close +} + +describe.skipIf(!dbAvailable)( + 'changelogViewFilter ↔ canViewChangelog parity (execution-level)', + () => { + beforeAll(async () => { + if (!activeDb) return + // Crash-safety belt: sweep leftover rows from prior crashed runs. + await activeDb + .delete(changelogEntries) + .where(sql`${changelogEntries.title} ~ '^parity-cl-[0-9]+-'`) + for (const { name, access } of accessShapes) { + const id = createId('changelog') as ChangelogId + await activeDb.insert(changelogEntries).values({ + id, + title: `parity-cl-${runSuffix}-${name}`, + content: 'parity', + access, + }) + seeded.push({ id, name, access }) + } + }) + + afterAll(async () => { + if (!activeDb) return + try { + await activeDb + .delete(changelogEntries) + .where(sql`${changelogEntries.title} LIKE ${`parity-cl-${runSuffix}-%`}`) + } finally { + await closeDb?.() + } + }) + + for (const [actorName, actor] of Object.entries(actors)) { + for (const accessCase of accessShapes) { + it(`actor=${actorName} access=${accessCase.name}`, async () => { + if (!activeDb) return + const seededRow = seeded.find((s) => s.name === accessCase.name) + expect(seededRow, `seed row missing for ${accessCase.name}`).toBeDefined() + if (!seededRow) return + + const expectMemoryAllowed = canViewChangelog(actor, { access: accessCase.access }).allowed + + const filter = changelogViewFilter(actor) + const matchedRows = await activeDb + .select({ id: changelogEntries.id }) + .from(changelogEntries) + .where(and(eq(changelogEntries.id, seededRow.id), filter)) + + const expectSqlAllowed = matchedRows.length === 1 + expect( + expectSqlAllowed, + `SQL admitted=${expectSqlAllowed} but in-memory admitted=${expectMemoryAllowed} ` + + `for actor=${actorName} access=${accessCase.name}` + ).toBe(expectMemoryAllowed) + }) + } + } + } +) diff --git a/apps/web/src/lib/server/policy/__tests__/changelog.test.ts b/apps/web/src/lib/server/policy/__tests__/changelog.test.ts new file mode 100644 index 000000000..22740dfaf --- /dev/null +++ b/apps/web/src/lib/server/policy/__tests__/changelog.test.ts @@ -0,0 +1,217 @@ +/** + * Matrix for canViewChangelog. + * + * Changelog audience visibility supports the full AccessTier surface + * (Public / Signed-in / Segments / Private), matching roadmaps. This matrix + * covers every tier × actor shape. + */ +import { describe, it, expect } from 'vitest' +import { canViewChangelog } from '../changelog' +import { ANONYMOUS_ACTOR, type Actor } from '../types' +import type { SegmentId, PrincipalId } from '@quackback/ids' +import type { ChangelogAccess } from '@/lib/server/db' + +const adminActor: Actor = { + principalId: 'principal_admin' as PrincipalId, + role: 'admin', + principalType: 'user', + segmentIds: new Set(), +} + +const memberActor: Actor = { + principalId: 'principal_member' as PrincipalId, + role: 'member', + principalType: 'user', + segmentIds: new Set(), +} + +const portalUserNoSegments: Actor = { + principalId: 'principal_user' as PrincipalId, + role: 'user', + principalType: 'user', + segmentIds: new Set(), +} + +const portalUserInAlpha: Actor = { + principalId: 'principal_alpha' as PrincipalId, + role: 'user', + principalType: 'user', + segmentIds: new Set(['segment_alpha' as SegmentId]), +} + +const servicePrincipal: Actor = { + principalId: 'principal_svc' as PrincipalId, + role: 'user', + principalType: 'service', + segmentIds: new Set(), +} + +const serviceInAlpha: Actor = { + principalId: 'principal_svc_seg' as PrincipalId, + role: 'user', + principalType: 'service', + segmentIds: new Set(['segment_alpha' as SegmentId]), +} + +const A: Record = { + public: { view: 'anonymous', segments: { view: [] } }, + authenticated: { view: 'authenticated', segments: { view: [] } }, + team: { view: 'team', segments: { view: [] } }, + segmentAlpha: { view: 'segments', segments: { view: ['segment_alpha'] } }, + segmentBeta: { view: 'segments', segments: { view: ['segment_beta'] } }, + segmentEmpty: { view: 'segments', segments: { view: [] } }, +} + +interface Row { + name: string + actor: Actor + access: ChangelogAccess + expected: boolean + reason?: string +} + +const matrix: Row[] = [ + // ---------- public ---------- + { name: 'public + anonymous', actor: ANONYMOUS_ACTOR, access: A.public, expected: true }, + { name: 'public + portal user', actor: portalUserNoSegments, access: A.public, expected: true }, + { name: 'public + service', actor: servicePrincipal, access: A.public, expected: true }, + { name: 'public + member', actor: memberActor, access: A.public, expected: true }, + { name: 'public + admin', actor: adminActor, access: A.public, expected: true }, + + // ---------- authenticated ---------- + { + name: 'authenticated + anonymous', + actor: ANONYMOUS_ACTOR, + access: A.authenticated, + expected: false, + reason: 'sign in', + }, + { + name: 'authenticated + portal user', + actor: portalUserNoSegments, + access: A.authenticated, + expected: true, + }, + { + name: 'authenticated + service principal (NOT a user)', + actor: servicePrincipal, + access: A.authenticated, + expected: false, + reason: 'sign in', + }, + { name: 'authenticated + member', actor: memberActor, access: A.authenticated, expected: true }, + { name: 'authenticated + admin', actor: adminActor, access: A.authenticated, expected: true }, + + // ---------- team (private) ---------- + { + name: 'team + anonymous', + actor: ANONYMOUS_ACTOR, + access: A.team, + expected: false, + reason: 'internal', + }, + { + name: 'team + portal user', + actor: portalUserNoSegments, + access: A.team, + expected: false, + reason: 'internal', + }, + { + name: 'team + segment-member portal user (still excluded)', + actor: portalUserInAlpha, + access: A.team, + expected: false, + reason: 'internal', + }, + { + name: 'team + service (non-team service is excluded)', + actor: servicePrincipal, + access: A.team, + expected: false, + reason: 'internal', + }, + { name: 'team + member', actor: memberActor, access: A.team, expected: true }, + { name: 'team + admin', actor: adminActor, access: A.team, expected: true }, + + // ---------- segments[alpha] ---------- + { + name: 'segments[alpha] + anonymous', + actor: ANONYMOUS_ACTOR, + access: A.segmentAlpha, + expected: false, + reason: 'restricted', + }, + { + name: 'segments[alpha] + portal user not in segment', + actor: portalUserNoSegments, + access: A.segmentAlpha, + expected: false, + reason: 'restricted', + }, + { + name: 'segments[alpha] + portal user in alpha', + actor: portalUserInAlpha, + access: A.segmentAlpha, + expected: true, + }, + { + name: 'segments[alpha] + service in alpha (non-user, rejected)', + actor: serviceInAlpha, + access: A.segmentAlpha, + expected: false, + reason: 'restricted', + }, + { + name: 'segments[alpha] + member (team always)', + actor: memberActor, + access: A.segmentAlpha, + expected: true, + }, + { + name: 'segments[alpha] + admin (team always)', + actor: adminActor, + access: A.segmentAlpha, + expected: true, + }, + + // ---------- segments[beta] — confirm no false-positive ---------- + { + name: 'segments[beta] + portal user in alpha (wrong segment)', + actor: portalUserInAlpha, + access: A.segmentBeta, + expected: false, + reason: 'restricted', + }, + + // ---------- segments[] (empty list) — fail closed for non-team ---------- + { + name: 'segments[] empty + portal user in alpha (no listed segment matches)', + actor: portalUserInAlpha, + access: A.segmentEmpty, + expected: false, + reason: 'restricted', + }, + { + name: 'segments[] empty + admin (team always)', + actor: adminActor, + access: A.segmentEmpty, + expected: true, + }, +] + +describe('canViewChangelog — access × actor matrix', () => { + for (const row of matrix) { + it(row.name, () => { + const decision = canViewChangelog(row.actor, { access: row.access }) + if (row.expected) { + expect(decision).toEqual({ allowed: true }) + } else { + expect(decision.allowed).toBe(false) + if (!decision.allowed && row.reason) { + expect(decision.reason.toLowerCase()).toContain(row.reason.toLowerCase()) + } + } + }) + } +}) diff --git a/apps/web/src/lib/server/policy/__tests__/roadmap-view-filter-parity.test.ts b/apps/web/src/lib/server/policy/__tests__/roadmap-view-filter-parity.test.ts new file mode 100644 index 000000000..75bc008cf --- /dev/null +++ b/apps/web/src/lib/server/policy/__tests__/roadmap-view-filter-parity.test.ts @@ -0,0 +1,229 @@ +/** + * Execution-level parity test: roadmapViewFilter (SQL) ↔ canViewRoadmap (in-memory). + * + * The view-only mirror of board-view-filter-parity.test.ts. For every + * (actor, RoadmapAccess) pair we care about, the SQL predicate's row-membership + * decision must be identical to canViewRoadmap's in-memory decision — otherwise + * a refactor could ship a subtle list-vs-detail visibility drift. + * + * Connects via DATABASE_URL (falling back to the dev DB). Skips gracefully if + * neither is reachable, matching the SKIP_INTEGRATION pattern. + */ +import { describe, it, expect, beforeAll, afterAll } from 'vitest' +import { sql, eq, and } from 'drizzle-orm' +import { roadmaps, type RoadmapAccess, type Database } from '@/lib/server/db' +// Direct client import to spin up our own pool — bypasses the global `db` +// proxy/singleton so this test keeps its own short-lived connection. +// eslint-disable-next-line no-restricted-imports +import { createDb } from '@quackback/db/client' +import { canViewRoadmap, roadmapViewFilter } from '../roadmaps' +import { ANONYMOUS_ACTOR, type Actor } from '../types' +import { createId, type SegmentId, type PrincipalId, type RoadmapId } from '@quackback/ids' + +const SEGMENT_ALPHA = createId('segment') as SegmentId +const SEGMENT_BETA = createId('segment') as SegmentId + +function mkAccess(view: RoadmapAccess['view'], segmentIds: string[] = []): RoadmapAccess { + return { view, segments: { view: segmentIds } } +} + +interface AccessCase { + name: string + access: RoadmapAccess +} + +const accessShapes: AccessCase[] = [ + { name: 'anonymous', access: mkAccess('anonymous') }, + { name: 'authenticated', access: mkAccess('authenticated') }, + { name: 'team', access: mkAccess('team') }, + { name: 'segments_alpha', access: mkAccess('segments', [SEGMENT_ALPHA]) }, + { name: 'segments_beta', access: mkAccess('segments', [SEGMENT_BETA]) }, + { name: 'segments_alpha_beta', access: mkAccess('segments', [SEGMENT_ALPHA, SEGMENT_BETA]) }, + // Empty segment list: in-memory canViewRoadmap pins this fail-closed; the SQL + // path (jsonb_array_elements_text over an empty array → 0 rows) collapses to + // the same. This closes the execution-level parity gap for the empty list. + { name: 'segments_empty', access: mkAccess('segments', []) }, +] + +function buildActor(overrides: Partial): Actor { + return { + principalId: null, + role: null, + principalType: 'anonymous', + segmentIds: new Set(), + ...overrides, + } +} + +const actors: Record = { + anon: ANONYMOUS_ACTOR, + user: buildActor({ + principalId: createId('principal') as PrincipalId, + role: 'user', + principalType: 'user', + }), + userInAlpha: buildActor({ + principalId: createId('principal') as PrincipalId, + role: 'user', + principalType: 'user', + segmentIds: new Set([SEGMENT_ALPHA]), + }), + userInAlphaBeta: buildActor({ + principalId: createId('principal') as PrincipalId, + role: 'user', + principalType: 'user', + segmentIds: new Set([SEGMENT_ALPHA, SEGMENT_BETA]), + }), + service: buildActor({ + principalId: createId('principal') as PrincipalId, + role: 'user', + principalType: 'service', + }), + serviceInAlpha: buildActor({ + principalId: createId('principal') as PrincipalId, + role: 'user', + principalType: 'service', + segmentIds: new Set([SEGMENT_ALPHA]), + }), + member: buildActor({ + principalId: createId('principal') as PrincipalId, + role: 'member', + principalType: 'user', + }), + admin: buildActor({ + principalId: createId('principal') as PrincipalId, + role: 'admin', + principalType: 'user', + }), +} + +const CANDIDATE_URLS = [ + process.env.DATABASE_URL, + 'postgresql://postgres:password@localhost:5432/quackback', +].filter((u): u is string => !!u) + +async function pickWorkingDb(): Promise<{ db: Database; close: () => Promise } | null> { + for (const url of CANDIDATE_URLS) { + try { + const db = createDb(url, { max: 2, prepare: false }) + await db.execute(sql`select 1`) + await db.execute(sql`select id, access from ${roadmaps} limit 0`) + return { + db, + close: async () => { + const raw = (db as unknown as { $client?: { end?: () => Promise } }).$client + await raw?.end?.() + }, + } + } catch { + // try next candidate + } + } + return null +} + +interface SeededRoadmap { + id: RoadmapId + name: string + access: RoadmapAccess +} + +let activeDb: Database | null = null +let closeDb: (() => Promise) | null = null +const seeded: SeededRoadmap[] = [] +// Soft-deleted roadmap — every actor (including team) should see it filtered +// out, since each branch ANDs isNull(roadmaps.deletedAt). +let deletedRoadmapId: RoadmapId | null = null +const runSuffix = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}` + +const resolved = await pickWorkingDb() +const dbAvailable = resolved !== null +if (resolved) { + activeDb = resolved.db + closeDb = resolved.close +} + +describe.skipIf(!dbAvailable)('roadmapViewFilter ↔ canViewRoadmap parity (execution-level)', () => { + beforeAll(async () => { + if (!activeDb) return + // Crash-safety belt: sweep leftover rows from prior crashed runs. Match + // only test-generated slugs so we never delete a real roadmap. + await activeDb.delete(roadmaps).where(sql`${roadmaps.slug} ~ '^parity-rm-[0-9]+-'`) + let position = 0 + for (const { name, access } of accessShapes) { + const id = createId('roadmap') as RoadmapId + const slug = `parity-rm-${runSuffix}-${name}` + await activeDb.insert(roadmaps).values({ + id, + slug, + name: `parity:${name}`, + access, + position: position++, + }) + seeded.push({ id, name, access }) + } + const deletedId = createId('roadmap') as RoadmapId + await activeDb.insert(roadmaps).values({ + id: deletedId, + slug: `parity-rm-${runSuffix}-deleted`, + name: 'parity:deleted', + access: mkAccess('anonymous'), + position, + deletedAt: new Date(), + }) + deletedRoadmapId = deletedId + }) + + afterAll(async () => { + if (!activeDb) return + try { + await activeDb + .delete(roadmaps) + .where(sql`${roadmaps.slug} LIKE ${`parity-rm-${runSuffix}-%`}`) + } finally { + await closeDb?.() + } + }) + + for (const [actorName, actor] of Object.entries(actors)) { + for (const accessCase of accessShapes) { + it(`actor=${actorName} access=${accessCase.name}`, async () => { + if (!activeDb) return + const seededRow = seeded.find((s) => s.name === accessCase.name) + expect(seededRow, `seed row missing for ${accessCase.name}`).toBeDefined() + if (!seededRow) return + + const expectMemoryAllowed = canViewRoadmap(actor, { access: accessCase.access }).allowed + + const filter = roadmapViewFilter(actor) + const matchedRows = await activeDb + .select({ id: roadmaps.id }) + .from(roadmaps) + .where(and(eq(roadmaps.id, seededRow.id), filter)) + + const expectSqlAllowed = matchedRows.length === 1 + expect( + expectSqlAllowed, + `SQL admitted=${expectSqlAllowed} but in-memory admitted=${expectMemoryAllowed} ` + + `for actor=${actorName} access=${accessCase.name}` + ).toBe(expectMemoryAllowed) + }) + } + } + + describe('roadmapViewFilter excludes soft-deleted roadmaps', () => { + for (const [actorName, actor] of Object.entries(actors)) { + it(`actor=${actorName} sees 0 rows for a soft-deleted roadmap`, async () => { + if (!activeDb) return + expect(deletedRoadmapId, 'deleted roadmap not seeded').not.toBeNull() + if (!deletedRoadmapId) return + + const matchedRows = await activeDb + .select({ id: roadmaps.id }) + .from(roadmaps) + .where(and(eq(roadmaps.id, deletedRoadmapId), roadmapViewFilter(actor))) + expect(matchedRows.length).toBe(0) + }) + } + }) +}) diff --git a/apps/web/src/lib/server/policy/__tests__/roadmaps.test.ts b/apps/web/src/lib/server/policy/__tests__/roadmaps.test.ts new file mode 100644 index 000000000..f00748e95 --- /dev/null +++ b/apps/web/src/lib/server/policy/__tests__/roadmaps.test.ts @@ -0,0 +1,263 @@ +/** + * Exhaustive matrix for canViewRoadmap. + * + * The view-only mirror of boards.test.ts: every access.view tier × every + * meaningful actor shape. A roadmap has a single `view` action, so this is the + * full surface of its visibility logic. + */ +import { describe, it, expect } from 'vitest' +import { canViewRoadmap } from '../roadmaps' +import { ANONYMOUS_ACTOR, type Actor } from '../types' +import type { SegmentId, PrincipalId } from '@quackback/ids' +import type { RoadmapAccess } from '@/lib/server/db' + +// ---------------------------------------------------------------------- +// Actor fixtures — one per meaningful shape +// ---------------------------------------------------------------------- + +const adminActor: Actor = { + principalId: 'principal_admin' as PrincipalId, + role: 'admin', + principalType: 'user', + segmentIds: new Set(), +} + +const memberActor: Actor = { + principalId: 'principal_member' as PrincipalId, + role: 'member', + principalType: 'user', + segmentIds: new Set(), +} + +const portalUserNoSegments: Actor = { + principalId: 'principal_user' as PrincipalId, + role: 'user', + principalType: 'user', + segmentIds: new Set(), +} + +const portalUserInAlpha: Actor = { + principalId: 'principal_alpha' as PrincipalId, + role: 'user', + principalType: 'user', + segmentIds: new Set(['segment_alpha' as SegmentId]), +} + +const portalUserInAlphaBeta: Actor = { + principalId: 'principal_alphabeta' as PrincipalId, + role: 'user', + principalType: 'user', + segmentIds: new Set(['segment_alpha', 'segment_beta'] as SegmentId[]), +} + +const servicePrincipal: Actor = { + principalId: 'principal_svc' as PrincipalId, + role: 'user', + principalType: 'service', + segmentIds: new Set(), +} + +const serviceInAlpha: Actor = { + principalId: 'principal_svc_seg' as PrincipalId, + role: 'user', + principalType: 'service', + segmentIds: new Set(['segment_alpha' as SegmentId]), +} + +// ---------------------------------------------------------------------- +// Access fixtures — one per meaningful (view tier, segments) shape +// ---------------------------------------------------------------------- + +const A: Record = { + public: { view: 'anonymous', segments: { view: [] } }, + authenticated: { view: 'authenticated', segments: { view: [] } }, + team: { view: 'team', segments: { view: [] } }, + segmentAlpha: { view: 'segments', segments: { view: ['segment_alpha'] } }, + segmentBeta: { view: 'segments', segments: { view: ['segment_beta'] } }, + segmentAlphaBeta: { view: 'segments', segments: { view: ['segment_alpha', 'segment_beta'] } }, + segmentEmpty: { view: 'segments', segments: { view: [] } }, +} + +interface Row { + name: string + actor: Actor + access: RoadmapAccess + expected: boolean + reason?: string +} + +const matrix: Row[] = [ + // ---------- public ---------- + { name: 'public + anonymous', actor: ANONYMOUS_ACTOR, access: A.public, expected: true }, + { name: 'public + portal user', actor: portalUserNoSegments, access: A.public, expected: true }, + { name: 'public + service', actor: servicePrincipal, access: A.public, expected: true }, + { name: 'public + member', actor: memberActor, access: A.public, expected: true }, + { name: 'public + admin', actor: adminActor, access: A.public, expected: true }, + + // ---------- authenticated ---------- + { + name: 'authenticated + anonymous', + actor: ANONYMOUS_ACTOR, + access: A.authenticated, + expected: false, + reason: 'Sign in', + }, + { + name: 'authenticated + portal user', + actor: portalUserNoSegments, + access: A.authenticated, + expected: true, + }, + { + name: 'authenticated + service principal (NOT a user)', + actor: servicePrincipal, + access: A.authenticated, + expected: false, + reason: 'Sign in', + }, + { name: 'authenticated + member', actor: memberActor, access: A.authenticated, expected: true }, + { name: 'authenticated + admin', actor: adminActor, access: A.authenticated, expected: true }, + + // ---------- team ---------- + { + name: 'team + anonymous', + actor: ANONYMOUS_ACTOR, + access: A.team, + expected: false, + reason: 'internal', + }, + { + name: 'team + portal user', + actor: portalUserNoSegments, + access: A.team, + expected: false, + reason: 'internal', + }, + { + name: 'team + segment-member portal user (still excluded)', + actor: portalUserInAlpha, + access: A.team, + expected: false, + reason: 'internal', + }, + { + name: 'team + service (non-team service is excluded)', + actor: servicePrincipal, + access: A.team, + expected: false, + reason: 'internal', + }, + { name: 'team + member', actor: memberActor, access: A.team, expected: true }, + { name: 'team + admin', actor: adminActor, access: A.team, expected: true }, + + // ---------- segments[alpha] ---------- + { + name: 'segments[alpha] + anonymous', + actor: ANONYMOUS_ACTOR, + access: A.segmentAlpha, + expected: false, + reason: 'restricted', + }, + { + name: 'segments[alpha] + portal user not in segment', + actor: portalUserNoSegments, + access: A.segmentAlpha, + expected: false, + reason: 'restricted', + }, + { + name: 'segments[alpha] + portal user in alpha', + actor: portalUserInAlpha, + access: A.segmentAlpha, + expected: true, + }, + { + name: 'segments[alpha] + portal user in alpha+beta (any-match)', + actor: portalUserInAlphaBeta, + access: A.segmentAlpha, + expected: true, + }, + { + // The 'segments' tier requires principalType==='user' (tierAllows). A + // service principal sharing a segment id is intentionally rejected. + name: 'segments[alpha] + service in alpha (service is non-user, rejected)', + actor: serviceInAlpha, + access: A.segmentAlpha, + expected: false, + reason: 'restricted', + }, + { + name: 'segments[alpha] + member (team always)', + actor: memberActor, + access: A.segmentAlpha, + expected: true, + }, + { + name: 'segments[alpha] + admin (team always)', + actor: adminActor, + access: A.segmentAlpha, + expected: true, + }, + + // ---------- segments[beta] — confirm no false-positive ---------- + { + name: 'segments[beta] + portal user in alpha (wrong segment)', + actor: portalUserInAlpha, + access: A.segmentBeta, + expected: false, + reason: 'restricted', + }, + + // ---------- segments[alpha, beta] — multi-allowed ---------- + { + name: 'segments[alpha,beta] + portal user in alpha only', + actor: portalUserInAlpha, + access: A.segmentAlphaBeta, + expected: true, + }, + { + name: 'segments[alpha,beta] + portal user not in either', + actor: portalUserNoSegments, + access: A.segmentAlphaBeta, + expected: false, + reason: 'restricted', + }, + + // ---------- segments[] (empty list) — fail closed for non-team ---------- + { + name: 'segments[] empty + anonymous', + actor: ANONYMOUS_ACTOR, + access: A.segmentEmpty, + expected: false, + reason: 'restricted', + }, + { + name: 'segments[] empty + portal user in alpha (no listed segment matches)', + actor: portalUserInAlpha, + access: A.segmentEmpty, + expected: false, + reason: 'restricted', + }, + { + name: 'segments[] empty + admin (team always)', + actor: adminActor, + access: A.segmentEmpty, + expected: true, + }, +] + +describe('canViewRoadmap — full access × actor matrix', () => { + for (const row of matrix) { + it(row.name, () => { + const decision = canViewRoadmap(row.actor, { access: row.access }) + if (row.expected) { + expect(decision).toEqual({ allowed: true }) + } else { + expect(decision.allowed).toBe(false) + if (!decision.allowed && row.reason) { + expect(decision.reason.toLowerCase()).toContain(row.reason.toLowerCase()) + } + } + }) + } +}) diff --git a/apps/web/src/lib/server/policy/changelog.ts b/apps/web/src/lib/server/policy/changelog.ts new file mode 100644 index 000000000..05d3d606c --- /dev/null +++ b/apps/web/src/lib/server/policy/changelog.ts @@ -0,0 +1,78 @@ +/** + * Changelog view authorization. + * + * Audience visibility for changelog entries — the view-only mirror of the + * board/roadmap policy. This is orthogonal to publish lifecycle: the public + * read paths still apply the published-and-not-deleted filter separately. Pair + * every canViewChangelog() with a matching changelogViewFilter() so list + * queries and single-row reads agree — the parity test enforces this. + * + * The full AccessTier surface is supported (Public / Signed-in / Segments / + * Private), matching the roadmap policy. + */ +import { sql, type SQL } from 'drizzle-orm' +import { changelogEntries, type ChangelogAccess, type AccessTier } from '@/lib/server/db' +import { allowDecision, denyDecision, isTeamActor, type Actor, type Decision } from './types' +import { tierAllows } from './access' + +function viewDenyMessage(tier: AccessTier): string { + switch (tier) { + case 'anonymous': + return 'This changelog entry is restricted' + case 'authenticated': + return 'Sign in to view this changelog entry' + case 'team': + return 'This changelog entry is internal' + case 'segments': + return 'This changelog entry is restricted' + } +} + +/** Single-row changelog read authorization. */ +export function canViewChangelog(actor: Actor, entry: { access: ChangelogAccess }): Decision { + return tierAllows(actor, entry.access.view, entry.access.segments.view) + ? allowDecision() + : denyDecision(viewDenyMessage(entry.access.view)) +} + +/** + * SQL predicate for changelog list queries. The row-by-row truthiness must + * match canViewChangelog exactly — invariant test enforces this. + * + * Unlike boards/roadmaps this filter does NOT AND in `isNull(deletedAt)`: the + * public changelog readers compose it alongside their own published-and-not- + * deleted predicates (publicChangelogConditions), so adding deletedAt here + * would be redundant. It is purely the audience gate. + */ +export function changelogViewFilter(actor: Actor): SQL { + if (isTeamActor(actor)) { + return sql`true` + } + const memberIds = Array.from(actor.segmentIds) as string[] + const isUser = actor.principalType === 'user' + // The segments branch can only match a user principal who belongs to a + // listed segment (matches tierAllows). With no memberships, collapse to a + // constant — this also avoids rendering `ANY(()::text[])`, which Postgres + // rejects. A non-empty list is built as `ARRAY[$1, …]` because a bare array + // in a `sql` template is spread as comma-separated params, not an array. + const segmentsMatch = + memberIds.length > 0 && isUser + ? sql` + ${changelogEntries.access}->>'view' = 'segments' + AND EXISTS ( + SELECT 1 FROM jsonb_array_elements_text(${changelogEntries.access}->'segments'->'view') seg + WHERE seg = ANY(ARRAY[${sql.join( + memberIds.map((id) => sql`${id}`), + sql`, ` + )}]::text[]) + ) + ` + : sql`false` + return sql` + ( + ${changelogEntries.access}->>'view' = 'anonymous' + OR (${changelogEntries.access}->>'view' = 'authenticated' AND ${isUser}) + OR (${segmentsMatch}) + ) + ` +} diff --git a/apps/web/src/lib/server/policy/index.ts b/apps/web/src/lib/server/policy/index.ts index c39fa400d..2809941a6 100644 --- a/apps/web/src/lib/server/policy/index.ts +++ b/apps/web/src/lib/server/policy/index.ts @@ -1,3 +1,5 @@ export * from './types' export * from './boards' +export * from './roadmaps' +export * from './changelog' export * from './posts' diff --git a/apps/web/src/lib/server/policy/roadmaps.ts b/apps/web/src/lib/server/policy/roadmaps.ts new file mode 100644 index 000000000..e62babbeb --- /dev/null +++ b/apps/web/src/lib/server/policy/roadmaps.ts @@ -0,0 +1,81 @@ +/** + * Roadmap view authorization. + * + * Roadmaps have a single `view` action (no vote/comment/submit), so this is + * the view-only mirror of the board policy. Pair every canViewRoadmap() with a + * matching roadmapViewFilter() so list queries and single-row reads use the + * same predicate — the parity test enforces they agree row-by-row. + */ +import { sql, isNull, type SQL } from 'drizzle-orm' +import { roadmaps, type RoadmapAccess, type AccessTier } from '@/lib/server/db' +import { allowDecision, denyDecision, isTeamActor, type Actor, type Decision } from './types' +import { tierAllows } from './access' + +function viewDenyMessage(tier: AccessTier): string { + switch (tier) { + case 'anonymous': + // Anonymous tier never denies via this function (tierAllows always + // returns true). Kept for exhaustiveness with the Decision deny variant. + return 'This roadmap is restricted' + case 'authenticated': + return 'Sign in to view this roadmap' + case 'team': + return 'This roadmap is internal' + case 'segments': + return 'This roadmap is restricted' + } +} + +/** Single-row roadmap read authorization. */ +export function canViewRoadmap(actor: Actor, roadmap: { access: RoadmapAccess }): Decision { + return tierAllows(actor, roadmap.access.view, roadmap.access.segments.view) + ? allowDecision() + : denyDecision(viewDenyMessage(roadmap.access.view)) +} + +/** + * SQL predicate for roadmap list queries. The row-by-row truthiness must + * match canViewRoadmap exactly — invariant test enforces this. + * + * Every branch is AND-ed with `isNull(roadmaps.deletedAt)`: a soft-deleted + * roadmap must never surface through any public reader path, regardless of + * actor (even team members viewing the portal see only non-deleted roadmaps; + * admin-side queries do not use this filter and have their own logic). + */ +export function roadmapViewFilter(actor: Actor): SQL { + if (isTeamActor(actor)) { + return sql`${isNull(roadmaps.deletedAt)}` + } + const memberIds = Array.from(actor.segmentIds) as string[] + const isUser = actor.principalType === 'user' + // The segments branch can only match an actor who belongs to a segment AND + // is a user principal (matches tierAllows semantics — a service principal + // in a segment is denied). With no memberships, collapse to a constant — + // this also avoids rendering `ANY(()::text[])`, which Postgres rejects. A + // non-empty list is built as `ARRAY[$1, …]` because a bare array in a + // `sql` template is spread as comma-separated params, not a single array + // literal. + const segmentsMatch = + memberIds.length > 0 && isUser + ? sql` + ${roadmaps.access}->>'view' = 'segments' + AND EXISTS ( + SELECT 1 FROM jsonb_array_elements_text(${roadmaps.access}->'segments'->'view') seg + WHERE seg = ANY(ARRAY[${sql.join( + memberIds.map((id) => sql`${id}`), + sql`, ` + )}]::text[]) + ) + ` + : sql`false` + return sql` + ( + ${isNull(roadmaps.deletedAt)} + AND ( + ${roadmaps.access}->>'view' = 'anonymous' + OR (${roadmaps.access}->>'view' = 'authenticated' AND ${isUser}) + OR (${segmentsMatch}) + ) + ) + ` +} diff --git a/apps/web/src/lib/shared/db-types.ts b/apps/web/src/lib/shared/db-types.ts index 5355bbea8..9c69786f8 100644 --- a/apps/web/src/lib/shared/db-types.ts +++ b/apps/web/src/lib/shared/db-types.ts @@ -21,6 +21,8 @@ export { ACCESS_TIERS, ACCESS_TIER_RANK, DEFAULT_BOARD_ACCESS, + DEFAULT_ROADMAP_ACCESS, + DEFAULT_CHANGELOG_ACCESS, MODERATION_RULE_VALUES, CONVERSATION_STATUSES, CONVERSATION_END_REASONS, @@ -28,6 +30,8 @@ export { export type { AccessTier, BoardAccess, + RoadmapAccess, + ChangelogAccess, ModerationRuleValue, ConversationEndReason, } from '@quackback/db/types' diff --git a/apps/web/src/lib/shared/schemas/changelog.ts b/apps/web/src/lib/shared/schemas/changelog.ts index 6c01fe64d..7db91b787 100644 --- a/apps/web/src/lib/shared/schemas/changelog.ts +++ b/apps/web/src/lib/shared/schemas/changelog.ts @@ -5,6 +5,7 @@ */ import { z } from 'zod' +import { ACCESS_TIERS } from '@/lib/shared/db-types' import { tiptapContentSchema } from './posts' /** @@ -16,6 +17,32 @@ export const publishStateSchema = z.discriminatedUnion('type', [ z.object({ type: z.literal('published'), publishAt: z.coerce.date().optional() }), ]) +/** + * Changelog audience visibility schema. + * + * The view-only mirror of the roadmap access schema. A changelog entry has a + * single `view` action across the full tier surface (Public / Signed-in / + * Segments / Private). The only rule is that selecting the `segments` tier + * requires a non-empty allowlist (an empty list would hide the entry from + * everyone). This gate is orthogonal to publish lifecycle. + */ +export const changelogAccessSchema = z + .object({ + view: z.enum(ACCESS_TIERS), + segments: z.object({ + view: z.array(z.string()).max(50, 'At most 50 segments per changelog entry.'), + }), + }) + .superRefine((val, ctx) => { + if (val.view === 'segments' && val.segments.view.length === 0) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ['segments', 'view'], + message: 'Pick at least one segment — an empty allowlist hides the entry.', + }) + } + }) + /** * Create changelog input schema */ @@ -25,6 +52,7 @@ export const createChangelogSchema = z.object({ contentJson: tiptapContentSchema.nullable().optional(), linkedPostIds: z.array(z.string()).optional(), publishState: publishStateSchema, + access: changelogAccessSchema.optional(), }) /** @@ -37,6 +65,7 @@ export const updateChangelogSchema = z.object({ contentJson: tiptapContentSchema.nullable().optional(), linkedPostIds: z.array(z.string()).optional(), publishState: publishStateSchema.optional(), + access: changelogAccessSchema.optional(), }) /** diff --git a/apps/web/src/lib/shared/schemas/roadmaps.ts b/apps/web/src/lib/shared/schemas/roadmaps.ts new file mode 100644 index 000000000..763e326a4 --- /dev/null +++ b/apps/web/src/lib/shared/schemas/roadmaps.ts @@ -0,0 +1,37 @@ +import { z } from 'zod' +import { ACCESS_TIERS } from '@/lib/shared/db-types' + +// ============================================ +// Roadmap access (single `view` action + segments) +// ============================================ +// +// The view-only mirror of boardAccessSchema. Kept alongside the other shared +// schemas (out of `server/`) so client code can import it without dragging the +// @quackback/db/client guard — it imports only zod + @quackback/db/types. + +const tierSchema = z.enum(ACCESS_TIERS) + +/** + * Validation for the `RoadmapAccess` payload. A roadmap has a single `view` + * action, so there are no cross-action tier-rank invariants — the only rule is + * that selecting the `segments` tier requires a non-empty allowlist (an empty + * list would hide the roadmap from everyone). + */ +export const roadmapAccessSchema = z + .object({ + view: tierSchema, + segments: z.object({ + view: z.array(z.string()).max(50, 'At most 50 segments per roadmap.'), + }), + }) + .superRefine((val, ctx) => { + if (val.view === 'segments' && val.segments.view.length === 0) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ['segments', 'view'], + message: 'Pick at least one segment — an empty allowlist hides the roadmap.', + }) + } + }) + +export type RoadmapAccessInput = z.infer diff --git a/apps/web/src/routes/api/v1/changelog/$entryId.ts b/apps/web/src/routes/api/v1/changelog/$entryId.ts index 3c30b9998..95babd0f2 100644 --- a/apps/web/src/routes/api/v1/changelog/$entryId.ts +++ b/apps/web/src/routes/api/v1/changelog/$entryId.ts @@ -13,14 +13,17 @@ import { updateChangelog, deleteChangelog, } from '@/lib/server/domains/changelog/changelog.service' -import type { PublishState } from '@/lib/shared/schemas/changelog' +import { changelogAccessSchema, type PublishState } from '@/lib/shared/schemas/changelog' +import type { ChangelogAccess } from '@/lib/server/db' import type { ChangelogId } from '@quackback/ids' -// Input validation schema +// Input validation schema. `access` is the optional audience visibility +// control; it's independent of the publish lifecycle (publishedAt). const updateChangelogSchema = z.object({ title: z.string().min(1).max(200).optional(), content: z.string().min(1).optional(), publishedAt: z.string().datetime().nullable().optional(), + access: changelogAccessSchema.optional(), }) function formatChangelogResponse(entry: { @@ -28,6 +31,7 @@ function formatChangelogResponse(entry: { title: string content: string publishedAt: Date | null + access: ChangelogAccess createdAt: Date updatedAt: Date }) { @@ -36,6 +40,7 @@ function formatChangelogResponse(entry: { title: entry.title, content: entry.content, publishedAt: entry.publishedAt?.toISOString() || null, + access: entry.access, createdAt: entry.createdAt.toISOString(), updatedAt: entry.updatedAt.toISOString(), } @@ -106,6 +111,7 @@ export const Route = createFileRoute('/api/v1/changelog/$entryId')({ title: parsed.data.title, content: parsed.data.content, ...(publishState && { publishState }), + ...(parsed.data.access && { access: parsed.data.access }), }) return successResponse(formatChangelogResponse(updated)) diff --git a/apps/web/src/routes/api/v1/changelog/index.ts b/apps/web/src/routes/api/v1/changelog/index.ts index 81cf1cb73..86f9bf7c9 100644 --- a/apps/web/src/routes/api/v1/changelog/index.ts +++ b/apps/web/src/routes/api/v1/changelog/index.ts @@ -9,16 +9,18 @@ import { } from '@/lib/server/domains/api/responses' import { createChangelog } from '@/lib/server/domains/changelog/changelog.service' import { listChangelogs } from '@/lib/server/domains/changelog/changelog.query' -import { publishedAtToPublishState } from '@/lib/shared/schemas/changelog' +import { publishedAtToPublishState, changelogAccessSchema } from '@/lib/shared/schemas/changelog' import { db, principal, eq } from '@/lib/server/db' import type { PostId } from '@quackback/ids' -// Input validation schema +// Input validation schema. `access` is the optional audience visibility +// control (defaults to public); it's independent of the publish lifecycle. const createChangelogSchema = z.object({ title: z.string().min(1, 'Title is required').max(200), content: z.string().min(1, 'Content is required'), publishedAt: z.string().datetime().optional(), linkedPostIds: z.array(z.string()).optional(), + access: changelogAccessSchema.optional(), }) export const Route = createFileRoute('/api/v1/changelog/')({ @@ -54,6 +56,7 @@ export const Route = createFileRoute('/api/v1/changelog/')({ title: entry.title, content: entry.content, publishedAt: entry.publishedAt?.toISOString() || null, + access: entry.access, createdAt: entry.createdAt.toISOString(), updatedAt: entry.updatedAt.toISOString(), })), @@ -102,6 +105,7 @@ export const Route = createFileRoute('/api/v1/changelog/')({ content: parsed.data.content, publishState, linkedPostIds: parsed.data.linkedPostIds as PostId[] | undefined, + access: parsed.data.access, }, { principalId: authResult.principalId, @@ -114,6 +118,7 @@ export const Route = createFileRoute('/api/v1/changelog/')({ title: entry.title, content: entry.content, publishedAt: entry.publishedAt?.toISOString() || null, + access: entry.access, createdAt: entry.createdAt.toISOString(), updatedAt: entry.updatedAt.toISOString(), }) diff --git a/apps/web/src/routes/api/v1/roadmaps/$roadmapId.ts b/apps/web/src/routes/api/v1/roadmaps/$roadmapId.ts index 1ff792e0b..c981ce364 100644 --- a/apps/web/src/routes/api/v1/roadmaps/$roadmapId.ts +++ b/apps/web/src/routes/api/v1/roadmaps/$roadmapId.ts @@ -8,13 +8,17 @@ import { handleDomainError, } from '@/lib/server/domains/api/responses' import { parseTypeId } from '@/lib/server/domains/api/validation' +import { roadmapAccessSchema } from '@/lib/shared/schemas/roadmaps' import type { RoadmapId } from '@quackback/ids' -// Input validation schema +// Input validation schema. `isPublic` is the legacy boolean (kept for backward +// compatibility); `access` is the richer tier+segments control. When both are +// present, `access` wins. const updateRoadmapSchema = z.object({ name: z.string().min(1).max(100).optional(), description: z.string().max(500).optional(), isPublic: z.boolean().optional(), + access: roadmapAccessSchema.optional(), }) export const Route = createFileRoute('/api/v1/roadmaps/$roadmapId')({ @@ -30,7 +34,8 @@ export const Route = createFileRoute('/api/v1/roadmaps/$roadmapId')({ const roadmapId = parseTypeId(params.roadmapId, 'roadmap', 'roadmap ID') - const { getRoadmap } = await import('@/lib/server/domains/roadmaps/roadmap.service') + const { getRoadmap, roadmapAccessToIsPublic } = + await import('@/lib/server/domains/roadmaps/roadmap.service') const roadmap = await getRoadmap(roadmapId) @@ -39,7 +44,8 @@ export const Route = createFileRoute('/api/v1/roadmaps/$roadmapId')({ name: roadmap.name, slug: roadmap.slug, description: roadmap.description, - isPublic: roadmap.isPublic, + isPublic: roadmapAccessToIsPublic(roadmap.access), + access: roadmap.access, position: roadmap.position, createdAt: roadmap.createdAt.toISOString(), }) @@ -67,12 +73,20 @@ export const Route = createFileRoute('/api/v1/roadmaps/$roadmapId')({ }) } - const { updateRoadmap } = await import('@/lib/server/domains/roadmaps/roadmap.service') + const { updateRoadmap, roadmapAccessToIsPublic, isPublicToRoadmapAccess } = + await import('@/lib/server/domains/roadmaps/roadmap.service') + + // `access` takes precedence; otherwise map the legacy `isPublic`. + const access = + parsed.data.access ?? + (parsed.data.isPublic !== undefined + ? isPublicToRoadmapAccess(parsed.data.isPublic) + : undefined) const roadmap = await updateRoadmap(roadmapId, { name: parsed.data.name, description: parsed.data.description, - isPublic: parsed.data.isPublic, + access, }) return successResponse({ @@ -80,7 +94,8 @@ export const Route = createFileRoute('/api/v1/roadmaps/$roadmapId')({ name: roadmap.name, slug: roadmap.slug, description: roadmap.description, - isPublic: roadmap.isPublic, + isPublic: roadmapAccessToIsPublic(roadmap.access), + access: roadmap.access, position: roadmap.position, createdAt: roadmap.createdAt.toISOString(), }) diff --git a/apps/web/src/routes/api/v1/roadmaps/index.ts b/apps/web/src/routes/api/v1/roadmaps/index.ts index e1bc928c1..512b12d0e 100644 --- a/apps/web/src/routes/api/v1/roadmaps/index.ts +++ b/apps/web/src/routes/api/v1/roadmaps/index.ts @@ -7,8 +7,11 @@ import { badRequestResponse, handleDomainError, } from '@/lib/server/domains/api/responses' +import { roadmapAccessSchema } from '@/lib/shared/schemas/roadmaps' -// Input validation schema +// Input validation schema. `isPublic` is the legacy boolean (kept for backward +// compatibility); `access` is the richer tier+segments control. When both are +// present, `access` wins. const createRoadmapSchema = z.object({ name: z.string().min(1, 'Name is required').max(100), slug: z @@ -17,7 +20,8 @@ const createRoadmapSchema = z.object({ .max(100) .regex(/^[a-z0-9-]+$/, 'Slug must contain only lowercase letters, numbers, and hyphens'), description: z.string().max(500).optional(), - isPublic: z.boolean().optional().default(true), + isPublic: z.boolean().optional(), + access: roadmapAccessSchema.optional(), }) export const Route = createFileRoute('/api/v1/roadmaps/')({ @@ -32,7 +36,8 @@ export const Route = createFileRoute('/api/v1/roadmaps/')({ await withApiKeyAuth(request, { role: 'team' }) // Import service function - const { listRoadmaps } = await import('@/lib/server/domains/roadmaps/roadmap.service') + const { listRoadmaps, roadmapAccessToIsPublic } = + await import('@/lib/server/domains/roadmaps/roadmap.service') const roadmaps = await listRoadmaps() @@ -42,7 +47,8 @@ export const Route = createFileRoute('/api/v1/roadmaps/')({ name: roadmap.name, slug: roadmap.slug, description: roadmap.description, - isPublic: roadmap.isPublic, + isPublic: roadmapAccessToIsPublic(roadmap.access), + access: roadmap.access, position: roadmap.position, createdAt: roadmap.createdAt.toISOString(), })) @@ -71,13 +77,18 @@ export const Route = createFileRoute('/api/v1/roadmaps/')({ } // Import service function - const { createRoadmap } = await import('@/lib/server/domains/roadmaps/roadmap.service') + const { createRoadmap, roadmapAccessToIsPublic, isPublicToRoadmapAccess } = + await import('@/lib/server/domains/roadmaps/roadmap.service') + + // `access` takes precedence; otherwise map the legacy `isPublic` + // (defaulting to public when neither is supplied). + const access = parsed.data.access ?? isPublicToRoadmapAccess(parsed.data.isPublic ?? true) const roadmap = await createRoadmap({ name: parsed.data.name, slug: parsed.data.slug, description: parsed.data.description, - isPublic: parsed.data.isPublic, + access, }) return createdResponse({ @@ -85,7 +96,8 @@ export const Route = createFileRoute('/api/v1/roadmaps/')({ name: roadmap.name, slug: roadmap.slug, description: roadmap.description, - isPublic: roadmap.isPublic, + isPublic: roadmapAccessToIsPublic(roadmap.access), + access: roadmap.access, position: roadmap.position, createdAt: roadmap.createdAt.toISOString(), }) diff --git a/packages/db/drizzle/0114_roadmap_access.sql b/packages/db/drizzle/0114_roadmap_access.sql new file mode 100644 index 000000000..99ca6e6e9 --- /dev/null +++ b/packages/db/drizzle/0114_roadmap_access.sql @@ -0,0 +1,19 @@ +-- Replace the legacy `is_public` boolean on roadmaps with a richer `access` +-- jsonb, mirroring boards. Roadmaps only have a single `view` action, so the +-- shape is the minimal slice of BoardAccess: one tier + one segment allowlist. +ALTER TABLE "roadmaps" ADD COLUMN "access" jsonb DEFAULT '{"view":"anonymous","segments":{"view":[]}}'::jsonb NOT NULL; +--> statement-breakpoint +-- Backfill from the boolean: +-- is_public = true → anyone can view ('anonymous') +-- is_public = false → workspace members only ('team') +-- No segment data existed before, so the allowlist starts empty in both cases. +-- This UPDATE is mandatory: without it every previously-private roadmap would +-- silently inherit the column default ('anonymous') and become public. +UPDATE "roadmaps" SET "access" = jsonb_build_object( + 'view', CASE WHEN "is_public" THEN 'anonymous' ELSE 'team' END, + 'segments', jsonb_build_object('view', '[]'::jsonb) +); +--> statement-breakpoint +DROP INDEX IF EXISTS "roadmaps_is_public_idx"; +--> statement-breakpoint +ALTER TABLE "roadmaps" DROP COLUMN "is_public"; diff --git a/packages/db/drizzle/0115_changelog_access.sql b/packages/db/drizzle/0115_changelog_access.sql new file mode 100644 index 000000000..70fb17876 --- /dev/null +++ b/packages/db/drizzle/0115_changelog_access.sql @@ -0,0 +1,6 @@ +-- Add audience visibility to changelog entries, mirroring roadmaps. This is +-- independent of the publish lifecycle (published_at): publish state decides +-- whether/when an entry is live; `access` decides who may see a live entry. +-- Purely additive — the column default makes every existing entry public +-- ('anonymous'), which preserves today's behavior, so no backfill is needed. +ALTER TABLE "changelog_entries" ADD COLUMN "access" jsonb DEFAULT '{"view":"anonymous","segments":{"view":[]}}'::jsonb NOT NULL; diff --git a/packages/db/drizzle/meta/_journal.json b/packages/db/drizzle/meta/_journal.json index 4248756fb..575bf2273 100644 --- a/packages/db/drizzle/meta/_journal.json +++ b/packages/db/drizzle/meta/_journal.json @@ -799,6 +799,20 @@ "when": 1782432000000, "tag": "0113_integration_channelid_backfill", "breakpoints": true + }, + { + "idx": 114, + "version": "7", + "when": 1782518400000, + "tag": "0114_roadmap_access", + "breakpoints": true + }, + { + "idx": 115, + "version": "7", + "when": 1782604800000, + "tag": "0115_changelog_access", + "breakpoints": true } ] } diff --git a/packages/db/src/__tests__/schema.test.ts b/packages/db/src/__tests__/schema.test.ts index 4dcc41af9..4a40a25f9 100644 --- a/packages/db/src/__tests__/schema.test.ts +++ b/packages/db/src/__tests__/schema.test.ts @@ -52,10 +52,17 @@ describe('Schema definitions', () => { expect(columns).toContain('slug') expect(columns).toContain('name') expect(columns).toContain('description') - expect(columns).toContain('isPublic') + expect(columns).toContain('access') expect(columns).toContain('createdAt') expect(columns).toContain('updatedAt') }) + + it('no longer has the legacy isPublic column', () => { + // Regression guard for migration 0114, which dropped is_public in favour + // of the `access` jsonb. New code reads access exclusively. + const columns = Object.keys(getTableColumns(roadmaps)) + expect(columns).not.toContain('isPublic') + }) }) describe('tags schema', () => { @@ -226,6 +233,7 @@ describe('Schema definitions', () => { expect(columns).toContain('title') expect(columns).toContain('content') expect(columns).toContain('publishedAt') + expect(columns).toContain('access') expect(columns).toContain('createdAt') expect(columns).toContain('updatedAt') }) diff --git a/packages/db/src/__tests__/types.test.ts b/packages/db/src/__tests__/types.test.ts index ac34c361a..2cd65f090 100644 --- a/packages/db/src/__tests__/types.test.ts +++ b/packages/db/src/__tests__/types.test.ts @@ -67,7 +67,7 @@ describe('Type definitions', () => { expectTypeOf().toHaveProperty('id') expectTypeOf().toHaveProperty('slug') expectTypeOf().toHaveProperty('name') - expectTypeOf().toHaveProperty('isPublic') + expectTypeOf().toHaveProperty('access') expectTypeOf().toHaveProperty('position') }) diff --git a/packages/db/src/schema/boards.ts b/packages/db/src/schema/boards.ts index 810a62f26..19aa0c423 100644 --- a/packages/db/src/schema/boards.ts +++ b/packages/db/src/schema/boards.ts @@ -1,7 +1,13 @@ -import { pgTable, text, timestamp, boolean, jsonb, integer, index } from 'drizzle-orm/pg-core' +import { pgTable, text, timestamp, jsonb, integer, index } from 'drizzle-orm/pg-core' import { relations } from 'drizzle-orm' import { typeIdWithDefault } from '@quackback/ids/drizzle' -import { type BoardSettings, type BoardAccess, DEFAULT_BOARD_ACCESS } from '../types' +import { + type BoardSettings, + type BoardAccess, + DEFAULT_BOARD_ACCESS, + type RoadmapAccess, + DEFAULT_ROADMAP_ACCESS, +} from '../types' export const boards = pgTable( 'boards', @@ -10,9 +16,6 @@ export const boards = pgTable( slug: text('slug').notNull().unique(), name: text('name').notNull(), description: text('description'), - // v1 access controls — per-action tier matrix. Replaces the legacy - // `audience` jsonb column (dropped in migration 0080) and the older - // `is_public` boolean before that. access: jsonb('access').$type().default(DEFAULT_BOARD_ACCESS).notNull(), settings: jsonb('settings').$type().default({}).notNull(), createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(), @@ -33,7 +36,9 @@ export const roadmaps = pgTable( slug: text('slug').notNull().unique(), name: text('name').notNull(), description: text('description'), - isPublic: boolean('is_public').default(true).notNull(), + // v1 access controls — single `view` action tier + segment allowlist. + // Replaces the legacy `is_public` boolean (dropped in migration 0114). + access: jsonb('access').$type().default(DEFAULT_ROADMAP_ACCESS).notNull(), position: integer('position').notNull().default(0), createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(), updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(), @@ -43,7 +48,6 @@ export const roadmaps = pgTable( (table) => [ // Note: roadmaps_slug_unique constraint already provides uniqueness; no separate index needed index('roadmaps_position_idx').on(table.position), - index('roadmaps_is_public_idx').on(table.isPublic), index('roadmaps_deleted_at_idx').on(table.deletedAt), ] ) diff --git a/packages/db/src/schema/changelog.ts b/packages/db/src/schema/changelog.ts index d00dc5176..d8faa465d 100644 --- a/packages/db/src/schema/changelog.ts +++ b/packages/db/src/schema/changelog.ts @@ -3,7 +3,7 @@ import { relations } from 'drizzle-orm' import { typeIdWithDefault, typeIdColumn, typeIdColumnNullable } from '@quackback/ids/drizzle' import { principal } from './auth' import { posts } from './posts' -import type { TiptapContent } from '../types' +import { type TiptapContent, type ChangelogAccess, DEFAULT_CHANGELOG_ACCESS } from '../types' export const changelogEntries = pgTable( 'changelog_entries', @@ -18,6 +18,7 @@ export const changelogEntries = pgTable( onDelete: 'set null', }), publishedAt: timestamp('published_at', { withTimezone: true }), + access: jsonb('access').$type().default(DEFAULT_CHANGELOG_ACCESS).notNull(), createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(), updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(), // Soft delete support diff --git a/packages/db/src/seed.ts b/packages/db/src/seed.ts index 0a1f1e93e..151463e48 100644 --- a/packages/db/src/seed.ts +++ b/packages/db/src/seed.ts @@ -443,7 +443,7 @@ async function seed() { slug: r.slug, name: r.name, description: r.description, - isPublic: true, + access: { view: 'anonymous', segments: { view: [] } }, position: i, createdAt: randomDate(30), }) diff --git a/packages/db/src/types.ts b/packages/db/src/types.ts index 0cbe95c1b..eeeba112c 100644 --- a/packages/db/src/types.ts +++ b/packages/db/src/types.ts @@ -109,6 +109,37 @@ export const DEFAULT_BOARD_ACCESS: BoardAccess = { moderation: { anonPosts: 'inherit', signedPosts: 'inherit', comments: 'inherit' }, } +/** Roadmap visibility — a single `view` action. Unlike BoardAccess there is + * no vote/comment/submit on a roadmap itself, so this is the minimal slice + * of the board access model: one tier plus one segment allowlist. The + * allowlist is used only when `view` is 'segments'; an empty list there + * fails closed (the roadmap is hidden) and is rejected on save. */ +export interface RoadmapAccess { + view: AccessTier + segments: { view: string[] } +} + +export const DEFAULT_ROADMAP_ACCESS: RoadmapAccess = { + view: 'anonymous', + segments: { view: [] }, +} + +/** Changelog visibility — a single `view` action, like RoadmapAccess. This is + * orthogonal to publish lifecycle (publishedAt): publish state decides + * whether/when an entry is live, `access` decides who may see a live entry. + * Supports the full AccessTier surface (anonymous / authenticated / segments / + * team), matching roadmaps. The segments allowlist is used only when `view` is + * 'segments'; an empty list there fails closed and is rejected on save. */ +export interface ChangelogAccess { + view: AccessTier + segments: { view: string[] } +} + +export const DEFAULT_CHANGELOG_ACCESS: ChangelogAccess = { + view: 'anonymous', + segments: { view: [] }, +} + // Integration config (stored in integrations.config JSONB column) // Each integration defines its own typed config at the integration layer. export type IntegrationConfig = Record