Bulletin Board — The Access Ladder and Who Can Do What
Overview
This is the permission layer for the entire bulletin domain. Every request, read or write, passes through resolveAccess exactly once, and the resulting AccessContext is then handed down through the stack — no handler ever recomputes permissions.
Read this before any other bulletin feature. The three rule sets described here — permissions by user_type, blocks, and category visibility — determine the behaviour of the feed, posting, commenting, and reactions alike.
Business Flow
resolveAccess(c) — five steps, in an order that matters
The ordering exists because a caller's LIFF token only means anything once it has been bound to that OA's LINE Login channel. Everything with a side effect — creating the board row, provisioning a guest — therefore happens after the token is bound. An unauthenticated caller, or one arriving from a different channel, can never write a row.
- Resolve the OA and its LINE Login channel from
:hash— not found returns 404 withboard not found. - Verify the token against that specific channel via
resolveUserID— failure returns 401. EnsureBoard— create thebulletin.boardrow for the OA if it does not exist yet.- Look up the
line_userrow. A miss here does not prove the row is absent, because the query filters on status and deleted_date. The code therefore callsEnsureGuest, a conflict-safe insert that reports what actually exists in the table — a row that exists but is disabled or deleted is a deliberate rejection, answered with 403BULLETIN_USER_INACTIVE, not a re-admission as a guest. - Load any still-active block and assemble the
AccessContext, carryinglineUserID,displayName,userType,lineOaID,oaName,oaCover,orgID,boardID,settings,audienceIDs, andblock.
Settings and how permissions are decided
The bulletin.board.settings column is jsonb, and ParseSettings unmarshals it on top of the defaults. Keys absent from the blob keep their default value, and malformed JSON degrades to the defaults rather than producing a board that rejects everyone.
An empty array in view_access or write_access is a legitimate deny-all; null means "not configured" and falls back to the default.
The defaults are: view_access of member and guest, write_access of member only, require_post_approval and require_comment_approval false, max_images_per_post 4, max_images_per_comment 2, max_body_length 2000, max_comment_length 500, allow_comments_default true, allow_user_reports true, and a standard set of six reaction_emojis.
The five capabilities
| Method | Rule |
|---|---|
CanView() | user_type must appear in view_access, otherwise 403 BULLETIN_VIEW_FORBIDDEN |
CanWrite() | Must appear in write_access (otherwise BULLETIN_WRITE_FORBIDDEN) and have no block whose scope is anything other than comment. This is deny-by-default: a misspelled scope or a zero value counts as a full block, returning BULLETIN_BLOCKED |
CanComment() | Must appear in write_access and have no block at all — blocks of either scope forbid commenting |
CanReact() | Identical to CanWrite(), so a user muted with the comment scope can still react |
CanReport() | Requires allow_user_reports to be enabled (otherwise BULLETIN_REPORTS_DISABLED), then checks CanWrite() |
A block whose expires_date lies in the past is inert. The repository already filters those out; the check at this layer is a second safety belt.
Category visibility (CanSeeCategory) — two independent axes
- The read axis is
access_mode.inherituses board-level permissions, so guests can see it.restrictedwith an empty audience means all members.restrictedwith an audience means only members whose audiences overlap. Restricted always implies member-only, since a guest is never in a meaningful audience. - The write axis is
post_access.member(the default) lets LINE users post;adminallows posting from the CMS only. - The two axes compose. For example,
inheritplusadminproduces an "announcements" category everyone can read but only administrators can write to. - An
audience_idsvalue that cannot be read as an array — a literal null, or an array whose elements are not integers — denies access rather than widening to all members. Only a genuinely absent value (length 0) counts as the empty array meaning "all members". - The same rule has a SQL twin,
categoryVisibilitySQL, used to filter the feed. The two must agree, and paired tests pin them together. GET /bulletin/:hashfilters the category list through this rule on the way out, because a category's name can itself be confidential and must be removed wholesale.
Error codes
Error codes are returned as messages so the client can branch on them: BULLETIN_VIEW_FORBIDDEN, BULLETIN_WRITE_FORBIDDEN, BULLETIN_BLOCKED, BULLETIN_REPORTS_DISABLED, BULLETIN_COMMENTS_CLOSED, BULLETIN_IMAGE_LIMIT, BULLETIN_BODY_TOO_LONG, BULLETIN_EMPTY_BODY, BULLETIN_EMPTY_TITLE, BULLETIN_TITLE_TOO_LONG, BULLETIN_INVALID_EMOJI, BULLETIN_INVALID_REASON, BULLETIN_DETAIL_TOO_LONG, BULLETIN_INVALID_IMAGE, BULLETIN_CATEGORY_ADMIN_ONLY, and BULLETIN_USER_INACTIVE.
A package-wide rule worth memorizing
Bulletin ids are a global serial, so every query must take a lineOaID — an id alone never proves ownership. And every kind of failure (does not exist, belongs to another tenant, deleted, or sits in an invisible category) must collapse into the same 404, never a 403, which would confirm the thing exists.
Key Files & Functions
| File | Functions |
|---|---|
internal/bulletin/handler.go | (*Handler).resolveAccess, the boardRepo interface |
internal/bulletin/accessctx.go | AccessContext, CanView, CanWrite, CanComment, CanReact, CanReport, CanSeeCategory, activeBlock, decodeAudienceIDs, resolveUserID, ChannelBinding, channelBindingOf, and the error code set |
internal/bulletin/entity.go | Settings, DefaultSettings, ParseSettings, Allows, AllowsEmoji, Category, AllowsLineUserPosts, plus the status, author, target, scope, access-mode, and post-access constants |
internal/bulletin/repository.go | FindOaByHash, EnsureBoard, FindLineUser, EnsureGuest, FindActiveBlock, FindCategories, categoryVisibilitySQL |
internal/bulletin/view.go | visibleCategoryViews, NewCategoryView, publicURLOf |
internal/bulletin/register.go | Mounts /bulletin/:hash behind appguard.AppEnabledGuard(db,"bulletin") |
Connections to Other Services
- Database — tables
bulletin.board(jsonbsettings),bulletin.category,bulletin.block,line_oa, andline_user - App-enabled guard — lets a platform administrator switch the whole feature group off
- LIFF authentication — supplies the channel-binding ladder
- Related features — the foundation for the board and feed, post writing, commenting, and reactions/reporting
- client-web — corresponds to
bulletin-moderation, whose capability system reads the settings returned here, and underpinsbulletin-board