The public portal (Phase J): anonymous issue submission + a read-only public issue list + a status summary for one project, behind a random capability slug. Security model per the 2026-08-06 design doc + the external security panel's findings, folded in:
- The slug is the grant — CSPRNG, ~22 chars, regenerable
(
rotate_slug/2); rotation broadcasts so LIVE portal sessions downgrade immediately (panel #7). - One whitelisting doorway — every public read goes through
public_view/1, which returns plain DTO maps (never structs, so an LV can't over-assign internal fields — panel #5) and scopes every query from the portal row +public == true(panel #6). - Uniform failure — unknown slug, disabled extension, disabled
capability all collapse to
:error(panel #11). - Rate limiting lives in the submit path itself (panel #2), keyed on the PEER address (never XFF — panel #3), IPv6 bucketed at /64 and a per-project global ceiling on top (panel #8). A limiter failure DENIES (fail-closed).
- No submitter email in v1 — the notify-submitter feature needs double-opt-in (panel #1); collecting nothing eliminates the mail-bomb + header-injection surface. Submissions notify project MEMBERS through the Phase H fan-out instead.
ip_hashis a truncated peppered HMAC (panel #12) — abuse telemetry only.
Host requirement for per-IP limits
The peer address comes from LiveView's get_connect_info(socket, :peer_data), which is only populated when the HOST endpoint's socket
declares it — socket "/live", ..., websocket: [connect_info: [:peer_data, session: ...]]. Without it every submitter shares one
bucket (still fail-closed — stricter, not weaker — and the per-project
ceiling is IP-independent). Hosts behind a proxy additionally need
RemoteIp (or equivalent) terminating XFF BEFORE Phoenix; this module
never parses forwarded headers itself.
Summary
Functions
The access mode behind a slug, for decisions that must be made BEFORE the page renders — the response headers, chiefly.
How many images one report may carry, and how large each may be.
Tasks already flagged public that a switch to public mode would
expose — the number an admin has to see BEFORE flipping.
Whether a portal capability flag is on for the project.
Why anyone currently behaves as members for commenting — shown in the
admin UI so the setting doesn't lie about what it does.
Lets a project member sign a portal comment with the project's name instead of their own — or returns nil, which is what most people get.
Deletes the images attached to an issue's submission.
The comments namespace a PUBLIC board discussion lives in.
Ensures the project has a portal row (fresh slug on first enable).
The extension's on_enable callback — idempotent, keeps the existing
slug on re-enable (the link keeps working across toggles; rotation is
the explicit revoke).
The portal row for a project, or nil.
The portal and project behind a slug, but ONLY when it is a public board.
May this viewer COMMENT on a board issue?
May this viewer SUBMIT an issue through the portal?
Typeahead candidates for the composer on a PUBLIC portal page.
ONE public issue, or :error.
The whole public read surface as ONE plain-map DTO — the whitelist
(panel #5/#6). Returns {:ok, view} or the uniform :error.
Resolves a slug to {:ok, portal, project} iff the portal extension is
enabled for that project. EVERY failure mode is the same :error
(panel #11 — errors must not enumerate portals or capabilities).
What a reviewer needs to see about a pending submission, in one read.
The images a stranger attached to this issue, for the STAFF review of it.
Regenerates the slug — the revoke: the old link 404s and every LIVE
portal session on the old slug downgrades (the :portal_rotated
broadcast, panel #7).
Switches a portal's access mode.
Puts one task on the public board, or takes it off.
Sets the two participation policies. Values outside the vocabulary are refused by the changeset rather than silently coerced.
Flips an assignment's public flag — the ONLY write path for it
(server-set, never cast from params). Caller gates :edit_tasks.
Turns uploaded temp files into storage uuids, or refuses.
Anonymous issue submission — the full guard chain, in order: resolve →
capability → honeypot → min-fill-time → rate limits (peer bucket +
project ceiling; limiter failure DENIES) → size caps → create. The
created issue lands in the project's FIRST status with
source: "portal", public: false (nothing self-publishes), and the
activity entry fans out to every project member (the Phase H
pipeline).
Functions
The access mode behind a slug, for decisions that must be made BEFORE the page renders — the response headers, chiefly.
Returns "link" for anything it cannot confirm: an unknown slug, a
database error, a portal whose extension is off. The restrictive answer
is the safe one, so it is also the default.
@spec attachment_limits() :: %{count: pos_integer(), bytes: pos_integer()}
How many images one report may carry, and how large each may be.
Small on purpose. A bug report needs a screenshot or three, and every megabyte past that is storage someone else is paying for on a page that accepts writes from strangers.
@spec board_exposure_count(binary()) :: non_neg_integer()
Tasks already flagged public that a switch to public mode would
expose — the number an admin has to see BEFORE flipping.
They were flagged for an audience of "whoever holds the link". Publishing them to the open web is a different decision, and this is what makes it one someone takes rather than one that happens to them.
Whether a portal capability flag is on for the project.
@spec comment_access_note() :: String.t()
Why anyone currently behaves as members for commenting — shown in the
admin UI so the setting doesn't lie about what it does.
@spec comment_attribution(PhoenixKitProjects.Schemas.Portal.t(), map(), term()) :: map() | nil
Lets a project member sign a portal comment with the project's name instead of their own — or returns nil, which is what most people get.
Someone answering on their employer's board may be speaking personally or on the project's behalf, and those are different acts. Offering the choice also gives an affiliated person who does not want their own name on an indexed page a legitimate way to take part.
Deliberately not offered on a link board. The slug is the grant
there, so a project-signed comment tells everyone holding the link who is
affiliated with the project — the one thing a capability URL is not
supposed to reveal. public boards are already open, and members
boards face people who signed in.
The verify function is re-run by the comments component at submit,
because the composer may have been rendered long before the send and
membership can be revoked in between.
Defaults to CHECKED on a public board: someone replying on a company's open issue tracker is usually doing it as the company, and the safe option should also be the lazy one.
@spec delete_attachments_for(binary()) :: :ok
Deletes the images attached to an issue's submission.
Call before deleting the issue itself. The submission row cascades with the assignment, but the storage rows do not — nothing points at them once the submission is gone, so without this every rejected report leaves its screenshots behind forever, owned by the project owner and invisible to everyone.
Never blocks the delete: losing the issue matters more than reclaiming a file, and an orphaned file is recoverable where a blocked delete is a stuck queue.
@spec discussion_resource_type() :: String.t()
The comments namespace a PUBLIC board discussion lives in.
Deliberately NOT the "assignment" type the admin hub uses for staff
discussion on the same task. They are two conversations about one piece
of work: one internal, one with the outside world. If these two strings
ever converge, every internal note staff have written lands on a public
page — so the value is named here rather than typed into a template.
Ensures the project has a portal row (fresh slug on first enable).
The extension's on_enable callback — idempotent, keeps the existing
slug on re-enable (the link keeps working across toggles; rotation is
the explicit revoke).
@spec get_portal(binary()) :: PhoenixKitProjects.Schemas.Portal.t() | nil
The portal row for a project, or nil.
@spec get_public_board(String.t()) :: {:ok, PhoenixKitProjects.Schemas.Portal.t(), map()} | :none
The portal and project behind a slug, but ONLY when it is a public board.
For the response-header and social-preview decisions, which are made before the page renders and must be conservative: anything this cannot confirm is a portal to be treated as secret.
@spec may_comment?(PhoenixKitProjects.Schemas.Portal.t(), map(), term()) :: boolean()
May this viewer COMMENT on a board issue?
Separate from submitting on purpose: a submission is invisible until
staff act on it, while a comment is live the moment it lands. Defaults to
nobody, so discussion is something a project turns on rather than
something it discovers it had.
anyone is deliberately NOT honoured yet — see comment_access_note/0.
@spec may_submit?(PhoenixKitProjects.Schemas.Portal.t(), map(), term()) :: boolean()
May this viewer SUBMIT an issue through the portal?
Two gates, both of which must pass: the project's portal_submit
capability (does the portal offer submission at all) and the portal's
submit_access policy (who specifically). Fail-closed on anything
unexpected.
@spec mention_candidates( :user | :resource, String.t(), PhoenixKitProjects.Schemas.Portal.t(), term() ) :: [map()]
Typeahead candidates for the composer on a PUBLIC portal page.
The rule this encodes: a mention may only name something the page already shows. Nothing here discloses anything a reader could not already have read by scrolling, which is what makes it safe to offer the menu to an outsider at all.
#— issues listed on THIS board, via the very sameissues_query/1the board renders from. Sharing the query is the point: a typeahead with its own copy of the scoping rule is a typeahead that will eventually disagree with the page and offer a title that was never published.@— people who have already COMMENTED on this issue. Their names are on the page. The site's user directory is not, and the ordinaryMentions.search/3would hand it to anyone with a comment box. Deliberately per-ISSUE, not per-board: board-wide would reveal that somebody took part in a discussion the reader has not opened.
Refuses outright for a viewer who may not comment. The composer is already hidden from them, but a hidden control is not a control — this is reachable by anyone who can send a LiveView event.
ONE public issue, or :error.
Goes through the same doorway as the list: resolve the slug, check the list capability, and scope from the portal row. An issue that isn't published to this board is indistinguishable from one that doesn't exist — the uniform failure that stops the page being an oracle.
Returns the issue DTO plus its uuid, which the LIST deliberately omits:
here it is the thing being addressed, and the reader already has it in
their URL bar.
The whole public read surface as ONE plain-map DTO — the whitelist
(panel #5/#6). Returns {:ok, view} or the uniform :error.
The DTO contains exactly: project_name, project_status,
started_at, completed_at, capabilities, issue_counts, and
issues (each: title, status, status_label, inserted_at,
updated_at). Nothing else — no assignees, estimates, money, AI
figures, or internal notes, ever.
Resolves a slug to {:ok, portal, project} iff the portal extension is
enabled for that project. EVERY failure mode is the same :error
(panel #11 — errors must not enumerate portals or capabilities).
What a reviewer needs to see about a pending submission, in one read.
Images and — when the person was signed in — who sent it. The dialog used to state flatly that every sender was anonymous, which was false for everyone who submitted while logged in, including colleagues.
The images a stranger attached to this issue, for the STAFF review of it.
Deliberately not board_images/2: that one is gated on the board already
being public, which is exactly backwards for triage. The person deciding
whether to publish has to see the files before they are published, and
until this existed nobody ever saw them at all — the text went through a
human and the images went straight from an anonymous stranger onto an
indexable page. "A person approves it" was the entire argument for
accepting anonymous uploads, so it has to be true of the part that can
actually carry something harmful.
Caller must have already checked the viewer may read the assignment.
@spec rotate_slug( binary(), keyword() ) :: {:ok, PhoenixKitProjects.Schemas.Portal.t()} | {:error, term()}
Regenerates the slug — the revoke: the old link 404s and every LIVE
portal session on the old slug downgrades (the :portal_rotated
broadcast, panel #7).
@spec set_access_mode(binary(), String.t(), keyword()) :: {:ok, PhoenixKitProjects.Schemas.Portal.t()} | {:error, term()}
Switches a portal's access mode.
Changing mode MINTS A NEW SLUG in both directions, and that is the point:
- going to
public, the old slug is a 22-char CSPRNG secret that has been passed around as one. Promoting it to a permanent public URL would publish the secret and give the board an unreadable address. - leaving
public, the old slug is a name that has been indexed, linked and cached. Keeping it as a capability would mean the "secret" is written down in a search engine.
:publish_existing decides what happens to tasks already flagged
public when moving TO a public board: true puts them on it, false
(the default) leaves the board empty until staff publish each one. There
is no third option, because "do nothing and hope" is how the surprising
version of this feature would ship.
Puts one task on the public board, or takes it off.
Independent of public — a task can be visible to link-holders without
being on the open-web board, and taking it off the board leaves the
link-holder view untouched.
@spec set_participation(binary(), map(), keyword()) :: {:ok, PhoenixKitProjects.Schemas.Portal.t()} | {:error, term()}
Sets the two participation policies. Values outside the vocabulary are refused by the changeset rather than silently coerced.
@spec set_public(PhoenixKitProjects.Schemas.Assignment.t(), boolean(), keyword()) :: {:ok, PhoenixKitProjects.Schemas.Assignment.t()} | {:error, Ecto.Changeset.t()}
Flips an assignment's public flag — the ONLY write path for it
(server-set, never cast from params). Caller gates :edit_tasks.
@spec store_attachments([%{path: String.t(), name: String.t()}], binary() | nil) :: {:ok, [String.t()]} | {:error, :invalid}
Turns uploaded temp files into storage uuids, or refuses.
Every file is RE-ENCODED before it is stored — what ends up on disk is our encoder's output, not the uploader's bytes — so a polyglot, an EXIF payload or a trailing archive does not survive the trip. Anything that cannot be decoded as an image is refused whatever it was called.
All-or-nothing: if one file can't be processed the whole report is refused, because a report that silently loses the screenshot it refers to is worse than one that says so.
@spec submit(term(), map(), map()) :: {:ok, :submitted} | {:error, :rate_limited} | {:error, :invalid} | :error
Anonymous issue submission — the full guard chain, in order: resolve →
capability → honeypot → min-fill-time → rate limits (peer bucket +
project ceiling; limiter failure DENIES) → size caps → create. The
created issue lands in the project's FIRST status with
source: "portal", public: false (nothing self-publishes), and the
activity entry fans out to every project member (the Phase H
pipeline).
meta carries :peer_ip (a :inet.ip_address() tuple or nil),
:honeypot (the hidden field value) and :mounted_ms (monotonic ms at
form mount). Returns {:ok, :submitted}, {:error, :rate_limited},
{:error, :invalid} (caps/blank), or the uniform :error.