feat(boards): board access matrix v1 (per-action tiers, segments, moderation)#191
feat(boards): board access matrix v1 (per-action tiers, segments, moderation)#191mortondev wants to merge 133 commits into
Conversation
Adds a moderation_state column to the comments table (mirroring the existing posts.moderation_state shape), with a NOT NULL default of 'published' and a btree index for filtering. Reuses the shared MODERATION_STATES enum so the column stays pinned to the canonical set of states. This is infrastructure for per-board comment approval gating (Phase 0 of the board access matrix). No callers read or write the column yet — follow-up tasks add list/approve/reject server functions and surface pending comments in the moderation admin UI.
…sFn insertion Inserting listPendingCommentsFn at index 1 in moderation.ts shifted the handlers used by the existing test file. Rename listPending() -> listPendingPosts(), add a listPendingComments() accessor for the forthcoming test task, and bump approve/reject/getModerationStatus indices to 2/3/4.
Adds boards.access (jsonb, NOT NULL with default) alongside the legacy audience column. Migration 0079 backfills access by mapping each audience kind to the matching AccessTier across view/comment/submit. The audience column stays for now; subsequent tasks migrate readers/writers before T24 drops it.
Pure predicate that maps (Actor, AccessTier, segmentIds) -> boolean. Centralizes how anonymous / authenticated / segments / team tiers are interpreted so canViewBoard, canCreatePost, and canCreateComment can share a single definition. Team actors bypass every tier; segments tier requires a portal user AND a matching membership, with empty segment lists failing closed for non-team actors.
…board Codex review of Phase 0 flagged two TOCTOU windows in the guarded UPDATE queries: the list/count queries filter through parent .deletedAt but the mutation WHERE clauses did not, so a moderator clicking a stale queue item could publish into a soft-deleted board or mutate a comment whose parent post was already archived. Add EXISTS subqueries to the four mutation handlers (approvePostFn, rejectPostFn, approveCommentFn, rejectCommentFn) that mirror the parent-deletedAt filters already applied to the queue display. Failure of the EXISTS guard surfaces the same POST_NOT_PENDING/COMMENT_NOT_PENDING ConflictError the queue already uses, so the moderator UI handles it without changes. Test mock extended to evaluate EXISTS subqueries against the in-memory state (column-to-column eq, recursive context). Six new TOCTOU tests cover all four handlers; existing 36 tests still pass.
Adds a pure `audienceToAccess` helper that mirrors the 0079 migration backfill (view/comment/submit all land on the matching tier, approval off, segment ids preserved for kind='segments'). createBoard now writes the derived `access` payload alongside `audience` so the two stay consistent for newly created boards instead of relying on the schema default.
Rewrite canViewBoard to delegate to tierAllows() using board.access.view
and board.access.segmentIds — the central authorization predicate now
runs through the access-matrix model instead of the legacy audience kind.
boardViewFilter (the SQL list-query predicate) still reads from the
audience column; the dual-write from PR1 keeps audience and access in
lockstep so the two predicates stay aligned during the transition. T12
will switch boardViewFilter to access.
Caller updates required to satisfy the new BoardShape = { access } type:
- comment/post/notification/event services + policy/posts pass
{ access } from the joined boards row (selecting boards.access where
they previously selected boards.audience).
- targets.ts fast path now keys on access.view === 'anonymous' (the
access-matrix equivalent of the legacy 'public' audience kind).
Behaviour pin: service-principal-in-segment is now correctly rejected by
the 'segments' tier (was incorrectly admitted in the legacy
canViewBoard). Matches the design pinned by access.test.ts ("rejects
service even if in segment").
Also bumps the boards column-count regression test to 10 (audience +
access live side-by-side until T24 drops audience).
Pairs with T11's canViewBoard switch. Both predicates now read the same column (boards.access) so the in-memory and SQL paths stay row-by-row aligned. The segments branch additionally requires `isUser` to match tierAllows semantics — a service principal in a segment is denied. The legacy boards.audience column is still written by the dual-write path for backward compatibility but no longer read by any production policy code.
… is on createComment was wiring canCreateComment but ignoring its requiresApproval return, so comments always landed published even with approval.comments=true. Both INSERT paths now set moderationState from decision.requiresApproval, emit a comment.moderation.held audit row when held, and skip dispatch (webhooks, mentions, subscriptions) until a moderator approves the comment. Mirrors the post-moderation flow.
21f2fcf to
55b76db
Compare
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 55b76dbed5
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
…capability
canCreatePost gated only on access.submit, so a board with view=segments[A]
and submit=segments[B] (valid: the schema pins submit.rank >= view.rank but
lets the per-action segment lists differ) admitted a submit-segment member who
is not in the view segment — letting them create a post on a board they cannot
see. Gate on canViewBoard first, matching canVotePost/canCreateComment and the
file invariant ("create is denied when view is denied"). (Codex P1, #191)
Also extend boardCapabilitiesForActor to return canComment alongside
canSubmit/canVote so the widget post-detail + feed gating reuses one composition
helper instead of re-deriving the per-board tier + workspace anonymous ceiling.
The accessor was unused and only kept alive by an eslint-disable. Each handler accessor hardcodes its own index, so dropping it leaves the others correct. (code-quality finding, #191)
The widget advertised submit/vote/comment off the workspace-wide allowAnonymous switch, so anonymous visitors saw enabled CTAs on boards whose per-action tier requires sign-in (e.g. the default Public preset: view=anonymous but vote/comment/submit=authenticated) and only hit the server rejection on click. (Codex P2, #191) Post detail: fetchPublicPostDetail already runs with the widget's Bearer identity and the query re-keys on sessionVersion, so it now returns server-computed canVote/canComment for the real (possibly just-identified) actor via boardCapabilitiesForActor. getPublicPostDetail surfaces boardAccess server-side for the computation; fetchPublicPostDetail strips it before the response so segment ids never reach the client. The getPortalConfig read overlaps the merge queries. Home feed: threads the existing per-board boardPermissions map (anonymous SSR baseline) into WidgetHome — submit follows the selected board, each card's vote follows its own board, composed with isIdentified. The root comment form keeps its email-identify entry (which escalates anonymous -> real user, so it stays valid on authenticated-comment boards). Drops the workspace-wide flag from the widget entirely. Known limitation: on the home feed an identified user still sees vote enabled on segments/team boards they are not a member of (server rejects) — the SSR feed map can't see the Bearer identity. The post detail gates this correctly.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 678346560a
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
…ntified The home feed seeded its per-board capability map from the anonymous SSR baseline and OR-ed in a blanket isIdentified, so an identified viewer saw vote and submit enabled on segments/team boards they are not a member of (server rejected on click). (Codex #191 follow-up) New fetchBoardCapabilitiesFn returns the per-board {canSubmit,canVote} map for the request actor; the widget refetches it with its Bearer identity, keyed on sessionVersion, so the feed gates per the actual actor. initialData seeds only the initial (anonymous, SSR) key — seeding every key would mark the post-identify key fresh and suppress the Bearer refetch within staleTime. Dropped the isIdentified OR from rowCanVote/canPost; handleSubmit now stops on !canPost instead of falling through to a server-rejected createPost. Extracted buildBoardPermissions so fetchPortalData and the new fn share the map shape + composition; the new fn parallelizes the settings read with the board query.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 95fbc32a87
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| const canPost = boardPermissions?.[selectedBoardId]?.canSubmit ?? false | ||
| // An anonymous visitor on a board that does not allow anonymous submission must | ||
| // identify first; the form collects an email and escalates to a real user. | ||
| const needsEmail = !isIdentified && !hmacRequired && !canPost |
There was a problem hiding this comment.
Only prompt for email when authentication can satisfy submit
For an anonymous widget visitor on a board that is public to view but has submit: 'segments' or submit: 'team', this treats the failed anonymous capability as an email-identification flow (needsEmail becomes true) and enables submission once an email is entered. After identifyWithEmail the user is still not in the required segment/team tier, but the needsEmail branch skips the !canPost guard and proceeds to createPublicPostFn, which the server rejects. This should only show the email flow for boards where signing in can actually satisfy the submit tier (e.g. authenticated), or refetch/recheck capabilities after identification before creating.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Fixed in 0c2d964. After identifyWithEmail succeeds, the widget refetches the selected board's capability with the new Bearer identity and surfaces a no-access message instead of firing a createPost the server rejects. Identifying into an 'authenticated' board still falls through to create, so that flow is unchanged.
…ubmit
A viewer who can SEE a board but cannot act on it was treated as unauthenticated
("Sign in to …") even when signed in — and the portal vote even fired a doomed
request. Distinguish authorization from authentication across portal + widget:
- Signed-in real user denied by the board tier (segments/team) → "You don't have
access to {vote,comment,post} on this board"; no sign-in prompt, action not
attempted.
- Anonymous / no-session viewer → existing "Sign in to …" affordance.
Vote buttons: denied real users get a dimmed, non-actionable button with a
hover tooltip (native title) and no hover animation; the base VoteButton and
WidgetVoteButton gain a noAccessReason prop, and the bespoke post-card button
mirrors it. Submit (portal header + widget form) and comments (comment-thread +
widget post detail) show the message inline instead of a sign-in prompt.
The authz/authn split is derived per surface from the signal each already has:
resolveSubmitState/resolveCommentingState gain a noAccess flag (real-user via
principalType); the post-detail vote uses sidebarData.isMember; the feed uses a
real-user check (not !!effectiveUser, which is truthy for anon sessions); the
widget uses isIdentified. Unit tests cover the helper noAccess cases.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: c80abf9077
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| // commentCount. Pending comments are held back from public reads | ||
| // (see post.public.detail.ts) — `approveCommentFn` re-increments | ||
| // the count when the comment becomes visible. | ||
| if (!isPrivate && initialModerationState !== 'pending') { |
There was a problem hiding this comment.
Preserve counts when deleting held comments
When comment moderation is enabled, this branch intentionally skips post.commentCount for pending public comments, but the existing delete path still decrements every non-private comment in softDeleteComment without checking moderationState. If an author deletes their own held comment before approval (the permission check still allows authors), any post with existing published comments has its public count reduced even though the pending comment was never counted; the delete path needs the same pending-state guard or a recompute.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Fixed in b3bbabc. softDeleteComment and deleteComment now skip the commentCount decrement when moderationState is 'pending', mirroring the insert/approve guard, so deleting a held comment before approval can't underflow the count of already-published comments. Added red-green regression pins for both paths.
Pending (held-for-review) comments are never added to the public commentCount on insert, but both delete paths still decremented for any non-private comment regardless of moderationState. Deleting a held comment before approval therefore underflowed the count of already-published comments. softDeleteComment and deleteComment now skip the decrement when moderationState is 'pending', mirroring the insert/approve guard. Adds red-green regression pins for both paths.
An anonymous widget visitor on a board that is public to view but has submit: 'segments' or 'team' was routed through the email-identify flow, which then skipped the !canPost guard and fired a createPublicPostFn the server rejects, surfacing a misleading network error. After identifyWithEmail succeeds, the widget now refetches the selected board's capability with the new Bearer identity and shows a no-access message instead of posting. Identifying into an 'authenticated' board still falls through to create as before, so that flow is unchanged.
Quality-only cleanups over the board-access-matrix work (no behavior change): - policy/posts: collapse three near-identical deny-message switches into one verb-parameterized tierDenyMessage; messages are byte-identical - boards: extract the duplicated sticky save dock into a shared BoardSettingsSaveDock; drop a redundant RequireApproval type alias and a no-op resolveWorkspaceDefault wrapper; loop ACTIONS in handlePresetClick - moderation fn: extract the team-gate preamble (repeated 7x) into requireTeamAuth - moderation route: lift the shared approve/reject mutation options - portal: fold getPortalConfig into fetchPortalData's Promise.all to drop a serial round-trip on the main loader; route getCommentsSectionDataFn through boardCapabilitiesForActor (single source of truth) with the config read kept lazy + conditional - posts: add loadBoardAccessForPost and share the soft-delete-guarded board lookup across the vote/comment sidebar fns
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 84f0872cad
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| const board = await createBoard({ | ||
| name: parsed.data.name, |
There was a problem hiding this comment.
Use the public preset for API-created boards
When a team API key creates a board through POST /api/v1/boards, this call omits access, so createBoard falls back to the column default (DEFAULT_BOARD_ACCESS, all actions anonymous). That diverges from the admin create dialog and onboarding path, which now pass accessForPreset('public') (view anonymous but vote/comment/submit authenticated); on a default workspace where allowAnonymous is true, API-created boards therefore allow anonymous submissions/comments even though newly created Public boards are supposed to require sign-in for those actions. Pass the same public preset access here, since the API schema also strips access and callers cannot choose the safer matrix themselves.
Useful? React with 👍 / 👎.
Address findings from the merge-readiness review (none were blockers): - portal: strip the internal access matrix (segment ids, per-action tiers, moderation rules) from fetchPortalData / fetchPublicBoards / fetchPublicBoardBySlug payloads — clients gate via boardPermissions and never read board.access, so it was shipped to anonymous viewers as dead weight that exposed segmentation structure. Add PublicBoardWithStats (Omit access) and use it for the portal board props. - policy/posts: tighten the boardCapabilitiesForActor anon-ceiling guard with !isTeam so a non-user team actor can't be falsely gated (latent hazard). - public-posts: the anonymous-vote read path now fails closed (?? false) when allowAnonymous is absent, matching the write paths. - policy/boards: correct the stale boardViewFilter doc (audience was dropped in 0080; the REST shape is synthesised via accessToAudience). - test: pin the segments_empty fail-closed shape in the post view-filter parity test, matching the board parity test.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: fa3686a9ab
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| isPrivate: comments.isPrivate, | ||
| postModerationState: posts.moderationState, | ||
| postPrincipalId: posts.principalId, | ||
| audience: boards.audience, | ||
| access: boards.access, |
There was a problem hiding this comment.
Block reactions on held comments
When comment moderation is enabled, public reads only return published comments (plus an author's own pending comment), but this access check never loads or checks comments.moderationState. addReaction/removeReaction rely on assertCommentViewable, so any signed-in user who can view the post and learns a pending comment id can add reactions to a comment that is still in the moderation queue; those reactions then become visible if the comment is approved, bypassing the intended hold.
Useful? React with 👍 / 👎.
| const { data: livePermissions } = useQuery({ | ||
| queryKey: ['widget', 'boardPermissions', sessionVersion], | ||
| queryFn: () => fetchBoardCapabilitiesFn({ headers: getWidgetAuthHeaders() }), |
There was a problem hiding this comment.
Refetch widget data after identification
This refetch only updates the per-board capability map after sessionVersion changes, but the boards/posts rendered by WidgetHome remain the loader's anonymous snapshot, and its paginated feed still calls listPublicPostsFn without the widget Bearer headers or a sessionVersion key. For a widget visitor who identifies to access a board with access.view = 'authenticated' or segments, the server can now compute permissions for that actor, but the board and its posts never appear in the widget feed/selector until a full reload.
Useful? React with 👍 / 👎.
…elete races From the Codex adversarial review of the branch: - post.service: re-run canCreatePost against the board.access read under the FOR UPDATE row lock and derive moderationState from it. The lock previously only re-checked liveness, so an admin tightening the submit tier or flipping moderation during the network-bound image rehost (between the precheck and the insert) could be bypassed. - comments: decide the commentCount decrement from the locked returning row in softDeleteComment / deleteComment, not the pre-transaction snapshot. A concurrent approval could publish + count a held comment between the read and the delete, drifting the count. - public-posts: document the deliberate fail-closed anonymous-posting gate (a missing allowAnonymous flag denies; pinned by the feature-flag test).
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 33cb000681
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| const initialModerationState: ModerationState = decision.requiresApproval | ||
| ? 'pending' | ||
| : 'published' |
There was a problem hiding this comment.
Do not publish replies to held parents
When input.parentId points at a pending comment, the child moderation state is derived only from the board's current rule, while the parent lookup below never checks the parent's moderationState or whether this actor can see it. If comment moderation is later turned off after a parent was held (or a team member posts a public reply from the queue), the reply is inserted as published and can increment commentCount even though public reads still filter out the parent, leaving counts/threads inconsistent and making the reply appear automatically once the parent is approved; replies to non-published parents should be rejected for ordinary users or kept pending with the parent.
Useful? React with 👍 / 👎.
…anon gates The vote/comment write gates (toggleVoteFn, createCommentFn) resolved allowAnonymous via getPortalConfig, whose merged default-true silently fail-opened on a malformed/partial portal_config — diverging from the posting gate, which deliberately reads raw and fails closed. Add a single fail-closed helper workspaceAllowsAnonymous(portalConfig) (only an explicit true permits anonymous interaction) and route every anonymous gate through it: createPublicPostFn, getVoteSidebarDataFn, toggleVoteFn, createCommentFn. The per-board tier remains the inner authorization gate, and existing tenants carry an explicit value from migration 0084. Adds missing-config fail-closed coverage for vote + comment.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: f2a8603104
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| const onSubmit = useCallback( | ||
| (next: FormShape) => { | ||
| if (segsError) return | ||
| mutation.mutate({ boardId: board.id, access: next }) |
There was a problem hiding this comment.
Preserve moderation when saving access changes
When the Access tab is saved from a stale page, this submits the entire BoardAccess snapshot, including the tab's old moderation object, to updateBoardAccessFn, which replaces the whole JSONB column. If another admin or another browser tab changed the board's Moderation settings after this form loaded, a later access-only save silently reverts those moderation rules; the split tabs should submit/patch only their owned slice or merge against the latest board access before saving.
Useful? React with 👍 / 👎.
…ity gates From the Codex verification pass on the prior fixes: - moderation: wrap approveCommentFn's publish + commentCount increment in one transaction. They were separate statements, so a concurrent delete could observe the comment as published-but-not-yet-counted, decrement (clamped to 0), and let the later increment drift the count. The row lock now spans both. - portal: route the capability gates (fetchPortalData, fetchPublicPostDetail, getCommentsSectionDataFn, fetchBoardCapabilitiesFn) through a fail-closed workspaceAllowsAnonymous read instead of getPortalConfig's merged default-true. They were failing open on a missing flag while the write gates fail closed, so the UI could advertise an anonymous action the server rejects on a malformed config (#191). All anonymous gates now resolve the ceiling identically.
|
You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard. |
Add board-access-matrix.spec.ts proving the per-action view/vote/comment/submit tiers, the allowAnonymous master switch, and team bypass across four real identities (anon, authenticated user, segment member, admin) via magic-link. Backed by reusable fixtures: setup-access-fixtures.ts (e2e-* boards across tiers + a segment + a seeded post per board) and set-workspace-anon.ts. Rewrite settings-permissions.spec.ts for the consolidated Allow-anonymous master switch and approval rules (heading-role card titles, exact switch aria-labels, auto-save persist-on-reload). Fix boards.spec.ts for the access-matrix UI: preset tiles replace the public/private radios, ?tab=access tab buttons replace the /access link, font-medium marks the active tab, renames regenerate the slug. Access-toggle and name-edit tests own a throwaway board so they do not race other parallel tests, and wait on the save dock / slug-orphan instead of networkidle.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 3b313abdad
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| const lockedDecision = canCreatePost( | ||
| author.actor ?? ANONYMOUS_ACTOR, | ||
| { access: lockedBoard.access }, | ||
| portalConfig.moderationDefault.requireApproval |
There was a problem hiding this comment.
Honor vote tier before creating the author upvote
When a board allows the actor to submit but not vote (for example view/submit = anonymous with vote = authenticated, or different submit/vote segment allowlists), this recheck admits the create path and the transaction still sets voteCount: 1 and inserts an author vote below. That records a vote that assertPostVotable/toggleVoteFn would reject under the new per-action access matrix, corrupting vote rows and counts; gate the auto-upvote with the board's vote policy or skip it when the submitter cannot vote.
Useful? React with 👍 / 👎.
Completes the per-action matrix (view/vote/comment/submit): anon vote on a vote:authenticated board raises the sign-in dialog (no vote recorded), an authenticated user can vote there, and anon can vote on an all-anonymous board (vote:anonymous) without sign-in. Vote toggles are clicked twice to restore the seeded post's count.
e2e-mod now holds signed-in posts too (signedPosts:'on'), so an authenticated user's submission is held deterministically. Two tests submit via the portal composer -> the held post is invisible to anonymous viewers -> it appears in the /admin/moderation queue, where Approve publishes it into the public feed and Reject soft-deletes it so it stays hidden.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: eaf3d463b3
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| mutation.mutate({ | ||
| boardId: board.id, | ||
| access: { ...board.access, moderation: next }, | ||
| }) |
There was a problem hiding this comment.
Preserve access tiers when saving moderation
When the Moderation tab is saved from a stale page, this spreads the tab's old board.access snapshot and posts the whole JSONB object to updateBoardAccessFn, which replaces boards.access wholesale. If another admin changes the Access tab after this page loaded, a later moderation-only save silently rolls back the view/vote/comment/submit tiers and segment lists to the stale values; the moderation form should patch only the moderation slice or merge against the latest access before saving.
Useful? React with 👍 / 👎.
Mirrors the post-moderation tests for comments (e2e-mod comments:'on'): a signed-in user's comment on the seeded post is held -> invisible to anonymous viewers -> appears under the queue's pending comments, where Approve publishes it onto the post and Reject soft-deletes it so it stays hidden. The comment is typed into the TipTap editor and the queue row is matched by its unique body text.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 0c34fc79d5
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| export function workspaceAllowsAnonymous( | ||
| portalConfig: string | Record<string, unknown> | null | undefined | ||
| ): boolean { | ||
| const parsed = typeof portalConfig === 'string' ? JSON.parse(portalConfig) : portalConfig |
There was a problem hiding this comment.
Fail closed instead of throwing on raw portal config
When settings.portal_config is an empty or malformed text value, this JSON.parse throws instead of returning false. The rest of the settings layer uses parseJsonConfig/parseJsonOrNull to tolerate those rows, and this helper now sits on hot anonymous gates such as fetchPortalData, toggleVoteFn, createPublicPostFn, and comments; a single bad raw config therefore turns portal reads/writes into 500s instead of the intended fail-closed anonymous-denied state.
Useful? React with 👍 / 👎.
Summary
Replaces the coarse per-board
audience+approvalbooleans with a per-action access matrix. Every board now carries an independent tier for each action — view / vote / comment / submit — drawn from an ordered set:Each action also has its own segment allowlist, and submission/commenting carry tri-state moderation rules (
inherit | on | off). A single workspace-levelallowAnonymousmaster switch is the outer ceiling for non-user (anonymous / no-session) actors. The tier interpretation lives in one place (tierAllows) and every read/write path composes the same policy helpers, so the matrix is enforced consistently from SQL list filters to single-row reads to mutations.The default presets:
view: anonymous,vote/comment/submit: authenticatedteamAccess model
BoardAccessJSONB column replaces the legacyaudienceenum and theapprovalboolean group. Per-action tiers + per-action segment lists + tri-state moderation.voteis now a first-class action with its own tier (previously implied by audience).anonPosts/signedPosts/comments);inheritresolves against the workspace default via a single shared resolver used by both policy and UI.boardAccessSchema(inlib/shared/schemas) enforces the rank invariants: each action's tier cannot be more permissive thanview, and asegments-tier action must name at least one segment.Policy enforcement
tierAllowsis the single source of truth for "does this actor satisfy this tier on this board".canViewBoard/boardViewFilter(row read + SQL list predicate, parity-tested),canViewPost/postViewFilter,canCreatePost,canCreateComment,canVotePostall readboards.access.boardCapabilitiesForActorreturns the per-board{ canSubmit, canVote, canComment }for a viewer (per-action tier composed with theallowAnonymousceiling for non-user actors). This is the single helper the portal and widget UIs use to decide whether to advertise a CTA, so the client never re-derives gating from the workspace flag.assertPostVotable,assertCommentViewable, real caller role oncreate_post) so no path bypasses the matrix.Moderation
moderation_stateand can be held for review per board; the moderation queue is a two-section page (posts + comments) with approve/reject server functions and audit metadata.Admin UI
BoardAccessFormis a permissions matrix with an inline segment picker; the create modal uses preset tiles; moderation moves to its own settings sub-page with a standalone save.PageHeader+ typography on scale, mobile stacking for the matrix / stat grids, a11y fixes, token-based colors, and loader prefetch so first paint reflects server state (no value flip).Migrations
0078–0084: commentmoderation_state;board_accesscolumn with backfill fromaudience; dropaudience; per-action segment lists;voteaction; tri-state moderation; and theallowAnonymousmaster switch (collapsing the legacy anonymous toggle trio). The0084backfill fails closed (anonymous commenting/posting default tofalsewhen the prior config lacked the keys, rather than silently re-opening on upgrade), with a regression pin.Hardening / correctness
createPostlocks the board row to close a TOCTOU, and filters soft-deleted boards in its precheck to avoid orphan uploads.safeFetch.Review findings addressed in this round
canCreatePostgated only onaccess.submit. Because the schema pinssubmit.rank >= view.rankbut lets the per-action segment lists differ, a board withview: segments[A]andsubmit: segments[B]admitted a submit-segment member who is not in the view segment — letting them create a post on a board they cannot see. It now gates oncanViewBoardfirst, matchingcanVotePost/canCreateCommentand the documented invariant.allowAnonymousswitch, so anonymous visitors saw enabled CTAs on sign-in-required boards and only hit the server rejection on click.fetchPublicPostDetailalready runs with the widget's Bearer identity and refetches on identify, so it now returns server-computedcanVote/canCommentfor the real actor (boardAccessis used server-side then stripped before the response, so segment ids never reach the client).isIdentified, so an identified user saw vote/submit enabled onsegments/teamboards they are not a member of (the server rejected on click). NewfetchBoardCapabilitiesFnreturns the per-board map for the request actor; the widget refetches it with its Bearer identity (keyed onsessionVersion,initialDataseeding only the initial SSR key so the post-identify refetch is not suppressed bystaleTime), and theisIdentifiedOR is gone.handleSubmitnow stops on!canPostinstead of firing a server-rejected request.fetchPortalDataand the new fn share abuildBoardPermissionshelper.listPendingCommentstest accessor.The portal-side equivalents (submit, vote, comment CTAs) were addressed earlier on this branch and confirmed in the current code.
Testing
boardCapabilitiesForActorextended tocanCommentandfetchBoardCapabilitiesFncovered, both with matching tests.canViewBoard↔boardViewFilter,canViewPost↔postViewFilter) and the0084backfill regression pin included.