Skip to content

feat(boards): board access matrix v1 (per-action tiers, segments, moderation)#191

Open
mortondev wants to merge 133 commits into
mainfrom
feat/board-access-matrix-v1
Open

feat(boards): board access matrix v1 (per-action tiers, segments, moderation)#191
mortondev wants to merge 133 commits into
mainfrom
feat/board-access-matrix-v1

Conversation

@mortondev
Copy link
Copy Markdown
Member

@mortondev mortondev commented May 28, 2026

Summary

Replaces the coarse per-board audience + approval booleans 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:

anonymous  ⊂  authenticated  ⊂  segments  ⊂  team

Each action also has its own segment allowlist, and submission/commenting carry tri-state moderation rules (inherit | on | off). A single workspace-level allowAnonymous master 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:

  • Publicview: anonymous, vote/comment/submit: authenticated
  • Private — every action team

Access model

  • BoardAccess JSONB column replaces the legacy audience enum and the approval boolean group. Per-action tiers + per-action segment lists + tri-state moderation.
  • vote is now a first-class action with its own tier (previously implied by audience).
  • Moderation is tri-state per axis (anonPosts / signedPosts / comments); inherit resolves against the workspace default via a single shared resolver used by both policy and UI.
  • boardAccessSchema (in lib/shared/schemas) enforces the rank invariants: each action's tier cannot be more permissive than view, and a segments-tier action must name at least one segment.

Policy enforcement

  • tierAllows is 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, canVotePost all read boards.access.
  • boardCapabilitiesForActor returns the per-board { canSubmit, canVote, canComment } for a viewer (per-action tier composed with the allowAnonymous ceiling 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.
  • MCP and REST entry points route through the same chokepoints (assertPostVotable, assertCommentViewable, real caller role on create_post) so no path bypasses the matrix.

Moderation

  • Comments gain a moderation_state and 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.
  • Pending items are excluded from public reads and counts; held authors can still see their own pending content.

Admin UI

  • BoardAccessForm is 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.
  • A cluster of consistency passes: canonical card + 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

00780084: comment moderation_state; board_access column with backfill from audience; drop audience; per-action segment lists; vote action; tri-state moderation; and the allowAnonymous master switch (collapsing the legacy anonymous toggle trio). The 0084 backfill fails closed (anonymous commenting/posting default to false when the prior config lacked the keys, rather than silently re-opening on upgrade), with a regression pin.

Hardening / correctness

  • Soft-deleted boards are excluded from every public reader path and rejected across vote/comment/post/changeBoard/suggestion-accept writes.
  • createPost locks the board row to close a TOCTOU, and filters soft-deleted boards in its precheck to avoid orphan uploads.
  • DNS-rebinding SSRF in the image rehoster closed via a pinned safeFetch.

Review findings addressed in this round

  • (P1) Require board view before create. canCreatePost gated only on access.submit. Because the schema pins submit.rank >= view.rank but lets the per-action segment lists differ, a board with view: segments[A] and submit: 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 on canViewBoard first, matching canVotePost / canCreateComment and the documented invariant.
  • (P2) Widget CTAs gated per board. The widget advertised submit/vote/comment off the workspace-wide allowAnonymous switch, so anonymous visitors saw enabled CTAs on sign-in-required boards and only hit the server rejection on click.
    • Post detail: fetchPublicPostDetail already runs with the widget's Bearer identity and refetches on identify, so it now returns server-computed canVote / canComment for the real actor (boardAccess is used server-side then stripped before the response, so segment ids never reach the client).
    • Home feed: threads the per-board capability map in; submit follows the selected board, each card's vote follows its own board. The root comment form keeps its email-identify entry, which escalates an anonymous visitor to a real user and stays valid on authenticated-comment boards.
  • (P2 follow-up) Feed gated per the real actor. The home feed initially seeded its capability map from the anonymous SSR baseline and OR-ed in a blanket isIdentified, so an identified user saw vote/submit enabled on segments/team boards they are not a member of (the server rejected on click). New fetchBoardCapabilitiesFn returns the per-board map for the request actor; the widget refetches it with its Bearer identity (keyed on sessionVersion, initialData seeding only the initial SSR key so the post-identify refetch is not suppressed by staleTime), and the isIdentified OR is gone. handleSubmit now stops on !canPost instead of firing a server-rejected request. fetchPortalData and the new fn share a buildBoardPermissions helper.
  • Cleanup. Removed a dead listPendingComments test accessor.

The portal-side equivalents (submit, vote, comment CTAs) were addressed earlier on this branch and confirmed in the current code.

Testing

  • Red/green TDD for the P1 fix; boardCapabilitiesForActor extended to canComment and fetchBoardCapabilitiesFn covered, both with matching tests.
  • Full suite green: typecheck, lint, and 4062 unit tests passing. Policy parity tests (canViewBoardboardViewFilter, canViewPostpostViewFilter) and the 0084 backfill regression pin included.

mortondev added 30 commits May 27, 2026 19:32
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.
@mortondev mortondev force-pushed the feat/board-access-matrix-v1 branch from 21f2fcf to 55b76db Compare May 29, 2026 00:37
Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 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".

Comment thread apps/web/src/routes/widget/index.tsx Outdated
mortondev added 3 commits May 29, 2026 02:19
…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.
@mortondev mortondev changed the title Feat/board access matrix v1 feat(boards): board access matrix v1 (per-action tiers, segments, moderation) May 29, 2026
Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 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".

Comment thread apps/web/src/components/widget/widget-home-animated.tsx Outdated
Comment thread apps/web/src/components/widget/widget-home-animated.tsx Outdated
…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.
Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 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
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge 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 👍 / 👎.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.
Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 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') {
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge 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 👍 / 👎.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

mortondev added 3 commits May 29, 2026 09:28
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
Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 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".

Comment on lines 85 to 86
const board = await createBoard({
name: parsed.data.name,
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge 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.
Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 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".

Comment on lines 135 to +138
isPrivate: comments.isPrivate,
postModerationState: posts.moderationState,
postPrincipalId: posts.principalId,
audience: boards.audience,
access: boards.access,
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge 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 👍 / 👎.

Comment on lines +143 to +145
const { data: livePermissions } = useQuery({
queryKey: ['widget', 'boardPermissions', sessionVersion],
queryFn: () => fetchBoardCapabilitiesFn({ headers: getWidgetAuthHeaders() }),
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge 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).
Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 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".

Comment on lines +101 to +103
const initialModerationState: ModerationState = decision.requiresApproval
? 'pending'
: 'published'
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge 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.
Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 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 })
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge 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.
@chatgpt-codex-connector
Copy link
Copy Markdown

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.
Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 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".

Comment on lines +201 to +204
const lockedDecision = canCreatePost(
author.actor ?? ANONYMOUS_ACTOR,
{ access: lockedBoard.access },
portalConfig.moderationDefault.requireApproval
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge 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 👍 / 👎.

mortondev added 2 commits May 29, 2026 17:29
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.
Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 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".

Comment on lines +123 to +126
mutation.mutate({
boardId: board.id,
access: { ...board.access, moderation: next },
})
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge 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.
Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 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
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge 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 👍 / 👎.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant