Initial commit on Forgejo
All checks were successful
CI and deploy / Test (push) Successful in 4m52s
CI and deploy / Deploy production (push) Successful in 23s

Fresh repository history for elektrine/tarakan hosted at
https://git.elektrine.com/elektrine/tarakan.
This commit is contained in:
Maxfield Luke 2026-07-29 04:43:40 -04:00
commit af6077b9c3
455 changed files with 78366 additions and 0 deletions

View file

@ -0,0 +1,87 @@
defmodule TarakanWeb.AccountLive.Confirmation do
use TarakanWeb, :live_view
alias Tarakan.Accounts
@impl true
def render(assigns) do
~H"""
<Layouts.app flash={@flash} current_scope={@current_scope} current_path={assigns[:current_path]}>
<Layouts.page width={:form}>
<div class="text-center">
<.header>Welcome {@account.email}</.header>
</div>
<.form
for={@form}
id="login_form"
phx-mounted={JS.focus_first()}
phx-submit="submit"
action={~p"/accounts/log-in"}
phx-trigger-action={@trigger_submit}
class="space-y-3"
>
<input type="hidden" name={@form[:token].name} value={@form[:token].value} />
<input :if={@return_to} type="hidden" name="account[return_to]" value={@return_to} />
<.input
field={@form[:remember_me]}
type="checkbox"
label="Stay logged in on this device"
checked
/>
<button
phx-disable-with="Logging in..."
class="clip-notch inline-flex h-11 w-full items-center justify-center bg-btn px-4 font-display text-sm uppercase tracking-[0.12em] text-btn-fg transition hover:opacity-90"
>
Log in
</button>
</.form>
</Layouts.page>
</Layouts.app>
"""
end
@impl true
def mount(%{"token" => token} = params, _session, socket) do
return_to =
case params do
%{"return_to" => path} when is_binary(path) ->
TarakanWeb.SafeRedirect.local_path(path, nil)
_ ->
nil
end
if account = Accounts.get_account_by_magic_link_token(token) do
form = to_form(%{"token" => token}, as: "account")
{:ok,
assign(socket,
account: account,
form: form,
trigger_submit: false,
return_to: return_to
), temporary_assigns: [form: nil]}
else
next =
if return_to,
do: ~p"/accounts/log-in?#{[return_to: return_to]}",
else: ~p"/accounts/log-in"
{:ok,
socket
|> put_flash(:error, "Magic link is invalid or it has expired.")
|> push_navigate(to: next)}
end
end
@impl true
def handle_event("submit", %{"account" => params}, socket) do
params =
if socket.assigns.return_to,
do: Map.put(params, "return_to", socket.assigns.return_to),
else: params
{:noreply, assign(socket, form: to_form(params, as: "account"), trigger_submit: true)}
end
end

View file

@ -0,0 +1,211 @@
defmodule TarakanWeb.AccountLive.Login do
use TarakanWeb, :live_view
alias Tarakan.Accounts
@impl true
def render(assigns) do
~H"""
<Layouts.app flash={@flash} current_scope={@current_scope} current_path={assigns[:current_path]}>
<Layouts.page width={:form} class={["space-y-5"]}>
<div class="text-center">
<.header>
<p>Log in</p>
<:subtitle>
<%= if @current_scope && @current_scope.account do %>
Confirm it's you, or connect another host.
<% else %>
One click. No password required.
<% end %>
</:subtitle>
</.header>
</div>
<div
:if={local_mail_adapter?()}
class="flex gap-3 border-2 border-rule bg-panel p-4 text-sm text-ink-muted"
>
<.icon name="hero-information-circle" class="size-6 shrink-0" />
<div>
<p>You are running the local mail adapter.</p>
<p>
To see sent emails, visit <.link href="/dev/mailbox" class="underline">the mailbox page</.link>.
</p>
</div>
</div>
<div class="space-y-3">
<.link
id="github-login-button"
href={~p"/auth/github?#{[return_to: @provider_return_to]}"}
class="clip-notch flex h-12 w-full items-center justify-center bg-btn px-4 font-display text-sm uppercase tracking-[0.12em] text-btn-fg transition hover:opacity-90"
>
Continue with GitHub
</.link>
<.link
id="gitlab-login-button"
href={~p"/auth/gitlab?#{[return_to: @provider_return_to]}"}
class="flex h-12 w-full items-center justify-center border border-strong px-4 font-display text-sm uppercase tracking-[0.12em] text-ink transition hover:bg-panel"
>
Continue with GitLab
</.link>
</div>
<div class="flex items-center gap-3 py-1 font-mono text-[10px] uppercase tracking-[0.12em] text-ink-faint">
<span class="h-px flex-1 bg-rule"></span>email<span class="h-px flex-1 bg-rule"></span>
</div>
<.form
:let={f}
for={@form}
id="login_form_magic"
action={~p"/accounts/log-in"}
phx-submit="submit_magic"
class="space-y-3 border-2 border-strong bg-panel p-6"
>
<input :if={@return_to} type="hidden" name="account[return_to]" value={@return_to} />
<.input
readonly={!!(@current_scope && @current_scope.account)}
field={f[:email]}
type="email"
label="Email"
autocomplete="username"
spellcheck="false"
required
phx-mounted={JS.focus()}
/>
<button class="clip-notch inline-flex h-11 w-full items-center justify-center bg-btn px-4 font-display text-sm uppercase tracking-[0.12em] text-btn-fg transition hover:opacity-90">
Email me a login link
</button>
</.form>
<details class="group border-2 border-rule">
<summary class="flex cursor-pointer items-center justify-between px-4 py-3 font-mono text-[11px] uppercase tracking-[0.12em] text-ink-faint transition hover:text-ink">
Use a password instead
<.icon
name="hero-chevron-down"
class="size-4 transition-transform group-open:rotate-180"
/>
</summary>
<.form
:let={f}
for={@form}
id="login_form_password"
action={~p"/accounts/log-in"}
phx-submit="submit_password"
phx-trigger-action={@trigger_submit}
class="space-y-3 border-t-2 border-rule bg-panel p-6"
>
<input :if={@return_to} type="hidden" name="account[return_to]" value={@return_to} />
<.input
readonly={!!(@current_scope && @current_scope.account)}
field={f[:identifier]}
type="text"
label="Handle or email"
autocomplete="username"
spellcheck="false"
required
/>
<.input
field={@form[:password]}
type="password"
label="Password"
autocomplete="current-password"
spellcheck="false"
/>
<button class="clip-notch inline-flex h-11 w-full items-center justify-center bg-btn px-4 font-display text-sm uppercase tracking-[0.12em] text-btn-fg transition hover:opacity-90">
Log in
</button>
</.form>
</details>
</Layouts.page>
</Layouts.app>
"""
end
@impl true
def mount(params, session, socket) do
account = get_in(socket.assigns, [:current_scope, Access.key(:account)])
email =
Phoenix.Flash.get(socket.assigns.flash, :email) ||
(account && account.email)
identifier =
Phoenix.Flash.get(socket.assigns.flash, :identifier) ||
(account && account.handle)
form = to_form(%{"email" => email, "identifier" => identifier}, as: "account")
return_to =
case params["return_to"] || session["account_return_to"] do
path when is_binary(path) -> TarakanWeb.SafeRedirect.local_path(path, nil)
_other -> nil
end
provider_return_to = return_to || if(account, do: ~p"/accounts/settings", else: ~p"/")
{:ok,
assign(socket,
form: form,
trigger_submit: false,
return_to: return_to,
provider_return_to: provider_return_to
)}
end
@impl true
def handle_event("submit_password", _params, socket) do
{:noreply, assign(socket, :trigger_submit, true)}
end
def handle_event("submit_magic", %{"account" => %{"email" => email} = params}, socket) do
email_key = :crypto.hash(:sha256, email |> String.trim() |> String.downcase())
allowed? =
TarakanWeb.BrowserRateLimit.allowed?(:magic_ip, socket.assigns.client_ip) and
TarakanWeb.BrowserRateLimit.allowed?(:magic_email, email_key)
return_to =
case params["return_to"] || socket.assigns.return_to do
path when is_binary(path) and path != "" ->
TarakanWeb.SafeRedirect.local_path(path, nil)
_ ->
nil
end
if allowed? do
account = Accounts.get_account_by_email(email)
# Delivery runs in the background so response time does not reveal
# whether the email is registered.
if account && Accounts.access_allowed?(account) do
Accounts.deliver_login_instructions_async(account, fn token ->
if return_to do
url(~p"/accounts/log-in/#{token}?#{[return_to: return_to]}")
else
url(~p"/accounts/log-in/#{token}")
end
end)
end
end
info =
"If your email is in our system, you will receive instructions for logging in shortly."
next =
if return_to,
do: ~p"/accounts/log-in?#{[return_to: return_to]}",
else: ~p"/accounts/log-in"
{:noreply,
socket
|> put_flash(:info, info)
|> push_navigate(to: next)}
end
defp local_mail_adapter? do
Application.get_env(:tarakan, Tarakan.Mailer)[:adapter] == Swoosh.Adapters.Local
end
end

View file

@ -0,0 +1,111 @@
defmodule TarakanWeb.AccountLive.Profile do
use TarakanWeb, :live_view
alias Tarakan.Credits
alias Tarakan.Profiles
alias Tarakan.Reputation
@impl true
def mount(%{"handle" => handle}, _session, socket) do
case Profiles.get_profile(handle) do
nil ->
raise Ecto.NoResultsError, queryable: Tarakan.Accounts.Account
account ->
{:ok,
socket
|> assign(:page_title, "@#{account.handle}")
|> assign(:meta_description, meta_description(account))
|> assign(:canonical_path, ~p"/#{account.handle}")
|> assign(:account, account)
|> assign(:reputation, Reputation.score(account))
|> assign(:stake_summary, Reputation.stake_summary(account))
|> assign(:credit_balance, Credits.balance(account))
|> assign(:credit_entries, Credits.ledger(account, limit: 20))
|> assign(:stats, Profiles.contribution_stats(account))
|> assign(:badges, Profiles.badges(account))
|> assign(:repositories, Profiles.list_repositories(account))
|> assign(:reviews, Profiles.list_reviews(account))
|> assign(:findings, Profiles.list_findings(account))
|> assign(:checks, Profiles.list_checks(account))}
end
end
defp meta_description(account) do
"@#{account.handle} on Tarakan - #{tier_label(account.trust_tier)} contributor to the public security record for open source."
end
@doc false
def tier_label("reviewer"), do: "Reviewer"
def tier_label("contributor"), do: "Contributor"
def tier_label(_new), do: "New"
@doc false
def role_label("admin"), do: "Admin"
def role_label("moderator"), do: "Moderator"
def role_label(_member), do: nil
@doc false
def badge_label(:first_blood), do: "First blood"
def badge_label(:century), do: "Century"
def badge_label(:eradicator), do: "Eradicator"
def badge_label(:exorcist), do: "Exorcist"
def badge_label(:sharpshooter), do: "Sharpshooter"
def badge_label(badge), do: badge |> to_string() |> String.replace("_", " ")
@doc false
def joined_on(%DateTime{} = datetime), do: Calendar.strftime(datetime, "%B %Y")
def joined_on(%NaiveDateTime{} = datetime), do: Calendar.strftime(datetime, "%B %Y")
@doc false
def activity_time(%DateTime{} = datetime), do: Calendar.strftime(datetime, "%Y-%m-%d")
@doc false
def credit_kind_label(kind) when is_binary(kind), do: String.replace(kind, "_", " ")
def credit_kind_label(_kind), do: "entry"
@doc false
def credit_amount(amount) when is_integer(amount) and amount > 0, do: "+#{amount}"
def credit_amount(amount) when is_integer(amount), do: Integer.to_string(amount)
@doc false
def short_sha(sha) when is_binary(sha), do: String.slice(sha, 0, 7)
def short_sha(_sha), do: nil
@doc false
def review_kind_label("code_review"), do: "Code review"
def review_kind_label("threat_model"), do: "Threat model"
def review_kind_label("privacy_review"), do: "Privacy review"
def review_kind_label("business_logic"), do: "Business logic"
def review_kind_label("diff_review"), do: "Diff review"
def review_kind_label("refute_finding"), do: "Refute a finding"
def review_kind_label("reproduce_finding"), do: "Reproduce a finding"
def review_kind_label("calibrate_severity"), do: "Re-score severity"
def review_kind_label("synthesize_rule"), do: "Write a detector"
def review_kind_label(other) when is_binary(other), do: String.replace(other, "_", " ")
def review_kind_label(_other), do: "Report"
@doc false
def review_status_label("accepted"), do: "Accepted"
def review_status_label("quarantined"), do: "Quarantined"
def review_status_label("rejected"), do: "Rejected"
def review_status_label("contested"), do: "Contested"
def review_status_label(other) when is_binary(other), do: String.capitalize(other)
def review_status_label(_other), do: nil
@doc false
def provider_label("github"), do: "GitHub"
def provider_label("gitlab"), do: "GitLab"
def provider_label(other), do: String.capitalize(other)
@doc false
def check_path(%{public_id: public_id}) when is_binary(public_id) do
~p"/findings/#{public_id}"
end
def check_path(%{repository: repository}) when not is_nil(repository) do
TarakanWeb.RepositoryPaths.repository_security_path(repository)
end
def check_path(_entry), do: ~p"/"
end

View file

@ -0,0 +1,354 @@
<Layouts.app flash={@flash} current_scope={@current_scope} current_path={assigns[:current_path]}>
<Layouts.page>
<.breadcrumbs id="profile-breadcrumb">
<:crumb navigate={~p"/"}>registry</:crumb>
<:crumb>@{@account.handle}</:crumb>
</.breadcrumbs>
<header id="profile-header" class="border-b-2 border-strong pb-6">
<div class="flex flex-wrap items-center gap-3">
<.notch_badge
:if={role_label(@account.platform_role)}
id="profile-role"
class="text-signal"
>
{role_label(@account.platform_role)}
</.notch_badge>
<.notch_badge id="profile-tier" class="text-ink-muted">
{tier_label(@account.trust_tier)}
</.notch_badge>
<span :if={@badges != []} id="profile-badges" class="contents">
<.notch_badge
:for={badge <- @badges}
id={"profile-badge-#{badge}"}
class="text-quote"
>
{badge_label(badge)}
</.notch_badge>
</span>
</div>
<h1
id="profile-handle"
class="mt-3 break-words font-display text-3xl font-medium uppercase leading-tight tracking-[0.02em] text-ink sm:text-4xl"
>
<span class="text-ink-faint">@</span>{@account.handle}
</h1>
<p :if={@account.display_name} class="mt-1 text-sm text-ink-muted">
{@account.display_name}
</p>
<div class="mt-4 flex flex-wrap items-center gap-x-5 gap-y-2 font-mono text-xs text-ink-faint">
<span
class="inline-flex items-center gap-1.5"
title={"#{@reputation.verification} from verified work · #{@reputation.votes} from votes"}
>
<.icon name="hero-bolt" class="size-3.5" />
<span id="profile-reputation" class="tabular-nums text-ink-muted">
{@reputation.total}
</span>
reputation
<span class="text-ink-faint">
({@reputation.verification} verified · {@reputation.votes} votes<span :if={
@reputation.at_risk > 0
}> · {@reputation.at_risk} at stake in {@stake_summary.at_risk} {if(
@stake_summary.at_risk == 1,
do: "report",
else: "reports"
)}</span><span
:if={@reputation.slashed > 0}
class="text-signal"
> · {@reputation.slashed} slashed across {@stake_summary.slashed} {if(
@stake_summary.slashed == 1,
do: "report",
else: "reports"
)}</span>)
</span>
</span>
<span class="inline-flex items-center gap-1.5">
<.icon name="hero-clock" class="size-3.5" /> joined {joined_on(@account.inserted_at)}
</span>
<a
:for={identity <- @account.identities}
:if={identity.profile_url}
id={"profile-identity-#{identity.provider}"}
href={identity.profile_url}
rel="noreferrer"
target="_blank"
class="inline-flex items-center gap-1.5 transition hover:text-signal"
>
<.icon name="hero-arrow-up-right" class="size-3.5" />
{provider_label(identity.provider)}<span :if={identity.provider_login}>/{identity.provider_login}</span>
</a>
</div>
</header>
<section
id="profile-stats"
class="mt-8 grid grid-cols-2 border-2 border-strong lg:grid-cols-5"
>
<div class="border-b border-r border-rule px-6 py-7 lg:border-b-0">
<p class="text-xs font-medium text-ink-faint">Reports</p>
<p
id="profile-review-count"
class="mt-3 font-display text-4xl font-medium tabular-nums text-ink"
>
{@stats.reviews}
</p>
</div>
<div class="border-b border-rule px-6 py-7 lg:border-b-0 lg:border-r">
<p class="text-xs font-medium text-ink-faint">Findings</p>
<p
id="profile-finding-count"
class="mt-3 font-display text-4xl font-medium tabular-nums text-signal"
>
{@stats.findings}
</p>
</div>
<div class="border-b border-r border-rule px-6 py-7 lg:border-b-0">
<p class="text-xs font-medium text-ink-faint">Checks</p>
<p
id="profile-verdict-count"
class="mt-3 font-display text-4xl font-medium tabular-nums text-ink"
>
{@stats.verdicts}
</p>
</div>
<div class="border-b border-rule px-6 py-7 lg:border-b-0 lg:border-r">
<p class="text-xs font-medium text-ink-faint">
Repositories
</p>
<p
id="profile-repo-count"
class="mt-3 font-display text-4xl font-medium tabular-nums text-ink"
>
{@stats.repositories}
</p>
</div>
<div class="px-6 py-7">
<p class="text-xs font-medium text-ink-faint">Credits</p>
<p
id="profile-credit-balance"
class="mt-3 font-display text-4xl font-medium tabular-nums text-ink"
>
{@credit_balance}
</p>
</div>
</section>
<div class="mt-10 grid gap-10 lg:grid-cols-2">
<section id="profile-repositories">
<h2 class="font-display text-lg uppercase tracking-[0.02em] text-ink">
Repositories added
</h2>
<p class="mt-1 font-mono text-[11px] text-ink-faint">
Publicly listed repositories this contributor registered.
</p>
<p
:if={@repositories == []}
id="profile-no-repositories"
class="mt-4 font-mono text-xs text-ink-faint"
>
No listed repositories yet.
</p>
<ul
:if={@repositories != []}
class="mt-4 divide-y divide-rule border-t border-rule"
>
<li :for={repository <- @repositories} id={"profile-repo-#{repository.id}"}>
<.link
navigate={TarakanWeb.RepositoryPaths.repository_path(repository)}
class="group flex items-baseline gap-x-3 px-3 py-3 font-mono text-xs transition-colors hover:bg-panel"
>
<span class="shrink-0 tabular-nums text-ink-faint">
{activity_time(repository.inserted_at)}
</span>
<span class="min-w-0 truncate text-ink-muted transition group-hover:text-ink">
<span class="text-ink-faint">{repository.host}/</span>{repository.owner}/{repository.name}
</span>
<span class="ml-auto shrink-0 text-[10px] text-ink-faint">
<span :if={repository.primary_language}>{repository.primary_language}</span>
</span>
</.link>
</li>
</ul>
</section>
<section id="profile-reviews">
<h2 class="font-display text-lg uppercase tracking-[0.02em] text-ink">
Reports submitted
</h2>
<p class="mt-1 font-mono text-[11px] text-ink-faint">
Security reviews pinned to exact commits.
</p>
<p
:if={@reviews == []}
id="profile-no-reviews"
class="mt-4 font-mono text-xs text-ink-faint"
>
No public reports yet.
</p>
<ul :if={@reviews != []} class="mt-4 divide-y divide-rule border-t border-rule">
<li :for={entry <- @reviews} id={"profile-review-#{entry.id}"}>
<.link
navigate={TarakanWeb.RepositoryPaths.repository_security_path(entry.repository)}
class="group flex items-baseline gap-x-3 px-3 py-3 font-mono text-xs transition-colors hover:bg-panel"
>
<span class="shrink-0 tabular-nums text-ink-faint">{activity_time(entry.at)}</span>
<span class="min-w-0 truncate text-ink-muted transition group-hover:text-ink">
<span class="text-ink-faint">reviewed</span>
{entry.repository.owner}/{entry.repository.name}
<span :if={entry.commit_sha} class="text-ink-faint">
· {short_sha(entry.commit_sha)}
</span>
</span>
<span class="ml-auto shrink-0 text-[10px] text-ink-faint">
<span :if={entry.verified} class="text-ink-muted">verified · </span>
<span :if={entry.findings_count > 0} class="text-signal">
{entry.findings_count} findings
</span>
<span :if={entry.findings_count == 0}>no findings</span>
</span>
</.link>
</li>
</ul>
</section>
<section id="profile-findings">
<h2 class="font-display text-lg uppercase tracking-[0.02em] text-ink">
Findings filed
</h2>
<p class="mt-1 font-mono text-[11px] text-ink-faint">
Individual findings from public reports.
</p>
<p
:if={@findings == []}
id="profile-no-findings"
class="mt-4 font-mono text-xs text-ink-faint"
>
No public findings yet.
</p>
<ul :if={@findings != []} class="mt-4 divide-y divide-rule border-t border-rule">
<li :for={entry <- @findings} id={"profile-finding-#{entry.public_id}"}>
<.link
navigate={~p"/findings/#{entry.public_id}"}
class="group flex items-baseline gap-x-3 px-3 py-3 font-mono text-xs transition-colors hover:bg-panel"
>
<span class="shrink-0 tabular-nums text-ink-faint">{activity_time(entry.at)}</span>
<span class="min-w-0 truncate text-ink-muted transition group-hover:text-ink">
<span class={[
"mr-1.5 inline-block border px-1 text-[10px] uppercase tracking-[0.12em]",
entry.severity in ~w(critical high) && "border-signal text-signal",
entry.severity not in ~w(critical high) && "border-rule text-ink-faint"
]}>
{entry.severity}
</span>
{entry.title}
</span>
<span class="ml-auto shrink-0 text-[10px] text-ink-faint">
{entry.repository.owner}/{entry.repository.name}
</span>
</.link>
</li>
</ul>
</section>
<section id="profile-checks">
<h2 class="font-display text-lg uppercase tracking-[0.02em] text-ink">
Checks cast
</h2>
<p class="mt-1 font-mono text-[11px] text-ink-faint">
Independent confirmations, disputes, and fix notes.
</p>
<p
:if={@checks == []}
id="profile-no-checks"
class="mt-4 font-mono text-xs text-ink-faint"
>
No public checks yet.
</p>
<ul :if={@checks != []} class="mt-4 divide-y divide-rule border-t border-rule">
<li :for={entry <- @checks} id={"profile-check-#{entry.id}"}>
<.link
:if={entry.public_id || entry.repository}
navigate={check_path(entry)}
class="group flex items-baseline gap-x-3 px-3 py-3 font-mono text-xs transition-colors hover:bg-panel"
>
<span class="shrink-0 tabular-nums text-ink-faint">{activity_time(entry.at)}</span>
<span class="min-w-0 truncate text-ink-muted transition group-hover:text-ink">
<span class={[
entry.verdict == "confirmed" && "text-quote",
entry.verdict == "disputed" && "text-signal",
entry.verdict == "fixed" && "text-ink"
]}>
{entry.verdict}
</span>
<span :if={entry.title} class="text-ink-faint"> · </span>
<span :if={entry.title}>{entry.title}</span>
<span
:if={is_nil(entry.title) and entry.repository}
class="text-ink-faint"
>
on {entry.repository.owner}/{entry.repository.name}
</span>
</span>
<span class="ml-auto shrink-0 text-[10px] text-ink-faint">
{provenance_label(entry.provenance)}
</span>
</.link>
</li>
</ul>
</section>
</div>
<section id="profile-credits" class="mt-10">
<h2 class="font-display text-lg uppercase tracking-[0.02em] text-ink">
Credits
</h2>
<p class="mt-1 font-mono text-[11px] text-ink-faint">
Earned on verification events only - never purchasable, never transferable.
</p>
<p
:if={@credit_entries == []}
id="profile-no-credits"
class="mt-4 font-mono text-xs text-ink-faint"
>
No credit activity yet.
</p>
<ul :if={@credit_entries != []} class="mt-4 divide-y divide-rule border-t border-rule">
<li
:for={entry <- @credit_entries}
id={"profile-credit-#{entry.id}"}
class="flex items-baseline gap-x-3 px-3 py-3 font-mono text-xs"
>
<span class="shrink-0 tabular-nums text-ink-faint">
{activity_time(entry.inserted_at)}
</span>
<span class="min-w-0 truncate text-ink-muted">
{credit_kind_label(entry.kind)}
</span>
<span class={[
"ml-auto shrink-0 tabular-nums",
entry.amount > 0 && "text-quote",
entry.amount < 0 && "text-signal"
]}>
{credit_amount(entry.amount)}
</span>
<span class="shrink-0 tabular-nums text-ink-faint">
balance {entry.balance_after}
</span>
</li>
</ul>
</section>
</Layouts.page>
</Layouts.app>

View file

@ -0,0 +1,165 @@
defmodule TarakanWeb.AccountLive.Registration do
use TarakanWeb, :live_view
alias Tarakan.Accounts
alias Tarakan.Accounts.Account
@impl true
def render(assigns) do
~H"""
<Layouts.app flash={@flash} current_scope={@current_scope} current_path={assigns[:current_path]}>
<Layouts.page width={:form}>
<div class="text-center">
<.header>
Join Tarakan
<:subtitle>
Fastest path: GitHub. Already here?
<.link navigate={~p"/accounts/log-in"} class="font-semibold text-signal hover:underline">
Log in
</.link>
</:subtitle>
</.header>
</div>
<div class="mt-6 space-y-3">
<.link
id="github-register-button"
href={~p"/auth/github?return_to=/"}
class="clip-notch flex h-12 w-full items-center justify-center bg-btn px-4 font-display text-sm uppercase tracking-[0.12em] text-btn-fg transition hover:opacity-90"
>
Continue with GitHub
</.link>
<.link
id="gitlab-register-button"
href={~p"/auth/gitlab?return_to=/"}
class="flex h-12 w-full items-center justify-center border border-strong px-4 font-display text-sm uppercase tracking-[0.12em] text-ink transition hover:bg-panel"
>
Continue with GitLab
</.link>
</div>
<details class="mt-6 group border-2 border-rule">
<summary class="cursor-pointer px-4 py-3 font-mono text-[11px] uppercase tracking-[0.12em] text-ink-faint transition hover:text-ink">
Prefer email instead
</summary>
<.form
for={@form}
id="registration_form"
phx-submit="save"
phx-change="validate"
class="space-y-4 border-t-2 border-rule bg-panel p-6"
>
<.input
field={@form[:handle]}
type="text"
label="Handle"
autocomplete="username"
spellcheck="false"
placeholder="signalghost"
required
/>
<.input
field={@form[:email]}
type="email"
label="Email"
autocomplete="email"
spellcheck="false"
required
/>
<button
type="submit"
phx-disable-with="Creating account..."
class="clip-notch inline-flex h-11 w-full items-center justify-center bg-btn px-4 font-display text-sm uppercase tracking-[0.12em] text-btn-fg transition hover:opacity-90 focus:outline-none focus:ring-2 focus:ring-phosphor"
>
Email me a link
</button>
</.form>
</details>
<.form
for={@login_form}
id="registration_login_form"
action={~p"/accounts/log-in"}
phx-trigger-action={@trigger_login}
class={["hidden"]}
>
<input
type="hidden"
name={@login_form[:session_token].name}
value={@login_form[:session_token].value}
/>
<input type="hidden" name={@login_form[:remember_me].name} value="true" />
</.form>
</Layouts.page>
</Layouts.app>
"""
end
@impl true
def mount(_params, _session, %{assigns: %{current_scope: %{account: account}}} = socket)
when not is_nil(account) do
{:ok, redirect(socket, to: TarakanWeb.AccountAuth.signed_in_path(socket))}
end
def mount(_params, _session, socket) do
changeset = Accounts.change_account_registration(%Account{}, %{}, validate_unique: false)
{:ok,
socket
|> assign_form(changeset)
|> assign(:login_form, to_form(%{"session_token" => ""}, as: "account"))
|> assign(:trigger_login, false), temporary_assigns: [form: nil]}
end
@impl true
def handle_event("save", %{"account" => account_params}, socket) do
if TarakanWeb.BrowserRateLimit.allowed?(:registration_ip, socket.assigns.client_ip) do
register_account(socket, account_params)
else
registration_limited(socket)
end
end
def handle_event("validate", %{"account" => account_params}, socket) do
changeset =
Accounts.change_account_registration(%Account{}, account_params, validate_unique: false)
{:noreply, assign_form(socket, Map.put(changeset, :action, :validate))}
end
# New accounts establish their first session immediately (via a one-time
# session token that does not confirm the email). Existing credentials keep
# the generic email outcome so the form does not disclose account details.
defp register_account(socket, account_params) do
case Accounts.request_registration(account_params, &url(~p"/accounts/log-in/#{&1}")) do
{:ok, {:created, token}} ->
{:noreply,
socket
|> assign(:login_form, to_form(%{"session_token" => token}, as: "account"))
|> assign(:trigger_login, true)}
{:ok, :accepted} ->
{:noreply,
socket
|> put_flash(
:info,
"If an account matches those details, a sign-in link will arrive shortly."
)
|> push_navigate(to: ~p"/accounts/log-in")}
{:error, %Ecto.Changeset{} = changeset} ->
{:noreply, assign_form(socket, Map.put(changeset, :action, :insert))}
end
end
defp registration_limited(socket) do
{:noreply,
put_flash(socket, :error, "Too many account attempts from this network. Try again later.")}
end
defp assign_form(socket, %Ecto.Changeset{} = changeset) do
form = to_form(changeset, as: "account")
assign(socket, form: form)
end
end

View file

@ -0,0 +1,918 @@
defmodule TarakanWeb.AccountLive.Settings do
use TarakanWeb, :live_view
on_mount {TarakanWeb.AccountAuth, :require_sudo_mode}
alias Tarakan.Accounts
alias Tarakan.Accounts.{ApiCredential, ApiCredentials, SshKeys}
alias Tarakan.ModelAnalytics
alias Tarakan.Repositories
alias TarakanWeb.AccountAuth
# One long scroll made every panel equally prominent, so the two people
# actually use daily - email and credentials - sat next to the API reference.
# Each panel is now its own URL behind a rail, and the rail carries the
# counts so nothing has to be opened to be seen.
@sections [
%{id: :account, slug: nil, label: "Account", icon: "hero-user-circle"},
%{id: :credentials, slug: "credentials", label: "Credentials", icon: "hero-key"},
%{id: :ssh, slug: "ssh-keys", label: "SSH keys", icon: "hero-command-line"},
%{id: :agents, slug: "agents", label: "Agents", icon: "hero-cpu-chip"}
]
@impl true
def render(assigns) do
~H"""
<Layouts.app flash={@flash} current_scope={@current_scope} current_path={assigns[:current_path]}>
<Layouts.page width={:wide}>
<div class="mx-auto w-full max-w-5xl">
<header class="border-b-2 border-strong pb-5">
<h1 class="font-display text-2xl font-medium uppercase leading-9 tracking-[0.02em] text-ink">
Account settings
</h1>
<p class="mt-1 flex flex-wrap items-center gap-x-3 gap-y-1 font-mono text-xs">
<span class="text-ink-muted">@{@current_scope.account.handle}</span>
<span class="text-ink-faint">{@current_scope.account.email}</span>
<span class={if @email_confirmed?, do: "text-phosphor", else: "text-signal"}>
{if @email_confirmed?, do: "confirmed", else: "unconfirmed"}
</span>
</p>
</header>
<div class="mt-6 grid gap-6 lg:grid-cols-[13rem_minmax(0,1fr)] lg:gap-8">
<nav
id="settings-navigation"
aria-label="Account settings"
class="flex overflow-x-auto overscroll-x-contain border-b-2 border-rule text-sm [-ms-overflow-style:none] [scrollbar-width:none] lg:sticky lg:top-20 lg:block lg:self-start lg:overflow-visible lg:border-b-0 lg:border-l-2 [&::-webkit-scrollbar]:hidden"
>
<.settings_tab
:for={section <- sections()}
id={"settings-tab-#{section.id}"}
active={@section == section.id}
patch={settings_path(section.slug)}
icon={section.icon}
label={section.label}
badge={section_badge(section.id, assigns)}
badge_class={section_badge_class(section.id, assigns)}
/>
</nav>
<div class="min-w-0">
<.account_section
:if={@section == :account}
email_form={@email_form}
password_form={@password_form}
trigger_submit={@trigger_submit}
email_confirmed?={@email_confirmed?}
identity_providers={@identity_providers}
/>
<.credentials_section
:if={@section == :credentials}
api_token={@api_token}
api_credentials={@api_credentials}
api_base_url={@api_base_url}
credential_form={@credential_form}
email_confirmed?={@email_confirmed?}
/>
<.ssh_section
:if={@section == :ssh}
handle={@current_scope.account.handle}
ssh_keys={@ssh_keys}
ssh_key_form={@ssh_key_form}
email_confirmed?={@email_confirmed?}
/>
<.agents_section :if={@section == :agents} model_scoreboard={@model_scoreboard} />
</div>
</div>
</div>
</Layouts.page>
</Layouts.app>
"""
end
# The active entry stays a link so it is focusable and re-clickable;
# aria-current carries "you are here". Horizontal rail on narrow screens,
# vertical rail from lg up - the same element either way.
attr :id, :string, required: true
attr :active, :boolean, required: true
attr :patch, :string, required: true
attr :icon, :string, required: true
attr :label, :string, required: true
attr :badge, :string, default: nil
attr :badge_class, :string, default: nil
defp settings_tab(assigns) do
~H"""
<.link
id={@id}
patch={@patch}
aria-current={@active && "page"}
class={[
"-mb-0.5 flex shrink-0 items-center gap-2 whitespace-nowrap border-b-2 px-3 py-3 transition",
"lg:-ml-0.5 lg:mb-0 lg:w-full lg:border-b-0 lg:border-l-2 lg:px-4 lg:py-2.5",
@active && "border-signal font-semibold text-ink lg:bg-panel",
!@active && "border-transparent text-ink-muted hover:border-rule hover:text-ink"
]}
>
<.icon name={@icon} class="size-4 shrink-0" />
<span>{@label}</span>
<span :if={@badge} class={["font-mono text-[11px] tabular-nums lg:ml-auto", @badge_class]}>
{@badge}
</span>
</.link>
"""
end
defp account_section(assigns) do
~H"""
<div class="space-y-5">
<.form
for={@email_form}
id="email_form"
phx-submit="update_email"
phx-change="validate_email"
class="space-y-3 border-2 border-strong bg-panel p-6"
>
<h2 class="text-sm font-semibold text-ink">Email address</h2>
<.input
field={@email_form[:email]}
type="email"
label="Email"
autocomplete="username"
spellcheck="false"
required
/>
<.button variant="primary" phx-disable-with="Changing...">Save email</.button>
</.form>
<section
:if={not @email_confirmed?}
id="email-confirmation-required"
class="border-2 border-strong bg-panel p-6"
>
<h2 class="text-sm font-semibold text-ink">Confirm your email to unlock credentials</h2>
<p class="mt-1 text-xs leading-5 text-ink-faint">
Setting a password, adding SSH keys, and creating client credentials require a
confirmed email address. Sign in with the login link we emailed you - or request a
new one from the
<.link navigate={~p"/accounts/log-in"} class="text-signal hover:underline">login page</.link>
- to confirm it.
</p>
</section>
<.form
:if={@email_confirmed?}
for={@password_form}
id="password_form"
action={~p"/accounts/update-password"}
method="post"
phx-change="validate_password"
phx-submit="update_password"
phx-trigger-action={@trigger_submit}
class="space-y-3 border-2 border-strong bg-panel p-6"
>
<h2 class="text-sm font-semibold text-ink">Password</h2>
<p class="text-xs leading-5 text-ink-faint">
At least 15 characters.
</p>
<.input
field={@password_form[:password]}
type="password"
label="New password"
autocomplete="new-password"
spellcheck="false"
required
/>
<.input
field={@password_form[:password_confirmation]}
type="password"
label="Confirm new password"
autocomplete="new-password"
spellcheck="false"
/>
<.button variant="primary" phx-disable-with="Saving...">
Save password
</.button>
</.form>
<section class="border-2 border-strong bg-panel p-6">
<h2 class="text-sm font-semibold text-ink">Connected code hosts</h2>
<div class="mt-4 grid gap-3 sm:grid-cols-2">
<.link
id="settings-github-identity"
href={~p"/auth/github?#{[return_to: ~p"/accounts/settings"]}"}
class="flex items-center justify-between border-2 border-rule px-4 py-3 text-sm text-ink-muted transition hover:bg-panel"
>
<span>GitHub</span>
<span class={
if MapSet.member?(@identity_providers, "github"),
do: "text-signal",
else: "text-ink-faint"
}>
{if MapSet.member?(@identity_providers, "github"), do: "Connected", else: "Connect"}
</span>
</.link>
<.link
id="settings-gitlab-identity"
href={~p"/auth/gitlab?#{[return_to: ~p"/accounts/settings"]}"}
class="flex items-center justify-between border-2 border-rule px-4 py-3 text-sm text-ink-muted transition hover:bg-panel"
>
<span>GitLab</span>
<span class={
if MapSet.member?(@identity_providers, "gitlab"),
do: "text-signal",
else: "text-ink-faint"
}>
{if MapSet.member?(@identity_providers, "gitlab"), do: "Connected", else: "Connect"}
</span>
</.link>
</div>
</section>
</div>
"""
end
defp credentials_section(assigns) do
~H"""
<section id="api-token" class="border-2 border-strong bg-panel p-6">
<h2 class="text-sm font-semibold text-ink">Client credentials</h2>
<p class="mt-1 text-xs leading-5 text-ink-faint">
<span class="font-mono">TARAKAN_API_TOKEN</span>, sent as <span class="font-mono">Authorization: Bearer &lt;token&gt;</span>. Defaults expire after {ApiCredentials.default_validity_days()} days (max {ApiCredentials.maximum_validity_days()}).
</p>
<div :if={@api_token} class="mt-4">
<p class="font-mono text-[11px] text-ink-faint">
Shown only once.
</p>
<p
id="api-token-value"
class="mt-1 break-all border-2 border-rule px-3 py-2 font-mono text-xs text-ink"
>
{@api_token}
</p>
</div>
<div
:if={@api_credentials != []}
id="api-credentials"
class="mt-4 divide-y-2 divide-rule border-y-2 border-rule"
>
<div
:for={credential <- @api_credentials}
id={"api-credential-#{credential.id}"}
class="flex items-center justify-between gap-4 py-3"
>
<div class="min-w-0">
<p class="truncate text-xs font-semibold text-ink">{credential.name}</p>
<p class="mt-1 font-mono text-[11px] text-ink-faint">
{credential.token_prefix} · {credential_status(credential)}
</p>
<p class="mt-1 font-mono text-[11px] text-ink-faint">
{Enum.join(credential.scopes, " · ")}
<span :if={credential.repository}>
· {credential.repository.owner}/{credential.repository.name}
</span>
</p>
</div>
<.button
:if={ApiCredential.active?(credential)}
variant="danger"
size="sm"
id={"revoke-api-credential-#{credential.id}"}
phx-click="revoke_api_credential"
phx-value-id={credential.id}
data-confirm="Revoke this credential? Clients using it will immediately lose access."
>
Revoke
</.button>
</div>
</div>
<.form
:if={@email_confirmed?}
for={@credential_form}
id="api-credential-form"
phx-submit="generate_api_token"
class="mt-5 space-y-4 border-t-2 border-rule pt-5"
>
<.input
field={@credential_form[:name]}
type="text"
label="Credential name"
maxlength="80"
required
/>
<fieldset>
<legend class="text-xs font-semibold text-ink">Permissions</legend>
<div class="mt-2 grid gap-2 sm:grid-cols-2">
<label
:for={scope <- ApiCredential.scopes()}
class="flex items-start gap-2 border-2 border-rule px-3 py-2 text-xs text-ink-muted"
>
<input
type="checkbox"
name="credential[scopes][]"
value={scope}
checked={scope in @credential_form.params["scopes"]}
class="mt-0.5 border-rule text-signal focus:ring-phosphor"
/>
<span>
<span class="block font-mono text-ink">{scope}</span>
<span class="mt-0.5 block text-ink-faint">{credential_scope_label(scope)}</span>
</span>
</label>
</div>
</fieldset>
<.input
field={@credential_form[:repository]}
type="text"
label="Limit to one repository (optional)"
placeholder="owner/repository"
autocomplete="off"
spellcheck="false"
/>
<.button id="generate-api-token-button" variant="primary">
Generate client credential
</.button>
</.form>
<details id="api-reference" class="group mt-5 border-t-2 border-rule pt-5">
<summary class="flex cursor-pointer list-none items-center justify-between gap-4 text-sm font-semibold text-ink marker:hidden">
<span>API reference</span>
<span class="font-mono text-[11px] font-normal uppercase tracking-[0.12em] text-ink-faint group-open:hidden">
Open
</span>
<span class="hidden font-mono text-[11px] font-normal uppercase tracking-[0.12em] text-ink-faint group-open:inline">
Close
</span>
</summary>
<div class="mt-4 space-y-5 text-xs leading-5 text-ink-muted">
<div>
<p class="font-semibold text-ink">Authentication</p>
<p class="mt-1">
Base URL:
<code id="api-reference-base-url" class="font-mono text-ink">{@api_base_url}</code>
</p>
<p>
Send <code class="font-mono text-ink">Authorization: Bearer &lt;token&gt;</code>
and <code class="font-mono text-ink">Accept: application/json</code>.
</p>
<pre class="mt-2 overflow-x-auto border-2 border-rule bg-ground p-3 font-mono text-[11px] leading-5 text-ink"><code>curl "{@api_base_url}/jobs" \
-H "Authorization: Bearer $TARAKAN_API_TOKEN" \
-H "Accept: application/json"</code></pre>
</div>
<div>
<p class="font-semibold text-ink">Client authorization</p>
<dl class="mt-2 grid gap-x-3 gap-y-1 font-mono text-[11px] sm:grid-cols-[4rem_1fr]">
<dt class="text-ink">POST</dt><dd>/client-auth/start</dd>
<dt class="text-ink">POST</dt><dd>/client-auth/exchange</dd>
<dt class="text-ink">DELETE</dt><dd>/client-auth/session</dd>
</dl>
<p class="mt-2">
Start with <code class="font-mono text-ink">&#123;&quot;client_name&quot;:&quot;Tarakan Client&quot;&#125;</code>,
open the returned verification URL, then poll exchange with
<code class="font-mono text-ink">&#123;&quot;device_code&quot;:&quot;...&quot;&#125;</code>
at the returned interval. Session revocation requires the issued bearer token.
</p>
</div>
<div>
<p class="font-semibold text-ink">Repository discovery</p>
<dl class="mt-2 grid gap-x-3 gap-y-1 font-mono text-[11px] sm:grid-cols-[4rem_1fr]">
<dt class="text-ink">GET</dt><dd>/repositories</dd>
<dt class="text-ink">POST</dt><dd>/repositories</dd>
</dl>
<p class="mt-2">
List with <code class="font-mono text-ink">status=unscanned</code>
and <code class="font-mono text-ink">limit=100</code>.
Register with
<code class="font-mono text-ink">&#123;&quot;url&quot;:&quot;owner/name&quot;&#125;</code>
(scopes: findings:submit or reviews:submit).
</p>
</div>
<div>
<p class="font-semibold text-ink">Jobs</p>
<dl class="mt-2 grid gap-x-3 gap-y-1 font-mono text-[11px] sm:grid-cols-[4rem_1fr]">
<dt class="text-ink">GET</dt><dd>/jobs</dd>
<dt class="text-ink">GET</dt><dd>/:host/:owner/:name/jobs</dd>
<dt class="text-ink">GET</dt><dd>/jobs/:id</dd>
<dt class="text-ink">POST</dt><dd>/jobs/:id/claim</dd>
<dt class="text-ink">POST</dt><dd>/jobs/:id/claim/renew</dd>
<dt class="text-ink">DELETE</dt><dd>/jobs/:id/claim</dd>
<dt class="text-ink">POST</dt><dd>/jobs/:id/complete</dd>
</dl>
<p class="mt-2">
Scopes: <code class="font-mono text-ink">tasks:read</code>, <code class="font-mono text-ink">tasks:claim</code>, and <code class="font-mono text-ink">contributions:write</code>.
</p>
</div>
<div>
<p class="font-semibold text-ink">Reports and Checks</p>
<dl class="mt-2 grid gap-x-3 gap-y-1 font-mono text-[11px] sm:grid-cols-[4rem_1fr]">
<dt class="text-ink">GET</dt><dd>/:host/:owner/:name/reports</dd>
<dt class="text-ink">POST</dt><dd>/:host/:owner/:name/reports</dd>
<dt class="text-ink">GET</dt><dd>/:host/:owner/:name/memory</dd>
<dt class="text-ink">POST</dt><dd>/:host/:owner/:name/reports/:id/check</dd>
<dt class="text-ink">POST</dt><dd>
/:host/:owner/:name/findings/:public_id/check
</dd>
</dl>
<p class="mt-2">
Use <code class="font-mono text-ink">reviews:submit</code>
to publish, <code class="font-mono text-ink">reviews:read</code>
for restricted evidence,
and <code class="font-mono text-ink">reviews:verify</code>
to check findings.
Checks also require qualified reviewer standing.
</p>
</div>
<div class="border-l-2 border-rule pl-3">
<p class="font-semibold text-ink">Repository host</p>
<p class="mt-1">
Use <code class="font-mono text-ink">github.com</code>
or <code class="font-mono text-ink">tarakan.lol</code>
for <code class="font-mono text-ink">:host</code>. JSON bodies only;
validation failures return field errors with a non-2xx status.
</p>
</div>
</div>
</details>
</section>
"""
end
defp ssh_section(assigns) do
~H"""
<section id="ssh-keys" class="border-2 border-strong bg-panel p-6">
<h2 class="text-sm font-semibold text-ink">SSH keys</h2>
<p class="mt-1 font-mono text-xs leading-5 text-ink-faint">
git clone ssh://git@&lt;host&gt;/{@handle}/&lt;name&gt;.git
</p>
<div
:if={@ssh_keys != []}
id="ssh-key-list"
class="mt-4 divide-y-2 divide-rule border-y-2 border-rule"
>
<div
:for={key <- @ssh_keys}
id={"ssh-key-#{key.id}"}
class="flex items-center justify-between gap-4 py-3"
>
<div class="min-w-0">
<p class="truncate text-xs font-semibold text-ink">{key.name}</p>
<p class="mt-1 break-all font-mono text-[11px] text-ink-faint">
{key.fingerprint_sha256}
</p>
<p class="mt-1 font-mono text-[11px] text-ink-faint">
{key.key_type} · {ssh_key_last_used(key)}
</p>
</div>
<.button
variant="danger"
size="sm"
id={"delete-ssh-key-#{key.id}"}
phx-click="delete_ssh_key"
phx-value-id={key.id}
data-confirm="Remove this key? Clients using it will immediately lose SSH access."
>
Remove
</.button>
</div>
</div>
<.form
:if={@email_confirmed?}
for={@ssh_key_form}
id="ssh-key-form"
phx-submit="add_ssh_key"
class="mt-5 space-y-4 border-t-2 border-rule pt-5"
>
<.input
field={@ssh_key_form[:name]}
type="text"
label="Key name"
placeholder="work laptop"
maxlength="100"
required
/>
<.input
field={@ssh_key_form[:public_key]}
type="textarea"
label="Public key"
placeholder="ssh-ed25519 AAAA…"
rows="3"
spellcheck="false"
required
/>
<p class="text-xs leading-5 text-ink-faint">
ed25519, ECDSA, and RSA (3072-bit or larger) keys are accepted.
</p>
<.button id="add-ssh-key-button" variant="primary">Add SSH key</.button>
</.form>
</section>
"""
end
# Which of the agents this contributor pays for actually finds real bugs,
# measured against their own submissions rather than the corpus average.
defp agents_section(assigns) do
~H"""
<section id="settings-model-scoreboard" class="border-2 border-strong bg-panel p-6">
<div class="flex flex-wrap items-baseline justify-between gap-x-4 gap-y-1">
<h2 class="text-sm font-semibold text-ink">Your agents</h2>
<.link navigate={~p"/models"} class="font-mono text-[11px] text-signal hover:underline">
All models
</.link>
</div>
<p :if={@model_scoreboard == []} class="mt-4 font-mono text-xs text-ink-faint">
None yet.
</p>
<div
:if={@model_scoreboard != []}
class="mt-4 overflow-x-auto border-2 border-rule bg-ground"
>
<table class="w-full min-w-[28rem] border-collapse text-left">
<thead>
<tr class="border-b border-rule font-mono text-[10px] uppercase tracking-[0.12em] text-ink-faint">
<th class="px-3 py-2 font-normal">Model</th>
<th class="w-20 px-2 py-2 text-right font-normal">Findings</th>
<th class="w-24 px-2 py-2 text-right font-normal">Confirmed</th>
<th class="w-24 px-2 py-2 text-right font-normal">Disputed</th>
<th class="w-24 px-3 py-2 text-right font-normal">Precision</th>
</tr>
</thead>
<tbody class="divide-y divide-rule">
<tr :for={row <- @model_scoreboard} id={"settings-model-#{row.model}"}>
<td class="min-w-0 px-3 py-2.5">
<span class="block truncate font-mono text-xs font-semibold text-ink">
{row.model}
</span>
</td>
<td class="px-2 py-2.5 text-right font-mono text-xs tabular-nums text-ink">
{row.findings}
</td>
<td class="px-2 py-2.5 text-right font-mono text-xs tabular-nums text-phosphor">
{row.confirmed}
</td>
<td class="px-2 py-2.5 text-right font-mono text-xs tabular-nums text-ink-muted">
{row.disputed}
</td>
<td class="px-3 py-2.5 text-right font-mono text-xs tabular-nums text-ink">
{if row.precision, do: "#{row.precision}%", else: "-"}
</td>
</tr>
</tbody>
</table>
</div>
</section>
"""
end
@impl true
def mount(%{"token" => token}, _session, socket) do
socket =
case Accounts.update_account_email(socket.assigns.current_scope.account, token) do
{:ok, _account} ->
put_flash(socket, :info, "Email changed successfully.")
{:error, _} ->
put_flash(socket, :error, "Email change link is invalid or it has expired.")
end
{:ok, push_navigate(socket, to: ~p"/accounts/settings")}
end
def mount(_params, _session, socket) do
account = socket.assigns.current_scope.account
email_changeset = Accounts.change_account_email(account, %{}, validate_unique: false)
password_changeset = Accounts.change_account_password(account, %{}, hash_password: false)
identity_providers =
account
|> Accounts.list_external_identities()
|> MapSet.new(& &1.provider)
socket =
socket
|> assign(:email_form, to_form(email_changeset))
|> assign(:password_form, to_form(password_changeset))
|> assign(:identity_providers, identity_providers)
|> assign(:trigger_submit, false)
|> assign(:email_confirmed?, not is_nil(account.confirmed_at))
|> assign(:api_token, nil)
|> assign(:api_credentials, ApiCredentials.list(account))
|> assign(:api_base_url, TarakanWeb.Endpoint.url() <> "/api")
|> assign(:credential_form, credential_form())
|> assign(:ssh_keys, SshKeys.list_for_account(account))
|> assign(:ssh_key_form, ssh_key_form())
|> assign(:model_scoreboard, ModelAnalytics.account_model_scoreboard(account.id))
|> assign(:section, :account)
{:ok, socket}
end
@impl true
def handle_params(params, _uri, socket) do
{:noreply, assign(socket, :section, section_from_params(params))}
end
@impl true
def handle_event("validate_email", params, socket) do
%{"account" => account_params} = params
email_form =
socket.assigns.current_scope.account
|> Accounts.change_account_email(account_params, validate_unique: false)
|> Map.put(:action, :validate)
|> to_form()
{:noreply, assign(socket, email_form: email_form)}
end
def handle_event("update_email", params, socket) do
%{"account" => account_params} = params
with :ok <- ensure_sudo(socket),
account <- socket.assigns.current_scope.account do
case Accounts.change_account_email(account, account_params) do
%{valid?: true} = changeset ->
Accounts.deliver_account_update_email_instructions(
Ecto.Changeset.apply_action!(changeset, :insert),
account.email,
&url(~p"/accounts/settings/confirm-email/#{&1}")
)
info = "A link to confirm your email change has been sent to the new address."
{:noreply, socket |> put_flash(:info, info)}
changeset ->
{:noreply, assign(socket, :email_form, to_form(changeset, action: :insert))}
end
else
{:error, :sudo_required} -> reauth_settings(socket)
end
end
def handle_event("validate_password", params, socket) do
%{"account" => account_params} = params
password_form =
socket.assigns.current_scope.account
|> Accounts.change_account_password(account_params, hash_password: false)
|> Map.put(:action, :validate)
|> to_form()
{:noreply, assign(socket, password_form: password_form)}
end
def handle_event("generate_api_token", %{"credential" => params}, socket) do
with :ok <- ensure_sudo(socket),
account <- socket.assigns.current_scope.account,
{:ok, repository_id} <- credential_repository_id(socket, params["repository"]),
attrs <- %{
"name" => params["name"],
"scopes" => List.wrap(params["scopes"]),
"repository_id" => repository_id
},
{:ok, token, _credential} <- ApiCredentials.create(account, attrs) do
{:noreply,
assign(socket,
api_token: token,
api_credentials: ApiCredentials.list(account),
credential_form: credential_form()
)}
else
{:error, :sudo_required} ->
reauth_settings(socket)
{:error, :repository_not_found} ->
{:noreply,
socket
|> assign(:credential_form, credential_form(params))
|> put_flash(:error, "Choose a registered repository you are allowed to view.")}
{:error, :credential_limit} ->
{:noreply,
socket
|> assign(:credential_form, credential_form(params))
|> put_flash(:error, "Revoke an active credential before creating another one.")}
{:error, %Ecto.Changeset{}} ->
{:noreply,
socket
|> assign(:credential_form, credential_form(params))
|> put_flash(:error, "Choose a name and at least one valid permission.")}
{:error, :unconfirmed_email} ->
{:noreply,
put_flash(socket, :error, "Confirm your email address before creating credentials.")}
{:error, _reason} ->
{:noreply, put_flash(socket, :error, "The credential could not be created.")}
end
end
def handle_event("generate_api_token", _params, socket) do
handle_event(
"generate_api_token",
%{"credential" => socket.assigns.credential_form.params},
socket
)
end
def handle_event("revoke_api_credential", %{"id" => credential_id}, socket) do
with :ok <- ensure_sudo(socket),
account <- socket.assigns.current_scope.account,
{:ok, _credential} <- ApiCredentials.revoke(account, credential_id) do
{:noreply,
socket
|> assign(:api_credentials, ApiCredentials.list(account))
|> put_flash(:info, "Client credential revoked.")}
else
{:error, :sudo_required} ->
reauth_settings(socket)
{:error, :not_found} ->
{:noreply, put_flash(socket, :error, "Client credential not found.")}
end
end
def handle_event("add_ssh_key", %{"ssh_key" => params}, socket) do
with :ok <- ensure_sudo(socket),
account <- socket.assigns.current_scope.account do
case SshKeys.add_key(account, params) do
{:ok, _key} ->
{:noreply,
socket
|> assign(:ssh_keys, SshKeys.list_for_account(account))
|> assign(:ssh_key_form, ssh_key_form())
|> put_flash(:info, "SSH key added.")}
{:error, :key_limit} ->
{:noreply,
socket
|> assign(:ssh_key_form, ssh_key_form(params))
|> put_flash(:error, "Remove an existing key before adding another one.")}
{:error, :unconfirmed_email} ->
{:noreply,
put_flash(socket, :error, "Confirm your email address before adding SSH keys.")}
{:error, %Ecto.Changeset{} = changeset} ->
{:noreply, assign(socket, :ssh_key_form, to_form(changeset, as: :ssh_key))}
end
else
{:error, :sudo_required} -> reauth_settings(socket)
end
end
def handle_event("delete_ssh_key", %{"id" => key_id}, socket) do
with :ok <- ensure_sudo(socket),
account <- socket.assigns.current_scope.account,
{:ok, _key} <- SshKeys.delete_key(account, key_id) do
{:noreply,
socket
|> assign(:ssh_keys, SshKeys.list_for_account(account))
|> put_flash(:info, "SSH key removed.")}
else
{:error, :sudo_required} -> reauth_settings(socket)
{:error, :not_found} -> {:noreply, put_flash(socket, :error, "SSH key not found.")}
end
end
def handle_event("update_password", params, socket) do
%{"account" => account_params} = params
with :ok <- ensure_sudo(socket),
account <- socket.assigns.current_scope.account do
case Accounts.change_account_password(account, account_params) do
%{valid?: true} = changeset ->
{:noreply, assign(socket, trigger_submit: true, password_form: to_form(changeset))}
changeset ->
{:noreply, assign(socket, password_form: to_form(changeset, action: :insert))}
end
else
{:error, :sudo_required} -> reauth_settings(socket)
end
end
defp sections, do: @sections
defp settings_path(nil), do: ~p"/accounts/settings"
defp settings_path(slug), do: ~p"/accounts/settings/#{slug}"
# An unknown slug falls back to the first panel rather than 404ing: the URL
# is a convenience for deep links, not a resource.
defp section_from_params(%{"section" => slug}) do
case Enum.find(@sections, &(&1.slug == slug)) do
%{id: id} -> id
nil -> :account
end
end
defp section_from_params(_params), do: :account
defp section_slug(id) do
case Enum.find(@sections, &(&1.id == id)) do
%{slug: slug} -> slug
nil -> nil
end
end
defp section_badge(:account, %{email_confirmed?: false}), do: "unconfirmed"
defp section_badge(:account, _assigns), do: nil
defp section_badge(:credentials, %{api_credentials: credentials}),
do: credentials |> Enum.count(&ApiCredential.active?/1) |> Integer.to_string()
defp section_badge(:ssh, %{ssh_keys: keys}), do: keys |> length() |> Integer.to_string()
defp section_badge(:agents, %{model_scoreboard: scoreboard}),
do: scoreboard |> length() |> Integer.to_string()
defp section_badge_class(:account, %{email_confirmed?: false}), do: "text-signal"
defp section_badge_class(_section, _assigns), do: "text-ink-faint"
defp ensure_sudo(socket) do
if Accounts.sudo_mode?(socket.assigns.current_scope.account),
do: :ok,
else: {:error, :sudo_required}
end
# Come back to the panel the action was attempted from, not to the first one.
defp reauth_settings(socket) do
return_to = socket.assigns.section |> section_slug() |> settings_path()
{:noreply,
socket
|> put_flash(
:error,
"Confirm it's you with a magic link before changing sensitive settings."
)
|> redirect(to: AccountAuth.reauth_path(return_to))}
end
defp credential_status(%ApiCredential{revoked_at: %DateTime{}}), do: "revoked"
defp credential_status(%ApiCredential{expires_at: expires_at}) do
if ApiCredential.active?(%ApiCredential{expires_at: expires_at}) do
"expires " <> Calendar.strftime(expires_at, "%Y-%m-%d")
else
"expired"
end
end
defp credential_form(params \\ %{}) do
defaults = %{
"name" => "Tarakan Client",
"repository" => "",
"scopes" => ApiCredentials.default_scopes()
}
to_form(Map.merge(defaults, params), as: :credential)
end
defp credential_repository_id(_socket, repository)
when repository in [nil, ""],
do: {:ok, nil}
defp credential_repository_id(socket, reference) when is_binary(reference) do
with {:ok, %{owner: owner, name: name}} <- Repositories.parse_github_repository(reference),
%{} = repository <-
Repositories.get_visible_github_repository(
owner,
name,
socket.assigns.current_scope
) do
{:ok, repository.id}
else
_invalid -> {:error, :repository_not_found}
end
end
defp credential_scope_label("tasks:read"), do: "Read visible jobs"
defp credential_scope_label("tasks:claim"), do: "Claim and release jobs"
defp credential_scope_label("contributions:write"), do: "Submit task evidence"
defp credential_scope_label("findings:submit"), do: "Submit quarantined scan results"
defp credential_scope_label("reports:write"), do: "Report abuse and view your reports"
defp credential_scope_label("reviews:read"),
do: "Read restricted review findings (reviewer tier)"
defp credential_scope_label("reviews:verify"), do: "Record checks on reports (reviewer tier)"
defp credential_scope_label("repo:read"), do: "Clone your visible hosted repositories"
defp credential_scope_label("repo:write"), do: "Push to hosted repositories you steward"
defp credential_scope_label(scope), do: scope
defp ssh_key_form(params \\ %{}) do
to_form(Map.merge(%{"name" => "", "public_key" => ""}, params), as: :ssh_key)
end
defp ssh_key_last_used(%{last_used_at: nil}), do: "never used"
defp ssh_key_last_used(%{last_used_at: last_used_at}),
do: "last used " <> Calendar.strftime(last_used_at, "%Y-%m-%d")
end

View file

@ -0,0 +1,75 @@
defmodule TarakanWeb.AdminLive.Index do
use TarakanWeb, :live_view
alias Tarakan.Accounts
alias Tarakan.Policy
alias TarakanWeb.AccountAuth
@impl true
def mount(_params, _session, socket) do
scope = socket.assigns.current_scope
cond do
not Policy.allowed?(scope, :administer) ->
{:ok, unauthorized(socket)}
not Accounts.sudo_mode?(scope.account) ->
{:ok,
socket
|> put_flash(:error, "Confirm it's you before opening platform administration.")
|> redirect(to: AccountAuth.reauth_path(~p"/admin"))}
true ->
with {:ok, accounts} <- Accounts.list_accounts_for_admin(scope),
{:ok, summary} <- Accounts.account_admin_summary(scope) do
{:ok,
socket
|> assign(:page_title, "Platform administration")
|> assign(:summary, summary)
|> assign(:account_count, length(accounts))
|> assign(:filter_form, to_form(%{"query" => ""}, as: :filters))
|> stream(:accounts, accounts)}
else
{:error, _reason} -> {:ok, unauthorized(socket)}
end
end
end
@impl true
def handle_event("filter", %{"filters" => %{"query" => query}}, socket) do
with_recent_auth(socket, fn ->
case Accounts.list_accounts_for_admin(socket.assigns.current_scope, query) do
{:ok, accounts} ->
{:noreply,
socket
|> assign(:account_count, length(accounts))
|> assign(:filter_form, to_form(%{"query" => query}, as: :filters))
|> stream(:accounts, accounts, reset: true)}
{:error, _reason} ->
{:noreply, unauthorized(socket)}
end
end)
end
def handle_event("filter", _params, socket), do: {:noreply, socket}
# The mount-time sudo check only covers the initial render; a long-lived
# LiveView can outlast the two-hour window, so admin events re-check.
defp with_recent_auth(socket, fun) do
if Accounts.sudo_mode?(socket.assigns.current_scope.account) do
fun.()
else
{:noreply,
socket
|> put_flash(:error, "Confirm it's you before opening platform administration.")
|> push_navigate(to: AccountAuth.reauth_path(~p"/admin"))}
end
end
defp unauthorized(socket) do
socket
|> put_flash(:error, "Administrator access is required.")
|> redirect(to: ~p"/")
end
end

View file

@ -0,0 +1,121 @@
<Layouts.app flash={@flash} current_scope={@current_scope} current_path={assigns[:current_path]}>
<Layouts.page id="admin-dashboard">
<.breadcrumbs>
<:crumb navigate={~p"/"}>registry</:crumb>
<:crumb>admin</:crumb>
</.breadcrumbs>
<header class={[
"mt-4 flex flex-col gap-5 border-b-2 border-strong pb-7 sm:flex-row sm:items-end sm:justify-between"
]}>
<div>
<h1 class={["font-display text-3xl uppercase tracking-[0.02em] text-ink sm:text-5xl"]}>
Administration
</h1>
<p class={["mt-3 max-w-2xl text-sm leading-6 text-ink-muted"]}>
Manage account standing and platform authority. Every change is re-authorized against locked database state and written to the audit record.
</p>
</div>
<.link
id="admin-moderation-link"
navigate={~p"/moderation/queue"}
class={[
"font-mono text-xs uppercase tracking-[0.12em] text-signal transition hover:underline"
]}
>
Moderation queue →
</.link>
</header>
<section id="admin-summary" class={["mt-6 grid gap-3 sm:grid-cols-2 lg:grid-cols-4"]}>
<%= for {id, label, value} <- [
{"total", "Accounts", @summary.total},
{"admins", "Administrators", @summary.admins},
{"moderators", "Moderators", @summary.moderators},
{"restricted", "Restricted", @summary.restricted}
] do %>
<article id={"admin-summary-#{id}"} class={["border-2 border-strong bg-panel px-5 py-5"]}>
<p class={["text-xs font-medium text-ink-faint"]}>
{label}
</p>
<p class={["mt-2 font-display text-3xl text-ink"]}>{value}</p>
</article>
<% end %>
</section>
<section class={["mt-8"]} aria-labelledby="admin-accounts-heading">
<div class={["flex flex-col gap-4 sm:flex-row sm:items-end sm:justify-between"]}>
<div>
<h2
id="admin-accounts-heading"
class={["font-display text-xl uppercase tracking-[0.02em] text-ink"]}
>
Accounts
</h2>
<p id="admin-account-count" class={["mt-1 font-mono text-[11px] text-ink-faint"]}>
{@account_count} shown · maximum 100
</p>
</div>
<.form
for={@filter_form}
id="admin-account-filter"
phx-change="filter"
class={["w-full sm:max-w-sm"]}
>
<.input
field={@filter_form[:query]}
type="search"
label="Search accounts"
placeholder="handle, email, or name"
autocomplete="off"
phx-debounce="250"
/>
</.form>
</div>
<div id="admin-accounts" phx-update="stream" class={["mt-5 grid gap-3"]}>
<div
id="admin-accounts-empty"
class={[
"hidden border-2 border-rule px-5 py-10 text-center text-sm text-ink-muted only:block"
]}
>
No accounts match that search.
</div>
<article
:for={{dom_id, account} <- @streams.accounts}
id={dom_id}
class={[
"group flex flex-col gap-4 border-2 border-strong bg-panel px-5 py-4 transition hover:-translate-y-0.5 hover:shadow-[4px_4px_0_0_var(--color-rule)] sm:flex-row sm:items-center sm:justify-between"
]}
>
<div class={["min-w-0"]}>
<div class={["flex flex-wrap items-center gap-2"]}>
<h3 class={["font-display text-lg text-ink"]}>@{account.handle}</h3>
<span class={[
"border border-rule px-2 py-0.5 font-mono text-[10px] uppercase tracking-[0.12em] text-ink-muted"
]}>
{account.platform_role}
</span>
<span class={["font-mono text-[10px] uppercase tracking-[0.12em] text-ink-faint"]}>
{account.state} · {account.trust_tier}
</span>
</div>
<p class={["mt-1 truncate text-xs text-ink-faint"]}>{account.email}</p>
</div>
<.link
id={"admin-account-#{account.id}-manage"}
navigate={~p"/admin/accounts/#{account.id}"}
class={[
"shrink-0 font-mono text-xs uppercase tracking-[0.12em] text-signal transition group-hover:underline"
]}
>
Manage →
</.link>
</article>
</div>
</section>
</Layouts.page>
</Layouts.app>

View file

@ -0,0 +1,112 @@
defmodule TarakanWeb.AdminLive.Show do
use TarakanWeb, :live_view
alias Tarakan.Accounts
alias Tarakan.Accounts.Account
alias Tarakan.Policy
alias TarakanWeb.AccountAuth
@impl true
def mount(%{"id" => id}, _session, socket) do
scope = socket.assigns.current_scope
return_to = ~p"/admin/accounts/#{id}"
cond do
not Policy.allowed?(scope, :administer) ->
{:ok, unauthorized(socket)}
not Accounts.sudo_mode?(scope.account) ->
{:ok,
socket
|> put_flash(:error, "Confirm it's you before changing account authority.")
|> redirect(to: AccountAuth.reauth_path(return_to))}
true ->
case Accounts.get_account_for_admin(scope, id) do
{:ok, account} -> {:ok, assign_account(socket, account)}
{:error, _reason} -> {:ok, unauthorized(socket)}
end
end
end
@impl true
def handle_event("validate", %{"authorization" => params}, socket) do
form =
socket.assigns.account
|> Account.authorization_changeset(params)
|> Map.put(:action, :validate)
|> to_form(as: :authorization)
{:noreply, assign(socket, :authorization_form, form)}
end
def handle_event("save", %{"authorization" => params}, socket) do
with_recent_auth(socket, fn ->
case Accounts.update_authorization(
socket.assigns.current_scope,
socket.assigns.account,
params
) do
{:ok, account} ->
{:noreply,
socket
|> assign_account(account)
|> put_flash(:info, "Authorization updated for @#{account.handle}.")}
{:error, :last_admin} ->
{:noreply,
socket
|> assign_invalid_form(params)
|> put_flash(:error, "The last active administrator cannot be demoted or restricted.")}
{:error, %Ecto.Changeset{} = changeset} ->
{:noreply, assign(socket, :authorization_form, to_form(changeset, as: :authorization))}
{:error, _reason} ->
{:noreply, unauthorized(socket)}
end
end)
end
def handle_event(_event, _params, socket), do: {:noreply, socket}
# The mount-time sudo check only covers the initial render; a long-lived
# LiveView can outlast the two-hour window, so mutating events re-check.
defp with_recent_auth(socket, fun) do
if Accounts.sudo_mode?(socket.assigns.current_scope.account) do
fun.()
else
return_to = ~p"/admin/accounts/#{socket.assigns.account.id}"
{:noreply,
socket
|> put_flash(:error, "Confirm it's you before changing account authority.")
|> push_navigate(to: AccountAuth.reauth_path(return_to))}
end
end
defp assign_account(socket, account) do
socket
|> assign(:page_title, "Manage @#{account.handle}")
|> assign(:account, account)
|> assign(
:authorization_form,
to_form(Account.authorization_changeset(account, %{}), as: :authorization)
)
end
defp assign_invalid_form(socket, params) do
changeset =
socket.assigns.account
|> Account.authorization_changeset(params)
|> Ecto.Changeset.add_error(:platform_role, "must leave at least one active administrator")
assign(socket, :authorization_form, to_form(changeset, as: :authorization))
end
defp unauthorized(socket) do
socket
|> put_flash(:error, "Administrator access is required.")
|> redirect(to: ~p"/")
end
end

View file

@ -0,0 +1,88 @@
<Layouts.app flash={@flash} current_scope={@current_scope} current_path={assigns[:current_path]}>
<Layouts.page id="admin-account" width={:focused}>
<.breadcrumbs>
<:crumb navigate={~p"/"}>registry</:crumb>
<:crumb navigate={~p"/admin"}>admin</:crumb>
<:crumb>@{@account.handle}</:crumb>
</.breadcrumbs>
<header class={["mt-4 border-b-2 border-strong pb-6"]}>
<h1 class={["font-display text-3xl uppercase tracking-[0.02em] text-ink sm:text-4xl"]}>
Manage @{@account.handle}
</h1>
<p class={["mt-3 text-sm leading-6 text-ink-muted"]}>
Changes take effect immediately, invalidate connected authorization state, and are recorded in the audit log.
</p>
</header>
<section class={["mt-6 grid gap-4 border-2 border-strong bg-panel p-5 sm:grid-cols-2 sm:p-6"]}>
<div>
<p class={["text-xs font-medium text-ink-faint"]}>Email</p>
<p id="admin-account-email" class={["mt-1 break-all text-sm text-ink"]}>
{@account.email}
</p>
</div>
<div>
<p class={["text-xs font-medium text-ink-faint"]}>
Reputation
</p>
<p id="admin-account-reputation" class={["mt-1 text-sm text-ink"]}>
{@account.reputation}
</p>
</div>
</section>
<.form
for={@authorization_form}
id="admin-authorization-form"
phx-change="validate"
phx-submit="save"
class={["mt-6 space-y-5 border-2 border-strong bg-panel p-5 sm:p-6"]}
>
<div>
<h2 class={["font-display text-xl uppercase tracking-[0.02em] text-ink"]}>
Authorization
</h2>
<p class={["mt-1 text-xs leading-5 text-ink-faint"]}>
Suspending or banning an account revokes its sessions, API credentials, and SSH keys.
</p>
</div>
<div class={["grid gap-4 sm:grid-cols-3"]}>
<.input
field={@authorization_form[:state]}
type="select"
label="Account state"
options={Enum.map(Account.states(), &{String.capitalize(&1), &1})}
/>
<.input
field={@authorization_form[:platform_role]}
type="select"
label="Platform role"
options={Enum.map(Account.platform_roles(), &{String.capitalize(&1), &1})}
/>
<.input
field={@authorization_form[:trust_tier]}
type="select"
label="Trust tier"
options={Enum.map(Account.trust_tiers(), &{String.capitalize(&1), &1})}
/>
</div>
<div class={["flex flex-col-reverse gap-3 sm:flex-row sm:items-center sm:justify-between"]}>
<.link
id="admin-account-back"
navigate={~p"/admin"}
class={[
"font-mono text-xs uppercase tracking-[0.12em] text-ink-muted transition hover:text-ink"
]}
>
← Back to accounts
</.link>
<.button id="admin-authorization-save" variant="primary" phx-disable-with="Saving…">
Save authorization
</.button>
</div>
</.form>
</Layouts.page>
</Layouts.app>

View file

@ -0,0 +1,156 @@
defmodule TarakanWeb.AgentsLive do
@moduledoc "Install tarakan-client."
use TarakanWeb, :live_view
alias Tarakan.Reports
@impl true
def mount(_params, _session, socket) do
site = TarakanWeb.Endpoint.url()
{:ok,
socket
|> assign(:page_title, "Agents")
|> assign(
:meta_description,
"Install tarakan-client. Publish Reports or pick up Jobs."
)
|> assign(:canonical_path, ~p"/agents")
|> assign(:guide, Reports.mass_path_guide())
|> assign(
:install_commands,
"""
curl -fsSL #{site}/install.sh | bash
tarakan login
"""
|> String.trim()
)
|> assign(
:dump_commands,
"""
tarakan worker --agent kimi
"""
|> String.trim()
)
|> assign(
:pickup_commands,
"""
tarakan --agent kimi --pickup
"""
|> String.trim()
)
|> assign(
:report_api,
"""
POST #{site}/api/github.com/:owner/:name/reports
Authorization: Bearer <credential>
{
"commit_sha": "<40-char sha>",
"provenance": "agent",
"model": "codex",
"prompt_version": "1",
"document": {
"tarakan_scan_format": 1,
"findings": [ /* file, severity, title, description, line_start? */ ]
}
}
"""
|> String.trim()
)}
end
@impl true
def render(assigns) do
~H"""
<Layouts.app flash={@flash} current_scope={@current_scope} current_path={assigns[:current_path]}>
<Layouts.page width={:focused}>
<div class="border-b-2 border-strong pb-6">
<h1 class="font-display text-3xl font-medium uppercase leading-[1.05] tracking-[0.02em] text-ink sm:text-5xl sm:leading-none">
Agents
</h1>
</div>
<section class="mt-8 grid gap-3 sm:grid-cols-3">
<div
:for={noun <- @guide.nouns}
class="bg-panel px-4 py-3"
>
<p class="font-display text-sm uppercase tracking-[0.12em] text-ink">
{noun.name}
</p>
<p class="mt-1.5 text-xs leading-5 text-ink-muted">{noun.meaning}</p>
</div>
</section>
<section class="mt-8 border-2 border-strong">
<div class="border-b border-rule bg-panel px-4 py-3">
<h2 class="font-display text-sm uppercase tracking-[0.12em] text-ink">
Install & login
</h2>
</div>
<pre
id="agents-commands"
class="overflow-x-auto bg-ground px-4 py-4 font-mono text-xs leading-6 text-ink whitespace-pre-wrap"
><code>{@install_commands}</code></pre>
</section>
<section class="mt-6 border-2 border-strong">
<div class="border-b border-rule bg-panel px-4 py-3">
<h2 class="font-display text-sm uppercase tracking-[0.12em] text-ink">
Publish Reports
</h2>
<p class="mt-1 text-xs text-ink-faint">
No Job claim. Mines listed repos.
</p>
</div>
<pre
id="agents-dump-commands"
class="overflow-x-auto bg-ground px-4 py-4 font-mono text-xs leading-6 text-ink whitespace-pre-wrap"
><code>{@dump_commands}</code></pre>
</section>
<section class="mt-6 border-2 border-strong">
<div class="border-b border-rule bg-panel px-4 py-3">
<h2 class="font-display text-sm uppercase tracking-[0.12em] text-ink">
Pick up Jobs
</h2>
<p class="mt-1 text-xs text-ink-faint">
Check jobs sort first after Reports with findings.
</p>
</div>
<pre
id="agents-pickup-commands"
class="overflow-x-auto bg-ground px-4 py-4 font-mono text-xs leading-6 text-ink whitespace-pre-wrap"
><code>{@pickup_commands}</code></pre>
</section>
<section class="mt-6 border-2 border-strong">
<div class="border-b border-rule bg-panel px-4 py-3">
<h2 class="font-display text-sm uppercase tracking-[0.12em] text-ink">
Report API
</h2>
</div>
<pre
id="agents-report-api"
class="overflow-x-auto bg-ground px-4 py-4 font-mono text-[11px] leading-6 text-ink whitespace-pre"
><code>{@report_api}</code></pre>
</section>
<p class="mt-6 text-sm text-ink-muted">
Kimi, Claude, Codex, Grok, Ollama, OpenRouter.
Update with the curl line or <code class="font-mono text-xs text-ink">tarakan --version</code>.
</p>
<p class="mt-6 font-mono text-xs text-ink-faint">
<.link navigate={~p"/explore"} class="text-signal hover:underline">Explore</.link>
· <.link navigate={~p"/jobs"} class="text-signal hover:underline">Jobs</.link>
·
<.link href={~p"/auth/github?return_to=/agents"} class="text-signal hover:underline">
Sign in
</.link>
</p>
</Layouts.page>
</Layouts.app>
"""
end
end

View file

@ -0,0 +1,281 @@
defmodule TarakanWeb.AlertLive do
@moduledoc """
Watchlist management for finding alerts.
Each watchlist is a named set of `owner/name` repository refs; the daily
`Tarakan.Billing.AlertWorker` emails the account when verified or fixed
findings first appear on those repositories (when `notify` is on).
"""
use TarakanWeb, :live_view
alias Tarakan.Billing
alias Tarakan.Billing.Watchlist
@impl true
def mount(_params, _session, socket) do
account = socket.assigns.current_scope.account
{:ok,
socket
|> assign(:page_title, "Alerts")
|> assign(:editing_id, nil)
|> assign(:form, watchlist_form(%{}))
|> stream(:watchlists, Billing.list_watchlists(account),
dom_id: fn watchlist -> "watchlist-#{watchlist.id}" end
)}
end
@impl true
def handle_event("validate", %{"watchlist" => params}, socket) do
{:noreply, assign(socket, :form, watchlist_form(params))}
end
def handle_event("save", %{"watchlist" => params}, socket) do
attrs = %{"name" => params["name"], "entries" => parse_entries(params["entries"])}
case socket.assigns.editing_id do
nil -> create(socket, attrs)
id -> save_edit(socket, id, attrs)
end
end
def handle_event("edit", %{"id" => id}, socket) do
watchlist = find_watchlist(socket, id)
if watchlist do
{:noreply,
socket
|> assign(:editing_id, watchlist.id)
|> assign(
:form,
watchlist_form(%{
"name" => watchlist.name,
"entries" => Enum.join(watchlist.entries, "\n")
})
)}
else
{:noreply, socket}
end
end
def handle_event("cancel_edit", _params, socket) do
{:noreply,
socket
|> assign(:editing_id, nil)
|> assign(:form, watchlist_form(%{}))}
end
def handle_event("delete", %{"id" => id}, socket) do
with %Watchlist{} = watchlist <- find_watchlist(socket, id),
{:ok, _} <- Billing.delete_watchlist(socket.assigns.current_scope, watchlist) do
{:noreply,
socket
|> stream_delete(:watchlists, watchlist)
|> put_flash(:info, "Watchlist deleted.")}
else
_other -> {:noreply, put_flash(socket, :error, "Could not delete that watchlist.")}
end
end
def handle_event("toggle_notify", %{"id" => id}, socket) do
with %Watchlist{} = watchlist <- find_watchlist(socket, id),
{:ok, updated} <-
Billing.update_watchlist(socket.assigns.current_scope, watchlist, %{
"notify" => !watchlist.notify
}) do
{:noreply, stream_insert(socket, :watchlists, updated)}
else
_other -> {:noreply, put_flash(socket, :error, "Could not update that watchlist.")}
end
end
defp create(socket, attrs) do
case Billing.create_watchlist(socket.assigns.current_scope, attrs) do
{:ok, watchlist} ->
{:noreply,
socket
|> stream_insert(:watchlists, watchlist, at: 0)
|> assign(:form, watchlist_form(%{}))
|> put_flash(:info, "Watchlist created.")}
{:error, %Ecto.Changeset{} = changeset} ->
{:noreply, put_flash(socket, :error, changeset_message(changeset))}
end
end
defp save_edit(socket, id, attrs) do
with %Watchlist{} = watchlist <- find_watchlist(socket, id),
{:ok, updated} <-
Billing.update_watchlist(socket.assigns.current_scope, watchlist, attrs) do
{:noreply,
socket
|> stream_insert(:watchlists, updated)
|> assign(:editing_id, nil)
|> assign(:form, watchlist_form(%{}))
|> put_flash(:info, "Watchlist updated.")}
else
{:error, %Ecto.Changeset{} = changeset} ->
{:noreply, put_flash(socket, :error, changeset_message(changeset))}
_other ->
{:noreply, put_flash(socket, :error, "Could not update that watchlist.")}
end
end
# Watchlists are small and owner-scoped; re-reading the list keeps the
# stream and form lookups simple.
defp find_watchlist(socket, id) do
id = if is_binary(id), do: String.to_integer(id), else: id
account = socket.assigns.current_scope.account
Enum.find(Billing.list_watchlists(account), fn watchlist ->
watchlist.id == id
end)
end
defp parse_entries(nil), do: []
defp parse_entries(text) when is_binary(text) do
text
|> String.split("\n", trim: true)
|> Enum.map(&String.trim/1)
|> Enum.reject(&(&1 == ""))
end
defp changeset_message(changeset) do
details =
changeset
|> Ecto.Changeset.traverse_errors(fn {message, _meta} -> message end)
|> Enum.map_join("; ", fn {field, messages} -> "#{field} #{Enum.join(messages, ", ")}" end)
"The watchlist could not be saved: #{details}."
end
defp watchlist_form(params), do: to_form(params, as: :watchlist)
@impl true
def render(assigns) do
~H"""
<Layouts.app flash={@flash} current_scope={@current_scope} current_path={assigns[:current_path]}>
<Layouts.page width={:focused} class="space-y-8">
<header class="border-b-2 border-strong pb-6">
<h1 class="font-display text-3xl font-medium uppercase leading-none tracking-[0.02em] text-ink">
Alerts
</h1>
<p class="mt-3 text-sm leading-6 text-ink-muted">
Watch repositories and get a daily email when verified or fixed findings first
appear on them. Alert delivery requires an active plan (see <.link
navigate={~p"/pricing"}
class="text-ink underline"
>pricing</.link>).
</p>
</header>
<.form
for={@form}
id="watchlist-form"
phx-change="validate"
phx-submit="save"
class="space-y-5 border-2 border-strong p-5"
>
<h2 class="font-display text-sm uppercase tracking-[0.12em] text-ink">
{if @editing_id, do: "Edit watchlist", else: "New watchlist"}
</h2>
<.input field={@form[:name]} type="text" label="Name" required maxlength="100" />
<.input
field={@form[:entries]}
type="textarea"
label="Repositories - one owner/name per line"
rows="5"
placeholder="openai/codex\nacme/widget"
/>
<div class="flex flex-wrap gap-3">
<button
id="watchlist-save-button"
type="submit"
class="cursor-pointer border-2 border-signal px-5 py-2 font-display text-xs uppercase tracking-[0.12em] text-signal transition hover:bg-signal hover:text-ground"
>
{if @editing_id, do: "Save changes", else: "Create watchlist"}
</button>
<button
:if={@editing_id}
id="watchlist-cancel-button"
type="button"
phx-click="cancel_edit"
class="cursor-pointer border border-strong px-5 py-2 font-display text-xs uppercase tracking-[0.12em] text-ink transition hover:bg-panel"
>
Cancel
</button>
</div>
</.form>
<div id="watchlists" phx-update="stream" class="space-y-4">
<div
id="watchlists-empty"
class="hidden only:block border-2 border-dashed border-rule p-5 text-sm text-ink-faint"
>
No watchlists yet - create one above to start getting alerts.
</div>
<div
:for={{id, watchlist} <- @streams.watchlists}
id={id}
class="border-2 border-strong p-5"
>
<div class="flex flex-wrap items-start justify-between gap-3">
<div class="min-w-0">
<h3 class="font-display text-base uppercase tracking-[0.02em] text-ink">
{watchlist.name}
</h3>
<p class="mt-1 font-mono text-[11px] uppercase tracking-[0.12em] text-ink-faint">
{length(watchlist.entries)} repositories
<span :if={watchlist.last_notified_at}>
· last alerted {Calendar.strftime(watchlist.last_notified_at, "%Y-%m-%d")}
</span>
</p>
</div>
<div class="flex flex-wrap gap-2">
<button
id={"watchlist-notify-#{watchlist.id}"}
type="button"
phx-click="toggle_notify"
phx-value-id={watchlist.id}
class={[
"cursor-pointer border-2 px-3 py-1.5 font-mono text-[11px] uppercase tracking-[0.12em] transition",
watchlist.notify && "border-signal text-signal hover:bg-signal hover:text-ground",
!watchlist.notify && "border-strong text-ink-faint hover:bg-panel hover:text-ink"
]}
>
{if watchlist.notify, do: "Alerts on", else: "Alerts off"}
</button>
<button
id={"watchlist-edit-#{watchlist.id}"}
type="button"
phx-click="edit"
phx-value-id={watchlist.id}
class="cursor-pointer border border-strong px-3 py-1.5 font-mono text-[11px] uppercase tracking-[0.12em] text-ink transition hover:bg-panel"
>
Edit
</button>
<button
id={"watchlist-delete-#{watchlist.id}"}
type="button"
phx-click="delete"
phx-value-id={watchlist.id}
data-confirm="Delete this watchlist?"
class="cursor-pointer border border-strong px-3 py-1.5 font-mono text-[11px] uppercase tracking-[0.12em] text-ink-faint transition hover:bg-panel hover:text-signal"
>
Delete
</button>
</div>
</div>
<ul :if={watchlist.entries != []} class="mt-3 space-y-1 font-mono text-xs text-ink-muted">
<li :for={entry <- watchlist.entries}>{entry}</li>
</ul>
</div>
</div>
</Layouts.page>
</Layouts.app>
"""
end
end

View file

@ -0,0 +1,159 @@
defmodule TarakanWeb.BillingLive do
@moduledoc """
Account billing page: managed-disclosure retainer status, period end, and
the Stripe customer portal exit. No feature gates hang off it.
"""
use TarakanWeb, :live_view
alias Tarakan.Billing
@impl true
def mount(_params, _session, socket) do
account = socket.assigns.current_scope.account
{:ok,
socket
|> assign(:page_title, "Billing")
|> assign(:subscription, Billing.subscription(account))}
end
@impl true
def handle_params(params, _uri, socket) do
socket =
case params["checkout"] do
"success" -> put_flash(socket, :info, "Subscription started - welcome aboard.")
"cancelled" -> put_flash(socket, :info, "Checkout cancelled; nothing was charged.")
_other -> socket
end
{:noreply, socket}
end
@impl true
def handle_event("subscribe", %{"plan" => plan}, socket) do
case Billing.ensure_checkout(socket.assigns.current_scope, plan) do
{:ok, checkout_url} ->
{:noreply, redirect(socket, external: checkout_url)}
{:error, _reason} ->
{:noreply, put_flash(socket, :error, "Checkout could not be started. Try again shortly.")}
end
end
def handle_event("manage", _params, socket) do
case Billing.portal_session(socket.assigns.current_scope.account) do
{:ok, portal_url} ->
{:noreply, redirect(socket, external: portal_url)}
{:error, _reason} ->
{:noreply,
put_flash(socket, :error, "The billing portal is unavailable for this account.")}
end
end
defp status_label(nil), do: "No subscription"
defp status_label(%{status: "active"}), do: "Active"
defp status_label(%{status: "past_due"}), do: "Past due - update your payment method"
defp status_label(%{status: "canceled"}), do: "Canceled"
defp status_label(%{status: "incomplete"}), do: "Incomplete"
defp plan_label(%{plan: "enterprise"}), do: "Managed disclosure"
# Retired tier; kept so historical rows still render.
defp plan_label(%{plan: "team"}), do: "Team (retired)"
defp plan_label(_other), do: "None"
@impl true
def render(assigns) do
~H"""
<Layouts.app flash={@flash} current_scope={@current_scope} current_path={assigns[:current_path]}>
<Layouts.page width={:focused} class="space-y-8">
<header class="border-b-2 border-strong pb-6">
<h1 class="font-display text-3xl font-medium uppercase leading-none tracking-[0.02em] text-ink">
Billing
</h1>
<p class="mt-3 text-sm leading-6 text-ink-muted">
Managed disclosure retainer. Everything else on Tarakan is free.
</p>
</header>
<section id="billing-current" class="border-2 border-strong p-5">
<dl class="grid gap-4 sm:grid-cols-3">
<div>
<dt class="font-mono text-[11px] uppercase tracking-[0.12em] text-ink-faint">Plan</dt>
<dd id="billing-plan" class="mt-1 font-display text-lg uppercase text-ink">
{plan_label(@subscription)}
</dd>
</div>
<div>
<dt class="font-mono text-[11px] uppercase tracking-[0.12em] text-ink-faint">
Status
</dt>
<dd id="billing-status" class="mt-1 font-display text-lg uppercase text-ink">
{status_label(@subscription)}
</dd>
</div>
<div>
<dt class="font-mono text-[11px] uppercase tracking-[0.12em] text-ink-faint">
Current period ends
</dt>
<dd id="billing-period-end" class="mt-1 font-mono text-sm text-ink">
<%= if @subscription && @subscription.current_period_end do %>
{Calendar.strftime(@subscription.current_period_end, "%Y-%m-%d %H:%M UTC")}
<% else %>
-
<% end %>
</dd>
</div>
</dl>
<div
:if={@subscription && @subscription.status == "past_due"}
class="mt-4 border border-signal p-3 text-sm text-ink-muted"
>
Your last payment failed. Update your payment method from the billing
portal to keep the retainer active.
</div>
<div
:if={@subscription && @subscription.status == "canceled"}
class="mt-4 bg-panel p-3 text-sm text-ink-muted"
>
This retainer is canceled. Your account keeps full access to the record,
alerts, and the API.
</div>
<div class="mt-5 flex flex-wrap gap-3">
<.button
:if={@subscription && @subscription.stripe_customer_id}
id="billing-manage-button"
type="button"
phx-click="manage"
size="sm"
>
Manage subscription
</.button>
<.button
:if={is_nil(@subscription) || @subscription.status in ["canceled", "incomplete"]}
id="billing-subscribe-enterprise"
type="button"
variant="primary"
size="sm"
phx-click="subscribe"
phx-value-plan="enterprise"
>
Start managed disclosure
</.button>
</div>
</section>
<p class="text-sm leading-6 text-ink-faint">
See <.link navigate={~p"/pricing"} class="text-ink-muted underline hover:text-ink">
pricing
</.link>. Payments are processed by Stripe.
</p>
</Layouts.page>
</Layouts.app>
"""
end
end

View file

@ -0,0 +1,159 @@
defmodule TarakanWeb.BountyLive.Index do
@moduledoc "Open contracts - the public reward board."
use TarakanWeb, :live_view
import TarakanWeb.BountyComponents
alias Tarakan.Market
@filters [
{"all", "All"},
{"repository", "Repositories"},
{"infestation", "Infestations"},
{"finding", "Findings"}
]
@impl true
def mount(_params, _session, socket) do
{:ok,
socket
|> assign(:page_title, "Contracts")
|> assign(
:meta_description,
"Open contracts on Tarakan. Sponsors post rewards for security work on public targets."
)
|> assign(:canonical_path, ~p"/bounties")
|> assign(:filters, @filters)}
end
@impl true
def handle_params(params, _uri, socket) do
filter = normalize_filter(params["type"])
bounties = Market.list_open(target_type: filter)
{:noreply,
socket
|> assign(:filter, filter || "all")
|> assign(:bounties, bounties)
|> assign(:bounty_count, length(bounties))
|> assign(:open_cents, sum_by(bounties, :amount_cents))
|> assign(:open_credits, sum_by(bounties, :credit_amount))}
end
defp sum_by(bounties, key) do
Enum.reduce(bounties, 0, fn bounty, total -> total + (Map.get(bounty, key) || 0) end)
end
defp normalize_filter(filter) when filter in ["repository", "infestation", "finding"],
do: filter
defp normalize_filter(_), do: nil
@impl true
def render(assigns) do
~H"""
<Layouts.app flash={@flash} current_scope={@current_scope} current_path={assigns[:current_path]}>
<Layouts.page width={:wide}>
<div class="flex flex-col gap-6 border-b-2 border-strong pb-6 sm:flex-row sm:items-end sm:justify-between">
<div class="min-w-0 max-w-2xl">
<h1 class="font-display text-3xl font-medium uppercase leading-none tracking-[0.02em] text-ink sm:text-5xl">
Contracts
</h1>
</div>
<div class="flex shrink-0 flex-wrap items-center gap-4">
<p class="font-mono text-sm tabular-nums text-ink">
<span class="font-display text-3xl">{@bounty_count}</span>
<span class="ml-1 text-xs uppercase tracking-[0.12em] text-ink-faint">open</span>
</p>
<p :if={@open_cents > 0} class="font-mono text-sm tabular-nums text-ink">
<span class="font-display text-3xl">${div(@open_cents, 100)}</span>
<span class="ml-1 text-xs uppercase tracking-[0.12em] text-ink-faint">escrowed</span>
</p>
<p :if={@open_credits > 0} class="font-mono text-sm tabular-nums text-ink">
<span class="font-display text-3xl">{@open_credits}</span>
<span class="ml-1 text-xs uppercase tracking-[0.12em] text-ink-faint">credits</span>
</p>
<.link
navigate={~p"/bounties/new"}
class="border-2 border-signal px-4 py-1.5 font-display text-xs uppercase tracking-[0.12em] text-signal transition hover:bg-signal hover:text-ground"
>
Post a contract
</.link>
</div>
</div>
<div id="bounty-filters" class="mt-6 flex flex-wrap items-center gap-2">
<.link
:for={{value, label} <- @filters}
patch={~p"/bounties?#{%{type: value}}"}
class={[
"wire-badge font-display text-[10px] uppercase tracking-[0.12em] transition",
@filter == value && "bg-signal text-ground",
@filter != value && "text-ink-muted hover:text-ink"
]}
>
<span class="wire-badge-label">{label}</span>
</.link>
</div>
<div
:if={@bounties == []}
id="bounties-empty"
class="mt-8 bg-panel px-6 py-12 text-center"
>
<p class="text-sm font-medium text-ink">No open contracts right now</p>
<p class="mt-2 text-xs leading-5 text-ink-muted">
<.link navigate={~p"/bounties/new"} class="font-semibold text-signal hover:underline">
Post the first one
</.link>
or browse the <.link
navigate={~p"/jobs"}
class="font-semibold text-signal hover:underline"
>jobs queue</.link>.
</p>
</div>
<ul
:if={@bounties != []}
id="bounties"
class="mt-8 grid gap-4 sm:grid-cols-2 xl:grid-cols-3"
>
<li :for={bounty <- @bounties} id={"bounty-#{bounty.public_id}"}>
<.link
navigate={~p"/bounties/#{bounty.public_id}"}
class="group flex h-full flex-col border-2 border-strong bg-panel transition-colors hover:bg-ground"
>
<div class="flex items-center justify-between border-b-2 border-strong px-4 py-2">
<.notch_badge class="bg-signal text-ground">Contract</.notch_badge>
<span class="font-mono text-[10px] uppercase tracking-[0.12em] text-ink-faint">
{bounty.target_type}
</span>
</div>
<div class="flex flex-1 flex-col px-4 py-4">
<p class="font-display text-3xl font-medium leading-none text-signal">
{amount_label(bounty)}
</p>
<p class="mt-3 text-sm font-semibold leading-5 text-ink group-hover:text-signal">
{bounty.title}
</p>
<p class="mt-2 line-clamp-2 text-xs leading-5 text-ink-muted">
{bounty.description}
</p>
<p class="mt-auto pt-4 font-mono text-[11px] text-ink-faint">
{target_label(bounty)}
<span :if={bounty.sponsor_account} class="mx-1">·</span>
<span :if={bounty.sponsor_account}>by @{bounty.sponsor_account.handle}</span>
<span :if={bounty.expires_at} class="mx-1">·</span>
<span :if={bounty.expires_at}>
expires {Calendar.strftime(bounty.expires_at, "%b %-d")}
</span>
</p>
</div>
</.link>
</li>
</ul>
</Layouts.page>
</Layouts.app>
"""
end
end

View file

@ -0,0 +1,386 @@
defmodule TarakanWeb.BountyLive.New do
@moduledoc """
Post a new contract (a bounty internally).
This route lives in the public live session so `/bounties/:public_id` can
stay public; it enforces authentication in mount and redirects anonymous
visitors to the login page.
Targets are picked, not typed: repositories come from a live search and
infestations from a select, so `target_ref` always holds a reference the
market can resolve. `?target_type=&target_ref=` prefills the form, which is
how repository and infestation pages link into it.
"""
use TarakanWeb, :live_view
alias Tarakan.Credits
alias Tarakan.Infestations
alias Tarakan.Market
alias Tarakan.Repositories
alias Tarakan.Repositories.Repository
@target_types [
{"Repository", "repository"},
{"Infestation", "infestation"},
{"Finding", "finding"}
]
@fundings [{"Credits", "credits"}, {"Card (fiat escrow)", "fiat"}]
@search_limit 8
@infestation_limit 50
@impl true
def mount(params, _session, socket) do
scope = socket.assigns.current_scope
if scope && scope.account do
form_params = prefill(params)
{:ok,
socket
|> assign(:page_title, "Post a contract")
|> assign(:target_types, @target_types)
|> assign(:fundings, @fundings)
|> assign(:credit_balance, Credits.balance(scope.account))
|> assign(:infestation_options, infestation_options())
|> assign(:repo_results, [])
|> assign_form(form_params)}
else
{:ok,
socket
|> put_flash(:error, "You must log in to access this page.")
|> redirect(to: ~p"/accounts/log-in")}
end
end
defp prefill(params) do
%{
"target_type" => present_or(params["target_type"], "repository"),
"target_ref" => present_or(params["target_ref"], ""),
"funding" => present_or(params["funding"], "credits")
}
end
defp present_or(value, default) when is_binary(value) do
if String.trim(value) == "", do: default, else: value
end
defp present_or(_value, default), do: default
@impl true
def handle_event("validate", %{"bounty" => params}, socket) do
{:noreply,
socket
|> assign_form(params)
|> search_repositories(params)}
end
def handle_event("pick_repository", %{"ref" => ref}, socket) do
params =
socket.assigns.form.params
|> Map.put("target_ref", ref)
|> Map.put("repo_query", "")
{:noreply,
socket
|> assign_form(params)
|> assign(:repo_results, [])}
end
def handle_event("clear_repository", _params, socket) do
{:noreply,
socket
|> assign_form(Map.put(socket.assigns.form.params, "target_ref", ""))
|> assign(:repo_results, [])}
end
def handle_event("create", %{"bounty" => params}, socket) do
attrs = build_attrs(params)
case Market.create_bounty(socket.assigns.current_scope, attrs) do
{:ok, bounty} ->
{:noreply,
socket
|> put_flash(:info, "Contract posted - credits are held in escrow until settlement.")
|> push_navigate(to: ~p"/bounties/#{bounty.public_id}")}
{:ok, _bounty, checkout_url} ->
{:noreply, redirect(socket, external: checkout_url)}
{:error, reason} ->
{:noreply, put_flash(socket, :error, create_error_message(reason))}
end
end
# The repository search runs only while the repository target is selected and
# nothing has been picked yet - once a ref is set the results list is noise.
defp search_repositories(socket, params) do
query = params["repo_query"] || ""
results =
if params["target_type"] == "repository" and blank?(params["target_ref"]) do
Repositories.search_repositories(query, @search_limit)
else
[]
end
assign(socket, :repo_results, results)
end
defp assign_form(socket, params) do
params = Map.put_new(params, "target_type", "repository")
socket
|> assign(:form, to_form(params, as: :bounty))
|> assign(:target_type, params["target_type"])
|> assign(:funding, if(params["funding"] == "fiat", do: "fiat", else: "credits"))
|> assign(:selected_repository, selected_repository(params))
end
defp selected_repository(%{"target_type" => "repository", "target_ref" => ref})
when is_binary(ref) do
if String.trim(ref) == "", do: nil, else: ref
end
defp selected_repository(_params), do: nil
defp blank?(nil), do: true
defp blank?(value) when is_binary(value), do: String.trim(value) == ""
defp infestation_options do
Infestations.list_infestations(limit: @infestation_limit)
|> Enum.map(fn infestation ->
{"#{infestation.title} (#{infestation.repo_count} repos)", infestation.pattern_key}
end)
end
# Hosted repositories are addressed without a host; remote ones need theirs.
defp repository_ref(%Repository{} = repository) do
if Repository.hosted?(repository) do
"#{repository.owner}/#{repository.name}"
else
"#{repository.host}/#{repository.owner}/#{repository.name}"
end
end
defp build_attrs(params) do
funding = if params["funding"] == "fiat", do: "fiat", else: "credits"
base = %{
"target_type" => params["target_type"],
"target_ref" => params["target_ref"],
"title" => params["title"],
"description" => params["description"],
"funding" => funding,
"expires_days" => params["expires_days"]
}
case funding do
"fiat" -> Map.put(base, "amount_cents", dollars_to_cents(params["amount"]))
"credits" -> Map.put(base, "credit_amount", parse_integer(params["amount"]))
end
end
defp dollars_to_cents(nil), do: nil
defp dollars_to_cents(value) when is_binary(value) do
case Float.parse(String.trim(value)) do
{dollars, ""} -> round(dollars * 100)
_invalid -> nil
end
end
defp parse_integer(nil), do: nil
defp parse_integer(value) when is_binary(value) do
case Integer.parse(String.trim(value)) do
{integer, ""} -> integer
_invalid -> nil
end
end
defp create_error_message(:insufficient_credits),
do: "Not enough credits to fund this contract."
defp create_error_message(:target_not_found),
do: "That target was not found. Check the reference and try again."
defp create_error_message(:target_not_public),
do: "Contracts can only target publicly visible repositories and findings."
defp create_error_message(:unauthorized),
do: "Only active accounts can post contracts."
defp create_error_message(%Ecto.Changeset{} = changeset) do
details =
changeset
|> Ecto.Changeset.traverse_errors(fn {message, _meta} -> message end)
|> Enum.map_join("; ", fn {field, messages} -> "#{field} #{Enum.join(messages, ", ")}" end)
"The contract could not be created: #{details}."
end
defp create_error_message(_reason),
do: "The contract could not be created. Try again shortly."
@impl true
def render(assigns) do
~H"""
<Layouts.app flash={@flash} current_scope={@current_scope} current_path={assigns[:current_path]}>
<Layouts.page width={:focused}>
<div class="border-b-2 border-strong pb-6">
<h1 class="font-display text-3xl font-medium uppercase leading-none tracking-[0.02em] text-ink">
Post a contract
</h1>
</div>
<.form
for={@form}
id="bounty-form"
phx-change="validate"
phx-submit="create"
class="mt-6 space-y-5"
>
<.input
field={@form[:target_type]}
type="select"
label="Target type"
options={@target_types}
/>
<div :if={@target_type == "repository"}>
<div :if={@selected_repository} class="flex items-center gap-3">
<span class="font-mono text-xs uppercase tracking-[0.12em] text-ink-faint">Target</span>
<span
id="bounty-selected-repository"
class="min-w-0 flex-1 truncate border-2 border-strong bg-panel px-3 py-2 font-mono text-xs text-ink"
>
{@selected_repository}
</span>
<button
type="button"
phx-click="clear_repository"
class="cursor-pointer font-mono text-[11px] uppercase tracking-[0.12em] text-signal hover:underline"
>
Change
</button>
</div>
<div :if={is_nil(@selected_repository)}>
<.input
field={@form[:repo_query]}
type="text"
label="Repository"
placeholder="Search listed repositories"
phx-debounce="200"
autocomplete="off"
/>
<ul
:if={@repo_results != []}
id="bounty-repo-results"
class="mt-2 divide-y divide-rule border-2 border-strong bg-ground"
>
<li :for={repository <- @repo_results}>
<button
type="button"
phx-click="pick_repository"
phx-value-ref={repository_ref(repository)}
class="flex w-full cursor-pointer items-baseline gap-2 px-3 py-2 text-left transition hover:bg-panel"
>
<span class="min-w-0 flex-1 truncate font-mono text-xs text-ink">
{repository.owner}/{repository.name}
</span>
<span class="shrink-0 font-mono text-[10px] uppercase tracking-[0.12em] text-ink-faint">
{repository.host}
</span>
</button>
</li>
</ul>
<p
:if={@repo_results == [] and not blank?(@form.params["repo_query"])}
class="mt-2 font-mono text-[11px] text-ink-faint"
>
No listed repository matches.
</p>
</div>
</div>
<.input
:if={@target_type == "infestation"}
field={@form[:target_ref]}
type="select"
label="Infestation"
options={@infestation_options}
prompt="Choose an infestation"
/>
<div :if={@target_type == "finding"}>
<.input
field={@form[:target_ref]}
type="text"
label="Finding"
placeholder="Public id from the finding page"
autocomplete="off"
/>
<p class="mt-2 font-mono text-[11px] leading-5 text-ink-faint">
The id in the finding's URL, <code>/findings/&lt;id&gt;</code>.
</p>
</div>
<input
:if={@target_type == "repository"}
type="hidden"
name="bounty[target_ref]"
value={@form.params["target_ref"] || ""}
/>
<.input field={@form[:title]} type="text" label="Title" required maxlength="200" />
<.input
field={@form[:description]}
type="textarea"
label="What should the hunter do?"
rows="5"
required
/>
<.input
field={@form[:funding]}
type="select"
label="Fund with"
options={@fundings}
/>
<.input
field={@form[:amount]}
type="text"
label={if @funding == "fiat", do: "Amount (USD)", else: "Amount (credits)"}
placeholder={if @funding == "fiat", do: "250", else: "500"}
required
/>
<p
:if={@funding == "credits"}
id="bounty-credit-balance"
class="-mt-3 font-mono text-[11px] leading-5 text-ink-faint"
>
Your balance: <span class="text-ink">{@credit_balance} credits</span>
· platform take rate applies at settlement.
</p>
<p
:if={@funding == "fiat"}
class="-mt-3 font-mono text-[11px] leading-5 text-ink-faint"
>
Held by Stripe until the work is accepted · platform take rate applies at settlement.
</p>
<button
id="bounty-create-button"
type="submit"
class="cursor-pointer border-2 border-signal px-5 py-2 font-display text-xs uppercase tracking-[0.12em] text-signal transition hover:bg-signal hover:text-ground"
>
Post contract
</button>
</.form>
</Layouts.page>
</Layouts.app>
"""
end
end

View file

@ -0,0 +1,321 @@
defmodule TarakanWeb.BountyLive.Show do
@moduledoc "One contract: target, reward, claims, and sponsor/moderator controls."
use TarakanWeb, :live_view
import TarakanWeb.BountyComponents
alias Tarakan.Market
alias Tarakan.Market.Bounty
alias Tarakan.Policy
@impl true
def mount(%{"public_id" => public_id}, _session, socket) do
bounty = Market.get_bounty!(public_id)
{:ok,
socket
|> assign(:page_title, "Contract: #{bounty.title}")
|> assign(:meta_description, meta_description(bounty))
|> assign(:canonical_path, ~p"/bounties/#{bounty.public_id}")
|> assign_bounty(bounty)}
end
@impl true
def handle_event("claim", _params, socket) do
case Market.claim_bounty(socket.assigns.current_scope, socket.assigns.bounty) do
{:ok, _claim} ->
{:noreply,
socket
|> put_flash(:info, "Contract claimed - the job is yours for the next two hours.")
|> reload_bounty()}
{:error, reason} ->
{:noreply, put_flash(socket, :error, claim_error_message(reason))}
end
end
def handle_event("cancel", _params, socket) do
case Market.cancel_bounty(socket.assigns.current_scope, socket.assigns.bounty) do
{:ok, _bounty} ->
{:noreply,
socket
|> put_flash(:info, "Contract cancelled. Escrowed funds were returned.")
|> reload_bounty()}
{:error, reason} ->
{:noreply, put_flash(socket, :error, cancel_error_message(reason))}
end
end
def handle_event("settle", _params, socket) do
case Market.settle_bounty(socket.assigns.current_scope, socket.assigns.bounty) do
{:ok, bounty} ->
message =
if bounty.funding == "fiat",
do: "Contract settled. Payout is pending manual processing.",
else: "Contract settled. Credits were paid to the winner."
{:noreply, socket |> put_flash(:info, message) |> reload_bounty()}
{:error, reason} ->
{:noreply, put_flash(socket, :error, settle_error_message(reason))}
end
end
def handle_event("mark_paid", _params, socket) do
case Market.mark_paid(socket.assigns.current_scope, socket.assigns.bounty) do
{:ok, _bounty} ->
{:noreply, socket |> put_flash(:info, "Contract marked paid.") |> reload_bounty()}
{:error, _reason} ->
{:noreply, put_flash(socket, :error, "The contract could not be marked paid.")}
end
end
defp reload_bounty(socket) do
assign_bounty(socket, Market.get_bounty!(socket.assigns.bounty.public_id))
end
defp assign_bounty(socket, bounty) do
scope = socket.assigns.current_scope
account_id = scope && scope.account_id
claims = Enum.sort_by(bounty.claims, & &1.id, :desc)
my_claim =
if account_id, do: Enum.find(claims, &(&1.account_id == account_id)), else: nil
socket
|> assign(:bounty, bounty)
|> assign(:claims, claims)
|> assign(:claim_count, length(claims))
|> assign(:my_claim, my_claim)
|> assign(:sponsor?, account_id != nil and account_id == bounty.sponsor_account_id)
|> assign(:winner?, account_id != nil and account_id == bounty.winner_account_id)
|> assign(:moderator?, scope != nil and Policy.moderator?(scope))
|> assign(:admin?, scope != nil and Policy.admin?(scope))
|> assign(:can_claim?, can_claim?(scope, bounty))
|> assign(:can_cancel?, can_cancel?(scope, bounty))
end
defp can_claim?(nil, _bounty), do: false
defp can_claim?(scope, %Bounty{} = bounty) do
scope.account_id != nil and scope.account_state == "active" and
bounty.status == "open" and scope.account_id != bounty.sponsor_account_id
end
defp can_cancel?(nil, _bounty), do: false
defp can_cancel?(scope, %Bounty{} = bounty) do
bounty.status in ["pending_funding", "open"] and bounty.claims == [] and
(scope.account_id == bounty.sponsor_account_id or Policy.moderator?(scope))
end
defp meta_description(bounty) do
"#{amount_label(bounty)} contract on #{target_label(bounty)}: #{bounty.title}"
|> String.replace(~r/\s+/, " ")
|> String.slice(0, 160)
end
defp claim_error_message(:own_bounty), do: "You cannot claim your own contract."
defp claim_error_message(:not_open), do: "This contract is no longer open."
defp claim_error_message(:claim_limit), do: "You already hold too many active claims."
defp claim_error_message(:unauthorized), do: "Your account cannot claim contracts."
defp claim_error_message(_reason), do: "The contract could not be claimed. Try again shortly."
defp cancel_error_message(:has_claims), do: "A contract with claims cannot be cancelled."
defp cancel_error_message(:invalid_state), do: "This contract can no longer be cancelled."
defp cancel_error_message(:unauthorized), do: "You cannot cancel this contract."
defp cancel_error_message(_reason),
do: "The contract could not be cancelled. Try again shortly."
defp settle_error_message(:no_winning_claim), do: "No submitted or accepted claim to settle."
defp settle_error_message(:invalid_state), do: "This contract is not awaiting settlement."
defp settle_error_message(_reason), do: "The contract could not be settled. Try again shortly."
@impl true
def render(assigns) do
~H"""
<Layouts.app flash={@flash} current_scope={@current_scope} current_path={assigns[:current_path]}>
<Layouts.page width={:focused}>
<div id={"bounty-poster-#{@bounty.public_id}"} class="border-2 border-strong">
<div class="flex items-center justify-between border-b-2 border-strong bg-panel px-5 py-3">
<.notch_badge class="bg-signal text-ground">Contract</.notch_badge>
<span
id="bounty-status"
class="font-mono text-[10px] uppercase tracking-[0.12em] text-ink-faint"
>
{status_label(@bounty.status)}
</span>
</div>
<div class="px-5 py-6">
<p id="bounty-amount" class="font-display text-5xl font-medium leading-none text-signal">
{amount_label(@bounty)}
</p>
<h1 class="mt-4 font-display text-2xl font-medium uppercase leading-tight tracking-[0.02em] text-ink">
{@bounty.title}
</h1>
<dl class="mt-5 space-y-2 border-y-2 border-rule py-4 font-mono text-xs text-ink-muted">
<div class="flex justify-between gap-4">
<dt class="uppercase tracking-[0.12em] text-ink-faint">Target</dt>
<dd class="text-right">
<%= if path = target_path(@bounty) do %>
<.link navigate={path} class="text-signal hover:underline">
{target_label(@bounty)}
</.link>
<% else %>
{target_label(@bounty)}
<% end %>
</dd>
</div>
<div class="flex justify-between gap-4">
<dt class="uppercase tracking-[0.12em] text-ink-faint">Sponsor</dt>
<dd>
<.link
navigate={~p"/#{@bounty.sponsor_account.handle}"}
class="text-signal hover:underline"
>
@{@bounty.sponsor_account.handle}
</.link>
</dd>
</div>
<div class="flex justify-between gap-4">
<dt class="uppercase tracking-[0.12em] text-ink-faint">Funding</dt>
<dd>{if @bounty.funding == "fiat", do: "card escrow", else: "credits"}</dd>
</div>
<div :if={@bounty.expires_at} class="flex justify-between gap-4">
<dt class="uppercase tracking-[0.12em] text-ink-faint">Expires</dt>
<dd>{Calendar.strftime(@bounty.expires_at, "%Y-%m-%d")}</dd>
</div>
<div class="flex justify-between gap-4">
<dt class="uppercase tracking-[0.12em] text-ink-faint">Claims</dt>
<dd>{@claim_count}</dd>
</div>
<div :if={@bounty.winner_account} class="flex justify-between gap-4">
<dt class="uppercase tracking-[0.12em] text-ink-faint">Winner</dt>
<dd>
<.link
navigate={~p"/#{@bounty.winner_account.handle}"}
class="text-signal hover:underline"
>
@{@bounty.winner_account.handle}
</.link>
</dd>
</div>
</dl>
<p class="mt-5 whitespace-pre-wrap text-sm leading-6 text-ink">{@bounty.description}</p>
<div
:if={@winner? and @bounty.status == "payout_pending"}
class="mt-5 border-2 border-rule bg-panel px-4 py-3 text-xs leading-5 text-ink-muted"
>
Payout pending - the platform processes fiat payouts manually. You'll be
contacted at your account email.
</div>
<div
:if={@sponsor? and @bounty.status == "payout_pending"}
class="mt-5 border-2 border-rule bg-panel px-4 py-3 text-xs leading-5 text-ink-muted"
>
Payout pending - escrowed funds are being released to the winner.
</div>
<div class="mt-6 flex flex-wrap items-center gap-3">
<button
:if={@can_claim?}
id="bounty-claim-button"
type="button"
phx-click="claim"
class="cursor-pointer border-2 border-signal px-5 py-2 font-display text-xs uppercase tracking-[0.12em] text-signal transition hover:bg-signal hover:text-ground"
>
Claim this contract
</button>
<.link
:if={not @can_claim? and @bounty.status == "open" and @current_scope == nil}
id="bounty-login-to-claim"
navigate={~p"/accounts/log-in"}
class="border-2 border-signal px-5 py-2 font-display text-xs uppercase tracking-[0.12em] text-signal transition hover:bg-signal hover:text-ground"
>
Log in to claim
</.link>
<button
:if={@can_cancel?}
id="bounty-cancel-button"
type="button"
phx-click="cancel"
data-confirm="Cancel this contract? Escrowed funds will be returned."
class="cursor-pointer border border-strong px-5 py-2 font-display text-xs uppercase tracking-[0.12em] text-ink-muted transition hover:border-ink hover:text-ink"
>
Cancel contract
</button>
<button
:if={@moderator? and @bounty.status == "claimed"}
id="bounty-settle-button"
type="button"
phx-click="settle"
class="cursor-pointer border-2 border-signal px-5 py-2 font-display text-xs uppercase tracking-[0.12em] text-signal transition hover:bg-signal hover:text-ground"
>
Settle contract
</button>
<button
:if={@admin? and @bounty.status == "payout_pending"}
id="bounty-mark-paid-button"
type="button"
phx-click="mark_paid"
class="cursor-pointer border-2 border-signal px-5 py-2 font-display text-xs uppercase tracking-[0.12em] text-signal transition hover:bg-signal hover:text-ground"
>
Mark paid
</button>
</div>
</div>
</div>
<section :if={@claims != []} id="bounty-claims" class="mt-8">
<h2 class="font-mono text-[11px] uppercase tracking-[0.12em] text-ink-faint">
Claims
</h2>
<ul class="mt-3 divide-y divide-rule border-2 border-strong">
<li
:for={claim <- @claims}
id={"bounty-claim-#{claim.id}"}
class="flex items-center justify-between gap-4 px-4 py-3"
>
<span class="font-mono text-xs text-ink">
@{claim.account.handle}
<span :if={claim.review_task} class="text-ink-faint">
·
<.link
navigate={~p"/jobs/#{claim.review_task.id}"}
class="text-signal hover:underline"
>job</.link>
</span>
</span>
<span class="font-mono text-[10px] uppercase tracking-[0.12em] text-ink-muted">
{claim.status}
</span>
</li>
</ul>
</section>
<p
:if={@my_claim && @my_claim.status in ["claimed", "submitted"]}
class="mt-6 text-xs leading-5 text-ink-muted"
>
Your claim is backed by
<.link
navigate={~p"/jobs/#{@my_claim.review_task.id}"}
class="font-semibold text-signal hover:underline"
>
this job
</.link>
- submit your work there. Settlement follows acceptance.
</p>
</Layouts.page>
</Layouts.app>
"""
end
end

View file

@ -0,0 +1,189 @@
defmodule TarakanWeb.ClientAuthorizationLive do
use TarakanWeb, :live_view
alias Tarakan.Accounts.ClientAuthorizations
@impl true
def mount(%{"user_code" => user_code}, _session, socket) do
actor_key = socket.assigns.current_scope.account.id
case ClientAuthorizations.get_for_browser(user_code, actor_key) do
{:ok, authorization} ->
{:ok,
socket
|> assign(:authorization, authorization)
|> assign(
:display_code,
ClientAuthorizations.display_user_code(authorization.user_code)
)
|> assign(:page_title, "Authorize Tarakan Client")}
# Throttled and missing look the same on purpose: telling a grinder which
# of its guesses were merely rate limited would hand back the oracle.
{:error, _reason} ->
{:ok,
socket
|> assign(:authorization, nil)
|> assign(:display_code, user_code)
|> assign(:page_title, "Login request expired")}
end
end
@impl true
def handle_event(action, _params, %{assigns: %{authorization: nil}} = socket)
when action in ["approve", "deny"] do
{:noreply, put_flash(socket, :error, "This login request has expired or was already used.")}
end
def handle_event("approve", _params, socket) do
account = socket.assigns.current_scope.account
case ClientAuthorizations.approve(socket.assigns.authorization, account) do
{:ok, authorization} ->
{:noreply,
socket
|> assign(:authorization, authorization)
|> put_flash(:info, "Connected. You can go back to the terminal.")}
{:error, _reason} ->
{:noreply,
socket
|> assign(:authorization, nil)
|> put_flash(:error, "This login request expired or was already used.")}
end
end
def handle_event("deny", _params, socket) do
account = socket.assigns.current_scope.account
case ClientAuthorizations.deny(socket.assigns.authorization, account) do
{:ok, authorization} ->
{:noreply,
socket
|> assign(:authorization, authorization)
|> put_flash(:info, "Denied.")}
{:error, _reason} ->
{:noreply, assign(socket, :authorization, nil)}
end
end
@impl true
def render(assigns) do
~H"""
<Layouts.app flash={@flash} current_scope={@current_scope} current_path={assigns[:current_path]}>
<Layouts.page id="client-authorization-page" width={:compact}>
<section class={[
"border-2 border-strong bg-panel p-6 shadow-[8px_8px_0_0_var(--color-rule)] sm:p-8"
]}>
<div class={["flex items-start gap-4"]}>
<div class={[
"flex size-11 shrink-0 items-center justify-center bg-signal text-ground"
]}>
<.icon name="hero-command-line" class={["size-6"]} />
</div>
<div class={["min-w-0"]}>
<h1 class={[
"font-display text-2xl font-medium uppercase tracking-[0.02em] text-ink"
]}>
Connect your terminal
</h1>
<p class={["mt-2 text-sm leading-6 text-ink-muted"]}>
Approving lets the client claim jobs as @{@current_scope.account.handle}.
</p>
</div>
</div>
<%= cond do %>
<% is_nil(@authorization) -> %>
<div id="client-authorization-expired" class={["mt-8 border-2 border-rule p-5"]}>
<p class={["text-sm text-ink"]}>
That code is invalid or expired. Run <code class="font-mono">tarakan login</code>
again.
</p>
</div>
<% @authorization.status == "approved" -> %>
<div
id="client-authorization-approved"
class={["mt-8 border-2 border-phosphor bg-ground p-5"]}
>
<p class={["text-sm font-semibold text-ink"]}>Connected.</p>
<p class={["mt-1 text-sm text-ink-muted"]}>Back to your terminal.</p>
</div>
<% @authorization.status == "denied" -> %>
<div id="client-authorization-denied" class={["mt-8 border-2 border-rule p-5"]}>
<p class={["text-sm text-ink"]}>Denied. Nothing was granted.</p>
</div>
<% true -> %>
<div id="client-authorization-pending" class={["mt-8"]}>
<p class={["text-xs font-semibold text-ink-muted"]}>
Code
</p>
<%!-- Deliberately outside the two-token tracking scale (0.12em
labels / 0.02em display): this is a code the user transcribes
by hand, and the wide gaps keep characters from running together. --%>
<div
id="client-authorization-code"
class={[
"mt-2 border-2 border-strong bg-ground px-4 py-3 font-mono text-2xl tracking-[0.2em] text-ink sm:text-3xl"
]}
>
{@display_code}
</div>
<%!-- The name comes from whoever started the login, not from
Tarakan, so it is attributed rather than asserted: a
request that calls itself something official must not read
as though this page vouches for it. --%>
<p class={["mt-5 text-xs font-semibold text-ink-muted"]}>
Calls itself
</p>
<p
id="client-authorization-client-name"
class={[
"mt-1 border-2 border-rule bg-ground px-3 py-2 font-mono text-sm text-ink"
]}
>
{@authorization.client_name}
</p>
<p class={["mt-4 text-sm text-ink-muted"]}>
Approving lets it claim jobs and submit reviews as
<span class="font-semibold text-ink">@{@current_scope.account.handle}</span>
for about 7 days. Revoke anytime in settings.
</p>
<p class={["mt-2 text-sm text-ink-muted"]}>
Only approve this if you just ran
<code class="font-mono text-ink">tarakan login</code>
yourself and the code above matches your terminal. Nobody at Tarakan will
ever ask you for it.
</p>
<div class={["mt-6 grid gap-3 sm:grid-cols-2"]}>
<button
id="client-authorization-deny-button"
phx-click="deny"
class={[
"flex h-12 items-center justify-center border border-strong px-4 font-display text-sm uppercase tracking-[0.12em] text-ink transition hover:bg-ground"
]}
>
Deny
</button>
<button
id="client-authorization-approve-button"
phx-click="approve"
class={[
"clip-notch flex h-12 items-center justify-center bg-btn px-4 font-display text-sm uppercase tracking-[0.12em] text-btn-fg transition hover:opacity-90"
]}
>
Approve
</button>
</div>
</div>
<% end %>
</section>
</Layouts.page>
</Layouts.app>
"""
end
end

View file

@ -0,0 +1,139 @@
defmodule TarakanWeb.ExploreLive do
use TarakanWeb, :live_view
alias Tarakan.Activity
alias Tarakan.Infestations
alias Tarakan.Reputation
alias TarakanWeb.Presence
@wire_limit 50
# A search sweeps a wider slice of history than the visible window.
@search_scan_limit 200
@presence_topic "explore:observers"
# Product language: Reports / Checks. Wire kinds stay :scan / :verdict internally.
@kinds %{
"all" => nil,
"registrations" => :registration,
"reports" => :scan,
"checks" => :verdict,
"comments" => :comment
}
@impl true
def mount(_params, _session, socket) do
if connected?(socket) do
Activity.subscribe()
Reputation.subscribe()
Phoenix.PubSub.subscribe(Tarakan.PubSub, @presence_topic)
{:ok, _ref} = Presence.track(self(), @presence_topic, socket.id, %{})
end
{:ok,
socket
|> assign(:page_title, "Explore")
|> assign(
:meta_description,
"Public record: Reports, Checks, registrations, discussion."
)
|> assign(:canonical_path, ~p"/explore")
|> assign(:kind, "all")
|> assign(:query, "")
|> assign(:watcher_count, watcher_count())
|> assign(:hot_findings, Activity.hot_findings())
|> assign(:infestations, Infestations.list_infestations(min_repos: 2, days: 30, limit: 5))
|> load_wire()}
end
@impl true
def handle_event("filter", %{"kind" => kind}, socket) when is_map_key(@kinds, kind) do
{:noreply, socket |> assign(:kind, kind) |> load_wire()}
end
def handle_event("search", params, socket) do
query = params |> Map.get("q", "") |> String.trim()
{:noreply, socket |> assign(:query, query) |> load_wire()}
end
@impl true
def handle_info({:activity, entry}, socket) do
if matches?(entry, socket.assigns.kind, socket.assigns.query) do
{:noreply, stream_insert(socket, :wire, entry, at: 0, limit: @wire_limit)}
else
{:noreply, socket}
end
end
def handle_info({:vote_changed, "canonical_finding", _id}, socket) do
{:noreply, assign(socket, :hot_findings, Activity.hot_findings())}
end
def handle_info({:vote_changed, _subject_type, _id}, socket), do: {:noreply, socket}
def handle_info(%Phoenix.Socket.Broadcast{event: "presence_diff"}, socket) do
{:noreply, assign(socket, :watcher_count, watcher_count())}
end
defp load_wire(socket) do
%{kind: kind, query: query} = socket.assigns
scan_limit = if query == "", do: @wire_limit, else: @search_scan_limit
# Scope the kind in the query so a rare kind returns its own `scan_limit`
# rows instead of being crowded out of the merged newest-N window.
entries =
scan_limit
|> Activity.recent(verified_only: false, kind: Map.fetch!(@kinds, kind))
|> Enum.filter(&matches_query?(&1, query))
|> Enum.take(@wire_limit)
stream(socket, :wire, entries, reset: true)
end
defp matches?(entry, kind, query) do
matches_kind?(entry, kind) and matches_query?(entry, query)
end
defp matches_kind?(_entry, "all"), do: true
defp matches_kind?(entry, kind), do: entry.kind == Map.fetch!(@kinds, kind)
defp matches_query?(_entry, ""), do: true
defp matches_query?(entry, query) do
haystack =
[
Map.get(entry, :handle),
Map.get(entry, :finding_title),
entry.host,
"#{entry.owner}/#{entry.name}"
]
|> Enum.reject(&is_nil/1)
|> Enum.join(" ")
|> String.downcase()
String.contains?(haystack, String.downcase(query))
end
defp watcher_count do
@presence_topic |> Presence.list() |> map_size()
end
# Reviews and verdicts live on the security tab; comments on the finding
# page; a registration points at the repository itself.
defp wire_entry_path(%{kind: :registration} = entry) do
TarakanWeb.RepositoryPaths.repository_path(entry)
end
defp wire_entry_path(%{kind: :comment} = entry) do
"/findings/#{entry.finding_public_id}"
end
defp wire_entry_path(entry) do
TarakanWeb.RepositoryPaths.repository_security_path(entry)
end
defp ledger_time(%DateTime{} = datetime) do
Calendar.strftime(datetime, "%Y-%m-%d %H:%M")
end
defp filter_active?(current, value), do: current == value
end

View file

@ -0,0 +1,263 @@
<Layouts.app flash={@flash} current_scope={@current_scope} current_path={assigns[:current_path]}>
<Layouts.page>
<.breadcrumbs id="explore-breadcrumb">
<:crumb navigate={~p"/"}>registry</:crumb>
<:crumb>explore</:crumb>
</.breadcrumbs>
<div class="flex flex-col gap-3 border-b-2 border-strong pb-5 sm:flex-row sm:items-end sm:justify-between">
<div>
<h1 class="font-display text-3xl font-medium uppercase tracking-[0.02em] text-ink sm:text-4xl">
Explore
</h1>
<p class="mt-2 max-w-2xl text-sm leading-6 text-ink-muted">
Reports, Checks, registrations, and discussion as they hit the record.
</p>
</div>
<div class="flex flex-col items-start gap-2 sm:items-end">
<span
id="explore-watchers"
class="flex items-center gap-2 font-mono text-[10px] uppercase tracking-[0.12em] text-ink-faint"
>
<span class="size-1.5 rounded-full bg-phosphor"></span>
<span><span class="text-phosphor">{@watcher_count}</span> watching</span>
</span>
<div
id="explore-filter"
class="flex max-w-full overflow-x-auto overscroll-x-contain border border-strong font-display text-[10px] uppercase tracking-[0.12em] [-ms-overflow-style:none] [scrollbar-width:none] [&::-webkit-scrollbar]:hidden"
role="group"
aria-label="Filter by kind"
>
<button
:for={
{value, label} <- [
{"all", "Everything"},
{"registrations", "Registrations"},
{"reports", "Reports"},
{"checks", "Checks"},
{"comments", "Comments"}
]
}
type="button"
phx-click="filter"
phx-value-kind={value}
aria-pressed={to_string(filter_active?(@kind, value))}
class={[
"shrink-0 px-3 py-2 transition sm:py-1.5",
filter_active?(@kind, value) && "bg-btn text-btn-fg",
!filter_active?(@kind, value) && "text-ink-faint hover:text-ink"
]}
>
{label}
</button>
</div>
</div>
</div>
<div class="mt-6 grid gap-10 lg:grid-cols-[minmax(0,1fr)_minmax(15rem,0.32fr)]">
<section class="min-w-0">
<form id="explore-search-form" phx-change="search" phx-submit="search">
<label
for="explore-search-input"
class="group flex cursor-text items-center gap-3 border-b border-rule px-3 sm:px-4"
>
<span
aria-hidden="true"
class="select-none font-mono text-sm text-ink-faint transition group-focus-within:text-phosphor"
>
&gt;
</span>
<input
id="explore-search-input"
type="text"
name="q"
value={@query}
autocomplete="off"
placeholder="search the wire: handle or repository"
phx-debounce="200"
phx-hook="SearchShortcut"
class="h-12 w-full border-0 bg-transparent font-mono text-xs text-ink outline-none placeholder:text-ink-faint focus:ring-0"
/>
<kbd class="hidden shrink-0 border border-rule px-1.5 py-0.5 font-mono text-[10px] text-ink-faint sm:block">
/
</kbd>
</label>
</form>
<div id="activity-wire" phx-update="stream" class="divide-y divide-rule font-mono text-xs">
<div
id="activity-wire-empty"
class="hidden px-3 py-16 text-center text-ink-faint only:block sm:px-4"
>
Nothing on the record matches this view yet.
</div>
<article
:for={{id, entry} <- @streams.wire}
id={id}
class="flex items-baseline gap-x-3 px-3 py-3 transition-colors hover:bg-panel sm:px-4"
>
<span class="shrink-0 text-[10px] tabular-nums text-ink-faint">
{ledger_time(entry.at)}
</span>
<span :if={entry.kind == :registration} class="min-w-0 truncate text-ink-muted">
<span class="text-ink-faint">registered</span>
<.link
navigate={wire_entry_path(entry)}
class="transition hover:text-ink hover:underline"
>{entry.host}/{entry.owner}/{entry.name}</.link>
</span>
<span :if={entry.kind == :scan} class="min-w-0 truncate text-ink-muted">
<.handle_link handle={entry.handle} class="font-semibold" />
<span class="text-ink-faint">reported on</span>
<.link
navigate={wire_entry_path(entry)}
class="transition hover:text-ink hover:underline"
>{entry.owner}/{entry.name}</.link>
<span class="text-ink-faint">
{String.downcase(provenance_label(entry.provenance))} · {String.slice(
entry.commit_sha,
0,
7
)}
</span>
</span>
<span :if={entry.kind == :verdict} class="min-w-0 truncate text-ink-muted">
<.handle_link handle={entry.handle} class="font-semibold" />
<span class="text-ink-faint">{entry.verdict} a finding on</span>
<.link
navigate={wire_entry_path(entry)}
class="transition hover:text-ink hover:underline"
>{entry.owner}/{entry.name}</.link>
</span>
<span :if={entry.kind == :comment} class="min-w-0 truncate text-ink-muted">
<.handle_link handle={entry.handle} class="font-semibold" />
<span class="text-ink-faint">commented on</span>
<.link
navigate={wire_entry_path(entry)}
class="transition hover:text-ink hover:underline"
>{entry.finding_title || "a finding"}</.link>
<span class="text-ink-faint">in {entry.owner}/{entry.name}</span>
</span>
<span class="ml-auto shrink-0">
<span :if={entry.kind == :registration} class="text-[10px] text-ink-faint">
<span :if={entry.language}>{entry.language} · </span>★ {compact_stars(
entry.stars_count
)}
</span>
<.link :if={entry.kind == :scan} navigate={wire_entry_path(entry)}>
<.notch_badge
:if={entry.findings_count == 0 and entry.review_kind == "code_review"}
class="text-ink-muted"
>
No findings reported
</.notch_badge>
<.notch_badge
:if={entry.findings_count == 0 and entry.review_kind != "code_review"}
class="text-ink-muted"
>
Reported
</.notch_badge>
<.notch_badge :if={entry.findings_count > 0} class="text-signal">
{entry.findings_count}
{if entry.findings_count == 1, do: "finding", else: "findings"}
</.notch_badge>
</.link>
<.link :if={entry.kind == :verdict} navigate={wire_entry_path(entry)}>
<.notch_badge
:if={entry.verdict == "confirmed" and entry.scan_verified}
class="bg-ink text-ground"
>
Verified
</.notch_badge>
<.notch_badge :if={entry.verdict == "disputed"} class="text-signal">
Disputed
</.notch_badge>
</.link>
</span>
</article>
</div>
</section>
<aside class="space-y-10">
<div id="hot-infestations">
<div class="flex items-baseline justify-between gap-3 border-b-2 border-strong px-3 pb-3">
<h2 class="font-display text-sm uppercase tracking-[0.12em] text-ink">
Infestations
</h2>
<.link
navigate={~p"/infestations"}
class="font-mono text-[10px] uppercase tracking-[0.12em] text-signal hover:underline"
>
All →
</.link>
</div>
<p :if={@infestations == []} class="px-3 py-6 font-mono text-xs text-ink-faint">
No infestations this month.
</p>
<ol :if={@infestations != []} class="divide-y divide-rule">
<li :for={infestation <- @infestations}>
<.link
id={"hot-infestation-#{infestation.pattern_key}"}
navigate={~p"/infestations/#{infestation.pattern_key}"}
class="group flex items-baseline gap-4 px-3 py-4 transition-colors hover:bg-panel"
>
<span class="shrink-0 font-mono text-xs tabular-nums text-signal">
{infestation.repo_count}
</span>
<span class="min-w-0">
<span class="block truncate text-sm font-semibold leading-5 text-ink transition group-hover:underline">
{infestation.title}
</span>
<span class="mt-1.5 block font-mono text-[11px] text-ink-faint">
{infestation.repo_count} repos · {infestation.open_count} open
</span>
</span>
</.link>
</li>
</ol>
</div>
<div id="hot-findings">
<div class="flex items-baseline justify-between gap-3 border-b-2 border-strong px-3 pb-3">
<h2 class="font-display text-sm uppercase tracking-[0.12em] text-ink">
Hot findings
</h2>
<span class="font-mono text-[11px] text-ink-faint">
Last 7 days
</span>
</div>
<p :if={@hot_findings == []} class="px-3 py-8 font-mono text-xs text-ink-faint">
No findings upvoted this week.
</p>
<ol :if={@hot_findings != []} class="divide-y divide-rule">
<li :for={finding <- @hot_findings}>
<.link
id={"hot-finding-#{finding.id}"}
navigate={~p"/findings/#{finding.public_id}"}
class="group flex items-baseline gap-4 px-3 py-4 transition-colors hover:bg-panel"
>
<span class="shrink-0 font-mono text-xs tabular-nums text-phosphor">
+{finding.score}
</span>
<span class="min-w-0">
<span class="block truncate text-sm font-semibold leading-5 text-ink transition group-hover:underline">
{finding.title}
</span>
<span class="mt-1.5 block truncate font-mono text-[11px] text-ink-faint">
{finding.owner}/{finding.name}
<span :if={finding.severity}> · {finding.severity}</span>
</span>
</span>
</.link>
</li>
</ol>
</div>
</aside>
</div>
</Layouts.page>
</Layouts.app>

View file

@ -0,0 +1,454 @@
defmodule TarakanWeb.FindingLive.Show do
use TarakanWeb, :live_view
alias Tarakan.Discussion
alias Tarakan.FindingMemory
alias Tarakan.Policy
alias Tarakan.Reputation
alias Tarakan.Scans
alias Tarakan.Scans.Finding
alias Tarakan.Work
@impl true
def mount(%{"public_id" => public_id}, _session, socket) do
{scan, finding} = fetch_finding!(socket, public_id)
if connected?(socket) do
Scans.subscribe(scan.repository_id)
Discussion.subscribe(finding.id)
Reputation.subscribe()
end
public_url = TarakanWeb.Endpoint.url() <> ~p"/findings/#{finding.public_id}"
{:ok,
socket
|> assign(:page_title, finding.title)
|> assign(:meta_description, meta_description(scan, finding))
|> assign(:canonical_path, ~p"/findings/#{finding.public_id}")
|> assign(:public_url, public_url)
|> assign(
:open_bounties,
Tarakan.Market.open_bounties_for_target(:finding, finding.canonical_finding_id)
)
|> assign(:og_type, "article")
|> assign(:first_finder, FindingMemory.first_finder(finding.canonical_finding_id))
|> assign(:json_ld, finding_json_ld(scan, finding))
|> assign(:comment_body, "")
|> assign(:reply_to, nil)
|> assign(:can_vote, can_vote?(socket))
|> assign_record(scan, finding)
|> load_comments()
|> load_votes()}
end
@impl true
def handle_event(
"record_finding_verdict",
%{"verdict" => verdict} = params,
%{assigns: %{current_scope: %{account: account}}} = socket
)
when not is_nil(account) do
attrs = %{
"commit_sha" => socket.assigns.scan.commit_sha,
"verdict" => verdict,
"provenance" => "human",
"notes" => params["notes"]
}
case FindingMemory.record_check(
socket.assigns.current_scope,
socket.assigns.repository,
socket.assigns.finding.canonical_finding.public_id,
attrs
) do
{:ok, _check, _canonical} ->
{:noreply, socket |> refresh_record() |> load_votes()}
{:error, :conflict_of_interest} ->
{:noreply, put_flash(socket, :error, "You cannot verify a finding you submitted.")}
{:error, :unauthorized} ->
{:noreply, put_flash(socket, :error, "You are not authorized to verify this finding.")}
{:error, %Ecto.Changeset{errors: [{_field, {message, _meta}} | _]}} ->
{:noreply, put_flash(socket, :error, "Finding check not recorded: #{message}.")}
{:error, _reason} ->
{:noreply, put_flash(socket, :error, "Finding check could not be recorded.")}
end
end
def handle_event("record_finding_verdict", _params, socket) do
{:noreply, redirect(socket, to: ~p"/accounts/log-in")}
end
def handle_event(
"record_vendor_notification",
%{"vendor_notification" => %{"notified_on" => notified_on}},
%{assigns: %{current_scope: %{account: account}}} = socket
)
when not is_nil(account) do
case parse_notified_on(notified_on) do
{:ok, notified_at} ->
case FindingMemory.record_vendor_notification(
socket.assigns.current_scope,
socket.assigns.repository,
socket.assigns.finding.canonical_finding.public_id,
%{"vendor_notified_at" => notified_at}
) do
{:ok, _canonical} ->
Scans.broadcast_refresh(socket.assigns.scan)
{:noreply, refresh_record(socket)}
{:error, :unauthorized} ->
{:noreply,
put_flash(socket, :error, "You are not authorized to record vendor notification.")}
{:error, _reason} ->
{:noreply, put_flash(socket, :error, "Vendor notification could not be recorded.")}
end
:error ->
{:noreply, put_flash(socket, :error, "Enter a valid notification date.")}
end
end
def handle_event("open_finding_job", %{"kind" => kind}, socket) do
finding = socket.assigns.finding.canonical_finding
case Work.open_finding_job(socket.assigns.current_scope, finding, kind) do
{:ok, :skipped_duplicate} ->
{:noreply, put_flash(socket, :info, "That job is already open on this finding.")}
{:ok, task} ->
{:noreply,
socket
|> put_flash(:info, "Job opened.")
|> push_navigate(to: ~p"/jobs/#{task.id}")}
{:error, :unauthorized} ->
{:noreply, put_flash(socket, :error, "Not authorized to open jobs here.")}
{:error, _reason} ->
{:noreply, put_flash(socket, :error, "Could not open that job.")}
end
end
def handle_event("record_vendor_notification", _params, socket) do
{:noreply, redirect(socket, to: ~p"/accounts/log-in")}
end
def handle_event("reply_to", %{"parent" => parent_id}, socket) do
{:noreply, assign(socket, :reply_to, parent_id)}
end
def handle_event("cancel_reply", _params, socket) do
{:noreply, assign(socket, :reply_to, nil)}
end
def handle_event(
"post_comment",
%{"body" => body} = params,
%{assigns: %{current_scope: %{account: account}}} = socket
)
when not is_nil(account) do
attrs = %{"body" => body, "parent_id" => params["parent_id"]}
case Discussion.create_comment(socket.assigns.current_scope, socket.assigns.finding, attrs) do
{:ok, _comment} ->
{:noreply,
socket
|> assign(:comment_body, "")
|> assign(:reply_to, nil)
|> load_comments()}
{:error, :too_deep} ->
{:noreply, put_flash(socket, :error, "This thread is too deeply nested to reply to.")}
{:error, reason} when reason in [:invalid_parent, :not_found] ->
{:noreply, put_flash(socket, :error, "That comment is no longer available.")}
{:error, :unauthorized} ->
{:noreply,
put_flash(socket, :error, "Your account cannot post to the discussion right now.")}
{:error, %Ecto.Changeset{}} ->
{:noreply, put_flash(socket, :error, "Write a comment before posting.")}
end
end
def handle_event("post_comment", _params, socket) do
{:noreply, redirect(socket, to: ~p"/accounts/log-in")}
end
def handle_event(
"remove_comment",
%{"id" => id, "reason" => reason},
%{assigns: %{current_scope: %{account: account}}} = socket
)
when not is_nil(account) do
with {:ok, comment} <- Discussion.get_comment(id),
{:ok, _removed} <-
Discussion.remove_comment(socket.assigns.current_scope, comment, %{
"removed_reason" => reason
}) do
{:noreply, load_comments(socket)}
else
_error ->
{:noreply, put_flash(socket, :error, "The comment could not be removed.")}
end
end
def handle_event(
"vote",
%{"type" => subject_type, "id" => subject_id, "vote" => value},
%{assigns: %{current_scope: %{account: account}}} = socket
)
when not is_nil(account) do
with {:ok, subject_id} <- normalize_id(subject_id),
{:ok, value} <- normalize_vote(value) do
case Reputation.cast_vote(
socket.assigns.current_scope,
subject_type,
subject_id,
value
) do
{:ok, _summary} ->
{:noreply, socket |> load_votes() |> load_comments()}
{:error, :own_content} ->
{:noreply, put_flash(socket, :error, "You cannot vote on your own contribution.")}
{:error, _reason} ->
{:noreply, put_flash(socket, :error, "Your vote could not be recorded.")}
end
else
_invalid -> {:noreply, put_flash(socket, :error, "Your vote could not be recorded.")}
end
end
def handle_event("vote", _params, socket) do
{:noreply, redirect(socket, to: ~p"/accounts/log-in")}
end
@impl true
def handle_info({:scan_updated, %{id: scan_id}}, %{assigns: %{scan: %{id: scan_id}}} = socket) do
{:noreply, refresh_record(socket)}
end
def handle_info({event, _comment}, socket)
when event in [:comment_posted, :comment_removed] do
{:noreply, socket |> load_comments() |> load_votes()}
end
def handle_info({:vote_changed, _type, _id}, socket) do
{:noreply, load_votes(socket)}
end
def handle_info(_event, socket), do: {:noreply, socket}
defp load_comments(socket) do
comments = Discussion.list_comments(socket.assigns.current_scope, socket.assigns.finding)
socket
|> assign(:comments, comments)
|> assign(:comment_count, Enum.count(flatten_comments(comments)))
|> assign(
:can_moderate_comments,
Discussion.can_moderate?(socket.assigns.current_scope, socket.assigns.finding)
)
end
defp load_votes(socket) do
account_id = current_account_id(socket)
comment_ids =
socket.assigns |> Map.get(:comments, []) |> flatten_comments() |> Enum.map(& &1.id)
socket
|> assign(
:finding_votes,
Reputation.vote_summary(
"canonical_finding",
socket.assigns.finding.canonical_finding.id,
account_id
)
)
|> assign(:comment_votes, Reputation.vote_summaries("comment", comment_ids, account_id))
end
defp current_account_id(%{assigns: %{current_scope: %{account: %{id: id}}}}), do: id
defp current_account_id(_socket), do: nil
# phx-value params are client-controlled; never feed them to String.to_integer/1.
defp normalize_id(id) when is_integer(id) and id > 0, do: {:ok, id}
defp normalize_id(id) when is_binary(id) do
case Integer.parse(id) do
{parsed, ""} when parsed > 0 -> {:ok, parsed}
_other -> {:error, :invalid_id}
end
end
defp normalize_id(_id), do: {:error, :invalid_id}
defp normalize_vote(value) when is_integer(value), do: {:ok, value}
defp normalize_vote(value) when is_binary(value) do
case Integer.parse(value) do
{parsed, ""} -> {:ok, parsed}
_other -> {:error, :invalid_vote}
end
end
defp normalize_vote(_value), do: {:error, :invalid_vote}
defp can_vote?(socket), do: not is_nil(current_account_id(socket))
defp flatten_comments(comments) do
Enum.flat_map(comments, fn comment -> [comment | flatten_comments(comment.replies)] end)
end
defp refresh_record(socket) do
{scan, finding} = fetch_finding!(socket, socket.assigns.finding.public_id)
assign_record(socket, scan, finding)
end
defp assign_record(socket, scan, finding) do
socket
|> assign(:scan, scan)
|> assign(:finding, finding)
|> assign(:repository, scan.repository)
|> assign(
:finding_checks,
FindingMemory.list_checks(finding.canonical_finding.id, scan.commit_sha)
)
|> assign(
:finding_trust,
FindingMemory.trust_summary(finding.canonical_finding, scan.commit_sha)
)
|> assign(
:can_check,
FindingMemory.can_check?(
socket.assigns.current_scope,
scan.repository,
finding.canonical_finding.public_id,
scan.commit_sha
)
)
|> assign(
:can_record_vendor_notification,
FindingMemory.can_record_vendor_notification?(
socket.assigns.current_scope,
scan.repository
)
)
|> assign(:vendor_form, vendor_form(finding.canonical_finding))
|> assign(:review_stake, Reputation.review_stake(scan))
|> assign(
:can_open_finding_job,
Policy.allowed?(socket.assigns.current_scope, :propose_task, scan.repository)
)
end
defp fetch_finding!(socket, public_id) do
case Scans.get_finding(socket.assigns.current_scope, public_id) do
# Attach the resolved scan so Discussion can read the repository id
# without another query.
{:ok, {scan, finding}} -> {scan, %{finding | scan: scan}}
{:error, :not_found} -> raise Ecto.NoResultsError, queryable: Finding
end
end
# Search snippets truncate around 160 characters; lead with the facts that
# identify the finding before the free-text description.
defp meta_description(scan, finding) do
prefix =
"#{String.capitalize(finding.severity)} in " <>
"#{scan.repository.owner}/#{scan.repository.name} (#{finding.file_path}): "
truncate(prefix <> String.replace(finding.description, ~r/\s+/, " "), 160)
end
defp finding_json_ld(scan, finding) do
url = TarakanWeb.Endpoint.url() <> ~p"/findings/#{finding.public_id}"
canonical = finding.canonical_finding
%{
"@context" => "https://schema.org",
"@type" => "TechArticle",
"headline" => finding.title,
"description" => meta_description(scan, finding),
"url" => url,
"datePublished" => DateTime.to_iso8601(finding.inserted_at),
"dateModified" => DateTime.to_iso8601(finding.updated_at),
"author" => %{
"@type" => "Organization",
"name" => "Tarakan public security record"
},
"about" => %{
"@type" => "SoftwareSourceCode",
"name" => "#{scan.repository.owner}/#{scan.repository.name}",
"codeRepository" => scan.repository.canonical_url
},
"keywords" =>
Enum.join(
[
finding.severity,
"security finding",
scan.repository.owner,
scan.repository.name,
"open source"
],
", "
)
}
|> maybe_put_json_ld("cwe", canonical && canonical.cwe_id)
|> maybe_put_json_ld("cve", canonical && canonical.cve_id)
end
defp maybe_put_json_ld(map, _key, value) when value in [nil, false, ""], do: map
defp maybe_put_json_ld(map, key, value), do: Map.put(map, key, value)
# An empty date clears the recorded vendor notification; anything else must
# be an ISO calendar date, stored at midnight UTC.
defp parse_notified_on(value) when value in [nil, ""], do: {:ok, nil}
defp parse_notified_on(value) when is_binary(value) do
case Date.from_iso8601(value) do
{:ok, date} -> {:ok, DateTime.new!(date, ~T[00:00:00], "Etc/UTC")}
{:error, _reason} -> :error
end
end
defp vendor_form(canonical) do
notified_on =
case canonical.vendor_notified_at do
%DateTime{} = datetime -> datetime |> DateTime.to_date() |> Date.to_iso8601()
_other -> ""
end
to_form(%{"notified_on" => notified_on}, as: :vendor_notification)
end
defp truncate(text, max) when byte_size(text) <= max, do: text
defp truncate(text, max) do
String.slice(text, 0, max - 1) <> ""
end
defp record_time(%DateTime{} = datetime) do
Calendar.strftime(datetime, "%Y-%m-%d %H:%M")
end
defp stake_label(:at_risk), do: "at risk (awaiting review)"
defp stake_label(:returned), do: "returned (verified)"
defp stake_label(:slashed), do: "slashed (refuted)"
defp finding_lines(%{line_start: nil}), do: ""
defp finding_lines(%{line_start: line, line_end: line}), do: ":#{line}"
defp finding_lines(%{line_start: line_start, line_end: line_end}),
do: ":#{line_start}-#{line_end}"
end

View file

@ -0,0 +1,611 @@
<Layouts.app flash={@flash} current_scope={@current_scope} current_path={assigns[:current_path]}>
<Layouts.page>
<.breadcrumbs id="finding-breadcrumb">
<:crumb navigate={~p"/"}>registry</:crumb>
<:crumb navigate={TarakanWeb.RepositoryPaths.repository_security_path(@repository)}>
{@repository.owner}/{@repository.name}
</:crumb>
<:crumb>finding</:crumb>
</.breadcrumbs>
<TarakanWeb.BountyComponents.wanted_banner bounties={@open_bounties} />
<%!-- Status + trust chips --%>
<% canonical = @finding.canonical_finding %>
<% cwe_url = canonical && TarakanWeb.FindingPresentation.cwe_url(canonical.cwe_id) %>
<% cve_url = canonical && TarakanWeb.FindingPresentation.cve_url(canonical.cve_id) %>
<%!-- Chips are reserved for the two things that rank this finding against
every other one: how bad it is, and whether anyone has confirmed it.
Everything else - trust signals, references, run counts - is evidence,
and evidence reads better as a line than as a wall of badges. --%>
<div class="mt-4 flex flex-wrap items-center gap-2">
<.notch_badge id="finding-severity" class="bg-signal text-ground">
{@finding.severity}
</.notch_badge>
<.notch_badge
:if={@finding.canonical_finding && @finding.canonical_finding.status == "verified"}
id="finding-verified-badge"
class="bg-ink text-ground"
>
Verified
</.notch_badge>
<.notch_badge
:if={
@finding.canonical_finding && @finding.canonical_finding.status not in [nil, "verified"]
}
class="text-ink-muted"
>
{@finding.canonical_finding.status}
</.notch_badge>
<.notch_badge
:if={canonical && canonical.reproductions_count > 0}
id="finding-reproduced-badge"
class="bg-ink text-ground"
>
Reproduced
</.notch_badge>
<.notch_badge id="finding-disclosure-badge" class="bg-ink text-ground">
{TarakanWeb.FindingPresentation.disclosure_badge(@scan.visibility, @scan.provenance)}
</.notch_badge>
</div>
<%!-- The submitter's severity claim and a rubric-scored second opinion are
different statements. Showing only one of them hides a disagreement
that is itself information about the submitter. --%>
<p
:if={
canonical && canonical.calibrated_severity &&
canonical.calibrated_severity != @finding.severity
}
id="finding-severity-calibration"
class="mt-2 font-mono text-[11px] text-ink-faint"
>
submitter said <span class="text-ink-muted">{@finding.severity}</span>
· rescored <span class="text-ink">{canonical.calibrated_severity}</span>
<span :if={canonical.severity_rubric}>({canonical.severity_rubric})</span>
</p>
<p
id="finding-evidence-line"
class="mt-2 flex flex-wrap items-center gap-x-1.5 font-mono text-[11px] text-ink-faint"
>
<span class="text-ink-muted" title={@scan.review_status}>
{review_kind_label(@scan.review_kind)}
</span>
<span aria-hidden="true">·</span>
<span>{TarakanWeb.FindingPresentation.status_blurb(@scan.review_status)}</span>
<span :if={@finding.canonical_finding} id="finding-canonical-memory">
<span aria-hidden="true">·</span>
detected in {@finding.canonical_finding.detections_count}
{if @finding.canonical_finding.detections_count == 1, do: "run", else: "runs"}
</span>
<span :if={@finding_trust.agent_reproduced} id="finding-trust-agent">
<span aria-hidden="true">·</span> agent reproduced
</span>
<span
:if={@finding_trust.agent_disputed}
id="finding-trust-agent-disputed"
class="text-signal"
>
<span aria-hidden="true">·</span> agent disputed
</span>
<span :if={@finding_trust.human_checked} id="finding-trust-human">
<span aria-hidden="true">·</span> human checked
</span>
<span :if={canonical && canonical.reproductions_count > 0} id="finding-reproductions">
<span aria-hidden="true">·</span> reproduced {canonical.reproductions_count}
{if canonical.reproductions_count == 1, do: "time", else: "times"}
</span>
<%!-- Surviving attacks is a stronger claim than collecting agreements, so
it is reported as attempts survived rather than as a confirmation. --%>
<span :if={canonical && canonical.refutations_count > 0} id="finding-refutations">
<span aria-hidden="true">·</span>
survived {canonical.survived_refutations_count} of {canonical.refutations_count}
{if canonical.refutations_count == 1, do: "attack", else: "attacks"}
</span>
<span :if={cwe_url || cve_url} aria-hidden="true">·</span>
<.link
:if={cwe_url}
id="finding-cwe-badge"
href={cwe_url}
target="_blank"
rel="noopener"
class="text-ink-muted underline decoration-rule underline-offset-2 transition hover:text-signal"
>
{canonical.cwe_id}
</.link>
<.link
:if={cve_url}
id="finding-cve-badge"
href={cve_url}
target="_blank"
rel="noopener"
class="text-ink-muted underline decoration-rule underline-offset-2 transition hover:text-signal"
>
{canonical.cve_id}
</.link>
</p>
<%!-- Title + vote --%>
<div class="mt-4 flex items-start gap-3">
<.vote_control
subject_type="canonical_finding"
subject_id={@finding.canonical_finding.id}
summary={@finding_votes}
can_vote={@can_vote}
class="mt-1 shrink-0"
/>
<h1
id="finding-title"
class="min-w-0 break-words font-display text-2xl font-medium leading-tight tracking-[0.02em] text-ink sm:text-3xl"
>
{@finding.title}
</h1>
</div>
<div class="mt-4 flex min-w-0 flex-wrap items-center gap-x-4 gap-y-2">
<.link
id="finding-source-link"
navigate={~p"/findings/#{@finding.public_id}/code"}
class="inline-flex max-w-full min-w-0 items-center gap-1.5 border border-strong bg-panel px-3 py-2 font-mono text-xs text-ink transition hover:border-signal hover:text-signal"
aria-label={"Open #{@finding.file_path} at the finding's pinned commit"}
>
<span class="min-w-0 break-all">
{@finding.file_path}{finding_lines(@finding)}
</span>
<.icon name="hero-arrow-right" class="size-3.5 shrink-0" />
</.link>
<span class="font-mono text-[11px] text-ink-faint" title={@scan.commit_sha}>
@ {String.slice(@scan.commit_sha || "", 0, 7)}
</span>
<button
id="finding-copy-link"
type="button"
phx-hook="CopyLink"
data-copy-text={@public_url}
data-copied-label="Copied"
class="inline-flex items-center gap-1.5 border border-strong px-3 py-2 font-mono text-[11px] uppercase tracking-[0.12em] text-ink-muted transition hover:border-signal hover:text-signal"
>
<.icon name="hero-link" class="size-3.5" />
<span data-copy-label>Copy link</span>
</button>
</div>
<p id="finding-cite" class="mt-2 max-w-3xl break-all font-mono text-[10px] text-ink-faint">
{@public_url}
</p>
<%!-- What / why (main content) --%>
<% structured = TarakanWeb.FindingPresentation.structure_description(@finding.description) %>
<section id="finding-description" class="mt-8 max-w-3xl">
<h2 class="font-display text-sm uppercase tracking-[0.12em] text-ink-faint">
What & why
</h2>
<div class="mt-3 space-y-4">
<p
:if={structured.lead != ""}
class="whitespace-pre-line border-l-2 border-signal pl-4 text-sm leading-7 text-ink"
phx-no-format
>{structured.lead}</p>
<div
:for={{label, body} <- structured.sections}
class="bg-panel px-4 py-3"
>
<h3 class="text-sm font-semibold text-ink">
{label}
</h3>
<p class="mt-1.5 whitespace-pre-line text-sm leading-6 text-ink-muted" phx-no-format>{body}</p>
</div>
<p
:if={structured.lead == "" and structured.sections == []}
class="whitespace-pre-line border-l-2 border-signal pl-4 text-sm leading-7 text-ink-muted"
phx-no-format
>{@finding.description}</p>
<div
:if={canonical.reproduction_steps}
id="finding-reproduction"
class="bg-panel px-4 py-3"
>
<h3 class="text-sm font-semibold text-ink">
Reproduction
</h3>
<pre
class="mt-1.5 overflow-x-auto whitespace-pre-wrap font-mono text-xs leading-6 text-ink-muted"
phx-no-format
>{canonical.reproduction_steps}</pre>
</div>
<div
:if={canonical.affected_versions}
id="finding-affected-versions"
class="bg-panel px-4 py-3"
>
<h3 class="text-sm font-semibold text-ink">
Affected versions
</h3>
<p class="mt-1.5 font-mono text-xs leading-6 text-ink-muted">
{canonical.affected_versions}
</p>
</div>
</div>
</section>
<%!-- At a glance meta --%>
<section id="finding-review-record" class="mt-8">
<h2 class="font-display text-sm uppercase tracking-[0.12em] text-ink-faint">
At a glance
</h2>
<div class="mt-3 grid gap-px border-2 border-strong bg-rule sm:grid-cols-2 lg:grid-cols-4">
<div class="bg-ground px-4 py-3">
<p class="text-xs font-medium text-ink-faint">Commit</p>
<p class="mt-1 font-mono text-sm tabular-nums text-ink" title={@scan.commit_sha}>
{String.slice(@scan.commit_sha || "", 0, 12)}
</p>
<p :if={@scan.commit_committed_at} class="mt-0.5 font-mono text-[10px] text-ink-faint">
{record_time(@scan.commit_committed_at)}
</p>
</div>
<div class="bg-ground px-4 py-3">
<p class="text-xs font-medium text-ink-faint">How made</p>
<p id="finding-provenance" class="mt-1 text-sm text-ink">
{TarakanWeb.FindingPresentation.how_made_label(@scan.provenance)}
<span class="text-ink-faint"> (claim)</span>
</p>
<p :if={@scan.model} class="mt-0.5 font-mono text-[10px] text-ink-faint">
{@scan.model}
<span :if={@scan.prompt_version}> · {@scan.prompt_version}</span>
</p>
</div>
<div class="bg-ground px-4 py-3">
<p class="text-xs font-medium text-ink-faint">
Submitted
</p>
<p class="mt-1 text-sm text-ink">
<.handle_link handle={@scan.submitted_by.handle} />
</p>
<p class="mt-0.5 font-mono text-[10px] text-ink-faint">
{record_time(@scan.inserted_at)}
</p>
</div>
<%!--
One first finder per issue, permanently. Later reports corroborate
it but never displace the credit.
--%>
<div :if={@first_finder} id="finding-first-finder" class="bg-ground px-4 py-3">
<p class="text-xs font-medium text-ink-faint">First found by</p>
<p class="mt-1 text-sm text-ink">
<.handle_link handle={@first_finder.handle} />
</p>
<p class="mt-0.5 font-mono text-[10px] text-ink-faint">
{record_time(@first_finder.reported_at)} · {String.slice(
@first_finder.commit_sha || "",
0,
7
)}
</p>
</div>
<div class="bg-ground px-4 py-3">
<p class="text-xs font-medium text-ink-faint">Checks</p>
<p id="finding-verdict-counts" class="mt-1 font-mono text-sm text-ink">
{@finding.canonical_finding.confirmations_count} confirmed · {@finding.canonical_finding.disputes_count} disputed
</p>
<p class="mt-0.5 font-mono text-[10px] text-ink-faint">
status {@finding.canonical_finding.status}
</p>
</div>
</div>
<div
:if={@review_stake || @scan.reviewed_at || @scan.notes}
class="mt-3 space-y-2 text-xs text-ink-muted"
>
<p :if={@review_stake} id="finding-stake" class="font-mono">
<span class="text-ink-faint">Stake</span>
<span class={[
"ml-2 tabular-nums",
@review_stake.status == :slashed && "text-signal",
@review_stake.status == :returned && "text-quote",
@review_stake.status == :at_risk && "text-ink"
]}>
{@review_stake.amount} · {stake_label(@review_stake.status)}
</span>
</p>
<p :if={@scan.reviewed_at} class="font-mono text-ink-faint">
Disclosed {record_time(@scan.reviewed_at)}
</p>
<div :if={@scan.notes} class="max-w-3xl border border-rule bg-panel px-3 py-2">
<p class="text-xs font-semibold text-ink-muted">
Report summary
</p>
<% notes = TarakanWeb.FindingPresentation.humanize_notes(@scan.notes) %>
<div :if={notes && notes.kind == :summary} class="mt-1">
<p class="text-ink">
{notes.count} {if notes.count == 1, do: "finding", else: "findings"} in this report.
</p>
<ol :if={notes.tops != []} class="mt-1 list-decimal space-y-0.5 pl-5 text-ink-muted">
<li :for={top <- notes.tops}>{top}</li>
</ol>
</div>
<p
:if={notes && notes.kind == :plain}
class="mt-1 whitespace-pre-line text-ink-muted"
phx-no-format
>{notes.text}</p>
</div>
</div>
</section>
<%!-- Lifecycle timeline --%>
<% events = TarakanWeb.FindingPresentation.timeline_events(canonical, @finding_checks) %>
<section id="finding-timeline" class="mt-8 max-w-3xl">
<h2 class="font-display text-sm uppercase tracking-[0.12em] text-ink-faint">
Lifecycle
</h2>
<ol class="mt-3 border-l-2 border-rule">
<li
:for={event <- events}
id={"finding-timeline-#{event.id}"}
class="relative pb-4 pl-4 last:pb-0"
>
<span class="absolute top-1.5 -left-[5px] size-2 bg-ink-faint"></span>
<p class="text-sm leading-5 text-ink">
{event.title}
<span
:if={event.detail}
class="font-mono text-[10px] uppercase tracking-[0.12em] text-ink-faint"
>
· {event.detail}
</span>
</p>
<p class="mt-0.5 font-mono text-[10px] text-ink-faint">
{record_time(event.at)}
</p>
</li>
</ol>
<.form
:if={@can_record_vendor_notification}
for={@vendor_form}
id="finding-vendor-form"
phx-submit="record_vendor_notification"
class="mt-4 flex flex-wrap items-end gap-2 border border-strong bg-panel px-4 py-3"
>
<.input
field={@vendor_form[:notified_on]}
type="date"
label="Vendor notified on (empty clears)"
class="border border-strong bg-ground px-3 py-2 font-mono text-xs text-ink placeholder:text-ink-faint focus:border-signal focus:outline-none"
/>
<.button size="sm">Record</.button>
</.form>
</section>
<%!-- Independent checks --%>
<section id="finding-verification" class="mt-10">
<div class="flex flex-wrap items-end justify-between gap-3 border-b-2 border-strong pb-3">
<h2 class="font-display text-lg uppercase tracking-[0.02em] text-ink">
Checks
</h2>
</div>
<div class="mt-4">
<%!-- Hidden duplicate for tests that query counts only inside verification; primary counts live in glance grid --%>
<div class="sr-only" aria-hidden="true">
{@finding.canonical_finding.confirmations_count} confirmed · {@finding.canonical_finding.disputes_count} disputed
</div>
<p
:if={@finding_checks == []}
id="finding-no-checks"
class="py-6 text-center text-sm text-ink-faint"
>
No checks at this commit yet.
</p>
<div
:if={@finding_checks != []}
id="finding-checks"
class="divide-y divide-rule border-y border-rule"
>
<article :for={check <- @finding_checks} id={"finding-check-#{check.id}"} class="py-4">
<div class="flex flex-wrap items-baseline gap-x-2 gap-y-1">
<.handle_link handle={check.account.handle} class="font-mono text-xs font-semibold" />
<span class={[
"font-mono text-[10px] uppercase tracking-[0.12em]",
check.verdict == "confirmed" && "text-quote",
check.verdict == "disputed" && "text-signal",
check.verdict == "fixed" && "text-quote"
]}>
{check.verdict}
</span>
<span class="font-mono text-[10px] text-ink-faint">
· {provenance_label(check.provenance)} · {record_time(check.inserted_at)}
</span>
<span class={[
"ml-auto font-mono text-[10px] uppercase tracking-[0.12em]",
check.counts_toward_quorum && "text-ink-muted",
!check.counts_toward_quorum && "text-ink-faint"
]}>
{if check.counts_toward_quorum,
do: "counts toward quorum",
else: "corroboration only"}
</span>
</div>
<p class="mt-2 whitespace-pre-line text-sm leading-6 text-ink-muted" phx-no-format>{check.notes}</p>
<details :if={check.evidence} class="mt-2">
<summary class="cursor-pointer font-mono text-[10px] uppercase tracking-[0.12em] text-ink-faint hover:text-ink">
Evidence
</summary>
<pre class="mt-2 overflow-x-auto border border-rule bg-panel px-3 py-2 font-mono text-[11px] leading-5 text-ink-muted">{check.evidence}</pre>
</details>
</article>
</div>
<%!-- Queue an agent instead of doing it yourself. Each of these asks for
a different kind of evidence than "somebody agreed": an attack that
failed, a reproduction that ran, a score against a rubric. --%>
<div
:if={@can_open_finding_job}
id="finding-agent-jobs"
class="mt-5 flex flex-wrap items-center gap-2"
>
<span class="mr-1 font-mono text-[11px] uppercase tracking-[0.12em] text-ink-faint">
Open a job
</span>
<.button
id="finding-job-refute"
type="button"
size="sm"
phx-click="open_finding_job"
phx-value-kind="refute_finding"
>
Refute
</.button>
<.button
id="finding-job-reproduce"
type="button"
size="sm"
phx-click="open_finding_job"
phx-value-kind="reproduce_finding"
>
Reproduce
</.button>
<.button
id="finding-job-calibrate"
type="button"
size="sm"
phx-click="open_finding_job"
phx-value-kind="calibrate_severity"
>
Re-score
</.button>
</div>
<div
:if={@can_check}
id="finding-verdict"
class="mt-5 border border-strong bg-panel px-4 py-4"
>
<h3 class="text-sm font-semibold text-ink">
Record a check
</h3>
<form
id="finding-verdict-form"
phx-submit="record_finding_verdict"
class="mt-2 flex flex-wrap gap-2"
>
<input
id="finding-verdict-notes"
type="text"
name="notes"
required
minlength="20"
maxlength="2000"
placeholder="Evidence notes (required, 20+ chars)"
autocomplete="off"
class="min-w-0 flex-1 basis-48 border border-strong bg-ground px-3 py-2 font-mono text-xs text-ink placeholder:text-ink-faint focus:border-signal focus:outline-none"
/>
<.button name="verdict" value="confirmed" size="sm">Confirm</.button>
<.button name="verdict" value="disputed" variant="danger" size="sm">
Dispute
</.button>
<.button name="verdict" value="fixed" size="sm">Fixed</.button>
</form>
</div>
<.link
:if={is_nil(@current_scope) or is_nil(@current_scope.account)}
id="finding-verify-login"
navigate={~p"/accounts/log-in"}
class="mt-4 block font-mono text-xs text-signal hover:underline"
>
Sign in to confirm or dispute this finding.
</.link>
</div>
</section>
<%!-- Discussion --%>
<section id="finding-discussion" class="mt-10">
<div class="flex flex-wrap items-baseline justify-between gap-2 border-b-2 border-strong pb-3">
<h2 class="font-display text-lg uppercase tracking-[0.02em] text-ink">
Discussion
</h2>
<span id="finding-comment-count" class="font-mono text-[10px] tabular-nums text-ink-faint">
{@comment_count} {if @comment_count == 1, do: "comment", else: "comments"}
</span>
</div>
<div class="mt-4">
<form
:if={@current_scope && @current_scope.account}
id="finding-comment-form"
phx-submit="post_comment"
class="flex flex-col gap-2"
>
<textarea
id="finding-comment-body"
name="body"
rows="3"
placeholder="Add evidence, a counter-argument, or context…"
class="w-full border border-strong bg-transparent px-3 py-2 font-mono text-xs text-ink placeholder:text-ink-faint focus:outline-none focus:ring-2 focus:ring-phosphor"
></textarea>
<div>
<.button variant="primary" size="sm">Post comment</.button>
</div>
</form>
<.link
:if={is_nil(@current_scope) or is_nil(@current_scope.account)}
id="finding-comment-login"
navigate={~p"/accounts/log-in"}
class="block font-mono text-xs text-signal hover:underline"
>
Sign in to join the discussion.
</.link>
<p
:if={@comments == []}
id="finding-no-comments"
class="mt-6 text-center font-mono text-xs text-ink-faint"
>
No comments yet.
</p>
<div :if={@comments != []} class="mt-5 space-y-4">
<.comment_thread
:for={comment <- @comments}
comment={comment}
reply_to={@reply_to}
can_reply={!is_nil(@current_scope) and !is_nil(@current_scope.account)}
can_moderate={@can_moderate_comments}
can_vote={@can_vote}
votes={@comment_votes}
/>
</div>
</div>
</section>
<%!-- Technical dump last --%>
<details
:if={@scan.raw_document}
id="finding-raw-report"
class="mt-10 border-2 border-strong"
>
<summary class="cursor-pointer bg-panel px-4 py-3 font-mono text-[10px] uppercase tracking-[0.12em] text-ink-faint transition hover:text-ink">
Technical · raw report JSON
<span class="ml-2 normal-case tracking-normal text-ink-faint/80">
full report, not only this finding
</span>
</summary>
<pre class="max-h-[24rem] overflow-auto border-t border-rule px-4 py-4 font-mono text-[11px] leading-5 text-ink-muted">{raw_report(@scan)}</pre>
</details>
<p class="mt-8 font-mono text-xs">
<.link
id="finding-record-link"
navigate={TarakanWeb.RepositoryPaths.repository_security_path(@repository)}
class="text-signal transition hover:underline"
>
← All findings for {@repository.owner}/{@repository.name}
</.link>
</p>
</Layouts.page>
</Layouts.app>

View file

@ -0,0 +1,119 @@
defmodule TarakanWeb.InfestationLive.Index do
@moduledoc "Cross-repo infestation list: patterns spanning multiple listed repositories."
use TarakanWeb, :live_view
alias Tarakan.Infestations
@impl true
def mount(_params, _session, socket) do
{:ok,
socket
|> assign(:page_title, "Infestations")
|> assign(
:meta_description,
"Cross-repo infestations: the same finding title in multiple listed repositories."
)
|> assign(:canonical_path, ~p"/infestations")
|> assign(:days, 30)
|> assign(:min_repos, 2)
|> load_infestations()}
end
@impl true
def handle_event("filter", params, socket) do
days = parse_int(params["days"], socket.assigns.days, 1, 365)
min_repos = parse_int(params["min_repos"], socket.assigns.min_repos, 2, 20)
{:noreply,
socket
|> assign(:days, days)
|> assign(:min_repos, min_repos)
|> load_infestations()}
end
defp load_infestations(socket) do
infestations =
Infestations.list_infestations(
days: socket.assigns.days,
min_repos: socket.assigns.min_repos,
limit: 50
)
assign(socket, :infestations, infestations)
end
defp parse_int(value, default, min, max) do
case Integer.parse(to_string(value || "")) do
{n, _} -> n |> max(min) |> min(max)
:error -> default
end
end
@impl true
def render(assigns) do
~H"""
<Layouts.app flash={@flash} current_scope={@current_scope} current_path={assigns[:current_path]}>
<Layouts.page width={:wide}>
<div class="flex flex-col gap-4 border-b-2 border-strong pb-6 sm:flex-row sm:items-end sm:justify-between">
<div class="max-w-2xl">
<h1 class="font-display text-3xl font-medium uppercase tracking-[0.02em] text-ink sm:text-5xl">
Infestations
</h1>
<p class="mt-3 text-sm leading-6 text-ink-muted">
Same finding title in {@min_repos}+ listed repos within the window.
Ranked by affected repo count.
</p>
</div>
<form
id="infestations-filter"
phx-change="filter"
class="flex flex-wrap items-end gap-3 font-mono text-xs"
>
<label class="flex flex-col gap-1 text-ink-faint">
Min repos
<select
name="min_repos"
class="border border-strong bg-panel px-2 py-1.5 text-ink"
>
<option :for={n <- [2, 3, 5, 10]} value={n} selected={@min_repos == n}>{n}</option>
</select>
</label>
<label class="flex flex-col gap-1 text-ink-faint">
Window
<select name="days" class="border border-strong bg-panel px-2 py-1.5 text-ink">
<option :for={n <- [7, 30, 90, 365]} value={n} selected={@days == n}>
{n}d
</option>
</select>
</label>
</form>
</div>
<div
:if={@infestations == []}
id="infestations-empty"
class="mt-10 bg-panel px-6 py-16 text-center"
>
<p class="text-sm font-medium text-ink">No infestations in this window</p>
<p class="mt-2 text-xs text-ink-muted">
Infestations appear when the same finding title hits {@min_repos}+ listed repos.
</p>
</div>
<div :if={@infestations != []} class="mt-8">
<.constellation id="infestations-constellation" infestations={@infestations} />
<ul id="infestations-list" class="sr-only">
<li :for={infestation <- @infestations} id={"infestation-#{infestation.pattern_key}"}>
<.link navigate={~p"/infestations/#{infestation.pattern_key}"}>
{infestation.title} · {infestation.repo_count} repos
</.link>
</li>
</ul>
</div>
</Layouts.page>
</Layouts.app>
"""
end
end

View file

@ -0,0 +1,343 @@
defmodule TarakanWeb.InfestationLive.Show do
@moduledoc "One cross-repo infestation pattern: affected repos and instances."
use TarakanWeb, :live_view
alias Tarakan.Fixes
alias Tarakan.Infestations
alias Tarakan.Policy
alias TarakanWeb.RepositoryPaths
@graph_limit 64
@ledger_page 50
@impl true
def mount(%{"pattern_key" => pattern_key}, _session, socket) do
infestation = Infestations.get_infestation(pattern_key)
if is_nil(infestation) or infestation.repo_count < 1 do
raise Ecto.NoResultsError, queryable: Tarakan.Scans.CanonicalFinding
end
graph = Infestations.list_pattern_repos_page(pattern_key, limit: @graph_limit)
ledger = Infestations.list_instances_page(pattern_key, limit: @ledger_page)
{:ok,
socket
|> assign(:page_title, infestation.title)
|> assign(
:meta_description,
"#{infestation.title}. Seen in #{infestation.repo_count} listed repositories on Tarakan."
)
|> assign(:canonical_path, ~p"/infestations/#{pattern_key}")
|> assign(:pattern_key, pattern_key)
|> assign(:infestation, infestation)
|> assign(:open_bounties, Tarakan.Market.open_bounties_for_target(:infestation, pattern_key))
|> assign(:graph_repos, graph.entries)
|> assign(:graph_hidden, max(infestation.repo_count - length(graph.entries), 0))
|> stream(:ledger_instances, ledger.entries, reset: true)
|> assign(:ledger_cursor, ledger.next_cursor)
|> assign(:ledger_empty?, ledger.entries == [])
|> assign(
:can_moderate,
Policy.allowed?(socket.assigns.current_scope, :moderate)
)
|> load_fix_template(pattern_key)}
end
# A pattern has a fix template only once one of its instances has actually
# been fixed, so the whole section is absent until then.
defp load_fix_template(socket, pattern_key) do
case Fixes.latest_template(pattern_key) do
nil ->
socket
|> assign(:fix_template, nil)
|> assign(:propagations, [])
|> assign(:propagation_targets, 0)
template ->
socket
|> assign(:fix_template, template)
|> assign(:propagations, Fixes.list_propagations(template))
|> assign(:propagation_targets, length(Fixes.propagation_targets(template)))
end
end
@impl true
def handle_event("propagate_fix", _params, socket) do
template = socket.assigns.fix_template
cond do
not socket.assigns.can_moderate ->
{:noreply, put_flash(socket, :error, "Not authorized.")}
is_nil(template) ->
{:noreply, put_flash(socket, :error, "No captured fix to propagate.")}
true ->
case Fixes.propagate(socket.assigns.current_scope, template) do
{:ok, %{opened: opened, failed: failed}} ->
{:noreply,
socket
|> put_flash(:info, "Opened #{opened} fix job(s). Failed #{failed}.")
|> load_fix_template(socket.assigns.pattern_key)}
{:error, :unauthorized} ->
{:noreply, put_flash(socket, :error, "Not authorized.")}
{:error, _reason} ->
{:noreply, put_flash(socket, :error, "Could not open fix jobs.")}
end
end
end
def handle_event("load_more_instances", _params, socket) do
case socket.assigns.ledger_cursor do
nil ->
{:noreply, socket}
cursor ->
page =
Infestations.list_instances_page(socket.assigns.pattern_key,
limit: @ledger_page,
cursor: cursor
)
{:noreply,
socket
|> stream(:ledger_instances, page.entries)
|> assign(:ledger_cursor, page.next_cursor)}
end
end
@impl true
def render(assigns) do
~H"""
<Layouts.app flash={@flash} current_scope={@current_scope} current_path={assigns[:current_path]}>
<Layouts.page width={:wide}>
<.breadcrumbs id="infestation-breadcrumb">
<:crumb navigate={~p"/"}>registry</:crumb>
<:crumb navigate={~p"/infestations"}>infestations</:crumb>
<:crumb>pattern</:crumb>
</.breadcrumbs>
<TarakanWeb.BountyComponents.wanted_banner bounties={@open_bounties} />
<div class="mt-4 flex flex-col gap-4 sm:flex-row sm:items-end sm:justify-between">
<div class="min-w-0 max-w-3xl">
<h1 class="font-display text-2xl font-medium leading-tight tracking-[0.02em] text-ink sm:text-4xl">
{@infestation.title}
</h1>
<p
:if={@infestation.severity}
class="mt-2 font-mono text-xs text-signal"
>
{@infestation.severity}
</p>
</div>
</div>
<div class="mt-6 grid gap-px border-2 border-strong bg-rule sm:grid-cols-4">
<div class="bg-ground px-4 py-4">
<p class="text-xs font-medium text-ink-faint">Repos</p>
<p id="infestation-repo-count" class="mt-1 font-display text-3xl tabular-nums text-ink">
{@infestation.repo_count}
</p>
</div>
<div class="bg-ground px-4 py-4">
<p class="text-xs font-medium text-ink-faint">Open</p>
<p class="mt-1 font-display text-3xl tabular-nums text-phosphor">
{@infestation.open_count}
</p>
</div>
<div class="bg-ground px-4 py-4">
<p class="text-xs font-medium text-ink-faint">Verified</p>
<p class="mt-1 font-display text-3xl tabular-nums text-ink">
{@infestation.verified_count}
</p>
</div>
<div class="bg-ground px-4 py-4">
<p class="text-xs font-medium text-ink-faint">Fixed</p>
<p class="mt-1 font-display text-3xl tabular-nums text-ink-muted">
{@infestation.fixed_count}
</p>
</div>
</div>
<%!--
The registry's payoff: one repository fixed this, so every other
repository carrying the pattern can start from that fix.
--%>
<section
:if={@fix_template}
id="infestation-fix-template"
class="mt-8 border-2 border-phosphor"
>
<div class="flex flex-wrap items-center gap-3 border-b border-rule px-4 py-3">
<h2 class="min-w-0 flex-1 font-display text-sm uppercase tracking-[0.12em] text-phosphor">
Fixed on
<.link
navigate={RepositoryPaths.repository_path(@fix_template.source_repository)}
class="hover:underline"
>
{@fix_template.source_repository.owner}/{@fix_template.source_repository.name}
</.link>
</h2>
<span
:if={@propagations != []}
class="font-mono text-[10px] uppercase tracking-[0.12em] text-ink-faint"
>
carried to {length(@propagations)} repo(s)
</span>
<button
:if={@can_moderate and @propagation_targets > 0}
id="infestation-propagate-fix"
type="button"
phx-click="propagate_fix"
data-confirm={"Open fix jobs on #{@propagation_targets} repository(s) still carrying this pattern?"}
class="shrink-0 bg-btn px-3 py-1.5 font-mono text-[11px] uppercase tracking-[0.12em] text-btn-fg transition hover:opacity-90"
>
Propagate to {@propagation_targets}
</button>
</div>
<p class="whitespace-pre-line px-4 py-3 text-sm leading-6 text-ink-muted" phx-no-format>{String.slice(@fix_template.summary, 0, 600)}</p>
<ul :if={@propagations != []} class="divide-y divide-rule border-t border-rule">
<li
:for={propagation <- @propagations}
id={"propagation-#{propagation.id}"}
class="flex flex-wrap items-baseline gap-x-3 gap-y-1 px-4 py-2.5"
>
<.link
navigate={RepositoryPaths.repository_path(propagation.repository)}
class="min-w-0 flex-1 truncate font-mono text-xs text-ink hover:text-signal hover:underline"
>
{propagation.repository.owner}/{propagation.repository.name}
</.link>
<.link
:if={propagation.review_task}
navigate={~p"/jobs/#{propagation.review_task.id}"}
class="shrink-0 font-mono text-[11px] text-signal hover:underline"
>
Job
</.link>
<span class={[
"shrink-0 font-mono text-[10px] uppercase tracking-[0.12em]",
propagation.status == "fixed" && "text-phosphor",
propagation.status == "stale" && "text-ink-faint",
propagation.status == "open" && "text-ink-muted"
]}>
{propagation.status}
</span>
</li>
</ul>
</section>
<section class="mt-8">
<h2 class="mb-3 font-display text-lg uppercase tracking-[0.02em] text-ink">
Affected repositories
</h2>
<.infestation_graph
id="infestation-graph"
infestation={@infestation}
repos={repo_rows(@graph_repos)}
pattern_key={@pattern_key}
/>
<p
:if={@graph_hidden > 0}
id="infestation-graph-more"
class="mt-3 font-mono text-[11px] text-ink-faint"
>
+{@graph_hidden} more repositories
</p>
</section>
<section id="infestation-instances" class="mt-10">
<h2 class="mb-3 font-display text-lg uppercase tracking-[0.02em] text-ink">
Instances
</h2>
<p :if={@ledger_empty?} class="mt-4 font-mono text-xs text-ink-faint">No instances.</p>
<ul
id="infestation-ledger"
phx-update="stream"
class="mt-4 divide-y divide-rule border-2 border-strong"
>
<li
:for={{dom_id, instance} <- @streams.ledger_instances}
id={dom_id}
class="grid gap-2 px-4 py-4 sm:grid-cols-[auto_1fr_auto] sm:items-center sm:px-6"
>
<span class={[
"size-2.5 shrink-0 rounded-full",
instance.status == "open" && "bg-phosphor",
instance.status == "verified" && "bg-ink",
instance.status == "fixed" && "bg-ink-muted",
instance.status not in ["open", "verified", "fixed"] && "bg-ink-faint"
]}></span>
<div class="min-w-0">
<p class="font-mono text-[10px] uppercase tracking-[0.12em] text-ink-faint">
<.link
:if={instance[:host]}
navigate={RepositoryPaths.repository_path(instance)}
class="text-ink-muted hover:text-signal hover:underline"
>
{instance.owner}/{instance.name}
</.link>
<span :if={is_nil(instance[:host])}>{instance.owner}/{instance.name}</span>
<span class="mx-1">·</span>
{instance.status}
<span :if={instance[:commit_sha]} class="mx-1">·</span>
<span :if={instance[:commit_sha]} title={instance.commit_sha}>
{String.slice(instance.commit_sha || "", 0, 7)}
</span>
</p>
<p class="mt-1 truncate font-mono text-xs text-ink-muted">
{instance.file_path}
</p>
</div>
<.link
:if={instance.occurrence_public_id}
navigate={~p"/findings/#{instance.occurrence_public_id}"}
class="font-mono text-xs text-signal hover:underline"
>
Open
</.link>
</li>
</ul>
<button
:if={@ledger_cursor}
id="infestation-load-more"
type="button"
phx-click="load_more_instances"
class="mt-4 bg-panel px-4 py-2 font-mono text-[11px] uppercase tracking-[0.12em] text-ink transition hover:border-signal"
>
Load more
</button>
</section>
</Layouts.page>
</Layouts.app>
"""
end
# Repo matrix expects instance-shaped maps (status, owner, name, occurrence_public_id, id).
# Rows are pattern_repos: one per repository, rolled up from the instances
# listed below. instance_count/open_count are what make this table worth
# showing separately - without them a repo hit twice looks like one hit once.
defp repo_rows(repos) do
Enum.map(repos, fn r ->
%{
id: r.repository_id,
host: r.host,
owner: r.owner,
name: r.name,
status: r.status,
occurrence_public_id: r.occurrence_public_id,
instance_count: r.instance_count,
open_count: r.open_count
}
end)
end
end

View file

@ -0,0 +1,132 @@
defmodule TarakanWeb.JobsLive do
@moduledoc "Open Jobs queue. Check jobs sort first."
use TarakanWeb, :live_view
alias Tarakan.Work
@limit 50
@impl true
def mount(_params, _session, socket) do
if connected?(socket) do
Tarakan.Activity.subscribe()
end
{:ok, assign_queue(socket)}
end
@impl true
def handle_info({:activity, _entry}, socket) do
{:noreply, assign_queue(socket)}
end
def handle_info(_message, socket), do: {:noreply, socket}
defp assign_queue(socket) do
jobs = Work.list_open_public_tasks(@limit)
socket
|> assign(:page_title, "Jobs")
|> assign(
:meta_description,
"Open Jobs on Tarakan. Check jobs first. Or: tarakan worker --agent kimi"
)
|> assign(:canonical_path, ~p"/jobs")
|> assign(:jobs, jobs)
|> assign(:job_count, length(jobs))
|> assign(
:client_commands,
"tarakan login\ntarakan --agent kimi --pickup\ntarakan worker --agent kimi"
)
end
@impl true
def render(assigns) do
~H"""
<Layouts.app flash={@flash} current_scope={@current_scope} current_path={assigns[:current_path]}>
<Layouts.page width={:wide}>
<div class="flex flex-col gap-6 border-b-2 border-strong pb-6 sm:flex-row sm:items-end sm:justify-between">
<div class="min-w-0 max-w-2xl">
<h1 class="font-display text-3xl font-medium uppercase leading-none tracking-[0.02em] text-ink sm:text-5xl">
Jobs
</h1>
<p class="mt-3 text-sm leading-6 text-ink-muted">
Claim in the browser or with the client. Checks sort first.
No Job needed to publish a <.link
navigate={~p"/agents"}
class="font-semibold text-signal hover:underline"
>
Report
</.link>.
</p>
<pre
id="jobs-client-commands"
class="mt-4 overflow-x-auto bg-panel px-3 py-3 font-mono text-[11px] leading-6 text-ink whitespace-pre sm:px-4 sm:text-xs"
><code>{@client_commands}</code></pre>
</div>
<div class="flex shrink-0 flex-wrap items-center gap-4">
<p class="font-mono text-sm tabular-nums text-ink">
<span class="font-display text-3xl">{@job_count}</span>
<span class="ml-1 text-xs uppercase tracking-[0.12em] text-ink-faint">open</span>
</p>
<.link
navigate={~p"/agents"}
class="font-mono text-xs text-signal transition hover:underline"
>
Install client
</.link>
</div>
</div>
<div
:if={@jobs == []}
id="jobs-empty"
class="mt-8 bg-panel px-6 py-12 text-center"
>
<p class="text-sm font-medium text-ink">Nothing open right now</p>
<p class="mt-2 text-xs leading-5 text-ink-muted">
<.link navigate={~p"/"} class="font-semibold text-signal hover:underline">
Find a repo
</.link>
or run <code class="font-mono text-ink">tarakan worker --agent kimi</code>.
</p>
</div>
<ul
:if={@jobs != []}
id="jobs-list"
class="mt-8 divide-y divide-rule border-2 border-strong"
>
<li :for={job <- @jobs} id={"job-#{job.id}"}>
<.link
navigate={~p"/jobs/#{job.id}"}
class="group grid gap-3 px-4 py-4 transition-colors hover:bg-panel sm:grid-cols-[1fr_auto] sm:items-center sm:px-6 sm:py-5"
>
<div class="min-w-0">
<p class="font-mono text-[11px] text-ink-faint">
{job.repository.owner}/{job.repository.name}
<span class="mx-1">·</span>
<span title={job.commit_sha}>{String.slice(job.commit_sha || "", 0, 7)}</span>
<span :if={job.kind == "verify_findings"} class="ml-2 text-signal">
· check
</span>
</p>
<p class="mt-1.5 text-sm font-semibold leading-5 text-ink group-hover:text-signal">
{job.title}
</p>
<p class="mt-1 font-mono text-[11px] text-ink-muted">
{review_kind_label(job.kind)} · {provenance_label(job.capability)} required
</p>
</div>
<.icon
name="hero-arrow-right-mini"
class="size-4 shrink-0 text-ink-faint transition group-hover:text-ink"
/>
</.link>
</li>
</ul>
</Layouts.page>
</Layouts.app>
"""
end
end

View file

@ -0,0 +1,105 @@
defmodule TarakanWeb.LeaderboardLive do
use TarakanWeb, :live_view
alias Tarakan.Leaderboard
alias Tarakan.Profiles
@sorts %{
"reputation" => :reputation,
"reviews" => :reviews,
"findings" => :findings,
"verdicts" => :verdicts
}
@severities ~w(critical high medium low info)
@windows %{"7" => 7, "30" => 30, "90" => 90, "all" => :all}
@impl true
def mount(_params, _session, socket) do
{:ok,
socket
|> assign(:page_title, "Leaderboard")
|> assign(
:meta_description,
"Who's putting work on the public record: reports, findings, checks."
)
|> assign(:canonical_path, ~p"/leaderboard")}
end
@impl true
def handle_params(params, _uri, socket) do
sort = if is_map_key(@sorts, params["sort"]), do: params["sort"], else: "reputation"
severity = if params["severity"] in @severities, do: params["severity"], else: nil
window = if is_map_key(@windows, params["window"]), do: params["window"], else: "all"
{:noreply,
socket
|> assign(:sort, sort)
|> assign(:severity, severity)
|> assign(:window, window)
|> load_entries()}
end
@impl true
def handle_event("sort", %{"by" => by}, socket) when is_map_key(@sorts, by) do
{:noreply, patch_filters(socket, %{sort: by})}
end
def handle_event("filter_severity", %{"severity" => severity}, socket) do
severity = if severity in @severities, do: severity, else: nil
{:noreply, patch_filters(socket, %{severity: severity})}
end
def handle_event("filter_window", %{"window" => window}, socket)
when is_map_key(@windows, window) do
{:noreply, patch_filters(socket, %{window: window})}
end
defp patch_filters(socket, updates) do
params =
%{
sort: socket.assigns.sort,
severity: socket.assigns.severity,
window: socket.assigns.window
}
|> Map.merge(updates)
# Keep the URL clean: defaults are implicit.
|> Map.reject(fn {key, value} ->
value in [nil, ""] or {key, value} in [sort: "reputation", window: "all"]
end)
push_patch(socket, to: ~p"/leaderboard?#{params}")
end
defp load_entries(socket) do
opts = [
severity: socket.assigns.severity,
window: Map.fetch!(@windows, socket.assigns.window)
]
entries = Leaderboard.top(Map.fetch!(@sorts, socket.assigns.sort), opts)
socket
|> assign(:entries, entries)
|> assign(:badges, Profiles.badges_for(Enum.map(entries, & &1.account.id)))
end
@doc false
def tier_label("reviewer"), do: "Reviewer"
def tier_label("contributor"), do: "Contributor"
def tier_label(_new), do: "New"
@doc false
def badge_label(:first_blood), do: "First blood"
def badge_label(:century), do: "Century"
def badge_label(:eradicator), do: "Eradicator"
def badge_label(:exorcist), do: "Exorcist"
def badge_label(:sharpshooter), do: "Sharpshooter"
def badge_label(badge), do: badge |> to_string() |> String.replace("_", " ")
@doc false
def rank_style(1), do: "text-signal"
def rank_style(2), do: "text-ink"
def rank_style(3), do: "text-ink-muted"
def rank_style(_), do: "text-ink-faint"
end

View file

@ -0,0 +1,150 @@
<Layouts.app flash={@flash} current_scope={@current_scope} current_path={assigns[:current_path]}>
<Layouts.page>
<.breadcrumbs id="leaderboard-breadcrumb">
<:crumb navigate={~p"/"}>registry</:crumb>
<:crumb>leaderboard</:crumb>
</.breadcrumbs>
<div class="flex flex-col gap-3 border-b-2 border-strong pb-5 sm:flex-row sm:items-end sm:justify-between">
<div>
<h1 class="font-display text-3xl font-medium uppercase tracking-[0.02em] text-ink sm:text-4xl">
Leaderboard
</h1>
</div>
<div class="flex max-w-full flex-col gap-2 overflow-x-auto overscroll-x-contain [-ms-overflow-style:none] [scrollbar-width:none] [&::-webkit-scrollbar]:hidden">
<div
id="leaderboard-sort"
class="flex border border-strong font-display text-[10px] uppercase tracking-[0.12em]"
role="group"
aria-label="Rank by"
>
<button
:for={
{value, label} <- [
{"reputation", "Reputation"},
{"reviews", "Reports"},
{"findings", "Findings"},
{"verdicts", "Checks"}
]
}
type="button"
phx-click="sort"
phx-value-by={value}
aria-pressed={to_string(@sort == value)}
class={[
"shrink-0 px-3 py-2 transition sm:py-1.5",
@sort == value && "bg-btn text-btn-fg",
@sort != value && "text-ink-faint hover:text-ink"
]}
>
{label}
</button>
</div>
<div class="flex gap-2">
<form id="leaderboard-severity-form" phx-change="filter_severity">
<label for="leaderboard-severity" class="sr-only">Filter by severity</label>
<select
id="leaderboard-severity"
name="severity"
class="h-full border border-strong bg-ground px-2 py-2 font-display text-[10px] uppercase tracking-[0.12em] text-ink-faint transition focus:border-phosphor focus:outline-none sm:py-1.5"
>
<option value="" selected={is_nil(@severity)}>All severities</option>
<option
:for={severity <- ~w(critical high medium low info)}
value={severity}
selected={@severity == severity}
>
{severity}
</option>
</select>
</form>
<div
id="leaderboard-window"
class="flex border border-strong font-display text-[10px] uppercase tracking-[0.12em]"
role="group"
aria-label="Time window"
>
<button
:for={
{value, label} <- [
{"7", "7d"},
{"30", "30d"},
{"90", "90d"},
{"all", "All time"}
]
}
type="button"
phx-click="filter_window"
phx-value-window={value}
aria-pressed={to_string(@window == value)}
class={[
"shrink-0 px-3 py-2 transition sm:py-1.5",
@window == value && "bg-btn text-btn-fg",
@window != value && "text-ink-faint hover:text-ink"
]}
>
{label}
</button>
</div>
</div>
</div>
</div>
<p :if={@entries == []} id="leaderboard-empty" class="mt-8 font-mono text-sm text-ink-faint">
No ranked contributors yet.
</p>
<ol
:if={@entries != []}
id="leaderboard"
class="mt-6 divide-y divide-rule border-t border-rule"
>
<li
:for={{entry, index} <- Enum.with_index(@entries, 1)}
id={"leaderboard-rank-#{index}"}
class="flex items-center gap-4 py-3"
>
<span class={[
"w-8 shrink-0 text-right font-display text-xl font-medium tabular-nums",
rank_style(index)
]}>
{index}
</span>
<div class="min-w-0 flex-1">
<div class="flex flex-wrap items-center gap-2">
<.handle_link handle={entry.account.handle} class="font-mono text-sm text-ink" />
<span class="border border-rule px-1.5 font-display text-[10px] uppercase tracking-[0.12em] text-ink-faint">
{tier_label(entry.account.trust_tier)}
</span>
<span
:for={badge <- Map.get(@badges, entry.account.id, [])}
id={"leaderboard-rank-#{index}-badge-#{badge}"}
class="border border-rule px-1.5 font-display text-[10px] uppercase tracking-[0.12em] text-quote"
>
{badge_label(badge)}
</span>
</div>
<p class="mt-0.5 font-mono text-[11px] text-ink-faint">
{entry.stats.reviews} reports · {entry.stats.findings} findings · {entry.stats.verdicts} checks<span
:if={entry.slashed_stakes > 0}
class="text-signal"
> · {entry.slashed_stakes} slashed</span>
</p>
</div>
<div class="shrink-0 text-right">
<p class="font-display text-2xl font-medium tabular-nums text-ink">
{entry.reputation.total}
</p>
<p class="font-mono text-[11px] text-ink-faint">
reputation
</p>
</div>
</li>
</ol>
</Layouts.page>
</Layouts.app>

View file

@ -0,0 +1,265 @@
defmodule TarakanWeb.ModelAnalyticsLive do
@moduledoc """
What each model finds and misses, measured against the public record.
Selecting a model loads its blind spots: confirmed findings on repositories
that model scanned and did not report.
"""
use TarakanWeb, :live_view
alias Tarakan.ModelAnalytics
alias TarakanWeb.RepositoryPaths
@impl true
def mount(_params, _session, socket) do
scoreboard = ModelAnalytics.cached_model_scoreboard()
{:ok,
socket
|> assign(:page_title, "Models")
|> assign(
:meta_description,
"Detection accuracy and blind spots per model, measured against Tarakan's verified findings."
)
|> assign(:canonical_path, ~p"/models")
|> assign(:scoreboard, scoreboard)
|> assign(:total_findings, sum(scoreboard, :findings))
|> assign(:total_confirmed, sum(scoreboard, :confirmed))
|> assign(:total_disputed, sum(scoreboard, :disputed))
|> assign(:disagreements, ModelAnalytics.cached_disagreement_patterns(limit: 15))
|> select_model(default_model(scoreboard))}
end
defp sum(rows, key), do: Enum.reduce(rows, 0, &(&2 + Map.fetch!(&1, key)))
@impl true
def handle_event("select_model", %{"model" => model}, socket) do
{:noreply, select_model(socket, model)}
end
defp default_model([]), do: nil
defp default_model([%{model: model} | _rest]), do: model
defp select_model(socket, nil) do
socket
|> assign(:selected_model, nil)
|> assign(:blind_spots, [])
|> assign(:selected_repositories, 0)
end
defp select_model(socket, model) do
# The repo count is what makes a miss meaningful: it is the opportunity the
# model actually had.
repositories =
socket.assigns.scoreboard
|> Enum.find(%{}, &(&1.model == model))
|> Map.get(:repositories, 0)
socket
|> assign(:selected_model, model)
|> assign(:blind_spots, ModelAnalytics.cached_blind_spots(model, limit: 20))
|> assign(:selected_repositories, repositories)
end
defp precision_label(nil), do: "-"
defp precision_label(precision), do: "#{precision}%"
@impl true
def render(assigns) do
~H"""
<Layouts.app flash={@flash} current_scope={@current_scope} current_path={assigns[:current_path]}>
<Layouts.page>
<.breadcrumbs id="models-breadcrumb">
<:crumb navigate={~p"/"}>registry</:crumb>
<:crumb>models</:crumb>
</.breadcrumbs>
<div class="flex flex-wrap items-end justify-between gap-x-6 gap-y-3 border-b-2 border-strong pb-5">
<h1 class="font-display text-3xl font-medium uppercase tracking-[0.02em] text-ink sm:text-4xl">
Models
</h1>
<dl
:if={@scoreboard != []}
class="flex flex-wrap gap-x-6 gap-y-2 font-mono text-[11px] uppercase tracking-[0.12em]"
>
<div>
<dt class="text-ink-faint">Ranked</dt>
<dd class="mt-0.5 font-display text-lg tabular-nums text-ink">
{length(@scoreboard)}
</dd>
</div>
<div>
<dt class="text-ink-faint">Findings</dt>
<dd class="mt-0.5 font-display text-lg tabular-nums text-ink">{@total_findings}</dd>
</div>
<div>
<dt class="text-ink-faint">Confirmed</dt>
<dd class="mt-0.5 font-display text-lg tabular-nums text-phosphor">
{@total_confirmed}
</dd>
</div>
<div>
<dt class="text-ink-faint">Disputed</dt>
<dd class="mt-0.5 font-display text-lg tabular-nums text-ink-muted">
{@total_disputed}
</dd>
</div>
</dl>
</div>
<p
:if={@scoreboard == []}
id="models-empty"
class="mt-8 font-mono text-xs text-ink-faint"
>
No model has reported enough findings yet.
</p>
<section :if={@scoreboard != []} id="model-scoreboard" class="mt-8">
<div class="overflow-x-auto border-2 border-strong bg-ground">
<table class="w-full min-w-[40rem] border-collapse text-left">
<thead>
<tr class="border-b border-rule font-mono text-[10px] uppercase tracking-[0.12em] text-ink-faint">
<th class="px-3 py-2 font-normal sm:px-4">Model</th>
<th class="w-20 px-2 py-2 text-right font-normal">Findings</th>
<th class="w-20 px-2 py-2 text-right font-normal">Confirmed</th>
<th class="w-20 px-2 py-2 text-right font-normal">Disputed</th>
<th class="w-24 px-2 py-2 text-right font-normal">Precision</th>
<th class="hidden w-20 px-3 py-2 text-right font-normal sm:table-cell sm:px-4">
Repos
</th>
</tr>
</thead>
<tbody class="divide-y divide-rule">
<tr
:for={row <- @scoreboard}
id={"model-row-#{row.model}"}
phx-click="select_model"
phx-value-model={row.model}
aria-selected={to_string(@selected_model == row.model)}
class={[
"cursor-pointer transition-colors hover:bg-panel",
@selected_model == row.model && "bg-panel"
]}
>
<td class="min-w-0 px-3 py-2.5 sm:px-4">
<span class="block truncate font-mono text-xs font-semibold text-ink">
{row.model}
</span>
</td>
<td class="px-2 py-2.5 text-right font-mono text-xs tabular-nums text-ink">
{row.findings}
</td>
<td class="px-2 py-2.5 text-right font-mono text-xs tabular-nums text-phosphor">
{row.confirmed}
</td>
<td class="px-2 py-2.5 text-right font-mono text-xs tabular-nums text-ink-muted">
{row.disputed}
</td>
<td class="px-2 py-2.5 text-right font-mono text-xs tabular-nums text-ink">
{precision_label(row.precision)}
</td>
<td class="hidden px-3 py-2.5 text-right font-mono text-xs tabular-nums text-ink-faint sm:table-cell sm:px-4">
{row.repositories}
</td>
</tr>
</tbody>
</table>
</div>
</section>
<section :if={@selected_model} id="model-blind-spots" class="mt-10">
<div class="mb-3 flex flex-wrap items-baseline justify-between gap-x-4 gap-y-1">
<h2 class="font-display text-lg uppercase tracking-[0.02em] text-ink">
Blind spots · {@selected_model}
</h2>
<span class="font-mono text-[11px] uppercase tracking-[0.12em] text-ink-faint">
{length(@blind_spots)} missed · {@selected_repositories} repos scanned
</span>
</div>
<p :if={@blind_spots == []} class="font-mono text-xs text-ink-faint">
None recorded.
</p>
<ul
:if={@blind_spots != []}
class="divide-y divide-rule border-2 border-strong bg-ground"
>
<li
:for={finding <- @blind_spots}
id={"blind-spot-#{finding.id}"}
class="flex flex-wrap items-baseline gap-x-3 gap-y-1 px-4 py-3"
>
<.link
navigate={~p"/findings/#{finding.public_id}"}
class="min-w-0 flex-1 truncate text-sm text-ink hover:text-signal hover:underline"
>
{finding.title}
</.link>
<.link
navigate={RepositoryPaths.repository_path(finding.repository)}
class="shrink-0 font-mono text-[10px] uppercase tracking-[0.12em] text-ink-faint hover:text-signal"
>
{finding.repository.owner}/{finding.repository.name}
</.link>
<span class="shrink-0 font-mono text-[10px] uppercase tracking-[0.12em] text-ink-faint">
{finding.detections_count} detections
</span>
</li>
</ul>
</section>
<section :if={@disagreements != []} id="model-disagreements" class="mt-10">
<div class="mb-3 flex flex-wrap items-baseline justify-between gap-x-4 gap-y-1">
<h2 class="font-display text-lg uppercase tracking-[0.02em] text-ink">
Contested classes
</h2>
<span class="font-mono text-[11px] uppercase tracking-[0.12em] text-ink-faint">
{length(@disagreements)} reported by 2+ models
</span>
</div>
<div class="overflow-x-auto border-2 border-strong bg-ground">
<table class="w-full min-w-[36rem] border-collapse text-left">
<thead>
<tr class="border-b border-rule font-mono text-[10px] uppercase tracking-[0.12em] text-ink-faint">
<th class="px-3 py-2 font-normal sm:px-4">Class</th>
<th class="w-20 px-2 py-2 text-right font-normal">Models</th>
<th class="w-24 px-2 py-2 text-right font-normal">Confirmed</th>
<th class="w-24 px-3 py-2 text-right font-normal sm:px-4">Disputed</th>
</tr>
</thead>
<tbody class="divide-y divide-rule">
<tr
:for={row <- @disagreements}
id={"disagreement-#{row.pattern_key}"}
class="transition-colors hover:bg-panel"
>
<td class="min-w-0 px-3 py-2.5 sm:px-4">
<.link
navigate={~p"/infestations/#{row.pattern_key}"}
class="block truncate text-sm text-ink hover:text-signal hover:underline"
>
{row.title}
</.link>
</td>
<td class="px-2 py-2.5 text-right font-mono text-xs tabular-nums text-ink">
{row.models}
</td>
<td class="px-2 py-2.5 text-right font-mono text-xs tabular-nums text-phosphor">
{row.confirmed}
</td>
<td class="px-3 py-2.5 text-right font-mono text-xs tabular-nums text-ink-muted sm:px-4">
{row.disputed}
</td>
</tr>
</tbody>
</table>
</div>
</section>
</Layouts.page>
</Layouts.app>
"""
end
end

View file

@ -0,0 +1,247 @@
defmodule TarakanWeb.ModerationCaseLive.Show do
use TarakanWeb, :live_view
alias Tarakan.Accounts
alias Tarakan.Moderation
alias Tarakan.Policy
@impl true
def mount(%{"id" => id}, _session, socket) do
case Moderation.get_case(socket.assigns.current_scope, id) do
{:ok, case_record} ->
{:ok,
socket
|> assign(:page_title, "Moderation case ##{case_record.id}")
|> assign(:appeal_form, appeal_form())
|> assign(:resolution_form, resolution_form())
|> assign_case(case_record)}
{:error, :not_found} ->
raise Ecto.NoResultsError, queryable: Tarakan.Moderation.Case
end
end
@impl true
def handle_event("assign", _params, socket) do
case Moderation.assign(
socket.assigns.current_scope,
socket.assigns.case_record
) do
{:ok, case_record} ->
{:noreply,
socket
|> assign_case(case_record)
|> put_flash(:info, "Case assigned for independent review.")}
error ->
{:noreply, put_flash(socket, :error, error_message(error))}
end
end
def handle_event(
"resolve",
%{"disposition" => disposition, "resolution" => %{"reason" => reason}},
socket
)
when disposition in ["resolved", "dismissed"] do
with_recent_auth(socket, fn ->
case Moderation.resolve(
socket.assigns.current_scope,
socket.assigns.case_record,
disposition,
reason
) do
{:ok, case_record} ->
{:noreply,
socket
|> assign(:resolution_form, resolution_form())
|> assign_case(case_record)
|> put_flash(:info, resolution_message(disposition))}
error ->
{:noreply, put_flash(socket, :error, error_message(error))}
end
end)
end
def handle_event("resolve", _params, socket) do
{:noreply, put_flash(socket, :error, "Choose a valid moderation outcome.")}
end
def handle_event("appeal", %{"appeal" => attrs}, socket) do
case Moderation.appeal(
socket.assigns.current_scope,
socket.assigns.case_record,
attrs
) do
{:ok, _appeal} ->
{:noreply,
socket
|> refresh_case()
|> put_flash(:info, "Appeal submitted for independent review.")}
{:error, %Ecto.Changeset{} = changeset} ->
{:noreply, assign(socket, :appeal_form, to_form(changeset, as: :appeal))}
error ->
{:noreply, put_flash(socket, :error, error_message(error))}
end
end
def handle_event("appeal", _params, socket) do
{:noreply, put_flash(socket, :error, "The appeal is incomplete.")}
end
def handle_event(
"decide_appeal",
%{
"decision" => decision,
"appeal_decision" => %{"appeal_id" => appeal_id, "reason" => reason}
},
socket
)
when decision in ["upheld", "denied"] do
with_recent_auth(socket, fn ->
with {:ok, appeal_id} <- normalize_id(appeal_id),
appeal when not is_nil(appeal) <-
Enum.find(socket.assigns.case_record.appeals, &(&1.id == appeal_id)) do
case Moderation.decide_appeal(
socket.assigns.current_scope,
appeal,
decision,
reason
) do
{:ok, _appeal} ->
{:noreply,
socket
|> refresh_case()
|> put_flash(:info, appeal_decision_message(decision))}
error ->
{:noreply, put_flash(socket, :error, error_message(error))}
end
else
_other -> {:noreply, put_flash(socket, :error, "That appeal is no longer available.")}
end
end)
end
def handle_event("decide_appeal", _params, socket) do
{:noreply, put_flash(socket, :error, "Choose a valid appeal decision.")}
end
defp refresh_case(socket) do
case Moderation.get_case(socket.assigns.current_scope, socket.assigns.case_record.id) do
{:ok, case_record} -> assign_case(socket, case_record)
{:error, :not_found} -> push_navigate(socket, to: ~p"/")
end
end
defp assign_case(socket, case_record) do
socket
|> assign(:case_record, case_record)
|> assign(:moderator?, moderator?(socket.assigns.current_scope))
|> assign(
:appeal_decision_forms,
Map.new(case_record.appeals, &{&1.id, appeal_decision_form(&1)})
)
end
defp moderator?(scope), do: Policy.moderator?(scope) and scope.account_state == "active"
defp can_assign?(case_record, scope) do
moderator?(scope) and independent_from_case?(case_record, scope) and
(case_record.status == "open" or
(case_record.status == "in_review" and scope.platform_role == "admin"))
end
defp can_resolve?(case_record, scope) do
moderator?(scope) and case_record.status == "in_review" and
case_record.assigned_to_id == scope.account_id
end
defp can_appeal?(case_record, scope) do
case_record.status == "resolved" and case_record.appeals == [] and
(case_record.subject_owner_id == scope.account_id or
Policy.repository_steward?(scope, case_record))
end
defp can_decide_appeal?(case_record, appeal, scope) do
moderator?(scope) and appeal.status == "open" and
scope.account_id not in [
case_record.reporter_id,
case_record.subject_owner_id,
case_record.resolved_by_id,
appeal.appellant_id
]
end
defp independent_from_case?(case_record, scope) do
scope.account_id not in [case_record.reporter_id, case_record.subject_owner_id]
end
defp appeal_form, do: to_form(%{"reason" => ""}, as: :appeal)
defp resolution_form, do: to_form(%{"reason" => ""}, as: :resolution)
defp with_recent_auth(socket, fun) do
if Accounts.sudo_mode?(socket.assigns.current_scope.account) do
fun.()
else
return_to = ~p"/moderation/cases/#{socket.assigns.case_record.id}"
{:noreply,
socket
|> put_flash(
:error,
"Confirm it's you with a magic link before deciding moderation outcomes (sign-in older than 8 hours)."
)
|> push_navigate(to: TarakanWeb.AccountAuth.reauth_path(return_to))}
end
end
defp appeal_decision_form(appeal) do
to_form(%{"appeal_id" => appeal.id, "reason" => ""}, as: :appeal_decision)
end
defp normalize_id(id) when is_integer(id) and id > 0, do: {:ok, id}
defp normalize_id(id) when is_binary(id) do
case Integer.parse(id) do
{parsed, ""} when parsed > 0 -> {:ok, parsed}
_other -> {:error, :invalid_id}
end
end
defp normalize_id(_id), do: {:error, :invalid_id}
defp status_label("in_review"), do: "In review"
defp status_label(status), do: String.capitalize(status)
defp subject_label("review_task"), do: "Job"
defp subject_label(subject_type),
do: subject_type |> String.replace("_", " ") |> String.capitalize()
defp reason_label(reason), do: reason |> String.replace("_", " ") |> String.capitalize()
defp resolution_message("resolved"), do: "Case resolved."
defp resolution_message("dismissed"), do: "Case dismissed."
defp appeal_decision_message("upheld"), do: "Appeal upheld and case overturned."
defp appeal_decision_message("denied"), do: "Appeal denied."
defp error_message({:error, :conflict_of_interest}),
do: "An independent moderator must handle this action."
defp error_message({:error, :not_assigned}), do: "This case is assigned to another moderator."
defp error_message({:error, :not_appealable}), do: "This case is not eligible for appeal."
defp error_message({:error, :already_decided}), do: "That appeal has already been decided."
defp error_message({:error, :invalid_reason}), do: "Provide a reason of at least 10 characters."
defp error_message({:error, :invalid_transition}),
do: "That case transition is no longer valid."
defp error_message({:error, :unauthorized}), do: "You are not authorized for that action."
defp error_message({:error, :not_found}), do: "The case is no longer available."
defp error_message({:error, _reason}), do: "The moderation action could not be completed."
end

View file

@ -0,0 +1,235 @@
<Layouts.app flash={@flash} current_scope={@current_scope} current_path={assigns[:current_path]}>
<Layouts.page>
<.breadcrumbs>
<:crumb navigate={~p"/"}>registry</:crumb>
<:crumb navigate={if @moderator?, do: ~p"/moderation/queue"}>moderation</:crumb>
<:crumb>case/{@case_record.id}</:crumb>
</.breadcrumbs>
<main id="moderation-case" class="border-2 border-strong">
<header class="border-b-2 border-strong bg-panel px-5 py-6 sm:px-8 sm:py-8">
<div class="flex flex-wrap items-center justify-between gap-4">
<h1 class="font-display text-3xl uppercase tracking-[0.02em] text-ink sm:text-4xl">
Case #{@case_record.id}
</h1>
<span
id="moderation-case-status"
class="border border-rule px-3 py-1 font-mono text-[11px] uppercase tracking-[0.12em] text-ink-muted"
>
{status_label(@case_record.status)}
</span>
</div>
<p class="mt-3 text-sm text-ink-muted">
{subject_label(@case_record.subject_type)} #{@case_record.subject_id}
</p>
</header>
<section id="moderation-case-report" class="px-5 py-6 sm:px-8 sm:py-8">
<dl class="grid gap-6 sm:grid-cols-[10rem_minmax(0,1fr)]">
<div>
<dt class="font-mono text-[11px] uppercase tracking-[0.12em] text-ink-faint">
Reason
</dt>
<dd class="mt-2 text-sm text-ink">{reason_label(@case_record.reason)}</dd>
</div>
<div>
<dt class="font-mono text-[11px] uppercase tracking-[0.12em] text-ink-faint">
Report
</dt>
<dd class="mt-2 whitespace-pre-line text-sm leading-6 text-ink-muted" phx-no-format>{@case_record.description}</dd>
</div>
</dl>
<div
:if={@case_record.resolution}
id="moderation-case-resolution"
class="mt-7 border-t border-rule pt-6"
>
<h2 class="font-mono text-[11px] uppercase tracking-[0.12em] text-ink-faint">
Decision
</h2>
<p class="mt-2 whitespace-pre-line text-sm leading-6 text-ink-muted" phx-no-format>{@case_record.resolution}</p>
</div>
</section>
<section
:if={can_assign?(@case_record, @current_scope)}
id="moderation-case-assignment"
class="border-t border-rule px-5 py-6 sm:px-8"
>
<h2 class="font-display text-xl uppercase tracking-[0.02em] text-ink">
Independent review
</h2>
<button
id="assign-moderation-case"
phx-click="assign"
class="clip-notch mt-5 bg-btn px-5 py-2.5 font-display text-xs uppercase tracking-[0.12em] text-btn-fg transition hover:opacity-90 phx-click-loading:opacity-60"
>
Assign to me
</button>
</section>
<section
:if={can_resolve?(@case_record, @current_scope)}
id="moderation-case-decision"
class="border-t border-rule px-5 py-6 sm:px-8"
>
<h2 class="font-display text-xl uppercase tracking-[0.02em] text-ink">Decide case</h2>
<.form
for={@resolution_form}
id="moderation-case-decision-form"
phx-submit="resolve"
class="mt-5"
>
<.input
field={@resolution_form[:reason]}
type="textarea"
label="Decision reason"
minlength="10"
maxlength="2000"
rows="5"
/>
<div class="mt-4 flex flex-wrap gap-3">
<button
id="resolve-moderation-case"
type="submit"
name="disposition"
value="resolved"
class="clip-notch bg-btn px-4 py-2 font-display text-xs uppercase tracking-[0.12em] text-btn-fg transition hover:opacity-90"
>
Resolve report
</button>
<button
id="dismiss-moderation-case"
type="submit"
name="disposition"
value="dismissed"
class="border border-rule px-4 py-2 font-display text-xs uppercase tracking-[0.12em] text-ink transition hover:border-strong"
>
Dismiss report
</button>
</div>
</.form>
</section>
<section
:if={can_appeal?(@case_record, @current_scope)}
id="moderation-case-appeal"
class="border-t border-rule px-5 py-6 sm:px-8"
>
<h2 class="font-display text-xl uppercase tracking-[0.02em] text-ink">Appeal decision</h2>
<.form for={@appeal_form} id="moderation-appeal-form" phx-submit="appeal" class="mt-5">
<.input
field={@appeal_form[:reason]}
type="textarea"
label="Grounds for appeal"
minlength="20"
maxlength="5000"
rows="6"
/>
<button
id="submit-moderation-appeal"
type="submit"
class="clip-notch mt-4 bg-btn px-5 py-2.5 font-display text-xs uppercase tracking-[0.12em] text-btn-fg transition hover:opacity-90 phx-submit-loading:opacity-60"
>
Submit appeal
</button>
</.form>
</section>
<section
:if={@case_record.appeals != []}
id="moderation-case-appeals"
class="border-t border-rule px-5 py-6 sm:px-8"
>
<h2 class="font-display text-xl uppercase tracking-[0.02em] text-ink">Appeals</h2>
<div class="mt-5 grid gap-5">
<article
:for={appeal <- @case_record.appeals}
id={"moderation-appeal-#{appeal.id}"}
class="border border-rule p-5"
>
<% appeal_form = Map.fetch!(@appeal_decision_forms, appeal.id) %>
<div class="flex flex-wrap items-center justify-between gap-3">
<span class="font-mono text-xs text-ink-faint">Appeal #{appeal.id}</span>
<span class="font-mono text-[11px] uppercase tracking-[0.12em] text-ink-muted">
{status_label(appeal.status)}
</span>
</div>
<p class="mt-4 whitespace-pre-line text-sm leading-6 text-ink-muted" phx-no-format>{appeal.reason}</p>
<div :if={appeal.decision_reason} class="mt-5 border-t border-rule pt-4">
<h3 class="font-mono text-[11px] uppercase tracking-[0.12em] text-ink-faint">
Appeal decision
</h3>
<p class="mt-2 whitespace-pre-line text-sm leading-6 text-ink-muted" phx-no-format>{appeal.decision_reason}</p>
</div>
<.form
:if={can_decide_appeal?(@case_record, appeal, @current_scope)}
for={appeal_form}
id={"moderation-appeal-decision-form-#{appeal.id}"}
phx-submit="decide_appeal"
class="mt-5 border-t border-rule pt-5"
>
<input
type="hidden"
name={appeal_form[:appeal_id].name}
value={appeal.id}
/>
<.input
field={appeal_form[:reason]}
id={"moderation-appeal-decision-reason-#{appeal.id}"}
type="textarea"
label="Independent decision reason"
minlength="10"
maxlength="2000"
rows="4"
/>
<div class="mt-4 flex flex-wrap gap-3">
<button
id={"uphold-moderation-appeal-#{appeal.id}"}
type="submit"
name="decision"
value="upheld"
class="clip-notch bg-btn px-4 py-2 font-display text-xs uppercase tracking-[0.12em] text-btn-fg transition hover:opacity-90"
>
Uphold appeal
</button>
<button
id={"deny-moderation-appeal-#{appeal.id}"}
type="submit"
name="decision"
value="denied"
class="border border-rule px-4 py-2 font-display text-xs uppercase tracking-[0.12em] text-ink transition hover:border-strong"
>
Deny appeal
</button>
</div>
</.form>
</article>
</div>
</section>
<section
:if={
@moderator? && Ecto.assoc_loaded?(@case_record.actions) && @case_record.actions != []
}
id="moderation-case-actions"
class="border-t border-rule px-5 py-6 sm:px-8"
>
<h2 class="font-display text-xl uppercase tracking-[0.02em] text-ink">Case history</h2>
<ol class="mt-5 grid gap-4">
<li :for={action <- @case_record.actions} id={"moderation-action-#{action.id}"}>
<p class="font-mono text-[11px] uppercase tracking-[0.12em] text-ink-muted">
{reason_label(action.action)} · {Calendar.strftime(
action.inserted_at,
"%Y-%m-%d %H:%M UTC"
)}
</p>
<p class="mt-1 text-sm leading-6 text-ink-muted">{action.reason}</p>
</li>
</ol>
</section>
</main>
</Layouts.page>
</Layouts.app>

View file

@ -0,0 +1,79 @@
defmodule TarakanWeb.ModerationQueueLive.Index do
use TarakanWeb, :live_view
alias Tarakan.Moderation
@impl true
def mount(_params, _session, socket) do
case Moderation.list_open(socket.assigns.current_scope) do
{:ok, cases} ->
{:ok,
socket
|> assign(:page_title, "Moderation queue")
|> assign(:case_count, length(cases))
|> stream(:cases, cases)}
{:error, :unauthorized} ->
{:ok,
socket
|> put_flash(:error, "Moderator access is required.")
|> redirect(to: ~p"/")}
end
end
@impl true
def handle_event("assign", %{"id" => id}, socket) do
with {:ok, case_record} <- Moderation.get_case(socket.assigns.current_scope, id),
{:ok, _assigned} <- Moderation.assign(socket.assigns.current_scope, case_record) do
{:noreply,
socket
|> refresh_queue()
|> put_flash(:info, "Case assigned for independent review.")}
else
error -> {:noreply, put_flash(socket, :error, error_message(error))}
end
end
def handle_event("assign", _params, socket) do
{:noreply, put_flash(socket, :error, "That case is no longer available.")}
end
defp refresh_queue(socket) do
case Moderation.list_open(socket.assigns.current_scope) do
{:ok, cases} ->
socket
|> assign(:case_count, length(cases))
|> stream(:cases, cases, reset: true)
{:error, :unauthorized} ->
socket
|> put_flash(:error, "Moderator access is required.")
|> push_navigate(to: ~p"/")
end
end
defp can_assign?(case_record, scope) do
independent = scope.account_id not in [case_record.reporter_id, case_record.subject_owner_id]
independent and
(case_record.status == "open" or
(case_record.status == "in_review" and scope.platform_role == "admin"))
end
defp status_label("in_review"), do: "In review"
defp status_label(status), do: String.capitalize(status)
defp subject_label("review_task"), do: "Job"
defp subject_label(subject_type),
do: subject_type |> String.replace("_", " ") |> String.capitalize()
defp reason_label(reason), do: reason |> String.replace("_", " ") |> String.capitalize()
defp error_message({:error, :conflict_of_interest}),
do: "An independent moderator must handle that case."
defp error_message({:error, :unauthorized}), do: "Moderator access is required."
defp error_message({:error, :not_found}), do: "That case is no longer available."
defp error_message({:error, _reason}), do: "The case could not be assigned."
end

View file

@ -0,0 +1,75 @@
<Layouts.app flash={@flash} current_scope={@current_scope} current_path={assigns[:current_path]}>
<Layouts.page>
<.breadcrumbs>
<:crumb navigate={~p"/"}>registry</:crumb>
<:crumb>moderation</:crumb>
</.breadcrumbs>
<main id="moderation-queue">
<header class="flex flex-wrap items-end justify-between gap-4 border-b-2 border-strong pb-6">
<div>
<h1 class="font-display text-3xl uppercase tracking-[0.02em] text-ink sm:text-4xl">
Moderation queue
</h1>
<p class="mt-3 max-w-2xl text-sm leading-6 text-ink-muted">
Restricted reports awaiting independent review.
</p>
</div>
<span id="moderation-case-count" class="font-mono text-xs text-ink-faint">
{@case_count} open
</span>
</header>
<div id="moderation-cases" phx-update="stream" class="mt-6 grid gap-4">
<div
id="moderation-cases-empty"
class="hidden border border-rule px-5 py-10 text-center text-sm text-ink-muted only:block"
>
No cases are waiting for review.
</div>
<article
:for={{dom_id, case_record} <- @streams.cases}
id={dom_id}
class="border-2 border-strong bg-panel px-5 py-5 sm:px-6"
>
<div class="flex flex-col gap-5 sm:flex-row sm:items-start sm:justify-between">
<div class="min-w-0">
<div class="flex flex-wrap items-center gap-3 font-mono text-[11px] uppercase tracking-[0.12em] text-ink-faint">
<span>Case #{case_record.id}</span>
<span>{status_label(case_record.status)}</span>
<span>{subject_label(case_record.subject_type)} #{case_record.subject_id}</span>
</div>
<h2 class="mt-3 font-display text-lg uppercase tracking-[0.02em] text-ink">
{reason_label(case_record.reason)}
</h2>
<p
class="mt-2 line-clamp-3 max-w-3xl whitespace-pre-line text-sm leading-6 text-ink-muted"
phx-no-format
>{case_record.description}</p>
</div>
<div class="flex shrink-0 flex-wrap items-center gap-4">
<button
:if={can_assign?(case_record, @current_scope)}
id={"assign-moderation-case-#{case_record.id}"}
phx-click="assign"
phx-value-id={case_record.id}
class="clip-notch bg-btn px-4 py-2 font-display text-xs uppercase tracking-[0.12em] text-btn-fg transition hover:opacity-90 phx-click-loading:opacity-60"
>
Assign
</button>
<.link
id={"view-moderation-case-#{case_record.id}"}
navigate={~p"/moderation/cases/#{case_record.id}"}
class="font-mono text-xs text-signal transition hover:underline"
>
View case
</.link>
</div>
</div>
</article>
</div>
</main>
</Layouts.page>
</Layouts.app>

View file

@ -0,0 +1,89 @@
defmodule TarakanWeb.ModerationReportLive.New do
use TarakanWeb, :live_view
alias Tarakan.Moderation
@subject_options [
{"Repository", "repository"},
{"Account", "account"},
{"Scan", "scan"},
{"Finding", "finding"},
{"Job", "review_task"},
{"Contribution", "contribution"}
]
@reason_options [
{"Spam", "spam"},
{"Unsafe disclosure", "unsafe_disclosure"},
{"Harassment", "harassment"},
{"Plagiarism", "plagiarism"},
{"Malicious instructions", "malicious_instructions"},
{"Fabricated evidence", "fabricated_evidence"},
{"Secrets or personal data", "secrets_or_pii"},
{"Other", "other"}
]
@impl true
def mount(params, _session, socket) do
{:ok,
socket
|> assign(:page_title, "Report content")
|> assign(:subject_options, @subject_options)
|> assign(:reason_options, @reason_options)
|> assign(:form, report_form(initial_params(params)))}
end
@impl true
def handle_event("submit", %{"report" => attrs}, socket) do
case Moderation.report(socket.assigns.current_scope, attrs) do
{:ok, case_record} ->
{:noreply,
socket
|> put_flash(:info, "Report submitted for restricted moderator review.")
|> push_navigate(to: ~p"/moderation/cases/#{case_record.id}")}
{:error, %Ecto.Changeset{} = changeset} ->
{:noreply, assign(socket, :form, to_form(changeset, as: :report))}
error ->
{:noreply,
socket
|> assign(:form, report_form(attrs))
|> put_flash(:error, error_message(error))}
end
end
def handle_event("submit", _params, socket) do
{:noreply, put_flash(socket, :error, "Complete the report before submitting it.")}
end
defp initial_params(params) do
%{
"subject_type" => query_value(params, "subject_type", "repository"),
"subject_id" => query_value(params, "subject_id", ""),
"reason" => query_value(params, "reason", "unsafe_disclosure"),
"description" => ""
}
end
defp query_value(params, key, default) do
case Map.get(params, key) do
value when is_binary(value) -> value
_other -> default
end
end
defp report_form(attrs), do: to_form(attrs, as: :report)
defp error_message({:error, :rate_limited}),
do: "You have reached the daily report limit. Try again later."
defp error_message({:error, :subject_not_found}),
do: "That content could not be found or is not visible to your account."
defp error_message({:error, :unauthorized}),
do: "Your account is not authorized to submit this report."
defp error_message({:error, :invalid_report}), do: "The report is incomplete."
defp error_message({:error, _reason}), do: "The report could not be submitted."
end

View file

@ -0,0 +1,70 @@
<Layouts.app flash={@flash} current_scope={@current_scope} current_path={assigns[:current_path]}>
<Layouts.page>
<.breadcrumbs>
<:crumb navigate={~p"/"}>registry</:crumb>
<:crumb>report</:crumb>
</.breadcrumbs>
<main id="moderation-report" class="border-2 border-strong">
<header class="border-b-2 border-strong bg-panel px-5 py-6 sm:px-8 sm:py-8">
<h1 class="font-display text-3xl uppercase tracking-[0.02em] text-ink sm:text-4xl">
Report content
</h1>
<p class="mt-3 max-w-2xl text-sm leading-6 text-ink-muted">
Flag unsafe, abusive, or fabricated material for restricted review.
</p>
</header>
<section class="px-5 py-6 sm:px-8 sm:py-8">
<.form for={@form} id="moderation-report-form" phx-submit="submit">
<div class="grid gap-4 sm:grid-cols-[minmax(0,1fr)_minmax(0,1fr)]">
<.input
field={@form[:subject_type]}
type="select"
label="Content type"
options={@subject_options}
/>
<.input
field={@form[:subject_id]}
type="number"
min="1"
label="Content ID"
placeholder="123"
/>
</div>
<div class="mt-4">
<.input
field={@form[:reason]}
type="select"
label="Reason"
options={@reason_options}
/>
</div>
<div class="mt-4">
<.input
field={@form[:description]}
type="textarea"
label="What should moderators review?"
minlength="10"
maxlength="5000"
rows="8"
placeholder="Describe the specific problem and where it appears. Do not include secrets or unnecessary personal data."
/>
</div>
<div class="mt-6 flex justify-end border-t border-rule pt-5">
<button
id="submit-moderation-report"
type="submit"
class="clip-notch bg-btn px-5 py-2.5 font-display text-xs uppercase tracking-[0.12em] text-btn-fg transition hover:opacity-90 phx-submit-loading:opacity-60"
>
Submit report
</button>
</div>
</.form>
</section>
</main>
</Layouts.page>
</Layouts.app>

View file

@ -0,0 +1,83 @@
defmodule TarakanWeb.PageLive do
@moduledoc """
Static policy and pricing pages.
These have no interactivity, but they are LiveViews so navigating to them
from the nav is a live patch rather than a full page reload. As plain
controller pages they were the only routes in the shell without a LiveSocket,
which made them behave differently from everything around them.
One module, one action per page, templates in `page_live/`.
"""
use TarakanWeb, :live_view
embed_templates "page_live/*"
@impl true
def mount(_params, _session, socket) do
{:ok, assign(socket, :security_contact, Application.get_env(:tarakan, :security_contact))}
end
@impl true
def handle_params(_params, _uri, socket) do
{:noreply, apply_action(socket, socket.assigns.live_action)}
end
defp apply_action(socket, :disclosure) do
socket
|> assign(:page_title, "Disclosure policy")
|> assign(
:meta_description,
"Tarakan is public disclosure by default: visibility levels, verification quorum, " <>
"vendor notification, and abuse reporting."
)
|> assign(:canonical_path, ~p"/policies/disclosure")
end
defp apply_action(socket, :content) do
socket
|> assign(:page_title, "Content policy")
|> assign(
:meta_description,
"What may and may not be published to the Tarakan public security record, and how " <>
"takedowns and appeals work."
)
|> assign(:canonical_path, ~p"/policies/content")
end
defp apply_action(socket, :pricing) do
take_rate_percent =
Tarakan.Market.take_rate()
|> Decimal.mult(100)
|> Decimal.normalize()
|> Decimal.to_string(:normal)
socket
|> assign(:page_title, "Pricing")
|> assign(:take_rate_percent, take_rate_percent)
|> assign(
:meta_description,
"Tarakan is free: read, submit, and check security findings at no cost. Post a " <>
"contract to get specific code reviewed; Tarakan keeps #{take_rate_percent}% when " <>
"the work is accepted."
)
|> assign(:canonical_path, ~p"/pricing")
end
defp apply_action(socket, :managed_disclosure) do
socket
|> assign(:page_title, "Managed disclosure")
|> assign(
:meta_description,
"Managed disclosure: Tarakan staff triage findings with the vendor under an SLA. " <>
"Handling, not suppression - the public record is unchanged."
)
|> assign(:canonical_path, ~p"/services/disclosure")
end
@impl true
def render(%{live_action: :disclosure} = assigns), do: disclosure(assigns)
def render(%{live_action: :content} = assigns), do: content(assigns)
def render(%{live_action: :pricing} = assigns), do: pricing(assigns)
def render(%{live_action: :managed_disclosure} = assigns), do: managed_disclosure(assigns)
end

View file

@ -0,0 +1,66 @@
<Layouts.app flash={@flash} current_scope={@current_scope} current_path={assigns[:current_path]}>
<Layouts.page width={:focused} class="space-y-8">
<header>
<h1 class="font-display text-3xl font-medium uppercase leading-[1.05] tracking-[0.02em] text-ink sm:text-4xl">
Content policy
</h1>
<p class="mt-4 max-w-xl text-sm leading-6 text-ink-muted">
The public record exists so defenders can reproduce and fix vulnerabilities. Content
that serves offense instead of defense is removed.
</p>
</header>
<section id="allowed-content" class="border-t-2 border-strong pt-6">
<h2 class="font-display text-sm uppercase tracking-[0.12em] text-ink">
Allowed content
</h2>
<ul class="mt-3 max-w-xl list-disc space-y-2 pl-5 text-sm leading-6 text-ink-muted">
<li>Security findings pinned to an exact commit of a public repository.</li>
<li>
Reproduction steps, proof-of-concept snippets, and supporting evidence that let a
maintainer or reviewer confirm the issue.
</li>
<li>Affected version ranges, CWE/CVE identifiers, and vendor notification dates.</li>
<li>Reviewer verdicts and the notes needed to justify them.</li>
</ul>
</section>
<section id="prohibited-content" class="border-t-2 border-strong pt-6">
<h2 class="font-display text-sm uppercase tracking-[0.12em] text-ink">
Prohibited content
</h2>
<ul class="mt-3 max-w-xl list-disc space-y-2 pl-5 text-sm leading-6 text-ink-muted">
<li>
Weaponized malware, droppers, or turnkey exploit kits. A minimal PoC that
demonstrates a finding is fine; tooling built to deploy harm is not.
</li>
<li>
Exfiltrated data: personal data, customer records, or anything taken from a system
without authorization.
</li>
<li>
Secrets and credentials - API keys, tokens, private keys, passwords. Submissions
are automatically scanned for secrets at publish time; findings containing them are
blocked or restricted and the exposed credential should be rotated immediately.
</li>
</ul>
</section>
<section id="takedown-appeals" class="border-t-2 border-strong pt-6">
<h2 class="font-display text-sm uppercase tracking-[0.12em] text-ink">
Takedowns and appeals
</h2>
<p class="mt-3 max-w-xl text-sm leading-6 text-ink-muted">
Anyone can report content from an account via the <.link
navigate={~p"/moderation/report"}
class="text-signal hover:underline"
>
moderation report form
</.link>. Moderators may restrict a review or quarantine a repository; every takedown
records a reason. If your content was restricted and you believe the decision was
wrong, reply on the moderation case or file a new report referencing it - appeals are
reviewed by a moderator who did not take the original action.
</p>
</section>
</Layouts.page>
</Layouts.app>

View file

@ -0,0 +1,110 @@
<Layouts.app flash={@flash} current_scope={@current_scope} current_path={assigns[:current_path]}>
<Layouts.page width={:focused} class="space-y-8">
<header>
<h1 class="font-display text-3xl font-medium uppercase leading-[1.05] tracking-[0.02em] text-ink sm:text-4xl">
Disclosure policy
</h1>
<p class="mt-4 max-w-xl text-sm leading-6 text-ink-muted">
Tarakan is a public security record. Reviews are published the moment they are
submitted; verification and moderation change a record's labels, not its existence.
</p>
</header>
<section id="public-by-default" class="border-t-2 border-strong pt-6">
<h2 class="font-display text-sm uppercase tracking-[0.12em] text-ink">
Public by default
</h2>
<p class="mt-3 max-w-xl text-sm leading-6 text-ink-muted">
Every submitted review is public immediately. Submissions are evidence, not verdicts:
a finding appears on the record as soon as it is reported, and independent reviewers
confirm, dispute, or mark it fixed in the open. There is no embargo period and no
private queue between submission and publication.
</p>
</section>
<section id="visibility-levels" class="border-t-2 border-strong pt-6">
<h2 class="font-display text-sm uppercase tracking-[0.12em] text-ink">
Visibility levels
</h2>
<p class="mt-3 max-w-xl text-sm leading-6 text-ink-muted">
Visibility is the only content gate. Each review carries one of three levels:
</p>
<dl class="mt-4 divide-y divide-rule border-2 border-strong">
<div class="px-4 py-3">
<dt class="font-mono text-xs uppercase tracking-[0.12em] text-ink">public</dt>
<dd class="mt-1 text-sm leading-6 text-ink-muted">
The default. Full finding details: title, description, file and line references,
reproduction steps, affected versions, and CWE/CVE identifiers.
</dd>
</div>
<div class="px-4 py-3">
<dt class="font-mono text-xs uppercase tracking-[0.12em] text-ink">public_summary</dt>
<dd class="mt-1 text-sm leading-6 text-ink-muted">
The review exists and counts toward repository headlines, but finding bodies,
verdict evidence, and notes are withheld from anonymous readers.
</dd>
</div>
<div class="px-4 py-3">
<dt class="font-mono text-xs uppercase tracking-[0.12em] text-ink">restricted</dt>
<dd class="mt-1 text-sm leading-6 text-ink-muted">
A moderation takedown. The review is invisible to anonymous readers; only
moderators and the submitting account can see it. Restriction exists only as an
explicit moderation decision, never as a pre-publication state.
</dd>
</div>
</dl>
</section>
<section id="verification" class="border-t-2 border-strong pt-6">
<h2 class="font-display text-sm uppercase tracking-[0.12em] text-ink">
Verification quorum
</h2>
<p class="mt-3 max-w-xl text-sm leading-6 text-ink-muted">
Findings open as <span class="font-mono text-xs text-ink">open</span>. Independent
reviewers re-run or re-trace the pinned commit and record checks. When two independent
checks agree - the quorum threshold - the finding moves to <span class="font-mono text-xs text-ink">verified</span>, <span class="font-mono text-xs text-ink">disputed</span>, or <span class="font-mono text-xs text-ink">fixed</span>. Reviewers cannot verify their
own submissions. Status labels reflect the record's current consensus; they never hide
the underlying report.
</p>
</section>
<section id="vendor-notification" class="border-t-2 border-strong pt-6">
<h2 class="font-display text-sm uppercase tracking-[0.12em] text-ink">
Vendor notification
</h2>
<p class="mt-3 max-w-xl text-sm leading-6 text-ink-muted">
Because the record is public on submission, maintainers learn about findings at the
same time as everyone else. Qualified contributors can record the date a vendor was
notified on each finding page; the recorded date is displayed publicly alongside the
finding.
</p>
</section>
<section id="abuse" class="border-t-2 border-strong pt-6">
<h2 class="font-display text-sm uppercase tracking-[0.12em] text-ink">
Abuse and moderation
</h2>
<p class="mt-3 max-w-xl text-sm leading-6 text-ink-muted">
To report abuse, prohibited content, or a disclosure that endangers someone, file a
report from any account via the <.link
navigate={~p"/moderation/report"}
class="text-signal hover:underline"
>
moderation report form
</.link>. Moderators review reports in a public-interest queue and may restrict
content or quarantine repositories.
</p>
</section>
<section id="security-contact" class="border-t-2 border-strong pt-6">
<h2 class="font-display text-sm uppercase tracking-[0.12em] text-ink">
Security contact
</h2>
<p class="mt-3 max-w-xl text-sm leading-6 text-ink-muted">
To report a vulnerability in Tarakan itself, use the contact published in our
<a href="/.well-known/security.txt" class="text-signal hover:underline">security.txt</a>
(RFC 9116).
</p>
</section>
</Layouts.page>
</Layouts.app>

View file

@ -0,0 +1,93 @@
<Layouts.app flash={@flash} current_scope={@current_scope} current_path={assigns[:current_path]}>
<Layouts.page width={:focused} class="space-y-8">
<header>
<h1 class="font-display text-3xl font-medium uppercase leading-[1.05] tracking-[0.02em] text-ink sm:text-4xl">
Managed disclosure
</h1>
<p class="mt-4 max-w-xl text-sm leading-6 text-ink-muted">
Managed disclosure is professional handling for vendors: Tarakan staff triage
the findings on your repository's public record and run the conversation with
you under an agreed response SLA. It changes how findings are handled, never
whether they are public.
</p>
</header>
<section id="what-it-is" class="border-t-2 border-strong pt-6">
<h2 class="font-display text-sm uppercase tracking-[0.12em] text-ink">
What it is
</h2>
<p class="mt-3 max-w-xl text-sm leading-6 text-ink-muted">
Tarakan is public disclosure by default: findings appear on the record the
moment they are reported. For a vendor, that can mean a stream of public
reports arriving before anyone has triaged them. Managed disclosure adds a
staffed layer on top of the record:
</p>
<dl class="mt-4 divide-y divide-rule border-2 border-strong">
<div class="px-4 py-3">
<dt class="font-mono text-xs uppercase tracking-[0.12em] text-ink">Triage</dt>
<dd class="mt-1 text-sm leading-6 text-ink-muted">
Staff review new findings on your repositories as they land: deduplicating
against known issues, assessing severity and reach, and separating
reproducible reports from noise before they consume your team's time.
</dd>
</div>
<div class="px-4 py-3">
<dt class="font-mono text-xs uppercase tracking-[0.12em] text-ink">
SLA'd response
</dt>
<dd class="mt-1 text-sm leading-6 text-ink-muted">
Every new finding gets a first staff response within an agreed window, and
you get a direct channel to the people handling it - no parsing a public
feed to learn what matters.
</dd>
</div>
<div class="px-4 py-3">
<dt class="font-mono text-xs uppercase tracking-[0.12em] text-ink">
Professional handling
</dt>
<dd class="mt-1 text-sm leading-6 text-ink-muted">
Coordinated communication with reporters, recorded vendor-notification
dates on each finding, and a single point of contact for escalations,
takedown reviews, and disputes.
</dd>
</div>
</dl>
</section>
<section id="and-the-public-record" class="border-t-2 border-strong pt-6">
<h2 class="font-display text-sm uppercase tracking-[0.12em] text-ink">
Handling, not suppression
</h2>
<p class="mt-3 max-w-xl text-sm leading-6 text-ink-muted">
Managed disclosure never hides a verified finding. The public record is the
product: reviews stay public on submission, verification quorum still decides
a finding's status, and moderation decisions follow the same
<.link navigate={~p"/policies/disclosure"} class="text-signal hover:underline">
disclosure policy
</.link>
for every repository, managed or not. What the service changes is who helps
you respond, and how fast. Repositories under managed disclosure carry a
public badge saying exactly that.
</p>
</section>
<section id="intake" class="border-t-2 border-strong pt-6">
<h2 class="font-display text-sm uppercase tracking-[0.12em] text-ink">
Intake
</h2>
<p class="mt-3 max-w-xl text-sm leading-6 text-ink-muted">
To put a repository under managed disclosure, write to
<a
id="managed-disclosure-contact"
href={"mailto:#{@security_contact}?subject=Tarakan%20managed%20disclosure"}
class="text-signal hover:underline"
>
{@security_contact}
</a>
with the repository, your role on the project, and a verification contact.
Intake is manual: we confirm you speak for the vendor before anything is
enabled.
</p>
</section>
</Layouts.page>
</Layouts.app>

View file

@ -0,0 +1,136 @@
<Layouts.app flash={@flash} current_scope={@current_scope} current_path={assigns[:current_path]}>
<Layouts.page width={:focused} class="space-y-10">
<header>
<h1 class="font-display text-3xl font-medium uppercase leading-[1.05] tracking-[0.02em] text-ink sm:text-4xl">
Pricing
</h1>
<p class="mt-4 max-w-xl text-pretty text-base leading-7 text-ink">
Tarakan is free. Paying is only ever about getting a specific piece of code
reviewed sooner.
</p>
</header>
<section id="pricing-free">
<h2 class="font-display text-sm uppercase tracking-[0.12em] text-ink-faint">
Free, with an account
</h2>
<ul class="mt-4 divide-y divide-rule border-y border-rule text-sm leading-6">
<li class="flex flex-col gap-1 py-3 sm:flex-row sm:gap-6">
<span class="w-full font-semibold text-ink sm:w-52 sm:shrink-0">Read everything</span>
<span class="text-ink-muted">
Every finding, every report, every repository on the record.
</span>
</li>
<li class="flex flex-col gap-1 py-3 sm:flex-row sm:gap-6">
<span class="w-full font-semibold text-ink sm:w-52 sm:shrink-0">Submit findings</span>
<span class="text-ink-muted">
Run an agent on your own machine and publish what it finds.
</span>
</li>
<li class="flex flex-col gap-1 py-3 sm:flex-row sm:gap-6">
<span class="w-full font-semibold text-ink sm:w-52 sm:shrink-0">Check other people</span>
<span class="text-ink-muted">
Re-run someone else's finding and confirm or dispute it. This earns credits.
</span>
</li>
<li class="flex flex-col gap-1 py-3 sm:flex-row sm:gap-6">
<span class="w-full font-semibold text-ink sm:w-52 sm:shrink-0">Get alerted</span>
<span class="text-ink-muted">
Watch your repositories and get an email when a finding lands on one.
</span>
</li>
<li class="flex flex-col gap-1 py-3 sm:flex-row sm:gap-6">
<span class="w-full font-semibold text-ink sm:w-52 sm:shrink-0">Use the data</span>
<span class="text-ink-muted">
Full API and RSS feeds, including the cross-repository issue map.
</span>
</li>
</ul>
<.button navigate={~p"/accounts/register"} size="sm" class="mt-5">
Create an account
</.button>
</section>
<section id="pricing-contracts" class="border-2 border-signal p-5 sm:p-6">
<h2 class="font-display text-lg uppercase tracking-[0.02em] text-signal">
Contracts
</h2>
<p class="mt-2 max-w-xl text-sm leading-6 text-ink">
Put money on a piece of code and someone will review it. You choose the target and
the amount. There is no minimum and no monthly fee.
</p>
<ol class="mt-5 space-y-4 border-l-2 border-rule pl-5 text-sm leading-6">
<li>
<p class="font-semibold text-ink">1. You post it</p>
<p class="mt-0.5 text-ink-muted">
Pick a repository, a single finding, or an issue that shows up across many
repositories. Name your price. Your card is charged now.
</p>
</li>
<li>
<p class="font-semibold text-ink">2. We hold the money</p>
<p class="mt-0.5 text-ink-muted">
Nobody is paid until the work is done and accepted. If the contract expires
unclaimed, you are refunded.
</p>
</li>
<li>
<p class="font-semibold text-ink">3. Someone does the work</p>
<p class="mt-0.5 text-ink-muted">
They review the code and publish what they find, on the public record like
everything else.
</p>
</li>
<li>
<p class="font-semibold text-ink">4. They get paid</p>
<p class="mt-0.5 text-ink-muted">
Tarakan keeps {@take_rate_percent}%. That is the only cut we take, and we only
take it when the work is accepted.
</p>
</li>
</ol>
<p class="mt-5 max-w-xl border-t border-rule pt-4 text-sm leading-6 text-ink-muted">
<span class="font-semibold text-ink">Free alternative:</span>
checking other people's findings earns credits, and credits fund contracts in place
of money. Contribute reviews and you can direct reviews without paying anything.
</p>
<.button navigate={~p"/bounties/new"} variant="primary" size="sm" class="mt-5">
Post a contract
</.button>
</section>
<section id="pricing-managed" class="border-2 border-strong p-5 sm:p-6">
<h2 class="font-display text-lg uppercase tracking-[0.02em] text-ink">
Managed disclosure
</h2>
<p class="mt-2 max-w-xl text-sm leading-6 text-ink">
A retainer for teams that would rather not run disclosure themselves. Our staff take
findings to the vendor, agree timelines, and follow up. Billed by invoice.
</p>
<p class="mt-3 max-w-xl text-sm leading-6 text-ink-muted">
It changes who does the talking, not what gets published. The record stays public
either way.
</p>
<.button
id="pricing-managed-contact"
href={"mailto:#{@security_contact}?subject=Tarakan%20managed%20disclosure"}
size="sm"
class="mt-5"
>
Contact us
</.button>
</section>
<p class="max-w-xl text-sm leading-6 text-ink-faint">
Paying never buys access. There is no plan that shows you findings other people cannot
see, and staff get no special treatment. Payments are handled by Stripe and you can
cancel a retainer from your <.link
navigate={~p"/accounts/billing"}
class="text-ink-muted underline hover:text-ink"
>billing page</.link>.
</p>
</Layouts.page>
</Layouts.app>

View file

@ -0,0 +1,856 @@
defmodule TarakanWeb.RepositoryCodeLive do
use TarakanWeb, :live_view
alias Tarakan.Repositories
alias Tarakan.Repositories.Repository
alias Tarakan.RepositoryCode
alias Tarakan.RepositoryCode.{File, Tree}
alias Tarakan.RepositoryPath
alias Tarakan.Scans
alias TarakanWeb.RepositoryPaths
@commit_sha_pattern ~r/^[0-9a-f]{40}$/
@line_range_pattern ~r/^([1-9][0-9]{0,8})(?:-([1-9][0-9]{0,8}))?$/
@max_rendered_lines 10_000
@max_selected_line 1_000_000
@max_selected_span 1_000
@impl true
def mount(params, _session, socket) do
{action, params} = normalize_route_params(socket.assigns.live_action, params)
socket = assign(socket, :live_action, action)
case source_from_params(action, params, socket) do
{:ok, source} ->
repository = source.repository
if connected?(socket) do
Repositories.subscribe()
Scans.subscribe(repository.id)
end
{:ok,
socket
|> assign(:page_title, "Code · #{repository.owner}/#{repository.name}")
|> assign(:repository, repository)
|> assign(:clone_urls, RepositoryPaths.clone_urls(repository))
|> assign(:finding, source.finding)
|> assign(:finding_ref, source.finding && source.finding.public_id)
|> assign(:source_commit_sha, source.commit_sha)
|> assign(:source_path, source.path)
|> assign(:source_line_range, source.line_range)
|> assign(:commit_sha, nil)
|> assign(:path, "")
|> assign(:path_segments, [])
|> assign(:line_range, nil)
|> assign(:line_range_invalid?, false)
|> assign(:view_state, :loading)
|> assign(:browser_kind, nil)
|> assign(:tree, nil)
|> assign(:file, nil)
|> assign(:entry_count, 0)
|> assign(:visible_finding_count, 0)
|> assign(:line_count, 0)
|> assign(:line_range_outside_file?, false)
|> assign(:suspicious_controls?, false)
|> assign(:error_kind, nil)
|> assign(:request_id, nil)
|> assign(:branch_options, [])
|> assign(:selected_branch, repository.default_branch)
|> maybe_load_branch_options(action)
|> stream_configure(:entries, dom_id: &entry_dom_id/1)
|> stream_configure(:lines, dom_id: &line_dom_id/1)
|> stream(:entries, [])
|> stream(:lines, [])}
{:error, :not_found} ->
raise Ecto.NoResultsError, queryable: Repository
end
end
@impl true
def handle_params(params, _uri, socket) do
{action, params} = normalize_route_params(socket.assigns.live_action, params)
socket =
case action do
:entry -> prepare_entry_request(socket)
:finding -> prepare_finding_request(socket)
:show -> prepare_code_request(socket, params)
end
{:noreply, maybe_start_request(assign(socket, :live_action, action))}
end
@impl true
def handle_event("retry", _params, socket) do
{:noreply,
socket
|> assign(:view_state, :loading)
|> assign(:error_kind, nil)
|> maybe_start_request()}
end
# Switch the code browser to another branch tip (still commit-pinned under the hood).
def handle_event("select_branch", %{"branch" => branch}, socket) do
if socket.assigns.live_action == :finding do
{:noreply, put_flash(socket, :error, "Finding context is pinned to its report commit.")}
else
repository = socket.assigns.repository
branch = String.trim(to_string(branch || ""))
case RepositoryCode.resolve_branch_commit(repository, branch) do
{:ok, commit_sha} ->
path =
if is_binary(socket.assigns.path) and socket.assigns.path != "" do
split_path(socket.assigns.path)
else
[]
end
{:noreply,
socket
|> assign(:selected_branch, branch)
|> push_navigate(to: code_path(repository, commit_sha, path))}
{:error, _reason} ->
{:noreply,
put_flash(socket, :error, "Could not resolve branch #{inspect(branch)} to a commit.")}
end
end
end
@impl true
def handle_async(:browse, {:ok, {request_id, result}}, socket) do
if request_id == socket.assigns.request_id do
case reauthorize_view(socket) do
{:ok, authorized_socket} ->
{:noreply, apply_browse_result(authorized_socket, result)}
{:error, :not_found} ->
{:noreply, evict_source(socket)}
end
else
{:noreply, socket}
end
end
def handle_async(:browse, {:exit, _reason}, socket) do
{:noreply, assign_error(socket, :unavailable)}
end
@impl true
def handle_info(
{:repository_record_updated, %Repository{id: repository_id}},
%{assigns: %{repository: %Repository{id: repository_id}}} = socket
) do
case reauthorize_view(socket) do
{:ok, authorized_socket} -> {:noreply, refresh_finding_badges(authorized_socket)}
{:error, :not_found} -> {:noreply, evict_source(socket)}
end
end
def handle_info({event, %Repository{}}, socket)
when event in [:repository_registered, :repository_record_updated] do
{:noreply, socket}
end
def handle_info(
{:scan_updated, %{id: scan_id}},
%{assigns: %{live_action: :finding, finding: %{scan_id: scan_id}}} = socket
) do
case reauthorize_view(socket) do
{:ok, authorized_socket} -> {:noreply, authorized_socket}
{:error, :not_found} -> {:noreply, evict_source(socket)}
end
end
def handle_info({event, _scan}, socket) when event in [:scan_submitted, :scan_updated],
do: {:noreply, refresh_finding_badges(socket)}
defp source_from_params(:finding, %{"finding_ref" => finding_ref}, socket) do
with {:ok, {scan, finding}} <- Scans.get_finding(socket.assigns.current_scope, finding_ref),
%Repository{} = repository <-
Repositories.get_visible_repository(
scan.repository.host,
scan.repository.owner,
scan.repository.name,
socket.assigns.current_scope
) do
{:ok,
%{
repository: repository,
finding: finding,
commit_sha: scan.commit_sha,
path: finding.file_path,
line_range: finding_line_range(finding)
}}
end
end
defp source_from_params(_action, %{"host" => slug, "owner" => owner, "name" => name}, socket) do
with {:ok, host} <- Tarakan.Hosts.host_for_slug(slug),
%Repository{} = repository <-
Repositories.get_visible_repository(host, owner, name, socket.assigns.current_scope) do
{:ok,
%{
repository: repository,
finding: nil,
commit_sha: nil,
path: nil,
line_range: nil
}}
else
_not_visible -> {:error, :not_found}
end
end
defp source_from_params(_action, _params, _socket), do: {:error, :not_found}
# Resolves the bare GitHub-style routes (/owner/name...), which carry no
# :host segment. A handle-like first segment is a Tarakan-hosted
# repository. A host-like first segment means the bare pattern's literal
# ("code") swallowed a remote path - a repository literally named "code" -
# so shift the params: /github.com/rails/code[/code[/sha[/*path]]].
# The security tab of such a repository has no reachable URL form; those
# requests fall through to :not_found.
defp normalize_route_params(action, %{"owner" => owner, "name" => name} = params)
when not is_map_key(params, "host") do
if Tarakan.Hosts.host_segment?(owner) do
remote = %{"host" => owner, "owner" => name, "name" => "code"}
case {action, params["commit_sha"], params["path"]} do
{:code_entry, _sha, _path} ->
{:entry, remote}
{:show, "code", nil} ->
{:entry, remote}
{:show, "code", [commit_sha | path]} ->
{:show, remote |> Map.put("commit_sha", commit_sha) |> Map.put("path", path)}
_other ->
{action, params}
end
else
{action_without_code_alias(action), Map.put(params, "host", Repository.hosted_host())}
end
end
defp normalize_route_params(action, params), do: {action_without_code_alias(action), params}
# /:owner/:name/code is a distinct live action only so bare-route
# reinterpretation can tell it apart from /:owner/:name; everywhere else
# it behaves exactly like :entry.
defp action_without_code_alias(:code_entry), do: :entry
defp action_without_code_alias(action), do: action
defp prepare_entry_request(socket) do
prepare_request(socket, :resolve_entry, nil, "", nil, false)
end
defp prepare_finding_request(socket) do
prepare_request(
socket,
:browse_finding,
socket.assigns.source_commit_sha,
socket.assigns.source_path,
socket.assigns.source_line_range,
false
)
end
defp prepare_code_request(socket, params) do
with {:ok, commit_sha} <- normalize_commit_sha(params["commit_sha"]),
{:ok, path} <- normalize_route_path(params["path"]),
{:ok, line_range, line_range_invalid?} <- parse_line_range(params["lines"]) do
socket
|> prepare_request(
:browse,
commit_sha,
path,
line_range,
line_range_invalid?
)
|> sync_selected_branch(commit_sha)
else
{:error, reason} -> assign_error(socket, reason)
end
end
# After a branch switch (or deep link to a tip SHA), keep the picker in sync.
defp sync_selected_branch(socket, commit_sha) do
case RepositoryCode.branch_for_commit(socket.assigns.repository, commit_sha) do
{:ok, branch} -> assign(socket, :selected_branch, branch)
_unknown -> socket
end
end
defp prepare_request(
socket,
request_kind,
commit_sha,
path,
line_range,
line_range_invalid?
) do
request_id = System.unique_integer([:positive, :monotonic])
socket
|> assign(:request_id, request_id)
|> assign(:request_kind, request_kind)
|> assign(:commit_sha, commit_sha)
|> assign(:path, path)
|> assign(:path_segments, split_path(path))
|> assign(:line_range, line_range)
|> assign(:line_range_invalid?, line_range_invalid?)
|> assign(:view_state, :loading)
|> assign(:browser_kind, nil)
|> assign(:tree, nil)
|> assign(:file, nil)
|> assign(:entry_count, 0)
|> assign(:visible_finding_count, 0)
|> assign(:line_count, 0)
|> assign(:line_range_outside_file?, false)
|> assign(:suspicious_controls?, false)
|> assign(:error_kind, nil)
|> stream(:entries, [], reset: true)
|> stream(:lines, [], reset: true)
end
defp maybe_start_request(%{assigns: %{view_state: :loading}} = socket) do
if connected?(socket) do
request_id = socket.assigns.request_id
request_kind = socket.assigns.request_kind
repository = socket.assigns.repository
commit_sha = socket.assigns.commit_sha
path = socket.assigns.path
# Per-IP browsing budgets are enforced at the HTTP layer by
# TarakanWeb.Plugs.CodeBrowserRateLimit; the reads below stay on the
# default (non-bypassed) code path.
socket
|> cancel_async(:browse)
|> start_async(:browse, fn ->
{request_id, run_request(request_kind, repository, commit_sha, path)}
end)
else
socket
end
end
defp maybe_start_request(socket), do: socket
defp run_request(:browse, repository, commit_sha, path) do
# Generic browser may open any *current* branch tip (not only default),
# but still rejects guessed historical SHAs that are not branch tips.
with :ok <- RepositoryCode.authorize_public_commit(repository, commit_sha),
{:ok, result} <- RepositoryCode.browse(repository, commit_sha, path, []) do
{:ok, result}
else
# Never show the old "busy network" page for GitHub REST quota.
{:error, :rate_limited} -> {:error, :unavailable}
{:error, reason} -> {:error, reason}
end
end
defp run_request(:browse_finding, repository, commit_sha, path) do
case RepositoryCode.browse(repository, commit_sha, path, []) do
{:error, :rate_limited} -> {:error, :unavailable}
other -> other
end
end
defp run_request(:resolve_entry, repository, _commit_sha, _path) do
with {:ok, commit_sha} <- RepositoryCode.resolve_default_commit(repository, []),
{:ok, result} <- RepositoryCode.browse(repository, commit_sha, "", []) do
{:ok, result}
else
{:error, :rate_limited} -> {:error, :unavailable}
{:error, reason} -> {:error, reason}
end
end
defp apply_browse_result(socket, {:ok, %Tree{truncated: true}}) do
assign_error(socket, :tree_truncated)
end
defp apply_browse_result(socket, {:ok, %Tree{} = tree}) do
finding_paths = visible_finding_paths(socket, tree.commit_sha)
entries =
tree.entries
|> sort_entries()
|> Enum.map(&entry_with_finding_count(&1, finding_paths))
socket
|> assign(:view_state, :ready)
|> assign(:browser_kind, :tree)
|> assign(:commit_sha, tree.commit_sha)
|> assign(:path, tree.path)
|> assign(:path_segments, split_path(tree.path))
|> assign(:tree, tree)
|> assign(:file, nil)
|> assign(:entry_count, length(entries))
|> assign(:visible_finding_count, length(finding_paths))
|> assign(:line_count, 0)
|> assign(:line_range_outside_file?, false)
|> assign(:suspicious_controls?, false)
|> assign(:error_kind, nil)
|> stream(:entries, entries, reset: true)
|> stream(:lines, [], reset: true)
end
defp apply_browse_result(socket, {:ok, %File{} = file}) do
case file_lines(file.content, socket.assigns.line_range) do
{:ok, lines} ->
line_count = length(lines)
finding_count = visible_file_finding_count(socket, file.commit_sha, file.path)
socket
|> assign(:view_state, :ready)
|> assign(:browser_kind, :file)
|> assign(:commit_sha, file.commit_sha)
|> assign(:path, file.path)
|> assign(:path_segments, split_path(file.path))
|> assign(:tree, nil)
|> assign(:file, file)
|> assign(:entry_count, 0)
|> assign(:visible_finding_count, finding_count)
|> assign(:line_count, line_count)
|> assign(
:line_range_outside_file?,
line_range_outside_file?(socket.assigns.line_range, line_count)
)
|> assign(:suspicious_controls?, suspicious_source_controls?(file.content))
|> assign(:error_kind, nil)
|> stream(:entries, [], reset: true)
|> stream(:lines, lines, reset: true)
{:error, :too_many_lines} ->
assign_error(socket, :blob_too_large)
end
end
defp apply_browse_result(socket, {:ok, commit_sha}) when is_binary(commit_sha) do
case normalize_commit_sha(commit_sha) do
{:ok, commit_sha} ->
push_navigate(socket,
to: code_path(socket.assigns.repository, commit_sha, [])
)
{:error, _reason} ->
assign_error(socket, :invalid_response)
end
end
defp apply_browse_result(socket, {:error, reason}), do: assign_error(socket, reason)
defp apply_browse_result(socket, _unexpected), do: assign_error(socket, :invalid_response)
defp assign_error(socket, reason) do
socket
|> assign(:view_state, :error)
|> assign(:browser_kind, nil)
|> assign(:tree, nil)
|> assign(:file, nil)
|> assign(:entry_count, 0)
|> assign(:visible_finding_count, 0)
|> assign(:line_count, 0)
|> assign(:line_range_outside_file?, false)
|> assign(:suspicious_controls?, false)
|> assign(:error_kind, normalize_error(reason))
|> stream(:entries, [], reset: true)
|> stream(:lines, [], reset: true)
end
defp normalize_error(reason)
when reason in [
:invalid_commit_sha,
:invalid_path,
:empty_repository,
:identity_changed,
:commit_mismatch,
:not_found,
:tree_truncated,
:tree_too_large,
:blob_too_large,
:binary_blob,
:unsupported_entry,
:rate_limited,
:unavailable,
:invalid_response
],
do: reason
defp normalize_error(_reason), do: :unavailable
defp normalize_commit_sha(commit_sha) when is_binary(commit_sha) do
normalized = String.downcase(commit_sha)
if Regex.match?(@commit_sha_pattern, normalized) do
{:ok, normalized}
else
{:error, :invalid_commit_sha}
end
end
defp normalize_commit_sha(_commit_sha), do: {:error, :invalid_commit_sha}
defp normalize_route_path(nil), do: {:ok, ""}
defp normalize_route_path(path) when is_list(path) do
path
|> Enum.join("/")
|> RepositoryPath.normalize()
end
defp normalize_route_path(_path), do: {:error, :invalid_path}
defp parse_line_range(nil), do: {:ok, nil, false}
defp parse_line_range(""), do: {:ok, nil, false}
defp parse_line_range(value) when is_binary(value) do
case Regex.run(@line_range_pattern, value, capture: :all_but_first) do
[first, ""] -> validate_single_line(first)
[first] -> validate_single_line(first)
[first, last] -> validate_line_range(String.to_integer(first), String.to_integer(last))
nil -> {:ok, nil, true}
end
end
defp parse_line_range(_value), do: {:ok, nil, true}
defp validate_single_line(line) do
line = String.to_integer(line)
validate_line_range(line, line)
end
defp validate_line_range(first, last)
when last >= first and last <= @max_selected_line and
last - first <= @max_selected_span,
do: {:ok, {first, last}, false}
defp validate_line_range(_first, _last), do: {:ok, nil, true}
defp finding_line_range(%{line_start: line_start, line_end: line_end})
when is_integer(line_start) do
{line_start, line_end || line_start}
end
defp finding_line_range(_finding), do: nil
defp split_path(""), do: []
defp split_path(path), do: String.split(path, "/", trim: true)
defp file_lines("", _line_range), do: {:ok, []}
defp file_lines(content, line_range) do
lines = String.split(content, "\n", parts: @max_rendered_lines + 1, trim: false)
if length(lines) > @max_rendered_lines do
{:error, :too_many_lines}
else
{:ok,
lines
|> Enum.with_index(1)
|> Enum.map(fn {content, number} ->
%{number: number, content: content, highlighted?: line_selected?(number, line_range)}
end)}
end
end
defp line_selected?(_number, nil), do: false
defp line_selected?(number, {first, last}), do: number >= first and number <= last
defp line_range_outside_file?(nil, _line_count), do: false
defp line_range_outside_file?({first, last}, line_count),
do: first > line_count or last > line_count
defp sort_entries(entries) do
Enum.sort_by(entries, fn entry ->
{entry_type_order(entry.type), String.downcase(entry.name), entry.name}
end)
end
defp entry_with_finding_count(entry, finding_paths) do
count =
case entry.type do
:tree ->
prefix = entry.path <> "/"
Enum.count(finding_paths, &String.starts_with?(&1, prefix))
:blob ->
Enum.count(finding_paths, &(&1 == entry.path))
_unsupported ->
0
end
entry
|> Map.from_struct()
|> Map.put(:finding_count, count)
end
defp visible_file_finding_count(socket, commit_sha, path) do
socket
|> visible_finding_paths(commit_sha)
|> Enum.count(&(&1 == path))
end
defp visible_finding_paths(socket, commit_sha) do
socket.assigns.current_scope
|> Scans.list_scans(socket.assigns.repository)
|> Enum.filter(
&(&1.commit_sha == commit_sha and &1.details_visible and &1.review_status != "rejected")
)
|> Enum.flat_map(& &1.findings)
|> Enum.map(& &1.file_path)
end
defp refresh_finding_badges(%{assigns: %{browser_kind: :tree, tree: %Tree{} = tree}} = socket),
do: apply_browse_result(socket, {:ok, tree})
defp refresh_finding_badges(%{assigns: %{browser_kind: :file, file: %File{} = file}} = socket) do
assign(
socket,
:visible_finding_count,
visible_file_finding_count(socket, file.commit_sha, file.path)
)
end
defp refresh_finding_badges(socket), do: socket
defp entry_type_order(:tree), do: 0
defp entry_type_order(:blob), do: 1
defp entry_type_order(:symlink), do: 2
defp entry_type_order(:submodule), do: 3
defp entry_dom_id(entry) do
encoded_path = Base.url_encode64(entry.path, padding: false)
"code-entry-#{encoded_path}"
end
defp line_dom_id(line), do: "L#{line.number}"
defp repository_record_path(repository), do: RepositoryPaths.repository_path(repository)
defp repository_security_path(repository),
do: RepositoryPaths.repository_security_path(repository)
defp code_path(repository, commit_sha, path_segments),
do: RepositoryPaths.repository_code_path(repository, commit_sha, path_segments)
# Finding views stay on their report pin; free browse can switch branches.
defp maybe_load_branch_options(socket, :finding), do: socket
defp maybe_load_branch_options(socket, _action) do
repository = socket.assigns.repository
case RepositoryCode.list_branches(repository) do
{:ok, branches} ->
socket
|> assign(:branch_options, branches)
|> assign(
:selected_branch,
socket.assigns.selected_branch || repository.default_branch || List.first(branches)
)
{:error, _} ->
default = repository.default_branch
branches = if is_binary(default) and default != "", do: [default], else: []
socket
|> assign(:branch_options, branches)
|> assign(:selected_branch, default)
end
end
defp breadcrumb_items(path_segments) do
path_segments
|> Enum.with_index()
|> Enum.map(fn {name, index} ->
%{name: name, segments: Enum.take(path_segments, index + 1)}
end)
end
defp entry_path(repository, commit_sha, entry) do
code_path(repository, commit_sha, split_path(entry.path))
end
defp breadcrumb_path(repository, commit_sha, segments) do
code_path(repository, commit_sha, segments)
end
defp line_path(live_action, finding_ref, repository, commit_sha, path_segments, line_number) do
base =
if live_action == :finding do
~p"/findings/#{finding_ref}/code"
else
code_path(repository, commit_sha, path_segments)
end
if live_action == :finding do
base <> "#L#{line_number}"
else
base <> "?lines=#{line_number}#L#{line_number}"
end
end
defp format_bytes(nil), do: "-"
defp format_bytes(bytes) when bytes < 1_024, do: "#{bytes} B"
defp format_bytes(bytes) when bytes < 1_024 * 1_024,
do: "#{Float.round(bytes / 1_024, 1)} KB"
defp format_bytes(bytes), do: "#{Float.round(bytes / (1_024 * 1_024), 1)} MB"
defp short_sha(sha), do: String.slice(sha, 0, 7)
defp entry_icon(:tree), do: "hero-folder"
defp entry_icon(:blob), do: "hero-document-text"
defp entry_icon(:symlink), do: "hero-link"
defp entry_icon(:submodule), do: "hero-cube"
defp entry_label(:symlink), do: "symbolic link"
defp entry_label(:submodule), do: "submodule"
defp error_title(:invalid_commit_sha), do: "Invalid commit"
defp error_title(:invalid_path), do: "Invalid path"
defp error_title(:not_found), do: "Source not found"
defp error_title(:binary_blob), do: "Binary file"
defp error_title(:blob_too_large), do: "File too large"
defp error_title(:tree_too_large), do: "Directory too large"
defp error_title(:tree_truncated), do: "Incomplete directory"
defp error_title(:unsupported_entry), do: "Unsupported source entry"
defp error_title(:empty_repository), do: "Empty repository"
defp error_title(:rate_limited), do: "Code unavailable"
defp error_title(:identity_changed), do: "Repository identity changed"
defp error_title(:commit_mismatch), do: "Commit verification failed"
defp error_title(_error), do: "Code unavailable"
defp error_message(:invalid_commit_sha),
do: "Code views require a full 40-character commit SHA."
defp error_message(:invalid_path), do: "That repository path is not valid."
defp error_message(:not_found),
do: "No source exists at this path in the pinned commit."
defp error_message(:binary_blob),
do: "Tarakan does not render binary files as source code."
defp error_message(:blob_too_large),
do: "This file exceeds the safe source-preview limit."
defp error_message(reason) when reason in [:tree_too_large, :tree_truncated],
do: "This directory cannot be represented safely as a complete listing."
defp error_message(:unsupported_entry),
do: "This Git object type cannot be opened in the source browser."
defp error_message(:empty_repository),
do: "Nothing has been pushed to this repository yet. Push a branch and it will appear here."
defp error_message(:rate_limited),
do:
"Could not load this commit from git or GitHub yet. Wait a few seconds for the mirror to fill, then retry."
defp error_message(:unavailable),
do:
"Could not load this commit from git or GitHub yet. Wait a few seconds for the mirror to fill, then retry."
defp error_message(reason) when reason in [:identity_changed, :commit_mismatch],
do: "Tarakan could not verify this source against the registered public repository."
defp error_message(_error),
do:
"Could not load this commit from git or GitHub yet. Wait a few seconds for the mirror to fill, then retry."
defp suspicious_source_controls?(content) do
String.contains?(content, <<0>>) or
Enum.any?([0x202A, 0x202B, 0x202C, 0x202D, 0x202E, 0x2066, 0x2067, 0x2068, 0x2069], fn
codepoint -> String.contains?(content, <<codepoint::utf8>>)
end)
end
defp reauthorize_view(%{assigns: %{live_action: :finding}} = socket) do
repository = socket.assigns.repository
with %Repository{} = visible_repository <-
Repositories.get_visible_repository(
repository.host,
repository.owner,
repository.name,
socket.assigns.current_scope
),
{:ok, {scan, finding}} <-
Scans.get_finding(socket.assigns.current_scope, socket.assigns.finding_ref),
true <- scan.repository_id == visible_repository.id,
true <- scan.commit_sha == socket.assigns.source_commit_sha,
true <- finding.file_path == socket.assigns.source_path do
{:ok,
socket
|> assign(:repository, visible_repository)
|> assign(:finding, finding)}
else
_not_visible -> {:error, :not_found}
end
end
defp reauthorize_view(socket) do
repository = socket.assigns.repository
case Repositories.get_visible_repository(
repository.host,
repository.owner,
repository.name,
socket.assigns.current_scope
) do
%Repository{} = visible_repository ->
{:ok, assign(socket, :repository, visible_repository)}
nil ->
{:error, :not_found}
end
end
defp evict_source(socket) do
repository = socket.assigns.repository
destination =
case Repositories.get_visible_repository(
repository.host,
repository.owner,
repository.name,
socket.assigns.current_scope
) do
%Repository{} = visible_repository -> repository_record_path(visible_repository)
nil -> ~p"/"
end
socket
|> clear_source()
|> push_navigate(to: destination)
end
defp clear_source(socket) do
socket
|> assign(:view_state, :loading)
|> assign(:browser_kind, nil)
|> assign(:commit_sha, nil)
|> assign(:path, "")
|> assign(:path_segments, [])
|> assign(:line_range, nil)
|> assign(:finding, nil)
|> assign(:tree, nil)
|> assign(:file, nil)
|> assign(:entry_count, 0)
|> assign(:visible_finding_count, 0)
|> assign(:line_count, 0)
|> assign(:line_range_outside_file?, false)
|> assign(:suspicious_controls?, false)
|> assign(:error_kind, nil)
|> stream(:entries, [], reset: true)
|> stream(:lines, [], reset: true)
end
end

View file

@ -0,0 +1,338 @@
<Layouts.app flash={@flash} current_scope={@current_scope} current_path={assigns[:current_path]}>
<Layouts.page>
<.repository_header repository={@repository} active_tab={:code} />
<section
id="code-reference"
class="mt-5 flex flex-col gap-3 rounded-md border border-rule bg-panel px-3 py-2.5 sm:flex-row sm:items-center sm:justify-between"
>
<div class="flex min-w-0 flex-wrap items-center gap-2">
<%= if @live_action == :finding or @branch_options == [] do %>
<span class="inline-flex items-center gap-2 rounded-md border border-rule bg-ground px-3 py-1.5 font-mono text-[11px] text-ink">
<.icon name="hero-code-bracket" class="size-3.5 text-ink-faint" />
{@selected_branch || @repository.default_branch || "commit"}
</span>
<% else %>
<form id="code-branch-form" phx-change="select_branch" class="min-w-0">
<label for="code-branch-select" class="sr-only">Branch</label>
<select
id="code-branch-select"
name="branch"
class="max-w-[14rem] truncate rounded-md border border-rule bg-ground px-2.5 py-1.5 font-mono text-[11px] text-ink focus:border-signal focus:outline-none"
>
<option
:for={branch <- @branch_options}
value={branch}
selected={branch == @selected_branch}
>
{branch}{if branch == @repository.default_branch, do: " (default)", else: ""}
</option>
</select>
</form>
<% end %>
<span class="text-ink-faint" aria-hidden="true">·</span>
<span
id="code-commit-sha"
class="font-mono text-[11px] text-ink-muted"
title={@commit_sha}
>
<.icon name="hero-clock" class="mr-1 inline size-3.5" />
{if @commit_sha, do: short_sha(@commit_sha), else: "Resolving commit"}
</span>
</div>
<div class="flex items-center gap-3 font-mono text-[10px] text-ink-faint">
<.link
:if={@visible_finding_count > 0}
navigate={repository_security_path(@repository)}
class="inline-flex items-center gap-1.5 text-signal"
>
<.icon name="hero-shield-exclamation" class="size-3.5" />
{@visible_finding_count} {if @visible_finding_count == 1,
do: "finding",
else: "findings"}
</.link>
<%!-- Hosted status lives in the repository header only. --%>
<span :if={is_nil(@clone_urls)}>Read-only</span>
</div>
</section>
<section
:if={@clone_urls}
id="clone-urls"
class="mt-3 flex flex-col gap-2 rounded-md border border-rule bg-panel px-3 py-2.5"
>
<div class="flex min-w-0 flex-wrap items-center gap-2">
<span class="font-mono text-[10px] uppercase tracking-[0.12em] text-ink-faint">https</span>
<code id="clone-url-https" class="break-all font-mono text-[11px] text-ink">
{@clone_urls.https}
</code>
</div>
<div :if={@clone_urls.ssh} class="flex min-w-0 flex-wrap items-center gap-2">
<span class="font-mono text-[10px] uppercase tracking-[0.12em] text-ink-faint">ssh</span>
<code id="clone-url-ssh" class="break-all font-mono text-[11px] text-ink">
{@clone_urls.ssh}
</code>
</div>
</section>
<section
:if={@finding}
id="finding-code-context"
class="mt-4 border-l-4 border-signal bg-panel px-4 py-3"
>
<p class="text-sm font-semibold text-ink">{@finding.title}</p>
<p class="mt-1 text-xs leading-5 text-ink-muted">
Tarakan checks the finding's stored file and line range against this exact commit.
</p>
</section>
<%= if @commit_sha do %>
<nav
id="source-breadcrumbs"
class="mt-4 flex min-w-0 flex-wrap items-center gap-1 rounded-t-md border border-b-0 border-rule bg-ground px-4 py-3 font-mono text-xs"
aria-label="Source path"
>
<%= if @live_action == :finding do %>
<span class="text-ink-muted">{@repository.name}</span>
<% else %>
<.link
navigate={code_path(@repository, @commit_sha, [])}
class="text-ink-muted transition hover:text-signal"
>
{@repository.name}
</.link>
<% end %>
<%= for item <- breadcrumb_items(@path_segments) do %>
<span class="text-ink-faint" aria-hidden="true">/</span>
<%= if @live_action == :finding do %>
<span class="break-all text-ink">{item.name}</span>
<% else %>
<.link
navigate={breadcrumb_path(@repository, @commit_sha, item.segments)}
class="break-all text-ink-muted transition hover:text-signal"
>
{item.name}
</.link>
<% end %>
<% end %>
</nav>
<% end %>
<div
:if={@view_state == :loading}
id="code-browser-loading"
class="border border-rule px-5 py-8 text-center"
aria-live="polite"
>
<.icon name="hero-arrow-path" class="mx-auto size-5 text-ink-faint" />
<p class="mt-4 font-display text-sm uppercase tracking-[0.12em] text-ink">
Loading pinned source
</p>
<p class="mx-auto mt-2 max-w-md text-xs leading-5 text-ink-faint">
Tarakan is verifying the public repository and exact commit before rendering code.
</p>
</div>
<section
:if={@view_state == :error}
id="code-browser-error"
class="border border-rule px-6 py-8 text-center"
aria-live="polite"
>
<.icon name="hero-document-magnifying-glass" class="mx-auto size-7 text-ink-faint" />
<h2 class="mt-4 font-display text-lg uppercase tracking-[0.12em] text-ink">
{error_title(@error_kind)}
</h2>
<p class="mx-auto mt-2 max-w-lg text-sm leading-6 text-ink-muted">
{error_message(@error_kind)}
</p>
<div
:if={@error_kind == :empty_repository && @clone_urls}
id="empty-repository-push-hint"
class="mx-auto mt-5 max-w-lg border border-rule bg-ground px-4 py-3 text-left"
>
<pre class="overflow-x-auto font-mono text-[11px] leading-5 text-ink"><code>git remote add origin {@clone_urls.https}
git push -u origin main</code></pre>
</div>
<button
:if={@error_kind in [:rate_limited, :unavailable]}
id="retry-code-browser"
type="button"
phx-click="retry"
class="mt-5 border border-strong px-4 py-2 font-display text-xs uppercase tracking-[0.12em] text-ink transition hover:bg-panel"
>
Retry
</button>
</section>
<%= if @view_state == :ready && @browser_kind == :tree do %>
<section id="code-tree" class="overflow-hidden rounded-b-md border border-rule">
<header class="flex items-center justify-between gap-4 border-b border-rule bg-panel px-4 py-3">
<div class="flex min-w-0 items-center gap-2">
<.icon name="hero-folder-open" class="size-4 shrink-0 text-ink-faint" />
<h2 class="truncate font-mono text-xs text-ink">
{if @path == "", do: "/", else: @path}
</h2>
</div>
<span class="shrink-0 font-mono text-[10px] text-ink-faint">
{@entry_count} {if @entry_count == 1, do: "entry", else: "entries"}
</span>
</header>
<div id="code-tree-entries" phx-update="stream" class="divide-y divide-rule">
<div
id="code-tree-empty"
class="hidden px-6 py-6 text-center text-sm text-ink-faint only:block"
>
This directory is empty at the pinned commit.
</div>
<div
:for={{id, entry} <- @streams.entries}
id={id}
class="grid grid-cols-[minmax(0,1fr)_auto] items-center gap-4 px-4 py-2.5 text-sm transition-colors hover:bg-panel"
>
<%= if entry.type in [:tree, :blob] do %>
<.link
navigate={entry_path(@repository, @commit_sha, entry)}
class="flex min-w-0 items-center gap-3 text-ink transition hover:text-signal"
>
<.icon name={entry_icon(entry.type)} class="size-4 shrink-0 text-ink-faint" />
<span class="truncate font-mono text-xs">{entry.name}</span>
</.link>
<% else %>
<div class="flex min-w-0 items-center gap-3 text-ink-muted">
<.icon name={entry_icon(entry.type)} class="size-4 shrink-0 text-ink-faint" />
<span class="truncate font-mono text-xs">{entry.name}</span>
<span class="font-mono text-[10px] uppercase tracking-[0.12em] text-ink-faint">
{entry_label(entry.type)}
</span>
</div>
<% end %>
<div class="flex items-center justify-end gap-3">
<.link
:if={entry.finding_count > 0}
id={"#{id}-findings"}
navigate={repository_security_path(@repository)}
class="inline-flex items-center gap-1 rounded-full border border-signal px-2 py-0.5 font-mono text-[10px] font-semibold text-signal"
aria-label={"View security findings affecting #{entry.path}"}
>
<.icon name="hero-shield-exclamation" class="size-3" />
{entry.finding_count}
<span class="hidden sm:inline">
{if entry.finding_count == 1, do: "finding", else: "findings"}
</span>
</.link>
<span class="min-w-14 text-right font-mono text-[10px] tabular-nums text-ink-faint">
{format_bytes(entry.size)}
</span>
</div>
</div>
</div>
</section>
<% end %>
<%= if @view_state == :ready && @browser_kind == :file do %>
<section id="code-file" class="overflow-hidden rounded-b-md border border-rule">
<header class="flex flex-col gap-2 border-b border-rule bg-panel px-4 py-3 sm:flex-row sm:items-center sm:justify-between">
<div class="flex min-w-0 items-center gap-2">
<.icon name="hero-document-text" class="size-4 shrink-0 text-ink-faint" />
<h2 class="truncate font-mono text-xs text-ink" title={@file.path}>
{@file.path}
</h2>
</div>
<div class="flex shrink-0 items-center gap-3 font-mono text-[10px] text-ink-faint">
<.link
:if={@visible_finding_count > 0}
navigate={repository_security_path(@repository)}
class="inline-flex items-center gap-1 rounded-full border border-signal px-2 py-0.5 text-signal"
>
<.icon name="hero-shield-exclamation" class="size-3" />
{@visible_finding_count} {if @visible_finding_count == 1,
do: "finding",
else: "findings"}
</.link>
<span>{@line_count} {if @line_count == 1, do: "line", else: "lines"}</span>
<span>{format_bytes(@file.size)}</span>
</div>
</header>
<div
:if={@line_range_invalid?}
id="invalid-line-selection"
class="border-b border-rule px-4 py-2 text-xs text-ink-muted"
>
The requested line selection was invalid, so no lines are highlighted.
</div>
<div
:if={@line_range_outside_file?}
id="finding-line-outside-file"
class="border-b border-rule px-4 py-2 text-xs text-ink-muted"
>
The reported line range is outside this file at the pinned commit.
</div>
<div
:if={@suspicious_controls?}
id="source-control-warning"
class="border-b-2 border-signal px-4 py-3 text-xs leading-5 text-ink"
role="alert"
>
This file contains bidirectional text controls or NUL bytes. Tarakan preserves and renders
the source as plain text; inspect it carefully.
</div>
<div
:if={@line_count == 0}
id="code-file-empty"
class="px-6 py-6 text-center text-sm text-ink-faint"
>
This file is empty at the pinned commit.
</div>
<div :if={@line_count > 0} class="overflow-x-auto">
<table class="w-full border-collapse font-mono text-xs leading-5">
<tbody id="code-lines" phx-update="stream">
<tr
:for={{id, line} <- @streams.lines}
id={id}
class={[
"group scroll-mt-24 align-top",
line.highlighted? && "bg-panel"
]}
>
<th
scope="row"
class={[
"sticky left-0 w-px select-none border-r border-rule bg-ground px-3 py-0 text-right font-normal tabular-nums text-ink-faint",
line.highlighted? && "border-l-4 border-l-signal bg-panel text-ink"
]}
>
<a
href={
line_path(
@live_action,
@finding_ref,
@repository,
@commit_sha,
@path_segments,
line.number
)
}
class="block min-w-8 transition group-hover:text-signal"
aria-label={"Select line #{line.number}"}
>
{line.number}
</a>
</th>
<td class="w-full py-0 pl-4 pr-8 text-ink">
<code class="block min-h-5 whitespace-pre">{line.content}</code>
</td>
</tr>
</tbody>
</table>
</div>
</section>
<% end %>
</Layouts.page>
</Layouts.app>

View file

@ -0,0 +1,322 @@
defmodule TarakanWeb.RepositoryCommitsLive do
@moduledoc """
Commit history of a repository's branches, plus a per-commit detail view
with the full message and patch.
Every listed commit is reachable from a published branch tip, which is
exactly the set `RepositoryCode.authorize_public_commit/2` allows in the
code browser - so each commit links straight into the pinned source view.
"""
use TarakanWeb, :live_view
alias Tarakan.Repositories
alias Tarakan.Repositories.Repository
alias Tarakan.RepositoryCode
alias Tarakan.RepositoryCode.Commit
alias TarakanWeb.RepositoryPaths
@page_size 50
@max_diff_lines 5_000
@commit_sha_pattern ~r/^[0-9a-f]{40}$/
@impl true
def mount(params, _session, socket) do
case repository_from_params(params, socket) do
%Repository{} = repository ->
if connected?(socket) do
Repositories.subscribe()
end
{:ok,
socket
|> assign(:repository, repository)
|> assign(:view_state, :loading)
|> assign(:error_kind, nil)
|> assign(:limit, @page_size)
|> assign(:has_more?, false)
|> assign(:branch_options, [])
|> assign(:selected_branch, repository.default_branch)
|> assign(:commit_sha, nil)
|> assign(:commit, nil)
|> maybe_load_branch_options()
|> stream_configure(:commits, dom_id: &"commit-#{&1.sha}")
|> stream(:commits, [])}
nil ->
raise Ecto.NoResultsError, queryable: Repository
end
end
@impl true
def handle_params(params, _uri, socket) do
{:noreply, apply_action(socket, socket.assigns.live_action, params)}
end
@impl true
def handle_event("retry", _params, socket) do
{:noreply,
socket
|> assign(:view_state, :loading)
|> assign(:error_kind, nil)
|> maybe_load()}
end
def handle_event("load_more", _params, socket) do
{:noreply,
socket
|> assign(:limit, socket.assigns.limit + @page_size)
|> assign(:view_state, :loading)
|> maybe_load_commits()}
end
def handle_event("select_branch", %{"branch" => branch}, socket) do
branch = String.trim(to_string(branch || ""))
if socket.assigns.live_action == :index and branch != "" do
{:noreply,
socket
|> assign(:selected_branch, branch)
|> assign(:limit, @page_size)
|> assign(:view_state, :loading)
|> maybe_load_commits()}
else
{:noreply, socket}
end
end
@impl true
def handle_async(:commits, {:ok, result}, socket) do
case result do
{:ok, %{commits: commits}} ->
{:noreply,
socket
|> assign(:view_state, :ready)
|> assign(:error_kind, nil)
|> assign(:has_more?, length(commits) >= socket.assigns.limit)
|> stream(:commits, commits, reset: true)}
{:error, reason} ->
{:noreply,
socket
|> assign(:view_state, :error)
|> assign(:error_kind, reason)
|> stream(:commits, [], reset: true)}
end
end
def handle_async(:commits, {:exit, _reason}, socket) do
{:noreply,
socket
|> assign(:view_state, :error)
|> assign(:error_kind, :unavailable)}
end
def handle_async(:commit, {:ok, result}, socket) do
case result do
{:ok, %Commit{} = commit} ->
{:noreply,
socket
|> assign(:view_state, :ready)
|> assign(:error_kind, nil)
|> assign(:commit, commit)
|> assign(
:page_title,
"#{commit.subject} · #{socket.assigns.repository.owner}/#{socket.assigns.repository.name}"
)}
{:error, reason} ->
{:noreply,
socket
|> assign(:view_state, :error)
|> assign(:error_kind, reason)
|> assign(:commit, nil)}
end
end
def handle_async(:commit, {:exit, _reason}, socket) do
{:noreply,
socket
|> assign(:view_state, :error)
|> assign(:error_kind, :unavailable)}
end
@impl true
def handle_info(
{:repository_record_updated, %Repository{id: repository_id}},
%{assigns: %{repository: %Repository{id: repository_id} = repository}} = socket
) do
if Repositories.get_visible_repository(
repository.host,
repository.owner,
repository.name,
socket.assigns.current_scope
) do
{:noreply, socket}
else
{:noreply, push_navigate(socket, to: "/")}
end
end
def handle_info({event, %Repository{}}, socket)
when event in [:repository_registered, :repository_record_updated],
do: {:noreply, socket}
defp apply_action(socket, :index, _params) do
socket
|> assign(
:page_title,
"Commits · #{socket.assigns.repository.owner}/#{socket.assigns.repository.name}"
)
|> assign(:view_state, :loading)
|> assign(:error_kind, nil)
|> assign(:commit_sha, nil)
|> assign(:commit, nil)
|> maybe_load_commits()
end
defp apply_action(socket, :show, %{"commit_sha" => commit_sha}) do
if Regex.match?(@commit_sha_pattern, commit_sha) do
socket
|> assign(:view_state, :loading)
|> assign(:error_kind, nil)
|> assign(:commit_sha, commit_sha)
|> assign(:commit, nil)
|> stream(:commits, [], reset: true)
|> maybe_load_commit()
else
socket
|> assign(:view_state, :error)
|> assign(:error_kind, :not_found)
|> assign(:commit_sha, nil)
|> assign(:commit, nil)
end
end
defp maybe_load(socket) do
case socket.assigns.live_action do
:index -> maybe_load_commits(socket)
:show -> maybe_load_commit(socket)
end
end
defp maybe_load_commits(socket) do
if connected?(socket) do
repository = socket.assigns.repository
limit = socket.assigns.limit
branch = selected_branch(socket)
start_async(socket, :commits, fn ->
RepositoryCode.list_commits(repository, limit, branch: branch)
end)
else
socket
end
end
defp maybe_load_commit(socket) do
if connected?(socket) and is_binary(socket.assigns.commit_sha) do
repository = socket.assigns.repository
commit_sha = socket.assigns.commit_sha
start_async(socket, :commit, fn -> RepositoryCode.show_commit(repository, commit_sha) end)
else
socket
end
end
# The default branch goes through the default-tip resolution; any other
# picker choice names the branch explicitly.
defp selected_branch(socket) do
case socket.assigns.selected_branch do
branch when is_binary(branch) and branch != "" -> branch
_other -> nil
end
end
defp maybe_load_branch_options(socket) do
if connected?(socket) do
case RepositoryCode.list_branches(socket.assigns.repository) do
{:ok, branches} -> assign(socket, :branch_options, branches)
{:error, _reason} -> socket
end
else
socket
end
end
defp repository_from_params(params, socket) do
with {:ok, host, owner, name} <- normalize_params(params),
%Repository{} = repository <-
Repositories.get_visible_repository(host, owner, name, socket.assigns.current_scope) do
repository
else
_not_visible -> nil
end
end
defp normalize_params(%{"host" => slug, "owner" => owner, "name" => name}) do
with {:ok, host} <- Tarakan.Hosts.host_for_slug(slug) do
{:ok, host, owner, name}
end
end
# Bare GitHub-style route (/owner/name/commits...). A host-like first
# segment means the bare pattern swallowed a remote path - a repository
# literally named "commits" - so shift the params, same corner as
# "code"/"security".
defp normalize_params(%{"owner" => owner, "name" => name}) do
if Tarakan.Hosts.host_segment?(owner) do
normalize_params(%{"host" => owner, "owner" => name, "name" => "commits"})
else
{:ok, Repository.hosted_host(), owner, name}
end
end
defp normalize_params(_params), do: :error
defp short_sha(sha) when is_binary(sha), do: binary_part(sha, 0, 7)
defp commit_datetime(%DateTime{} = datetime),
do: Calendar.strftime(datetime, "%Y-%m-%d %H:%M UTC")
defp commit_datetime(_other), do: "unknown date"
defp diff_lines(nil), do: []
defp diff_lines(patch) do
patch
|> String.split("\n")
|> Enum.take(@max_diff_lines)
|> Enum.map(&{diff_line_type(&1), &1})
end
defp diff_line_over_limit?(nil), do: false
defp diff_line_over_limit?(patch), do: length(String.split(patch, "\n")) > @max_diff_lines
defp diff_line_type("+++" <> _rest), do: :meta
defp diff_line_type("---" <> _rest), do: :meta
defp diff_line_type("+" <> _rest), do: :add
defp diff_line_type("-" <> _rest), do: :del
defp diff_line_type("@@" <> _rest), do: :hunk
defp diff_line_type(_line), do: :context
defp diff_line_class(:add), do: "text-quote"
defp diff_line_class(:del), do: "text-signal"
defp diff_line_class(:hunk), do: "text-ink-faint"
defp diff_line_class(:meta), do: "text-ink-faint"
defp diff_line_class(:context), do: "text-ink"
defp error_title(:empty_repository), do: "No commits yet"
defp error_title(:not_found), do: "Commit not found"
defp error_title(_other), do: "History unavailable"
defp error_message(:empty_repository),
do: "Nothing has been pushed to this repository yet."
defp error_message(:not_found),
do: "This commit is not reachable from any published branch."
defp error_message(_other),
do: "The commit history could not be read right now. Try again in a moment."
end

View file

@ -0,0 +1,163 @@
<Layouts.app flash={@flash} current_scope={@current_scope} current_path={assigns[:current_path]}>
<Layouts.page>
<.repository_header repository={@repository} active_tab={:commits} />
<section
:if={@live_action == :index and @branch_options != []}
id="commits-reference"
class="mt-4 flex flex-col gap-3 rounded-md border border-rule bg-panel px-3 py-2.5 sm:flex-row sm:items-center sm:justify-between"
>
<form id="commits-branch-form" phx-change="select_branch" class="min-w-0">
<label for="commits-branch-select" class="sr-only">Branch</label>
<select
id="commits-branch-select"
name="branch"
class="max-w-[14rem] truncate rounded-md border border-rule bg-ground px-2.5 py-1.5 font-mono text-[11px] text-ink focus:border-signal focus:outline-none"
>
<option
:for={branch <- @branch_options}
value={branch}
selected={branch == @selected_branch}
>
{branch}{if branch == @repository.default_branch, do: " (default)", else: ""}
</option>
</select>
</form>
</section>
<div
:if={@view_state == :loading}
id="commits-loading"
class="mt-4 border border-rule px-5 py-8 text-center"
aria-live="polite"
>
<.icon name="hero-arrow-path" class="mx-auto size-5 text-ink-faint" />
<p class="mt-4 font-display text-sm uppercase tracking-[0.12em] text-ink">
Loading commit history
</p>
</div>
<section
:if={@view_state == :error}
id="commits-error"
class="mt-4 border border-rule px-6 py-8 text-center"
aria-live="polite"
>
<.icon name="hero-clock" class="mx-auto size-7 text-ink-faint" />
<h2 class="mt-4 font-display text-lg uppercase tracking-[0.12em] text-ink">
{error_title(@error_kind)}
</h2>
<p class="mx-auto mt-2 max-w-lg text-sm leading-6 text-ink-muted">
{error_message(@error_kind)}
</p>
<button
:if={@error_kind not in [:empty_repository, :not_found]}
id="retry-commits"
type="button"
phx-click="retry"
class="mt-5 border border-strong px-4 py-2 font-display text-xs uppercase tracking-[0.12em] text-ink transition hover:bg-panel"
>
Retry
</button>
</section>
<%= if @live_action == :index and @view_state == :ready do %>
<section id="commits" class="mt-4 overflow-hidden rounded-md border border-rule">
<div id="commits-list" phx-update="stream" class="divide-y divide-rule">
<.link
:for={{id, commit} <- @streams.commits}
id={id}
navigate={RepositoryPaths.repository_commit_path(@repository, commit.sha)}
class="grid grid-cols-[minmax(0,1fr)_auto] items-center gap-4 px-4 py-3 transition-colors hover:bg-panel"
>
<div class="min-w-0">
<p class="truncate text-sm text-ink">{commit.subject}</p>
<p class="mt-1 truncate font-mono text-[11px] text-ink-faint">
<span class="text-ink-muted">{commit.author_name}</span>
<span aria-hidden="true"> · </span>
{commit_datetime(commit.committed_at)}
</p>
</div>
<span class="shrink-0 font-mono text-[11px] text-ink-faint">
{short_sha(commit.sha)}
</span>
</.link>
</div>
</section>
<div :if={@has_more?} class="mt-4 text-center">
<button
id="commits-load-more"
type="button"
phx-click="load_more"
class="border border-strong px-4 py-2 font-display text-xs uppercase tracking-[0.12em] text-ink transition hover:bg-panel"
>
Load more
</button>
</div>
<% end %>
<%= if @live_action == :show and @view_state == :ready do %>
<section id="commit-detail" class="mt-4 overflow-hidden rounded-md border border-rule">
<header class="border-b border-rule bg-panel px-4 py-3">
<h2 id="commit-subject" class="break-words text-sm font-semibold text-ink">
{@commit.subject}
</h2>
<p class="mt-1 flex flex-wrap items-center gap-x-2 font-mono text-[11px] text-ink-faint">
<span class="text-ink-muted">{@commit.author_name}</span>
<span aria-hidden="true">·</span>
<span>{commit_datetime(@commit.committed_at)}</span>
<span aria-hidden="true">·</span>
<span id="commit-detail-sha" title={@commit.sha}>{short_sha(@commit.sha)}</span>
</p>
</header>
<div
:if={@commit.body}
id="commit-body"
class="whitespace-pre-wrap border-b border-rule px-4 py-3 text-sm leading-6 text-ink"
phx-no-format
>{@commit.body}</div>
<div class="flex items-center justify-between gap-4 border-b border-rule px-4 py-2.5">
<span class="font-mono text-[10px] uppercase tracking-[0.12em] text-ink-faint">
Patch
</span>
<.link
id="commit-browse-code"
navigate={RepositoryPaths.repository_code_path(@repository, @commit.sha)}
class="inline-flex items-center gap-1.5 font-mono text-[11px] text-ink-muted transition hover:text-signal"
>
Browse code at this commit <.icon name="hero-arrow-up-right" class="size-3.5" />
</.link>
</div>
<div
:if={@commit.patch == ""}
id="commit-patch-empty"
class="px-4 py-6 text-center text-sm text-ink-faint"
>
No textual changes in this commit.
</div>
<div :if={@commit.patch != ""} id="commit-patch" class="overflow-x-auto">
<div id="commit-patch-lines" class="min-w-max py-2">
<div
:for={{type, text} <- diff_lines(@commit.patch)}
class={["whitespace-pre px-4 font-mono text-xs leading-5", diff_line_class(type)]}
phx-no-format
>{text}</div>
</div>
</div>
<div
:if={@commit.patch_truncated or diff_line_over_limit?(@commit.patch)}
id="commit-patch-truncated"
class="border-t border-rule px-4 py-2.5 text-xs text-ink-muted"
>
This patch is too large to show in full; the beginning is shown above.
</div>
</section>
<% end %>
</Layouts.page>
</Layouts.app>

View file

@ -0,0 +1,310 @@
defmodule TarakanWeb.RepositoryLive.Index do
use TarakanWeb, :live_view
alias Tarakan.Activity
alias Tarakan.AnalyticsCache
alias Tarakan.Community
alias Tarakan.FindingMemory
alias Tarakan.Fixes
alias Tarakan.Infestations
alias Tarakan.ModelAnalytics
alias Tarakan.Repositories
alias Tarakan.Scans
alias Tarakan.SecurityPosture
alias Tarakan.Work
alias TarakanWeb.Presence
@presence_topic "registry:observers"
@impl true
def mount(_params, _session, socket) do
if connected?(socket) do
Repositories.subscribe()
Activity.subscribe()
Community.subscribe()
Phoenix.PubSub.subscribe(Tarakan.PubSub, @presence_topic)
{:ok, _ref} = Presence.track(self(), @presence_topic, socket.id, %{})
end
{:ok,
socket
|> assign(:page_title, "Public security record")
|> assign(
:meta_description,
"Local agents. Public Reports. Pick up Jobs or publish findings at a pinned commit."
)
|> assign(:canonical_path, ~p"/")
|> assign(:stats, Repositories.registry_stats())
|> assign(:contributor_count, Scans.public_contributor_count())
|> assign(:open_tasks, Work.list_open_public_tasks())
|> assign(:scan_queue, scan_queue())
|> assign(:infestations_window, 7)
|> assign(:infestations, home_infestations(7))
|> assign(:infestations_refreshed_at, System.monotonic_time(:millisecond))
|> assign(:observer_count, observer_count())
|> assign(:shouts, chronological_shouts(socket.assigns.current_scope))
|> assign(:shout_form, to_form(Community.change_shout(), as: :shout))
|> assign(:shout_form_version, 0)
|> assign(:can_moderate_shouts, Community.can_moderate?(socket.assigns.current_scope))
|> assign(:search_query, "")
|> assign(:search_results, [])
|> assign_record_effects()}
end
# Three aggregates over the whole record, identical for every visitor, on the
# busiest page there is. They go through the shared cache so a traffic spike
# cannot replay them per request.
defp assign_record_effects(socket) do
socket
|> assign_effect_lists()
|> assign_responsive_scale()
|> assign(
:pattern_graph,
AnalyticsCache.fetch({:home_pattern_graph, 12}, &encoded_pattern_graph/0,
on_unavailable: nil
)
)
end
# The slowest ranked median, used to scale the responsiveness bars.
defp assign_responsive_scale(socket) do
assign(
socket,
:responsive_max_days,
socket.assigns.responsive
|> Enum.map(& &1.median_days_to_fix)
|> Enum.reject(&is_nil/1)
|> Enum.max(fn -> 0 end)
)
end
# nil rather than an empty graph: a record with nothing entangled yet should
# render no field at all, not an empty one.
defp encoded_pattern_graph do
graph = Infestations.pattern_graph(patterns: 12)
if length(graph.nodes) >= 2, do: Jason.encode!(graph)
end
defp assign_effect_lists(socket) do
socket
|> assign(
:carried_fixes,
AnalyticsCache.fetch(
{:home_carried_fixes, 4},
fn ->
Fixes.list_recent_carried_fixes(limit: 4)
end,
on_unavailable: []
)
)
|> assign(
:regressions,
AnalyticsCache.fetch(
{:home_regressions, 4},
fn ->
FindingMemory.list_recent_regressions(limit: 4)
end,
on_unavailable: []
)
)
|> assign(
:responsive,
AnalyticsCache.fetch(
{:home_responsive, 5},
fn ->
SecurityPosture.leaderboard(limit: 5)
end,
on_unavailable: []
)
)
|> assign(
:model_precision,
AnalyticsCache.fetch(
{:home_model_precision, 5},
fn ->
# Ranked by precision rather than volume: the interesting question is
# which agent is right, not which one talks most.
ModelAnalytics.cached_model_scoreboard(limit: 25)
|> Enum.filter(& &1.precision)
|> Enum.sort_by(& &1.precision, :desc)
|> Enum.take(5)
end,
on_unavailable: []
)
)
end
# Bar width relative to the slowest ranked repository. A floor of 6% keeps the
# fastest row from rendering as an invisible sliver, and a zero maximum (every
# repo fixed same-day) fills the bar rather than dividing by zero.
defp home_bar_pct(_value, max) when max in [nil, 0, 0.0], do: 100
defp home_bar_pct(nil, _max), do: 0
defp home_bar_pct(value, max) do
value |> Kernel./(max) |> Kernel.*(100) |> Float.round(1) |> max(6.0) |> min(100.0)
end
@queue_limit 8
# list_shouts/2 serves "the most recent N", newest first. The shoutbox reads
# like a chat log - oldest visible message at the top, newest at the bottom.
defp chronological_shouts(scope) do
scope |> Community.list_shouts() |> Enum.reverse()
end
defp scan_queue do
Repositories.list_reviewable_repositories(
status: "unscanned",
limit: @queue_limit,
listing: :listed
)
end
@infestation_windows [7, 30, 90]
# Homepage floor is 3 repos: 2-repo matches are often unrelated findings that
# share a generic normalized title, so they would just add noise at scale.
# `list_infestations/1` ranks by the window's repo count (the pattern rollup
# keeps per-window counters), so the selected window drives the ranking.
@home_infestation_min_repos 3
defp home_infestations(days) when days in @infestation_windows do
Infestations.list_infestations(min_repos: @home_infestation_min_repos, days: days, limit: 12)
end
@infestations_debounce_ms 15_000
defp refresh_collective(socket) do
socket
|> assign(:contributor_count, Scans.public_contributor_count())
|> assign(:open_tasks, Work.list_open_public_tasks())
|> assign(:scan_queue, scan_queue())
|> maybe_refresh_infestations()
end
defp maybe_refresh_infestations(socket) do
now = System.monotonic_time(:millisecond)
last = Map.get(socket.assigns, :infestations_refreshed_at, 0)
if now - last >= @infestations_debounce_ms do
socket
|> assign(:infestations, home_infestations(socket.assigns.infestations_window))
|> assign(:infestations_refreshed_at, now)
else
socket
end
end
@impl true
def handle_info({:activity, entry}, socket) do
_entry = entry
{:noreply, refresh_collective(socket)}
end
def handle_info({:repository_registered, _repository}, socket) do
{:noreply,
socket
|> assign(:stats, Repositories.registry_stats())
|> assign(:scan_queue, scan_queue())}
end
def handle_info({:repository_record_updated, repository}, socket) do
_repository = repository
{:noreply,
socket
|> assign(:stats, Repositories.registry_stats())
|> refresh_collective()}
end
def handle_info(%Phoenix.Socket.Broadcast{event: "presence_diff"}, socket) do
{:noreply, assign(socket, :observer_count, observer_count())}
end
def handle_info({event, _shout}, socket) when event in [:shout_posted, :shout_removed] do
{:noreply, assign(socket, :shouts, chronological_shouts(socket.assigns.current_scope))}
end
@impl true
def handle_event("infestations_window", %{"days" => days}, socket)
when days in ["7", "30", "90"] do
days = String.to_integer(days)
{:noreply,
socket
|> assign(:infestations_window, days)
|> assign(:infestations, home_infestations(days))
|> assign(:infestations_refreshed_at, System.monotonic_time(:millisecond))}
end
def handle_event("search", params, socket) do
query = params |> Map.get("q", "") |> String.trim()
{:noreply,
socket
|> assign(:search_query, query)
|> assign(:search_results, Repositories.search_repositories(query))}
end
def handle_event(
"post_shout",
%{"shout" => attrs},
%{assigns: %{current_scope: %{account: account}}} = socket
)
when not is_nil(account) do
case Community.create_shout(socket.assigns.current_scope, attrs) do
{:ok, _shout} ->
{:noreply,
socket
|> assign(:shout_form, to_form(Community.change_shout(), as: :shout))
|> update(:shout_form_version, &(&1 + 1))
|> assign(:shouts, chronological_shouts(socket.assigns.current_scope))}
{:error, :rate_limited} ->
{:noreply, put_flash(socket, :error, "Slow down - a few shouts per minute is plenty.")}
{:error, %Ecto.Changeset{} = changeset} ->
{:noreply,
socket
|> assign(:shout_form, to_form(changeset, as: :shout))
|> put_flash(:error, "Write a message before sending.")}
{:error, _reason} ->
{:noreply, put_flash(socket, :error, "That shout could not be posted.")}
end
end
def handle_event("post_shout", _params, socket) do
{:noreply, redirect(socket, to: ~p"/accounts/log-in")}
end
def handle_event(
"remove_shout",
%{"id" => id},
%{assigns: %{current_scope: %{account: account}}} = socket
)
when not is_nil(account) do
with {:ok, shout} <- Community.get_shout(id),
{:ok, _removed} <-
Community.remove_shout(socket.assigns.current_scope, shout, %{
"removed_reason" => "community_moderation"
}) do
{:noreply, assign(socket, :shouts, chronological_shouts(socket.assigns.current_scope))}
else
_error -> {:noreply, put_flash(socket, :error, "That shout could not be removed.")}
end
end
def handle_event("remove_shout", _params, socket) do
{:noreply, put_flash(socket, :error, "That shout could not be removed.")}
end
defp observer_count do
@presence_topic |> Presence.list() |> map_size()
end
defp shout_time(%DateTime{} = datetime), do: Calendar.strftime(datetime, "%b %-d · %H:%M")
end

View file

@ -0,0 +1,681 @@
<Layouts.app flash={@flash} current_scope={@current_scope} current_path={assigns[:current_path]}>
<%!-- Hero: purpose and repository registration. --%>
<section class="border-b-2 border-strong">
<div class="mx-auto grid w-full max-w-[90rem] min-w-0 lg:grid-cols-[1.05fr_0.95fr]">
<div class="relative overflow-hidden border-b-2 border-strong px-4 py-8 sm:px-8 sm:py-14 lg:border-b-0 lg:border-r-2">
<%!--
The cross-repository graph, drawn instead of tabulated: a point per
repository carrying a multi-repo pattern, a line for every pattern two
of them share. Fills the hero column behind the content, which is
layered above it explicitly - z-0 here against z-10 there, because
mixing positioned and static siblings otherwise decides paint order
by accident. Costs no layout: if WebGL is missing, the chunk fails,
or the record has no shared patterns, nothing renders.
--%>
<div
:if={@pattern_graph}
id="infestation-field"
phx-hook="InfestationField"
phx-update="ignore"
data-graph={@pattern_graph}
aria-hidden="true"
class="pointer-events-none absolute inset-0 z-0 hidden opacity-70 lg:block"
>
</div>
<h1 class="relative z-10 font-display text-[2.35rem] font-medium uppercase leading-[1.05] tracking-[0.02em] text-ink sm:text-5xl sm:leading-[1.02] lg:text-[4.5rem]">
Public disclosure by default.
</h1>
<div class="relative z-10 mt-6 max-w-md border border-strong bg-panel px-3 py-3.5 sm:mt-8 sm:px-4 sm:py-4">
<pre
id="hero-client-start"
class="overflow-x-auto font-mono text-[11px] leading-6 text-ink whitespace-pre sm:text-xs"
><code>curl -fsSL https://tarakan.lol/install.sh | bash
tarakan login
tarakan worker --agent kimi</code></pre>
<p class="mt-3 text-xs leading-5 text-ink-muted">
Publishes public Reports. Use
<code class="font-mono text-[11px] text-ink">--pickup</code>
for open Jobs.
<.link navigate={~p"/agents"} class="font-semibold text-signal hover:underline">
Details
</.link>
</p>
</div>
<div
:if={is_nil(@current_scope) || is_nil(@current_scope.account)}
id="repository-auth-gate"
class="relative z-10 mt-6"
>
<.link
id="account-login-button"
href={~p"/auth/github?return_to=/"}
class="font-mono text-[11px] text-ink-muted underline decoration-rule underline-offset-2 transition hover:text-ink"
>
Or just sign in with GitHub
</.link>
</div>
</div>
<div id="registry-shoutbox" class="flex min-h-[18rem] flex-col sm:min-h-[22rem]">
<div class="flex items-center justify-between gap-3 border-b border-rule bg-panel px-4 py-3 sm:px-6">
<p class="text-sm font-semibold text-ink">Shoutbox</p>
<span class="flex items-center gap-2 font-mono text-[11px] text-ink-faint">
<span class="size-1.5 rounded-full bg-phosphor"></span>
<span><span class="text-phosphor">{@observer_count}</span> here</span>
</span>
</div>
<%!-- The absolute inner keeps message history out of the grid's height
math: the column tracks the hero cell, never the backlog. --%>
<div id="shoutbox-messages" class="relative min-h-0 flex-1">
<div
id="shoutbox-scroll"
phx-hook="PinToBottom"
class="absolute inset-0 divide-y divide-rule overflow-y-auto overscroll-contain [scrollbar-gutter:stable]"
>
<div
:if={@shouts == []}
id="shoutbox-empty"
class="flex min-h-full flex-col items-center justify-center px-5 py-12 text-center sm:px-6"
>
<p class="text-sm text-ink-muted">It is quiet in here.</p>
<p class="mt-1 font-mono text-[10px] text-ink-faint">Start the conversation.</p>
</div>
<article :for={shout <- @shouts} id={"shout-#{shout.id}"} class="px-5 py-3 sm:px-6">
<div class="flex flex-wrap items-baseline gap-x-2 gap-y-1">
<.handle_link
handle={shout.account.handle}
class="font-mono text-[11px] font-semibold"
/>
<time
datetime={DateTime.to_iso8601(shout.inserted_at)}
class="font-mono text-[10px] text-ink-faint"
>
{shout_time(shout.inserted_at)}
</time>
<button
:if={@can_moderate_shouts && is_nil(shout.removed_at)}
type="button"
phx-click="remove_shout"
phx-value-id={shout.id}
data-confirm="Remove this shout from public view?"
class="ml-auto font-mono text-[10px] uppercase tracking-[0.12em] text-ink-faint transition hover:text-signal"
>
Remove
</button>
</div>
<p
:if={is_nil(shout.removed_at)}
class="mt-1 whitespace-pre-line text-xs leading-5 text-ink-muted"
phx-no-format
>{shout.body}</p>
<p :if={shout.removed_at} class="mt-1 text-xs italic text-ink-faint">
Removed by moderation.
</p>
</article>
</div>
</div>
<.form
:if={@current_scope && @current_scope.account}
for={@shout_form}
id="shoutbox-form"
phx-submit="post_shout"
class="grid grid-cols-[minmax(0,1fr)_auto] border-t border-rule bg-panel transition-colors focus-within:border-phosphor"
>
<.input
field={@shout_form[:body]}
id={"shoutbox-body-#{@shout_form_version}"}
type="text"
maxlength="280"
required
hide_errors
autocomplete="off"
phx-mounted={@shout_form_version > 0 && JS.focus()}
placeholder="Message the registry…"
class="h-11 w-full border-0 bg-transparent px-4 font-mono text-xs text-ink placeholder:text-ink-faint focus:outline-none focus:ring-0"
/>
<button class="border-l border-rule bg-btn px-4 font-display text-[10px] uppercase tracking-[0.12em] text-btn-fg transition hover:opacity-90">
Send
</button>
</.form>
<div
:if={is_nil(@current_scope) || is_nil(@current_scope.account)}
class="border-t border-rule bg-panel px-5 py-3 sm:px-6"
>
<.link
navigate={~p"/accounts/log-in"}
class="font-mono text-[10px] text-ink-muted underline decoration-rule underline-offset-2 transition hover:text-ink"
>
Sign in to join the shoutbox
</.link>
</div>
</div>
</div>
</section>
<%!-- Registry state: live aggregates --%>
<section id="registry-stats" class="border-b-2 border-strong">
<div class="mx-auto grid w-full max-w-[90rem] grid-cols-2 sm:grid-cols-3 lg:grid-cols-5">
<div class="border-b border-r border-rule px-4 py-6 sm:px-8 sm:py-8 lg:border-b-0">
<p class="text-xs font-medium text-ink-faint">
Repositories
</p>
<p
id="repository-count"
class="mt-2 font-display text-4xl font-medium tabular-nums leading-none text-ink sm:mt-3 sm:text-5xl"
>
{@stats.repositories}
</p>
</div>
<div class="border-b border-rule px-4 py-6 sm:border-r sm:px-8 sm:py-8 lg:border-b-0">
<p class="text-xs font-medium text-ink-faint">
<span class="sm:hidden">No report</span>
<span class="hidden sm:inline">No report yet</span>
</p>
<p
id="unscanned-count"
class="mt-2 font-display text-4xl font-medium tabular-nums leading-none text-signal sm:mt-3 sm:text-5xl"
>
{@stats.unscanned}
</p>
</div>
<div class="border-b border-r border-rule px-4 py-6 sm:border-r-0 sm:px-8 sm:py-8 lg:border-b-0 lg:border-r">
<p class="text-xs font-medium text-ink-faint">
Contributors
</p>
<p
id="contributor-count"
class="mt-2 font-display text-4xl font-medium tabular-nums leading-none text-ink sm:mt-3 sm:text-5xl"
>
{@contributor_count}
</p>
</div>
<div class="border-b border-rule px-4 py-6 sm:border-b sm:border-r sm:px-8 sm:py-8 lg:border-b-0">
<p class="text-xs font-medium text-ink-faint">
<span class="sm:hidden">Verified</span>
<span class="hidden sm:inline">Verified findings</span>
</p>
<p
id="verified-findings-count"
class="mt-2 font-display text-4xl font-medium tabular-nums leading-none text-ink sm:mt-3 sm:text-5xl"
>
{@stats.verified_findings}
</p>
</div>
<div class="col-span-2 border-rule px-4 py-6 sm:col-span-1 sm:px-8 sm:py-8">
<p class="text-xs font-medium text-ink-faint">
Online
</p>
<p
id="observer-count"
class="mt-2 font-display text-4xl font-medium tabular-nums leading-none text-phosphor sm:mt-3 sm:text-5xl"
>
{@observer_count}
</p>
</div>
</div>
</section>
<%!-- Registry search: find a repository's public record. --%>
<section id="repository-search" class="border-b-2 border-strong">
<div class="mx-auto w-full max-w-[90rem]">
<form id="repository-search-form" phx-change="search" phx-submit="search">
<label
for="repository-search-input"
class="group flex cursor-text items-center gap-3 px-4 sm:px-8"
>
<span
aria-hidden="true"
class="select-none font-mono text-sm text-ink-faint transition group-focus-within:text-phosphor"
>
&gt;
</span>
<input
id="repository-search-input"
type="search"
name="q"
value={@search_query}
autocomplete="off"
enterkeyhint="search"
placeholder="search owner/name"
phx-debounce="200"
phx-hook="SearchShortcut"
class="h-12 w-full min-w-0 border-0 bg-transparent font-mono text-sm text-ink outline-none placeholder:text-ink-faint focus:ring-0 sm:h-14"
/>
<kbd class="hidden shrink-0 border border-rule px-1.5 py-0.5 font-mono text-[10px] text-ink-faint sm:block">
/
</kbd>
</label>
</form>
<div
:if={@search_query != ""}
id="repository-search-results"
class="border-t border-rule"
>
<p
:if={@search_results == []}
class="px-4 py-6 font-mono text-xs text-ink-faint sm:px-8"
>
No repositories match “{@search_query}”.
</p>
<ol :if={@search_results != []} class="divide-y divide-rule">
<li :for={repository <- @search_results}>
<.link
id={"search-result-#{repository.id}"}
navigate={TarakanWeb.RepositoryPaths.repository_path(repository)}
class="group flex flex-col gap-1 px-4 py-3 font-mono text-xs transition-colors hover:bg-panel sm:flex-row sm:items-baseline sm:gap-3 sm:px-8 sm:py-2.5"
>
<span class="min-w-0 truncate text-ink-muted transition group-hover:text-ink">
{repository.owner}/{repository.name}
</span>
<span class="shrink-0 text-[10px] text-ink-faint sm:ml-auto">
<span :if={repository.primary_language}>
{repository.primary_language} ·
</span>
★ {compact_stars(repository.stars_count)} · {Calendar.strftime(
repository.inserted_at,
"%Y-%m-%d"
)}
</span>
</.link>
</li>
</ol>
</div>
</div>
</section>
<%!-- Open work: unreviewed repositories and explicitly requested jobs. --%>
<section id="work-queue" class="border-b-2 border-strong">
<div class="mx-auto w-full max-w-[90rem] px-4 py-8 sm:px-8 sm:py-14">
<div class="mb-6 flex flex-col gap-3 sm:flex-row sm:items-end sm:justify-between">
<div>
<h2 class="font-display text-2xl font-medium uppercase leading-none tracking-[0.02em] text-ink sm:text-4xl">
Open work
</h2>
</div>
<div class="flex flex-col items-start gap-2 sm:items-end">
<p class="font-mono text-[11px] text-ink-faint">
{length(@scan_queue)} without reports · {length(@open_tasks)} open {if length(
@open_tasks
) ==
1,
do: "job",
else: "jobs"}
</p>
<.link
navigate={~p"/jobs"}
class="font-mono text-[11px] font-semibold text-signal transition hover:underline"
>
All jobs →
</.link>
</div>
</div>
<div class="block">
<div class="grid min-w-0 border-2 border-strong lg:grid-cols-[minmax(0,1.35fr)_minmax(19rem,0.65fr)]">
<div id="scan-queue" class="min-w-0 lg:border-r lg:border-rule">
<div class="border-b border-rule bg-panel px-5 py-4 sm:px-6">
<div class="flex flex-wrap items-baseline justify-between gap-x-4 gap-y-1">
<h3 class="font-display text-sm uppercase tracking-[0.12em] text-ink">
Repositories to report
</h3>
<span class="font-mono text-[11px] text-ink-faint">
Recently added
</span>
</div>
</div>
<div :if={@scan_queue == []} class="px-5 py-10 sm:px-6">
<p class="font-mono text-xs text-ink-faint">
Every registered repository has a report.
</p>
</div>
<ol :if={@scan_queue != []} class="divide-y divide-rule">
<li :for={{repository, position} <- Enum.with_index(@scan_queue, 1)}>
<.link
id={"scan-queue-#{repository.id}"}
navigate={TarakanWeb.RepositoryPaths.repository_path(repository)}
class="group grid grid-cols-[auto_minmax(0,1fr)_auto] items-center gap-x-4 px-5 py-4 transition-colors hover:bg-panel sm:px-6"
>
<span class="font-mono text-[10px] tabular-nums text-ink-faint">
{String.pad_leading(Integer.to_string(position), 2, "0")}
</span>
<div class="min-w-0">
<p class="truncate text-sm font-semibold text-ink">
{repository.owner}/<span class="font-bold">{repository.name}</span>
</p>
<p class="mt-1 flex flex-wrap items-center gap-x-2 font-mono text-[11px] text-ink-faint">
<span :if={repository.primary_language}>{repository.primary_language}</span>
<span :if={repository.primary_language} aria-hidden="true">·</span>
<span>★ {compact_stars(repository.stars_count || 0)}</span>
<span aria-hidden="true">·</span>
<span>Registered {Calendar.strftime(repository.inserted_at, "%b %-d, %Y")}</span>
</p>
</div>
<.icon
name="hero-arrow-right-mini"
class="size-3.5 text-ink-faint transition group-hover:text-ink"
/>
</.link>
</li>
</ol>
<div class="border-t border-rule px-5 py-3 sm:px-6">
<p class="break-all font-mono text-[10px] text-ink-faint">
<span class="uppercase tracking-[0.12em]">API</span>
<span class="mx-1.5">·</span> GET /api/repositories?status=unscanned
</p>
</div>
</div>
<aside id="open-review-work" class="border-t border-rule lg:border-t-0">
<div class="border-b border-rule bg-panel px-5 py-4 sm:px-6">
<h3 class="font-display text-sm uppercase tracking-[0.12em] text-ink">
Jobs
</h3>
</div>
<div :if={@open_tasks == []} class="px-5 py-10 sm:px-6 lg:py-12">
<p class="text-sm font-medium text-ink">No jobs are open right now.</p>
<p class="mt-2 max-w-sm text-xs leading-5 text-ink-faint">
Publish a Report with <code class="font-mono text-ink">tarakan worker</code>.
</p>
</div>
<ul :if={@open_tasks != []} class="divide-y divide-rule">
<li :for={task <- @open_tasks}>
<.link
id={"open-task-#{task.id}"}
navigate={~p"/jobs/#{task.id}"}
class="group block px-5 py-4 transition-colors hover:bg-panel sm:px-6"
>
<p class="font-mono text-[11px] text-ink-faint">
{task.repository.owner}/{task.repository.name}
<span class="mx-1">·</span>
<span title={task.commit_sha}>{String.slice(task.commit_sha, 0, 7)}</span>
</p>
<p class="mt-2 text-sm font-semibold leading-5 text-ink">
{task.title}
</p>
<p class="mt-2 font-mono text-[11px] text-ink-muted">
{review_kind_label(task.kind)} · {provenance_label(task.capability)} required
</p>
</.link>
</li>
</ul>
</aside>
</div>
</div>
</div>
</section>
<%!-- Trending infestations: multi-repo patterns ranked by spread in the window. --%>
<section id="home-infestations" class="border-b-2 border-strong">
<div class="mx-auto w-full max-w-[90rem] px-4 py-8 sm:px-8 sm:py-12">
<div class="mb-6 flex flex-col gap-3 sm:flex-row sm:items-end sm:justify-between">
<div>
<h2 class="font-display text-2xl font-medium uppercase leading-none tracking-[0.02em] text-ink sm:text-4xl">
Trending infestations
</h2>
</div>
<div class="flex flex-wrap items-center gap-3">
<div
id="home-infestations-window"
class="flex border border-strong font-display text-[10px] uppercase tracking-[0.12em]"
role="group"
aria-label="Time window"
>
<button
:for={{days, label} <- [{7, "7d"}, {30, "30d"}, {90, "90d"}]}
type="button"
phx-click="infestations_window"
phx-value-days={days}
aria-pressed={to_string(@infestations_window == days)}
class={[
"shrink-0 px-3 py-2 transition sm:py-1.5",
@infestations_window == days && "bg-btn text-btn-fg",
@infestations_window != days && "text-ink-faint hover:text-ink"
]}
>
{label}
</button>
</div>
<.link
navigate={~p"/infestations"}
class="font-mono text-[11px] font-semibold uppercase tracking-[0.12em] text-signal transition hover:underline"
>
All infestations →
</.link>
</div>
</div>
<div
:if={@infestations == []}
id="home-infestations-empty"
class="bg-panel px-5 py-12 text-center sm:px-6"
>
<p class="text-sm text-ink-muted">
Infestations appear when the same issue title hits 3+ listed repos.
</p>
</div>
<div :if={@infestations != []} id="home-infestations-list">
<.constellation id="home-infestation-map" infestations={@infestations} compact />
</div>
</div>
</section>
<%!--
Outcomes only a remembered record can produce, in the same label-over-data
register as the registry strip. The explanation lives in "How it works";
this is the evidence. Cells stay visible while empty because a terse
"none yet" is honest and a fabricated row is not.
--%>
<section
:if={
@carried_fixes != [] or @regressions != [] or @responsive != [] or @model_precision != []
}
id="record-signals"
class="border-b-2 border-strong"
>
<div class="mx-auto w-full max-w-[90rem] px-4 pt-8 sm:px-8 sm:pt-12">
<h2 class="font-display text-2xl font-medium uppercase leading-none tracking-[0.02em] text-ink sm:text-4xl">
Signals
</h2>
</div>
<%!-- auto-fit so the columns that have something to show fill the width;
a signal with no data is dropped rather than padded with "none yet". --%>
<div class="mx-auto mt-6 grid w-full max-w-[90rem] gap-px bg-rule sm:grid-cols-[repeat(auto-fit,minmax(18rem,1fr))]">
<div :if={@carried_fixes != []} class="bg-ground px-4 py-6 sm:px-8">
<h3 class="text-xs font-medium text-ink-faint">Fixes carried</h3>
<ul class="mt-2 space-y-2">
<li :for={fix <- Enum.take(@carried_fixes, 3)} class="min-w-0">
<.link
navigate={~p"/infestations/#{fix.pattern_key}"}
class="block truncate text-sm text-ink hover:text-signal hover:underline"
>
{fix.title}
</.link>
<p class="truncate font-mono text-[10px] text-ink-faint">
{fix.source_owner}/{fix.source_name}
<span class="text-phosphor">→</span>
{fix.carried_to} repos
</p>
</li>
</ul>
</div>
<div :if={@regressions != []} class="bg-ground px-4 py-6 sm:px-8">
<h3 class="text-xs font-medium text-ink-faint">Regressed</h3>
<ul class="mt-2 space-y-2">
<li :for={regression <- Enum.take(@regressions, 3)} class="min-w-0">
<.link
navigate={~p"/findings/#{regression.canonical_finding.public_id}"}
class="block truncate text-sm text-ink hover:text-signal hover:underline"
>
{regression.canonical_finding.title}
</.link>
<p class="truncate font-mono text-[10px] text-ink-faint">
{regression.repository.owner}/{regression.repository.name}
<span class="text-signal">↺</span>
{String.slice(regression.detected_commit_sha, 0, 7)}
</p>
</li>
</ul>
</div>
<div :if={@responsive != []} class="bg-ground px-4 py-6 sm:px-8">
<h3 class="text-xs font-medium text-ink-faint">Fastest to fix</h3>
<ol class="mt-2 space-y-2">
<li :for={row <- Enum.take(@responsive, 3)}>
<div class="flex items-baseline justify-between gap-2">
<.link
navigate={TarakanWeb.RepositoryPaths.repository_security_path(row)}
class="min-w-0 flex-1 truncate font-mono text-xs text-ink hover:text-signal hover:underline"
>
{row.owner}/{row.name}
</.link>
<span class="shrink-0 font-mono text-[10px] tabular-nums text-ink-faint">
{Tarakan.SecurityPosture.format_days(row.median_days_to_fix)}
</span>
</div>
<div class="mt-1 h-0.5 w-full bg-rule">
<div
class="h-full bg-phosphor"
style={"width: #{home_bar_pct(row.median_days_to_fix, @responsive_max_days)}%"}
>
</div>
</div>
</li>
</ol>
</div>
<div :if={@model_precision != []} class="bg-ground px-4 py-6 sm:px-8">
<h3 class="text-xs font-medium text-ink-faint">Model precision</h3>
<ol class="mt-2 space-y-2">
<li :for={row <- Enum.take(@model_precision, 3)}>
<div class="flex items-baseline justify-between gap-2">
<.link
navigate={~p"/models"}
class="min-w-0 flex-1 truncate font-mono text-xs text-ink hover:text-signal hover:underline"
>
{row.model}
</.link>
<span class="shrink-0 font-mono text-[10px] tabular-nums text-ink-faint">
{row.precision}%
</span>
</div>
<div class="mt-1 h-0.5 w-full bg-rule">
<div class="h-full bg-phosphor" style={"width: #{row.precision}%"}></div>
</div>
</li>
</ol>
</div>
</div>
</section>
<section id="how-it-works" class="border-b-2 border-strong bg-panel">
<div class="mx-auto grid w-full max-w-[90rem] gap-8 px-4 py-10 sm:gap-10 sm:px-8 sm:py-20 lg:grid-cols-[minmax(16rem,0.72fr)_minmax(0,1.28fr)] lg:gap-16">
<div>
<h2 class="max-w-md font-display text-2xl font-medium uppercase leading-none tracking-[0.02em] text-ink sm:text-4xl">
How it works
</h2>
<p class="mt-5 max-w-md text-sm leading-6 text-ink-muted">
Run the agent on your machine. Findings stay on a public record.
</p>
<div class="mt-6 max-w-md border border-strong bg-ground px-4 py-4">
<code class="block break-all font-mono text-xs leading-6 text-ink">
tarakan worker --agent kimi
</code>
<p class="mt-2 text-xs leading-5 text-ink-faint">
Kimi, Claude, Codex, Grok, Ollama, OpenRouter.
<.link navigate={~p"/agents"} class="text-signal hover:underline">Install</.link>
</p>
</div>
</div>
<ol class="border-t-2 border-strong">
<li class="grid gap-2 border-b border-rule py-6 sm:grid-cols-[minmax(8rem,0.4fr)_minmax(0,1fr)] sm:gap-8">
<p class="font-display text-sm uppercase tracking-[0.12em] text-ink">
1. Report
</p>
<p class="max-w-xl text-sm leading-6 text-ink-muted">
Pin a commit and publish findings. Public on submit. No Job required.
</p>
</li>
<li class="grid gap-2 border-b border-rule py-6 sm:grid-cols-[minmax(8rem,0.4fr)_minmax(0,1fr)] sm:gap-8">
<p class="font-display text-sm uppercase tracking-[0.12em] text-ink">
2. Check
</p>
<p class="max-w-xl text-sm leading-6 text-ink-muted">
Someone else re-runs the same commit. Confirm, dispute, or mark fixed.
</p>
</li>
<li class="grid gap-2 border-b border-rule py-6 sm:grid-cols-[minmax(8rem,0.4fr)_minmax(0,1fr)] sm:gap-8">
<p class="font-display text-sm uppercase tracking-[0.12em] text-ink">3. Job</p>
<p class="max-w-xl text-sm leading-6 text-ink-muted">
Optional pickup ticket. Reports with findings open a check Job automatically.
</p>
</li>
<li class="grid gap-2 border-b border-rule py-6 sm:grid-cols-[minmax(8rem,0.4fr)_minmax(0,1fr)] sm:gap-8">
<p class="font-display text-sm uppercase tracking-[0.12em] text-ink">
4. Merge
</p>
<p class="max-w-xl text-sm leading-6 text-ink-muted">
Duplicate findings merge into one issue. Stronger evidence ranks higher.
</p>
</li>
<%!-- The loop above is what a contributor does. These are what the
record can do once it remembers, and they are what the signals
strip near the top is showing. --%>
<li class="grid gap-2 border-b border-rule py-6 sm:grid-cols-[minmax(8rem,0.4fr)_minmax(0,1fr)] sm:gap-8">
<p class="font-display text-sm uppercase tracking-[0.12em] text-signal">
Then: carry
</p>
<p class="max-w-xl text-sm leading-6 text-ink-muted">
The same bug lives in codebases that never shared a line. When one fixes it,
the diff and evidence become a job on every other repo carrying the pattern.
</p>
</li>
<li class="grid gap-2 border-b border-rule py-6 sm:grid-cols-[minmax(8rem,0.4fr)_minmax(0,1fr)] sm:gap-8">
<p class="font-display text-sm uppercase tracking-[0.12em] text-signal">
Then: regress
</p>
<p class="max-w-xl text-sm leading-6 text-ink-muted">
Findings are pinned to exact commits, so a bug that comes back is recorded
against the original instead of filed again as new.
</p>
</li>
<li class="grid gap-2 border-b border-rule py-6 sm:grid-cols-[minmax(8rem,0.4fr)_minmax(0,1fr)] sm:gap-8">
<p class="font-display text-sm uppercase tracking-[0.12em] text-signal">
Then: rank
</p>
<p class="max-w-xl text-sm leading-6 text-ink-muted">
Median days from verified to fixed, per repository. Every other score here
ranks contributors; this one ranks maintainers, backlog published beside it.
</p>
</li>
<li class="grid gap-2 border-b border-rule py-6 sm:grid-cols-[minmax(8rem,0.4fr)_minmax(0,1fr)] sm:gap-8">
<p class="font-display text-sm uppercase tracking-[0.12em] text-signal">
Then: score
</p>
<p class="max-w-xl text-sm leading-6 text-ink-muted">
Contributors run their own agents, so the same commit meets different models.
Precision is how often a model's findings survive verification - measured
against the record, not self-reported.
</p>
</li>
</ol>
</div>
</section>
</Layouts.app>

View file

@ -0,0 +1,124 @@
defmodule TarakanWeb.RepositoryLive.New do
use TarakanWeb, :live_view
alias Tarakan.HostedRepositories
alias Tarakan.Repositories
alias Tarakan.Repositories.Repository
alias TarakanWeb.RepositoryPaths
@impl true
def mount(_params, _session, socket) do
{:ok,
socket
|> assign(:page_title, "Add repository")
|> assign(:remote_form, remote_form(%{}))
|> assign(:form, hosted_form(socket, %{}))}
end
@impl true
def handle_event("validate_remote", %{"repository" => params}, socket) do
form =
params
|> Repositories.registration_changeset()
|> Map.put(:action, :validate)
|> to_form(as: :repository)
{:noreply, assign(socket, :remote_form, form)}
end
def handle_event("register_remote", %{"repository" => %{"url" => url} = params}, socket) do
case Repositories.register_github_repository(url, socket.assigns.current_scope) do
{:ok, repository} ->
{:noreply, push_navigate(socket, to: RepositoryPaths.repository_path(repository))}
{:error, reason} ->
{:noreply, assign_remote_error(socket, params, registration_error_message(reason))}
end
end
def handle_event("validate", %{"repository" => params}, socket) do
form =
socket
|> hosted_changeset(params)
|> Map.put(:action, :validate)
|> to_form(as: :repository)
{:noreply, assign(socket, :form, form)}
end
def handle_event("create", %{"repository" => params}, socket) do
case HostedRepositories.create(socket.assigns.current_scope, params) do
{:ok, repository} ->
{:noreply,
socket
|> put_flash(:info, "Repository created. Push a branch to publish code.")
|> push_navigate(to: RepositoryPaths.repository_path(repository))}
{:error, %Ecto.Changeset{} = changeset} ->
{:noreply, assign(socket, :form, to_form(changeset, as: :repository))}
{:error, :registration_limit} ->
{:noreply,
put_flash(socket, :error, "You have reached today's repository registration limit.")}
{:error, _reason} ->
{:noreply,
put_flash(socket, :error, "The repository could not be created. Try again shortly.")}
end
end
defp assign_remote_error(socket, params, message) do
changeset = Repositories.registration_changeset(params)
form =
changeset
|> Ecto.Changeset.add_error(:url, message)
|> Map.put(:action, :validate)
|> to_form(as: :repository)
assign(socket, :remote_form, form)
end
defp registration_error_message(:invalid_github_repository),
do: "enter a valid public GitHub repository"
defp registration_error_message(:not_found), do: "repository was not found or is not public"
defp registration_error_message(:not_public), do: "repository was not found or is not public"
defp registration_error_message(:rate_limited),
do: "GitHub is rate limiting requests; try again shortly"
defp registration_error_message(:request_limited),
do: "too many repository requests; try again shortly"
defp registration_error_message(:unavailable),
do: "GitHub could not be reached; try again shortly"
defp registration_error_message(:registration_limit),
do: "daily repository registration limit reached"
defp registration_error_message(:unauthorized),
do: "this account cannot register repositories"
defp registration_error_message(_reason), do: "repository could not be registered"
defp remote_form(params) do
params
|> Repositories.registration_changeset()
|> to_form(as: :repository)
end
defp hosted_form(socket, params) do
socket
|> hosted_changeset(params)
|> to_form(as: :repository)
end
defp hosted_changeset(socket, params) do
Repository.hosted_changeset(
%Repository{},
params,
socket.assigns.current_scope.account.handle
)
end
end

View file

@ -0,0 +1,86 @@
<Layouts.app flash={@flash} current_scope={@current_scope} current_path={assigns[:current_path]}>
<Layouts.page>
<.breadcrumbs>
<:crumb navigate={~p"/"}>registry</:crumb>
<:crumb>add repository</:crumb>
</.breadcrumbs>
<main id="add-repository" class="border-2 border-strong">
<header class="border-b-2 border-strong bg-panel px-5 py-6 sm:px-8 sm:py-8">
<h1 class="font-display text-3xl uppercase tracking-[0.02em] text-ink sm:text-4xl">
Add repository
</h1>
</header>
<section id="register-remote" class="px-5 py-6 sm:px-8 sm:py-8">
<h2 class="font-display text-sm uppercase tracking-[0.02em] text-ink">
Register a public repository
</h2>
<.form
for={@remote_form}
id="register-remote-form"
phx-change="validate_remote"
phx-submit="register_remote"
class="mt-4"
>
<div class="flex flex-col border-2 border-strong bg-panel sm:flex-row sm:items-stretch">
<div class="min-w-0 flex-1">
<.input
field={@remote_form[:url]}
type="text"
autocomplete="off"
placeholder="github.com/owner/repository"
hide_errors
class="h-12 w-full border-0 bg-transparent px-4 py-3 font-mono text-sm text-ink outline-none placeholder:text-ink-faint focus:ring-0"
/>
</div>
<button
id="register-remote-button"
type="submit"
class="flex shrink-0 items-center justify-center gap-2 border-t-2 border-strong bg-btn px-6 py-3 font-display text-sm uppercase tracking-[0.12em] text-btn-fg transition hover:opacity-90 focus:outline-none focus:ring-2 focus:ring-phosphor phx-submit-loading:cursor-wait phx-submit-loading:opacity-60 sm:border-l-2 sm:border-t-0"
>
Register
</button>
</div>
<div class="mt-2">
<.field_errors field={@remote_form[:url]} />
</div>
</.form>
</section>
<section id="create-hosted" class="border-t border-rule px-5 py-6 sm:px-8 sm:py-8">
<h2 class="font-display text-sm uppercase tracking-[0.02em] text-ink">
Or create a repository hosted on Tarakan
</h2>
<.form
for={@form}
id="new-repository-form"
phx-change="validate"
phx-submit="create"
class="mt-4"
>
<.input
field={@form[:name]}
type="text"
label="Repository name"
placeholder="my-project"
autocomplete="off"
/>
<p class="mt-2 font-mono text-xs text-ink-faint">
{@current_scope.account.handle}/{@form[:name].value || "…"}
</p>
<div class="mt-6 flex justify-end border-t border-rule pt-5">
<button
id="create-repository"
type="submit"
class="border border-strong px-5 py-2.5 font-display text-xs uppercase tracking-[0.12em] text-ink transition hover:bg-panel phx-submit-loading:opacity-60"
>
Create repository
</button>
</div>
</.form>
</section>
</main>
</Layouts.page>
</Layouts.app>

View file

@ -0,0 +1,944 @@
defmodule TarakanWeb.RepositoryLive.Show do
use TarakanWeb, :live_view
alias Tarakan.Accounts
alias Tarakan.Repositories
alias Tarakan.FindingMemory
alias Tarakan.Repositories.Repository
alias Tarakan.RepositoryCode
alias Tarakan.Policy
alias Tarakan.Reputation
alias Tarakan.Scans
alias Tarakan.SecurityPosture
alias Tarakan.Work
alias Tarakan.Work.ReviewTask
@impl true
# Bare GitHub-style routes (/owner/name/security) carry no host segment.
# A host-like first segment means the literal "security" swallowed a
# remote path - a repository literally named "security" - whose record
# entry lives at the /code form, which routes unambiguously.
def mount(%{"owner" => owner, "name" => name} = params, session, socket)
when not is_map_key(params, "host") do
if Tarakan.Hosts.host_segment?(owner) do
{:ok, push_navigate(socket, to: "/#{owner}/#{name}/security/code")}
else
mount(Map.put(params, "host", Repository.hosted_host()), session, socket)
end
end
def mount(%{"host" => slug, "owner" => owner, "name" => name}, _session, socket) do
repository =
with {:ok, host} <- Tarakan.Hosts.host_for_slug(slug),
%Repository{} = repository <-
Repositories.get_visible_repository(host, owner, name, socket.assigns.current_scope) do
repository
else
_not_visible -> raise Ecto.NoResultsError, queryable: Repository
end
if connected?(socket) do
Repositories.subscribe()
Scans.subscribe(repository.id)
Work.subscribe(repository.id)
Reputation.subscribe()
end
{:ok,
socket
|> assign(:page_title, "#{repository.owner}/#{repository.name} security")
|> assign(
:meta_description,
repository_meta_description(repository)
)
|> assign(
:canonical_path,
TarakanWeb.RepositoryPaths.repository_security_path(repository)
)
|> assign(:repository, repository)
|> assign(
:open_bounties,
Tarakan.Market.open_bounties_for_target(:repository, repository.id)
)
|> assign(:task_form, empty_task_form(repository))
|> assign(:show_task_form, false)
|> assign(:task_kind_options, task_kind_options())
|> assign(:capability_options, capability_options())
|> assign(:branch_options, [])
|> assign(:selected_branch, repository.default_branch)
|> assign(:can_auto_open_job, can_auto_open_job?(socket, repository))
|> assign(:moderation_form, moderation_form())
|> assign(:can_vote, can_vote?(socket))
|> assign(:can_manage_disclosure, can_manage_disclosure?(socket, repository))
|> assign(:canonical_findings, [])
|> assign(:checkable_findings_count, 0)
|> assign(:posture, SecurityPosture.cached_repository_posture!(repository))
|> assign(:regressions, FindingMemory.list_repository_regressions(repository, limit: 10))
|> stream(:tasks, Work.list_tasks(repository, scope: socket.assigns.current_scope))
|> load_scans()}
end
@impl true
def handle_info({:scan_submitted, scan}, socket) do
{:noreply, sync_visible_scan(socket, scan, at: 0)}
end
def handle_info({:scan_updated, scan}, socket) do
{:noreply, sync_visible_scan(socket, scan)}
end
def handle_info(
{:repository_record_updated, %Repository{id: repository_id}},
%{assigns: %{repository: %Repository{id: repository_id}}} = socket
) do
{:noreply, refresh_visible_repository(socket)}
end
def handle_info({event, %Repository{}}, socket)
when event in [:repository_registered, :repository_record_updated] do
{:noreply, socket}
end
def handle_info({event, _task_id}, socket)
when event in [
:review_task_created,
:review_task_updated,
:review_task_published,
:review_task_submitted,
:review_task_accepted,
:review_task_disclosed,
:review_task_changes_requested,
:review_task_rejected,
:review_task_cancelled,
:review_task_quarantined
] do
{:noreply, reload_tasks(socket)}
end
def handle_info({:vote_changed, "canonical_finding", _id}, socket) do
{:noreply, load_scans(socket)}
end
def handle_info({:vote_changed, _type, _id}, socket), do: {:noreply, socket}
@impl true
def handle_event("toggle_task_form", _params, socket) do
show = !socket.assigns.show_task_form
socket =
if show do
socket
|> assign(:show_task_form, true)
|> load_branch_options()
|> assign(:task_form, draft_task_form(socket.assigns.repository, %{}))
else
assign(socket, :show_task_form, false)
end
{:noreply, socket}
end
def handle_event("select_branch", %{"branch" => branch}, socket) do
repository = socket.assigns.repository
branch = String.trim(to_string(branch || ""))
case RepositoryCode.resolve_branch_commit(repository, branch) do
{:ok, commit_sha} ->
params =
(socket.assigns.task_form.params || %{})
|> Map.put("commit_sha", commit_sha)
# Clear title so it re-fills with the new short SHA.
params = Map.put(params, "title", "")
{:noreply,
socket
|> assign(:selected_branch, branch)
|> assign(:task_form, draft_task_form(repository, params))}
{:error, _reason} ->
{:noreply,
put_flash(socket, :error, "Could not resolve branch #{inspect(branch)} to a commit.")}
end
end
# One-click mass path: default-branch HEAD, code_review, agent, auto title/description.
# Stewards/moderators get an immediately open job; others get a proposal.
def handle_event(
"quick_open_job",
_params,
%{assigns: %{current_scope: %{account: account}}} = socket
)
when not is_nil(account) do
repository = socket.assigns.repository
params =
case RepositoryCode.resolve_default_commit(repository) do
{:ok, commit_sha} -> %{"commit_sha" => commit_sha}
{:error, _} -> %{}
end
create_task_from_params(socket, params)
end
def handle_event("quick_open_job", _params, socket) do
{:noreply, redirect(socket, to: ~p"/accounts/log-in")}
end
def handle_event(
"vote",
%{"type" => subject_type, "id" => subject_id, "vote" => value},
%{assigns: %{current_scope: %{account: account}}} = socket
)
when not is_nil(account) do
with {:ok, subject_id} <- normalize_id(subject_id),
{:ok, value} <- normalize_vote(value) do
case Reputation.cast_vote(
socket.assigns.current_scope,
subject_type,
subject_id,
value
) do
{:ok, _summary} ->
{:noreply, load_scans(socket)}
{:error, :own_content} ->
{:noreply, put_flash(socket, :error, "You cannot vote on your own contribution.")}
{:error, _reason} ->
{:noreply, put_flash(socket, :error, "Your vote could not be recorded.")}
end
else
_invalid -> {:noreply, put_flash(socket, :error, "Your vote could not be recorded.")}
end
end
def handle_event("vote", _params, socket) do
{:noreply, redirect(socket, to: ~p"/accounts/log-in")}
end
def handle_event(
"toggle_managed_disclosure",
_params,
%{assigns: %{current_scope: %{account: account}}} = socket
)
when not is_nil(account) do
repository = socket.assigns.repository
case Repositories.set_managed_disclosure(
socket.assigns.current_scope,
repository,
not repository.managed_disclosure
) do
{:ok, _updated} ->
{:noreply,
socket
|> reload_repository()
|> put_flash(:info, "Managed disclosure updated.")}
{:error, reason} when reason in [:unauthorized, :not_found] ->
{:noreply, put_flash(socket, :error, "You cannot change managed disclosure.")}
{:error, _reason} ->
{:noreply, put_flash(socket, :error, "Managed disclosure could not be updated.")}
end
end
def handle_event("toggle_managed_disclosure", _params, socket) do
{:noreply, redirect(socket, to: ~p"/accounts/log-in")}
end
# Fills the proposal form with the selected (or default) branch tip.
def handle_event("use_default_commit", _params, socket) do
repository = socket.assigns.repository
branch = socket.assigns.selected_branch || repository.default_branch
result =
if is_binary(branch) and branch != "" do
RepositoryCode.resolve_branch_commit(repository, branch)
else
RepositoryCode.resolve_default_commit(repository)
end
case result do
{:ok, commit_sha} ->
params =
(socket.assigns.task_form.params || %{})
|> Map.put("commit_sha", commit_sha)
|> Map.put("title", "")
{:noreply, assign(socket, :task_form, draft_task_form(repository, params))}
{:error, _reason} ->
{:noreply, put_flash(socket, :error, "The branch tip could not be resolved to a commit.")}
end
end
def handle_event("validate_task", %{"review_task" => params}, socket) do
params = normalize_task_params(params)
form = draft_task_form(socket.assigns.repository, params, action: :validate)
{:noreply, assign(socket, :task_form, form)}
end
def handle_event(
"create_task",
%{"review_task" => params},
%{assigns: %{current_scope: %{account: account}}} = socket
)
when not is_nil(account) do
create_task_from_params(socket, normalize_task_params(params))
end
def handle_event("create_task", _params, socket) do
{:noreply, redirect(socket, to: ~p"/accounts/log-in")}
end
def handle_event(
"cancel_task",
%{"id" => id},
%{assigns: %{current_scope: %{account: account}}} = socket
)
when not is_nil(account) do
task =
Work.list_tasks(socket.assigns.repository, scope: socket.assigns.current_scope)
|> Enum.find(&(to_string(&1.id) == to_string(id)))
case task do
nil ->
{:noreply, put_flash(socket, :error, "Job not found.")}
task ->
case Work.cancel_task(task, socket.assigns.current_scope, %{
"reason" => "Cancelled from the repository security page by a steward or creator."
}) do
{:ok, _cancelled} ->
{:noreply,
socket
|> reload_tasks()
|> put_flash(:info, "Job cancelled.")}
{:error, :unauthorized} ->
{:noreply, put_flash(socket, :error, "You cannot cancel this job.")}
{:error, _} ->
{:noreply, put_flash(socket, :error, "Could not cancel this job.")}
end
end
end
def handle_event("cancel_task", _params, socket) do
{:noreply, redirect(socket, to: ~p"/accounts/log-in")}
end
def handle_event(
"record_finding_verdict",
%{
"finding_id" => public_id,
"commit_sha" => commit_sha,
"verdict" => verdict,
"notes" => notes
},
%{assigns: %{current_scope: %{account: account}}} = socket
)
when not is_nil(account) do
case apply_finding_check(socket, public_id, commit_sha, verdict, notes) do
:ok ->
{:noreply,
socket
|> reload_repository()
|> load_scans()
|> put_flash(:info, "Finding check recorded.")}
{:error, message} ->
{:noreply, put_flash(socket, :error, message)}
end
end
def handle_event("record_finding_verdict", _params, socket) do
{:noreply, redirect(socket, to: ~p"/accounts/log-in")}
end
def handle_event(
"bulk_check_findings",
%{"verdict" => verdict, "notes" => notes},
%{assigns: %{current_scope: %{account: account}}} = socket
)
when not is_nil(account) and verdict in ["confirmed", "disputed", "fixed"] do
notes = notes |> to_string() |> String.trim()
if String.length(notes) < 20 do
{:noreply, put_flash(socket, :error, "Notes need at least 20 characters.")}
else
targets =
socket.assigns.canonical_findings
|> Enum.filter(& &1.can_check)
|> Enum.map(fn finding ->
{finding.public_id, finding.last_seen_commit_sha}
end)
{ok, fail} =
Enum.reduce(targets, {0, 0}, fn {public_id, commit_sha}, {ok, fail} ->
case apply_finding_check(socket, public_id, commit_sha, verdict, notes) do
:ok -> {ok + 1, fail}
{:error, _} -> {ok, fail + 1}
end
end)
socket =
socket
|> reload_repository()
|> load_scans()
msg =
cond do
ok == 0 and fail == 0 ->
"No findings available to check."
fail == 0 ->
"Recorded #{ok} finding check#{if ok == 1, do: "", else: "s"}."
ok == 0 ->
"Could not record any finding checks."
true ->
"Recorded #{ok} check#{if ok == 1, do: "", else: "s"}; #{fail} failed."
end
flash = if ok > 0, do: :info, else: :error
{:noreply, put_flash(socket, flash, msg)}
end
end
def handle_event("bulk_check_findings", _params, socket) do
{:noreply, redirect(socket, to: ~p"/accounts/log-in")}
end
def handle_event(
"moderate_scan",
%{"scan_id" => scan_id, "decision" => decision, "moderation" => attrs},
%{assigns: %{current_scope: %{account: account}}} = socket
)
when not is_nil(account) do
with_recent_auth(socket, fn ->
with {:ok, scan} <- Scans.get_scan(socket.assigns.current_scope, scan_id) do
# Reason codes are machine labels for the audit log. The decision button
# picks them - moderators only write evidence notes in the UI.
attrs =
attrs
|> stringify_moderation_attrs()
|> Map.put("moderation_reason", default_moderation_reason(decision))
result =
case decision do
"accept" ->
Scans.accept_scan(socket.assigns.current_scope, scan, attrs)
"reject" ->
Scans.reject_scan(socket.assigns.current_scope, scan, attrs)
"contest" ->
Scans.contest_scan(socket.assigns.current_scope, scan, attrs)
"publish_summary" ->
Scans.update_visibility(socket.assigns.current_scope, scan, "public_summary", attrs)
"publish_full" ->
Scans.update_visibility(socket.assigns.current_scope, scan, "public", attrs)
"restrict" ->
Scans.update_visibility(socket.assigns.current_scope, scan, "restricted", attrs)
_other ->
{:error, :invalid_transition}
end
case result do
{:ok, updated_scan} ->
{:noreply,
socket
|> assign(:moderation_form, moderation_form())
|> sync_visible_scan(updated_scan)
|> put_flash(:info, "Review decision recorded.")}
{:error, :verification_required} ->
{:noreply, put_flash(socket, :error, "Acceptance requires verification quorum.")}
{:error, reason} when reason in [:unauthorized, :conflict_of_interest] ->
{:noreply, put_flash(socket, :error, "You cannot moderate this review.")}
{:error, %Ecto.Changeset{} = changeset} ->
{:noreply,
socket
|> assign(:moderation_form, to_form(changeset, as: :moderation))
|> put_flash(:error, "Add evidence notes (20+ characters).")}
{:error, _reason} ->
{:noreply, put_flash(socket, :error, "Review decision could not be recorded.")}
end
else
{:error, :not_found} ->
{:noreply, put_flash(socket, :error, "Review is unavailable.")}
end
end)
end
def handle_event("moderate_scan", _params, socket) do
{:noreply, redirect(socket, to: ~p"/accounts/log-in")}
end
defp apply_finding_check(socket, public_id, commit_sha, verdict, notes) do
attrs = %{
"commit_sha" => commit_sha,
"verdict" => verdict,
"provenance" => "human",
"notes" => notes
}
case FindingMemory.record_check(
socket.assigns.current_scope,
socket.assigns.repository,
public_id,
attrs
) do
{:ok, _check, _canonical} ->
:ok
{:error, :conflict_of_interest} ->
{:error, "You cannot verify a finding you submitted."}
{:error, :unauthorized} ->
{:error, "You are not authorized to verify this finding."}
{:error, %Ecto.Changeset{errors: [{_field, {message, _meta}} | _]}} ->
{:error, "Finding check not recorded: #{message}."}
{:error, _reason} ->
{:error, "Finding check could not be recorded."}
end
end
defp reload_repository(socket) do
%{host: host, owner: owner, name: name} = socket.assigns.repository
assign(socket, :repository, Repositories.get_repository(host, owner, name))
end
defp refresh_visible_repository(socket) do
%{host: host, owner: owner, name: name} = socket.assigns.repository
case Repositories.get_visible_repository(host, owner, name, socket.assigns.current_scope) do
nil ->
push_navigate(socket, to: ~p"/")
repository ->
findings =
canonical_findings(
repository,
socket.assigns.current_scope,
current_account_id(socket)
)
scans = Scans.list_scans(socket.assigns.current_scope, repository)
socket
|> assign(:repository, repository)
|> assign(:canonical_findings, findings)
|> assign(:checkable_findings_count, Enum.count(findings, & &1.can_check))
|> stream(:tasks, Work.list_tasks(repository, scope: socket.assigns.current_scope),
reset: true
)
|> assign(:task_target_options, task_target_options(scans))
|> stream(:scans, scans, reset: true)
end
end
defp reload_tasks(socket) do
tasks = Work.list_tasks(socket.assigns.repository, scope: socket.assigns.current_scope)
stream(socket, :tasks, tasks, reset: true)
end
# Raw reports are immutable provenance. Shared votes and checks are loaded
# once for each canonical issue instead of being repeated on occurrences.
defp load_scans(socket) do
scans = Scans.list_scans(socket.assigns.current_scope, socket.assigns.repository)
findings =
canonical_findings(
socket.assigns.repository,
socket.assigns.current_scope,
current_account_id(socket)
)
socket
|> assign(:canonical_findings, findings)
|> assign(:checkable_findings_count, Enum.count(findings, & &1.can_check))
|> assign(:task_target_options, task_target_options(scans))
|> stream(:scans, scans, reset: true)
end
defp canonical_findings(repository, scope, account_id) do
findings = FindingMemory.list_repository_memory(repository, limit: 200)
votes =
Reputation.vote_summaries("canonical_finding", Enum.map(findings, & &1.id), account_id)
checkable_ids = FindingMemory.checkable_public_ids(scope, repository, findings)
findings
|> Enum.map(fn finding ->
Map.merge(finding, %{
can_check: MapSet.member?(checkable_ids, finding.public_id),
vote_summary: Map.get(votes, finding.id, %{score: 0, my_vote: 0})
})
end)
end
defp current_account_id(%{assigns: %{current_scope: %{account: %{id: id}}}}), do: id
defp current_account_id(_socket), do: nil
# phx-value params are client-controlled; never feed them to String.to_integer/1.
defp normalize_id(id) when is_integer(id) and id > 0, do: {:ok, id}
defp normalize_id(id) when is_binary(id) do
case Integer.parse(id) do
{parsed, ""} when parsed > 0 -> {:ok, parsed}
_other -> {:error, :invalid_id}
end
end
defp normalize_id(_id), do: {:error, :invalid_id}
defp normalize_vote(value) when is_integer(value), do: {:ok, value}
defp normalize_vote(value) when is_binary(value) do
case Integer.parse(value) do
{parsed, ""} -> {:ok, parsed}
_other -> {:error, :invalid_vote}
end
end
defp normalize_vote(_value), do: {:error, :invalid_vote}
defp can_vote?(socket), do: not is_nil(current_account_id(socket))
defp can_manage_disclosure?(socket, repository) do
Policy.allowed?(socket.assigns.current_scope, :moderate, repository)
end
defp create_task_from_params(socket, params) do
params = normalize_task_params(params)
case Work.create_task(socket.assigns.repository, socket.assigns.current_scope, params) do
{:ok, task} ->
message =
if task.status == "open" do
"Job opened on the public queue."
else
"Job proposed for independent approval."
end
{:noreply,
socket
|> assign(:task_form, empty_task_form(socket.assigns.repository))
|> assign(:show_task_form, false)
|> stream_insert(:tasks, task, at: 0)
|> put_flash(:info, message)}
{:error, :commit_not_found} ->
{:noreply, assign_task_error(socket, params, :commit_sha, "commit was not found")}
{:error, :commit_mismatch} ->
{:noreply,
assign_task_error(socket, params, :commit_sha, "commit verification returned a mismatch")}
{:error, reason} when reason in [:rate_limited, :unavailable] ->
{:noreply,
assign_task_error(socket, params, :commit_sha, "commit could not be verified right now")}
{:error, reason} when reason in [:identity_changed, :not_public] ->
{:noreply,
assign_task_error(
socket,
params,
:commit_sha,
"repository identity is no longer confirmed public (GitHub-backed repos only - Tarakan-hosted repos use the local git object database)"
)}
{:error, :proposal_limit} ->
{:noreply, put_flash(socket, :error, "Daily review-task proposal limit reached.")}
{:error, :proposal_rate_limited} ->
{:noreply, put_flash(socket, :error, "Too many task proposals. Try again shortly.")}
{:error, :duplicate_job} ->
{:noreply,
put_flash(
socket,
:error,
"An open job already covers this commit and job type. Claim or complete that one first."
)}
{:error, :unauthorized} ->
{:noreply, put_flash(socket, :error, "This account cannot propose jobs.")}
{:error, %Ecto.Changeset{} = changeset} ->
{:noreply,
socket
|> assign(:show_task_form, true)
|> assign(:task_form, to_form(changeset, as: :review_task))}
end
end
defp assign_task_error(socket, params, field, message) do
filled = Work.fill_task_defaults(socket.assigns.repository, params)
form =
%ReviewTask{}
|> Work.change_task(filled)
|> Ecto.Changeset.add_error(field, message)
|> Map.put(:action, :validate)
|> to_form(as: :review_task)
socket
|> assign(:show_task_form, true)
|> assign(:task_form, form)
end
defp empty_task_form(repository) do
draft_task_form(repository, %{})
end
defp draft_task_form(repository, params, opts \\ []) do
params =
params
|> maybe_default_commit(repository)
|> then(&Work.fill_task_defaults(repository, &1))
changeset =
%ReviewTask{}
|> Work.change_task(params)
changeset =
case Keyword.get(opts, :action) do
nil -> changeset
action -> Map.put(changeset, :action, action)
end
to_form(changeset, as: :review_task)
end
defp load_branch_options(socket) do
repository = socket.assigns.repository
case RepositoryCode.list_branches(repository) do
{:ok, branches} ->
default = repository.default_branch
selected = socket.assigns[:selected_branch] || default || List.first(branches)
socket
|> assign(:branch_options, branches)
|> assign(:selected_branch, selected)
{:error, _} ->
default = repository.default_branch
branches =
if is_binary(default) and default != "", do: [default], else: []
socket
|> assign(:branch_options, branches)
|> assign(:selected_branch, default)
end
end
defp maybe_default_commit(params, repository) do
params = for {k, v} <- params, into: %{}, do: {to_string(k), v}
if present_param?(params["commit_sha"]) do
params
else
case RepositoryCode.resolve_default_commit(repository) do
{:ok, commit_sha} -> Map.put(params, "commit_sha", commit_sha)
{:error, _} -> params
end
end
end
defp present_param?(value) when value in [nil, ""], do: false
defp present_param?(value) when is_binary(value), do: String.trim(value) != ""
defp present_param?(_), do: true
defp can_auto_open_job?(%{assigns: %{current_scope: scope}}, repository)
when not is_nil(scope) do
# publish_task is checked against the repository for stewards; moderators always can.
Policy.allowed?(scope, :publish_task, repository) or
Policy.allowed?(scope, :manage_repository, repository)
end
defp can_auto_open_job?(_socket, _repository), do: false
defp can_cancel_job?(task, %{account: %{id: account_id}} = scope) when not is_nil(account_id) do
task.status in ["proposed", "open", "claimed", "changes_requested"] and
Policy.allowed?(scope, :cancel_task, task)
end
defp can_cancel_job?(_task, _scope), do: false
defp can_moderate?(scan, %{account: account} = scope) when not is_nil(account) do
Policy.allowed?(scope, :moderate_review, scan) and scan.submitted_by_id != account.id
end
defp can_moderate?(_scan, _scope), do: false
defp moderation_form do
to_form(
%{
"visibility" => "restricted",
"moderation_reason" => "",
"moderation_notes" => ""
},
as: :moderation
)
end
defp stringify_moderation_attrs(attrs) when is_map(attrs) do
Map.new(attrs, fn {k, v} -> {to_string(k), v} end)
end
defp stringify_moderation_attrs(_), do: %{}
# Stored on the scan for audit/activity feeds. Not a user-facing concept.
defp default_moderation_reason("accept"), do: "evidence_reviewed"
defp default_moderation_reason("reject"), do: "evidence_insufficient"
defp default_moderation_reason("contest"), do: "independently_contested"
defp default_moderation_reason("publish_full"), do: "disclosure_reviewed"
defp default_moderation_reason("publish_summary"), do: "safe_summary"
defp default_moderation_reason("restrict"), do: "takedown_review"
defp default_moderation_reason(_), do: "moderator_decision"
defp with_recent_auth(socket, fun) do
if Accounts.sudo_mode?(socket.assigns.current_scope.account) do
fun.()
else
repo = socket.assigns.repository
return_to = TarakanWeb.RepositoryPaths.repository_path(repo)
{:noreply,
socket
|> put_flash(
:error,
"Confirm it's you with a magic link before changing review disclosure (sign-in older than 8 hours)."
)
|> push_navigate(to: TarakanWeb.AccountAuth.reauth_path(return_to))}
end
end
defp sync_visible_scan(socket, scan, opts \\ []) do
socket = reload_repository(socket)
socket =
case Scans.get_scan(socket.assigns.current_scope, scan.id) do
{:ok, visible_scan} -> stream_insert(socket, :scans, visible_scan, opts)
{:error, :not_found} -> stream_delete(socket, :scans, scan)
end
scans = Scans.list_scans(socket.assigns.current_scope, socket.assigns.repository)
assign(socket, :task_target_options, task_target_options(scans))
end
defp short_sha(sha), do: String.slice(sha, 0, 7)
defp scan_time(%DateTime{} = datetime) do
Calendar.strftime(datetime, "%Y-%m-%d %H:%M")
end
# Signal is reserved for findings; quiet states stay quiet.
defp finding_lines(%{line_start: nil}), do: ""
defp finding_lines(%{line_start: line, line_end: line}), do: ":#{line}"
defp finding_lines(%{line_start: line_start, line_end: line_end}),
do: ":#{line_start}-#{line_end}"
defp repository_meta_description(repository) do
label = TarakanWeb.RepositoryComponents.repository_status_label(repository)
host = repository.host || "github.com"
base =
"Public security record for #{repository.owner}/#{repository.name} on #{host}. " <>
"#{label}."
detail =
cond do
(repository.open_findings_count || 0) > 0 ->
" See open findings and open jobs."
true ->
" Contribute a review or claim an open job."
end
String.slice(base <> detail, 0, 160)
end
# Mass UI: Report job + Check job first; advanced kinds still available.
defp task_kind_options do
primary = [
{"Security report (findings)", "code_review"},
{"Check an existing report", "verify_findings"}
]
advanced =
~w(diff_review threat_model privacy_review business_logic write_fix)
|> Enum.map(&{review_kind_label(&1) <> " (advanced)", &1})
primary ++ advanced
end
defp task_target_options(scans) do
scans
|> Enum.filter(&(&1.findings_count > 0))
|> Enum.map(fn scan ->
count_label =
if scan.findings_count == 1, do: "1 finding", else: "#{scan.findings_count} findings"
{
"Report ##{scan.id} · #{review_kind_label(scan.review_kind)} · #{short_sha(scan.commit_sha)} · #{count_label}",
scan.id
}
end)
end
# Each kind carries exactly one extra field. Leaving a stale value from a
# different kind in the params makes the changeset reject it, which reads to
# the user as an unrelated validation error.
defp normalize_task_params(%{"kind" => "verify_findings"} = params) do
Map.delete(params, "base_commit_sha")
end
defp normalize_task_params(%{"kind" => "diff_review"} = params) do
Map.delete(params, "target_review_id")
end
defp normalize_task_params(params) do
params |> Map.delete("target_review_id") |> Map.delete("base_commit_sha")
end
# Job form: only who should pick it up. Hybrid stays a report provenance
# (agent draft a person edited), not a separate "human-guided" work mode.
defp capability_options do
[
{"AI helper", "agent"},
{"Human only", "human"}
]
end
defp short_task_status(%{status: "claimed"} = task) do
if Tarakan.Work.ReviewTask.claim_active?(task), do: "Claimed", else: "Open"
end
defp short_task_status(%{status: "changes_requested"}), do: "Changes requested"
defp short_task_status(%{status: status}), do: String.capitalize(status)
defp empty_review_label(%{review_kind: "code_review"}), do: "No findings reported"
defp empty_review_label(_scan), do: "Reviewed"
end

View file

@ -0,0 +1,687 @@
<Layouts.app flash={@flash} current_scope={@current_scope} current_path={assigns[:current_path]}>
<Layouts.page>
<.repository_header repository={@repository} active_tab={:security} />
<TarakanWeb.BountyComponents.wanted_banner bounties={@open_bounties} />
<div
:if={@can_manage_disclosure}
id="managed-disclosure-controls"
class="mt-4 flex flex-wrap items-center gap-3 border border-rule bg-panel px-4 py-3"
>
<p class="text-xs text-ink-muted">
Managed disclosure is
<span class="font-semibold text-ink">
{if @repository.managed_disclosure, do: "on", else: "off"}
</span>
for this repository. Staff triage findings with the vendor; the public record is unchanged.
</p>
<button
id="toggle-managed-disclosure"
type="button"
phx-click="toggle_managed_disclosure"
class="ml-auto border border-strong px-3 py-1.5 font-mono text-[11px] uppercase tracking-[0.12em] text-ink transition hover:border-quote hover:text-quote"
>
{if @repository.managed_disclosure, do: "Disable", else: "Enable"}
</button>
</div>
<%!-- At a glance --%>
<section
id="security-summary"
class="mt-6 grid border-2 border-strong sm:grid-cols-2 lg:grid-cols-4"
>
<div class="border-b border-rule px-4 py-4 lg:border-b-0 sm:border-r">
<p class="text-xs font-medium text-ink-faint">
Open findings
</p>
<p id="open-findings-count" class="mt-1 font-display text-3xl tabular-nums text-ink">
{@repository.open_findings_count}
</p>
</div>
<div class="border-b border-rule px-4 py-4 lg:border-b-0 lg:border-r">
<p class="text-xs font-medium text-ink-faint">Verified</p>
<p id="verified-findings-count" class="mt-1 font-display text-3xl tabular-nums text-ink">
{@repository.verified_findings_count}
</p>
</div>
<div class="border-b border-rule px-4 py-4 sm:border-r lg:border-b-0">
<p class="text-xs font-medium text-ink-faint">Reports</p>
<p id="scan-count" class="mt-1 font-display text-3xl tabular-nums text-ink">
{@repository.scan_count}
</p>
</div>
<div class="px-4 py-4">
<p class="text-xs font-medium text-ink-faint">Median fix</p>
<p id="median-time-to-fix" class="mt-1 font-display text-3xl tabular-nums text-ink">
{Tarakan.SecurityPosture.format_days(@posture.median_days_to_fix)}
</p>
<p
:if={@posture.oldest_open_days}
class="mt-1 font-mono text-[10px] uppercase tracking-[0.12em] text-ink-faint"
>
oldest open {Tarakan.SecurityPosture.format_days(@posture.oldest_open_days)}
</p>
</div>
</section>
<%!-- Fixed, then back. Only rendered when it has actually happened. --%>
<section
:if={@regressions != []}
id="finding-regressions"
class="mt-6 border-2 border-signal"
>
<h2 class="border-b border-rule px-4 py-3 font-display text-sm uppercase tracking-[0.12em] text-signal">
Regressed - {@posture.regressions_count} fixed finding(s) came back
</h2>
<ul class="divide-y divide-rule">
<li
:for={regression <- @regressions}
id={"regression-#{regression.id}"}
class="flex flex-wrap items-baseline gap-x-3 gap-y-1 px-4 py-3"
>
<.link
navigate={~p"/findings/#{regression.canonical_finding.public_id}"}
class="min-w-0 flex-1 truncate text-sm text-ink hover:text-signal hover:underline"
>
{regression.canonical_finding.title}
</.link>
<span class="font-mono text-[10px] uppercase tracking-[0.12em] text-ink-faint">
fixed at {String.slice(regression.fixed_commit_sha || "", 0, 7)} · back at {String.slice(
regression.detected_commit_sha,
0,
7
)}
</span>
</li>
</ul>
</section>
<%!-- Findings first: what matters --%>
<section id="canonical-findings" class="mt-10">
<div class="flex flex-wrap items-end justify-between gap-3 border-b-2 border-strong pb-3">
<h2 class="font-display text-lg uppercase tracking-[0.02em] text-ink">
Findings
</h2>
<span class="font-mono text-[11px] text-ink-faint">
{@repository.open_findings_count} unique open
</span>
</div>
<form
:if={@checkable_findings_count > 0}
id="bulk-check-findings-form"
phx-submit="bulk_check_findings"
class="mt-4 border border-strong bg-panel px-4 py-4"
>
<div class="flex flex-col gap-3 sm:flex-row sm:items-end sm:justify-between">
<div class="min-w-0 flex-1">
<p class="text-sm font-semibold text-ink">
Check all ({@checkable_findings_count})
</p>
<input
id="bulk-check-notes"
type="text"
name="notes"
required
minlength="20"
maxlength="2000"
placeholder="Independent evidence notes (required, 20+ chars)"
autocomplete="off"
class="mt-3 w-full border border-strong bg-ground px-3 py-2 font-mono text-xs text-ink placeholder:text-ink-faint focus:border-signal focus:outline-none"
/>
</div>
<div class="flex shrink-0 flex-wrap gap-2">
<.button name="verdict" value="confirmed" size="sm">Confirm all</.button>
<.button name="verdict" value="disputed" variant="danger" size="sm">
Dispute all
</.button>
<.button name="verdict" value="fixed" size="sm">Fixed all</.button>
</div>
</div>
</form>
<div
:if={@canonical_findings == []}
id="findings-empty"
class="border-b border-rule px-1 py-10 text-center"
>
<p class="text-sm font-medium text-ink">No findings yet</p>
<p class="mt-1 text-xs leading-5 text-ink-muted">
<code class="font-mono text-ink">tarakan worker --agent kimi</code> or open a Job below.
</p>
</div>
<div :if={@canonical_findings != []} class="divide-y divide-rule border-b border-rule">
<article
:for={finding <- @canonical_findings}
id={"canonical-#{finding.public_id}"}
class="py-5"
>
<div class="flex flex-wrap items-start gap-3">
<.vote_control
subject_type="canonical_finding"
subject_id={finding.id}
summary={finding.vote_summary}
can_vote={@can_vote}
class="mt-0.5 shrink-0"
/>
<div class="min-w-0 flex-1">
<div class="flex flex-wrap items-center gap-2">
<span class="font-mono text-[10px] font-semibold uppercase tracking-[0.12em] text-signal">
{finding.severity}
</span>
<.notch_badge class={[
finding.status == "verified" && "bg-ink text-ground",
finding.status == "fixed" && "bg-quote text-ground",
finding.status in ["open", "disputed"] && "text-ink-muted"
]}>
{finding.status}
</.notch_badge>
<.notch_badge
:if={finding.trust && finding.trust.agent_reproduced}
class="text-ink-muted"
>
Agent reproduced
</.notch_badge>
<.notch_badge
:if={finding.trust && finding.trust.human_checked}
class="text-ink-muted"
>
Human checked
</.notch_badge>
</div>
<h3 class="mt-1.5 text-base font-semibold leading-snug text-ink">
<.link
navigate={~p"/findings/#{finding.occurrence_public_id}"}
class="transition hover:text-signal"
>
{finding.title}
</.link>
</h3>
<p class="mt-1 font-mono text-[11px] text-ink-faint">
<span class="text-ink-muted">{finding.file_path}{finding_lines(finding)}</span>
<span class="mx-1.5" aria-hidden="true">·</span>
last at {short_sha(finding.last_seen_commit_sha)}
</p>
<p class="mt-2 max-w-3xl text-sm leading-6 text-ink-muted">
{TarakanWeb.FindingPresentation.description_excerpt(finding.description, 220)}
</p>
<p class="mt-2 font-mono text-[10px] text-ink-faint">
detected in {finding.detections_count} {if finding.detections_count == 1,
do: "run",
else: "runs"} · {finding.distinct_submitters_count} {if finding.distinct_submitters_count ==
1,
do: "submitter",
else: "submitters"} · {finding.confirmations_count} confirmed · {finding.disputes_count} disputed
</p>
<details :if={finding.can_check} class="mt-3">
<summary class="cursor-pointer font-mono text-[11px] text-signal transition hover:underline">
Override this finding only
</summary>
<form
id={"canonical-#{finding.public_id}-check-form"}
phx-submit="record_finding_verdict"
class="mt-2 flex flex-wrap items-center gap-2"
>
<input type="hidden" name="finding_id" value={finding.public_id} />
<input type="hidden" name="commit_sha" value={finding.last_seen_commit_sha} />
<input
type="text"
name="notes"
required
minlength="20"
maxlength="2000"
placeholder="Notes for this finding only"
autocomplete="off"
class="min-w-0 flex-1 basis-64 border border-rule bg-transparent px-3 py-1.5 font-mono text-xs text-ink placeholder:text-ink-faint focus:outline-none focus:ring-1 focus:ring-phosphor"
/>
<.button name="verdict" value="confirmed" size="sm">Confirm</.button>
<.button name="verdict" value="disputed" variant="danger" size="sm">
Dispute
</.button>
<.button name="verdict" value="fixed" size="sm">Fixed</.button>
</form>
</details>
</div>
</div>
</article>
</div>
</section>
<%!-- Jobs: action --%>
<section id="review-work" class="mt-10">
<div class="flex flex-wrap items-end justify-between gap-3 border-b-2 border-strong pb-3">
<h2 class="font-display text-lg uppercase tracking-[0.02em] text-ink">
Jobs
</h2>
<.link
navigate={~p"/jobs"}
class="font-mono text-[10px] uppercase tracking-[0.12em] text-signal hover:underline"
>
All open jobs →
</.link>
</div>
<div class="mt-4 border border-strong bg-panel px-4 py-4">
<%= if @current_scope && @current_scope.account do %>
<div class="flex flex-col gap-3 sm:flex-row sm:flex-wrap sm:items-center">
<.button
id="quick-open-job-button"
type="button"
variant="primary"
phx-click="quick_open_job"
class="w-full phx-click-loading:opacity-60 sm:w-auto"
>
{if @can_auto_open_job,
do: "Open security report job",
else: "Propose security report job"}
</.button>
<button
id="propose-task-toggle"
type="button"
phx-click="toggle_task_form"
class="self-start py-1 font-mono text-xs text-signal transition hover:underline sm:self-auto"
>
{if @show_task_form, do: "Cancel", else: "Customize…"}
</button>
</div>
<p :if={!@can_auto_open_job} class="mt-2 max-w-xl text-xs leading-5 text-ink-muted">
A steward publishes proposals before they go live.
</p>
<.form
:if={@show_task_form}
for={@task_form}
id="review-task-form"
phx-change="validate_task"
phx-submit="create_task"
class="mt-5 border-t border-rule pt-5"
>
<div class="grid gap-4 md:grid-cols-2">
<div>
<label
for="task-branch-select"
class="mb-1.5 block text-sm font-semibold text-ink"
>
Branch
</label>
<select
id="task-branch-select"
name="branch"
phx-change="select_branch"
class="w-full border border-strong bg-ground px-3 py-2 font-mono text-sm text-ink focus:border-signal focus:outline-none"
>
<option
:for={branch <- @branch_options}
value={branch}
selected={branch == @selected_branch}
>
{branch}{if branch == @repository.default_branch, do: " (default)", else: ""}
</option>
<option :if={@branch_options == []} value="" selected>
{if @repository.default_branch,
do: @repository.default_branch,
else: "No branches listed"}
</option>
</select>
</div>
<div>
<.input
field={@task_form[:commit_sha]}
type="text"
label="Commit SHA"
autocomplete="off"
/>
<button
id="use-default-commit-button"
type="button"
phx-click="use_default_commit"
class="mt-1.5 font-mono text-[11px] text-signal transition hover:underline"
>
Refresh tip of {@selected_branch || @repository.default_branch || "branch"}
</button>
</div>
<.input
field={@task_form[:kind]}
type="select"
label="Job type"
options={@task_kind_options}
/>
<%!-- diff_review reviews base..head, so it needs the other end of
the range. Everything else reviews the snapshot at commit_sha. --%>
<div :if={@task_form[:kind].value == "diff_review"}>
<.input
field={@task_form[:base_commit_sha]}
type="text"
label="Base commit (reviews base..commit)"
autocomplete="off"
/>
</div>
<div :if={@task_form[:kind].value == "verify_findings"}>
<.input
field={@task_form[:target_review_id]}
type="select"
label="Report to check"
options={[{"Choose a report", ""} | @task_target_options]}
/>
<p
:if={@task_target_options == []}
class="mt-1.5 text-xs leading-5 text-ink-faint"
>
No reports with findings to check yet.
</p>
</div>
<.input
field={@task_form[:capability]}
type="select"
label="Who can do it"
options={@capability_options}
/>
<.input
field={@task_form[:title]}
type="text"
label="Title (optional)"
autocomplete="off"
/>
</div>
<div class="mt-4">
<.input
field={@task_form[:description]}
type="textarea"
label="What to do (optional)"
placeholder="Leave blank for an auto brief, or add focus."
/>
</div>
<.button
id="create-review-task-button"
type="submit"
variant="primary"
class="mt-4 phx-submit-loading:opacity-60"
>
{if @can_auto_open_job, do: "Open job", else: "Propose job"}
</.button>
</.form>
<% else %>
<div id="review-task-auth-gate">
<p class="text-sm text-ink-muted">Sign in to open or claim jobs on this repo.</p>
<.link
navigate={~p"/accounts/log-in"}
class="mt-2 inline-block font-mono text-xs text-signal hover:underline"
>
Sign in →
</.link>
</div>
<% end %>
</div>
<div
id="review-tasks"
phx-update="stream"
class="mt-4 divide-y divide-rule border-t border-rule"
>
<div
id="review-tasks-empty"
class="hidden py-8 text-center text-sm text-ink-faint only:block"
>
No jobs yet. Open one above or run <code class="font-mono text-ink">tarakan worker --agent kimi</code>.
</div>
<article
:for={{id, task} <- @streams.tasks}
id={id}
class="grid gap-3 px-1 py-4 transition-colors hover:bg-panel sm:grid-cols-[1fr_auto] sm:items-center sm:px-2"
>
<div class="min-w-0">
<div class="flex flex-wrap items-center gap-2 font-mono text-[10px] uppercase tracking-[0.12em] text-ink-faint">
<span class="text-ink-muted">{review_kind_label(task.kind)}</span>
<span aria-hidden="true">·</span>
<span>{provenance_label(task.capability)} required</span>
<span aria-hidden="true">·</span>
<span title={task.commit_sha}>{short_sha(task.commit_sha)}</span>
<span aria-hidden="true">·</span>
<span class="text-ink">{short_task_status(task)}</span>
</div>
<h3 class="mt-1.5 text-sm font-semibold text-ink">{task.title}</h3>
<p class="mt-1 line-clamp-2 text-xs leading-5 text-ink-muted">{task.description}</p>
</div>
<div class="flex shrink-0 flex-wrap items-center gap-2">
<.button
id={"review-task-#{task.id}-link"}
navigate={~p"/jobs/#{task.id}"}
size="sm"
>
Open
</.button>
<.button
:if={can_cancel_job?(task, @current_scope)}
id={"review-task-#{task.id}-cancel"}
type="button"
variant="danger"
size="sm"
phx-click="cancel_task"
phx-value-id={task.id}
data-confirm="Cancel this job? It will leave the open queue."
>
Cancel
</.button>
</div>
</article>
</div>
</section>
<%!-- Raw reports last: provenance --%>
<section id="security-record" class="mt-10">
<div class="border-b-2 border-strong pb-3">
<h2 class="font-display text-lg uppercase tracking-[0.02em] text-ink">
Reports
</h2>
</div>
<div id="scans" phx-update="stream" class="border-b border-rule">
<div
id="scans-empty"
class="hidden px-1 py-10 text-center text-sm text-ink-faint only:block"
>
No reports yet.
</div>
<article :for={{id, scan} <- @streams.scans} id={id} class="border-b border-rule py-5">
<div class="flex flex-wrap items-start justify-between gap-3">
<div class="min-w-0">
<div class="flex flex-wrap items-baseline gap-x-2 gap-y-1">
<.handle_link
handle={scan.submitted_by.handle}
class="text-sm font-semibold text-ink"
/>
<span
id={"scan-#{scan.id}-provenance-attestation"}
class="font-mono text-[11px] text-ink-muted"
title="Submitter claim, not independently verified"
>
{TarakanWeb.FindingPresentation.how_made_label(scan.provenance)}
</span>
</div>
<p class="mt-1 font-mono text-[11px] text-ink-faint">
{review_kind_label(scan.review_kind)}
<span class="mx-1" aria-hidden="true">·</span>
<span title={scan.review_status}>
{TarakanWeb.FindingPresentation.status_blurb(scan.review_status)}
</span>
<span class="mx-1" aria-hidden="true">·</span>
<span title={scan.commit_sha}>{short_sha(scan.commit_sha)}</span>
<span class="mx-1" aria-hidden="true">·</span>
{scan_time(scan.inserted_at)}
</p>
</div>
<.notch_badge :if={scan.findings_count > 0} class="shrink-0 text-signal">
{scan.findings_count}
{if scan.findings_count == 1, do: "finding", else: "findings"}
</.notch_badge>
<.notch_badge :if={scan.findings_count == 0} class="shrink-0 text-ink-muted">
{empty_review_label(scan)}
</.notch_badge>
</div>
<div
:if={scan.details_visible && (scan.model || scan.prompt_version)}
class="mt-2 font-mono text-[11px] text-ink-faint"
>
<span :if={scan.model}>via {scan.model}</span>
<span :if={scan.model && scan.prompt_version}> · </span>
<span :if={scan.prompt_version}>{scan.prompt_version}</span>
</div>
<div
:if={scan.details_visible && scan.notes}
class="mt-3 max-w-3xl text-sm leading-6 text-ink-muted"
>
<% notes = TarakanWeb.FindingPresentation.humanize_notes(scan.notes) %>
<p :if={notes && notes.kind == :summary} class="text-ink-muted">
{notes.count} findings
<span :if={notes.tops != []}>
· top: {Enum.join(Enum.take(notes.tops, 2), "; ")}
</span>
</p>
<p :if={notes && notes.kind == :plain}>{notes.text}</p>
</div>
<details
:if={scan.details_visible && scan.findings_count > 0}
class="mt-3"
open={scan.findings_count <= 3}
>
<summary class="cursor-pointer font-mono text-[10px] uppercase tracking-[0.12em] text-ink-faint transition hover:text-ink">
Findings in this report ({scan.findings_count})
</summary>
<ul class="mt-3 space-y-3 border-l-2 border-rule pl-4">
<li :for={finding <- scan.findings}>
<div class="flex flex-wrap items-center gap-2">
<span class="font-mono text-[10px] uppercase tracking-[0.12em] text-signal">
{finding.severity}
</span>
<.notch_badge
:if={
finding.canonical_finding && finding.canonical_finding.detections_count > 1
}
class="text-ink-muted"
>
seen in {finding.canonical_finding.detections_count} runs
</.notch_badge>
<.link
id={"finding-source-#{finding.id}"}
navigate={~p"/findings/#{finding.public_id}/code"}
class="font-mono text-[11px] text-ink-faint transition hover:text-ink"
aria-label={"Open #{finding.file_path} at the finding's pinned commit"}
>
{finding.file_path}{finding_lines(finding)}
</.link>
</div>
<h4 class="mt-0.5 text-sm font-semibold text-ink">
<.link
id={"finding-page-#{finding.id}"}
navigate={~p"/findings/#{finding.public_id}"}
class="transition hover:text-signal"
>
{finding.title}
</.link>
</h4>
<p class="mt-1 max-w-3xl text-sm leading-6 text-ink-muted">
{TarakanWeb.FindingPresentation.description_excerpt(finding.description, 180)}
</p>
</li>
</ul>
</details>
<details
:if={scan.details_visible && scan.raw_document}
class="mt-2"
>
<summary class="cursor-pointer font-mono text-[10px] uppercase tracking-[0.12em] text-ink-faint transition hover:text-ink">
Raw JSON
</summary>
<pre
id={"scan-#{scan.id}-raw-report"}
class="mt-2 max-h-48 overflow-auto border border-rule bg-panel px-3 py-2 font-mono text-[11px] leading-5 text-ink-muted"
>{raw_report(scan)}</pre>
</details>
<p
:if={!scan.details_visible}
class="mt-3 text-xs leading-5 text-ink-faint"
>
Detailed evidence is restricted.
</p>
<details
:if={can_moderate?(scan, @current_scope)}
class="mt-4 border-l-2 border-rule pl-4"
>
<summary class="cursor-pointer text-sm font-semibold text-ink transition hover:text-signal">
Moderate this report
</summary>
<.form
for={@moderation_form}
id={"scan-#{scan.id}-moderation-form"}
phx-submit="moderate_scan"
class="mt-3"
>
<input type="hidden" name="scan_id" value={scan.id} />
<.input
id={"scan-#{scan.id}-moderation-notes"}
field={@moderation_form[:moderation_notes]}
type="textarea"
label="What did you check?"
placeholder="What was independently verified (20+ characters)."
/>
<div class="mt-3 flex flex-wrap gap-2">
<.button
:if={scan.review_status != "accepted"}
name="decision"
value="accept"
variant="primary"
size="sm"
>
Accept
</.button>
<.button name="decision" value="contest" size="sm">Contest</.button>
<.button name="decision" value="reject" variant="danger" size="sm">
Reject
</.button>
<.button
:if={scan.visibility != "public"}
name="decision"
value="publish_full"
size="sm"
>
Restore full evidence
</.button>
<.button
:if={scan.visibility == "public"}
name="decision"
value="publish_summary"
size="sm"
>
Redact to summary
</.button>
<.button
:if={scan.visibility != "restricted"}
name="decision"
value="restrict"
variant="danger"
size="sm"
>
Restrict
</.button>
</div>
</.form>
</details>
</article>
</div>
</section>
</Layouts.page>
</Layouts.app>

View file

@ -0,0 +1,450 @@
defmodule TarakanWeb.ReviewTaskLive.Show do
use TarakanWeb, :live_view
alias Tarakan.Accounts
alias Tarakan.Policy
alias Tarakan.Scans
alias Tarakan.Work
alias Tarakan.Work.{Contribution, ReviewTask}
@impl true
def mount(%{"id" => id}, _session, socket) do
task =
Work.get_visible_task(id, socket.assigns.current_scope) ||
raise Ecto.NoResultsError, queryable: ReviewTask
if connected?(socket), do: Work.subscribe(task.repository_id)
{:ok,
socket
|> assign(:page_title, task.title)
|> assign(:meta_description, task_meta_description(task))
|> assign(:canonical_path, ~p"/jobs/#{task.id}")
|> assign_task(task)
|> assign(:decision_form, decision_form(task))
|> assign(:disclosure_form, disclosure_form())}
end
@impl true
def handle_info({event, task_id}, %{assigns: %{task: current_task}} = socket)
when event in [
:review_task_updated,
:review_task_published,
:review_task_submitted,
:review_task_accepted,
:review_task_disclosed,
:review_task_changes_requested,
:review_task_rejected,
:review_task_cancelled,
:review_task_quarantined
] do
if task_id == current_task.id do
case Work.get_visible_task(task_id, socket.assigns.current_scope) do
nil -> {:noreply, push_navigate(socket, to: repository_path(current_task))}
task -> {:noreply, assign_task(socket, task)}
end
else
{:noreply, socket}
end
end
@impl true
def handle_event("claim", _params, %{assigns: %{current_scope: scope, task: task}} = socket) do
respond(socket, Work.claim_task(task, scope), "Task claimed.")
end
def handle_event("release", _params, %{assigns: %{current_scope: scope, task: task}} = socket) do
respond(socket, Work.release_task(task, scope), "Claim released.")
end
def handle_event("validate_contribution", %{"contribution" => params}, socket) do
form =
%Contribution{}
|> Work.change_contribution(params)
|> Map.put(:action, :validate)
|> to_form(as: :contribution)
{:noreply, assign(socket, :contribution_form, form)}
end
def handle_event(
"complete",
%{"contribution" => params},
%{assigns: %{current_scope: scope, task: task}} = socket
) do
case Work.submit_task(task, scope, params) do
{:ok, task} ->
{:noreply,
socket
|> assign_task(task)
|> put_flash(:info, "Evidence submitted for independent review.")}
{:error, %Ecto.Changeset{} = changeset} ->
{:noreply, assign(socket, :contribution_form, to_form(changeset, as: :contribution))}
error ->
{:noreply, put_flash(socket, :error, error_message(error))}
end
end
def handle_event(
"complete_verification",
%{"verification" => params},
%{assigns: %{current_scope: scope, task: task}} = socket
) do
case Work.submit_task(task, scope, params) do
{:ok, task} ->
{:noreply,
socket
|> assign_task(task)
|> put_flash(:info, "Finding check submitted.")}
{:error, %Ecto.Changeset{} = changeset} ->
{:noreply,
socket
|> assign(:verification_form, verification_form(task, params))
|> put_flash(:error, changeset_error(changeset))}
error ->
{:noreply,
socket
|> assign(:verification_form, verification_form(task, params))
|> put_flash(:error, error_message(error))}
end
end
def handle_event(
"publish",
%{"decision" => params},
%{assigns: %{current_scope: scope, task: task}} = socket
) do
with_recent_auth(socket, fn ->
respond(
socket,
Work.publish_task(task, scope, params),
"Task approved for the public queue."
)
end)
end
def handle_event(
"review",
%{"action" => action, "decision" => params},
%{assigns: %{current_scope: scope, task: task}} = socket
)
when action in ["accept", "request_changes", "reject"] do
with_recent_auth(socket, fn ->
{result, message} =
case action do
"accept" ->
{Work.accept_task(task, scope, params),
"Contribution accepted and held for a separate disclosure decision."}
"request_changes" ->
{Work.request_changes(task, scope, params), "Changes requested."}
"reject" ->
{Work.reject_task(task, scope, params), "Contribution rejected."}
end
respond(socket, result, message)
end)
end
def handle_event("review", _params, socket) do
{:noreply, put_flash(socket, :error, "The requested review action is invalid.")}
end
def handle_event(
"disclose",
%{"visibility" => visibility, "disclosure" => params},
%{assigns: %{current_scope: scope, task: task}} = socket
)
when visibility in ["public_summary", "public"] do
with_recent_auth(socket, fn ->
message =
if visibility == "public",
do: "Full evidence disclosed after sensitive-data review.",
else: "A redacted result summary is now public."
respond_disclosure(
socket,
Work.disclose_task(task, scope, visibility, params),
message,
params
)
end)
end
def handle_event("disclose", _params, socket) do
{:noreply, put_flash(socket, :error, "The requested disclosure is invalid.")}
end
def handle_event(
"cancel",
%{"decision" => params},
%{assigns: %{current_scope: scope, task: task}} = socket
) do
respond(socket, Work.cancel_task(task, scope, params), "Task cancelled.")
end
defp respond(socket, {:ok, task}, message) do
{:noreply, socket |> assign_task(task) |> put_flash(:info, message)}
end
defp respond(socket, {:error, %Ecto.Changeset{} = changeset}, _message) do
{:noreply, assign(socket, :decision_form, to_form(changeset, as: :decision))}
end
defp respond(socket, error, _message) do
{:noreply, put_flash(socket, :error, error_message(error))}
end
defp respond_disclosure(socket, {:ok, task}, message, _params) do
{:noreply,
socket
|> assign_task(task)
|> assign(:disclosure_form, disclosure_form())
|> put_flash(:info, message)}
end
defp respond_disclosure(
socket,
{:error, %Ecto.Changeset{} = changeset},
_message,
params
) do
form =
params
|> Map.take(["reason", "sensitive_data_reviewed"])
|> to_form(as: :disclosure, errors: changeset.errors)
{:noreply, assign(socket, :disclosure_form, form)}
end
defp respond_disclosure(socket, error, _message, _params) do
{:noreply, put_flash(socket, :error, error_message(error))}
end
defp assign_task(socket, task) do
socket
|> assign(:task, task)
|> assign(:visible_target_review, visible_target_review(socket.assigns.current_scope, task))
|> assign(:provenance_options, provenance_options(task))
|> assign(:verification_form, verification_form(task))
|> assign(:decision_form, decision_form(task))
|> assign(
:contribution_form,
to_form(
Work.change_contribution(%Contribution{}, %{
"provenance" => task |> allowed_provenances() |> List.first()
}),
as: :contribution
)
)
end
defp decision_form(%ReviewTask{status: "proposed"} = task) do
to_form(%{"reason" => Work.default_publish_reason(task), "evidence" => ""}, as: :decision)
end
defp decision_form(_task) do
to_form(%{"reason" => "", "evidence" => ""}, as: :decision)
end
defp disclosure_form do
to_form(
%{"reason" => "", "sensitive_data_reviewed" => "false"},
as: :disclosure
)
end
defp verification_form(task, params \\ %{}) do
defaults = %{
"provenance" => task |> allowed_provenances() |> List.first(),
"verdict" => "confirmed",
"notes" => "",
"evidence" => ""
}
defaults
|> Map.merge(params)
|> to_form(as: :verification)
end
defp visible_target_review(_scope, %{target_review_id: nil}), do: nil
defp visible_target_review(scope, %{target_review_id: review_id}) do
case Scans.get_scan(scope, review_id) do
{:ok, review} -> review
{:error, :not_found} -> nil
end
end
defp changeset_error(%Ecto.Changeset{errors: [{_field, {message, _opts}} | _]}),
do: "Verification was not submitted: #{message}."
defp changeset_error(_changeset), do: "Verification was not submitted."
defp with_recent_auth(socket, fun) do
account = socket.assigns.current_scope && socket.assigns.current_scope.account
if account && Accounts.sudo_mode?(account) do
fun.()
else
return_to = ~p"/jobs/#{socket.assigns.task.id}"
{:noreply,
socket
|> put_flash(
:error,
"Confirm it's you with a magic link before changing the public record (sign-in older than 8 hours)."
)
|> push_navigate(to: TarakanWeb.AccountAuth.reauth_path(return_to))}
end
end
defp owns_claim?(task, %{account_id: account_id}) when is_integer(account_id) do
task.claimed_by_id == account_id and ReviewTask.claim_active?(task)
end
defp owns_claim?(_task, _scope), do: false
defp can_claim?(task, scope) do
claimable_for_display?(task) and Policy.allowed?(scope, :claim_task, task)
end
defp can_publish?(%{status: "proposed"} = task, %{account_id: account_id} = scope)
when is_integer(account_id) do
# Stewards/owners and moderators may publish, including their own proposals.
Policy.allowed?(scope, :publish_task, task)
end
defp can_publish?(_task, _scope), do: false
defp can_review?(%{status: "submitted"} = task, %{account_id: account_id} = scope)
when is_integer(account_id) do
task.created_by_id != account_id and task.claimed_by_id != account_id and
not Enum.any?(task.contributions, &(&1.account_id == account_id)) and
Policy.allowed?(scope, :review_contribution, task)
end
defp can_review?(_task, _scope), do: false
defp can_disclose?(%{status: "accepted"} = task, scope) do
Policy.allowed?(scope, :disclose_task, task)
end
defp can_disclose?(_task, _scope), do: false
defp full_disclosure_allowed?(task) do
task.repository.participation_mode in ["maintainer_verified", "curated"]
end
defp can_cancel?(task, scope) do
task.status in ["proposed", "open", "changes_requested"] and
Policy.allowed?(scope, :cancel_task, task)
end
defp created_by_current_account?(task, %{account_id: account_id})
when is_integer(account_id),
do: task.created_by_id == account_id
defp created_by_current_account?(_task, _scope), do: false
defp claimable_for_display?(%{status: "claimed"} = task),
do: not ReviewTask.claim_active?(task)
defp claimable_for_display?(task), do: ReviewTask.claimable?(task)
defp error_message({:error, :own_task}), do: "That action is not allowed on this job."
defp error_message({:error, :not_independent}), do: "An independent reviewer is required."
defp error_message({:error, :already_claimed}), do: "This task is already claimed."
defp error_message({:error, :claim_limit}), do: "You have reached your active claim limit."
defp error_message({:error, :claim_expired}), do: "Your claim expired. Claim the task again."
defp error_message({:error, :capability_mismatch}),
do: "The provenance does not match the task."
defp error_message({:error, :verdict_required}),
do: "Choose confirmed or disputed and provide verification notes."
defp error_message({:error, :target_review_required}),
do: "This check job has no target report."
defp error_message({:error, :target_review_mismatch}),
do: "The selected report does not match this check job."
defp error_message({:error, :not_claimant}), do: "You do not hold this claim."
defp error_message({:error, :closed}), do: "This task is closed."
defp error_message({:error, :not_open}), do: "This task is not open for claims."
defp error_message({:error, :invalid_state}), do: "That transition is no longer valid."
defp error_message({:error, :active_work}), do: "Resolve active work before cancelling."
defp error_message({:error, :invalid_visibility}), do: "That disclosure level is invalid."
defp error_message({:error, :full_disclosure_not_allowed}),
do: "Full evidence requires a maintainer-verified or curated repository."
defp error_message({:error, :sensitive_data_review_required}),
do: "Confirm that the evidence was checked for secrets and personal data."
defp error_message({:error, :identity_changed}),
do: "The repository is no longer confirmed public, so this result cannot be disclosed."
defp error_message({:error, :claim_rate_limited}),
do: "Too many claim changes. Wait a minute and try again."
defp error_message({:error, :unauthorized}), do: "You are not authorized for that action."
defp error_message({:error, _reason}), do: "The action could not be completed."
defp task_meta_description(task) do
repo =
case task.repository do
%{owner: owner, name: name} -> "#{owner}/#{name}"
_ -> "open source"
end
kind = task.kind |> to_string() |> String.replace("_", " ")
status = task.status |> to_string() |> String.replace("_", " ")
desc =
"Open security job on #{repo}: #{task.title}. " <>
"#{String.capitalize(kind)} · #{status}. Claim on Tarakan."
String.slice(String.replace(desc, ~r/\s+/, " "), 0, 160)
end
defp kind_label("code_review"), do: "Code review"
defp kind_label("threat_model"), do: "Threat model"
defp kind_label("privacy_review"), do: "Privacy review"
defp kind_label("business_logic"), do: "Business logic"
defp kind_label("verify_findings"), do: "Verify findings"
defp kind_label("write_fix"), do: "Write a fix"
defp provenance_options(task) do
Enum.map(allowed_provenances(task), &{provenance_label(&1), &1})
end
defp agent_primary_path?(%{capability: "agent"}), do: true
defp agent_primary_path?(_task), do: false
defp allowed_provenances(%{capability: "human"}), do: ["human", "hybrid"]
defp allowed_provenances(%{capability: "agent"}), do: ["agent", "hybrid"]
defp allowed_provenances(%{capability: "hybrid"}), do: ["hybrid"]
defp short_sha(sha), do: String.slice(sha, 0, 7)
defp task_status(%{status: "claimed"} = task) do
if ReviewTask.claim_active?(task), do: "Claimed", else: "Open"
end
defp task_status(%{status: "changes_requested"}), do: "Changes requested"
defp task_status(%{status: status}), do: String.capitalize(status)
defp visibility_label("restricted"), do: "Restricted"
defp visibility_label("public_summary"), do: "Public summary"
defp visibility_label("public"), do: "Full evidence public"
defp repository_path(task),
do: TarakanWeb.RepositoryPaths.repository_path(task.repository)
end

View file

@ -0,0 +1,576 @@
<Layouts.app flash={@flash} current_scope={@current_scope} current_path={assigns[:current_path]}>
<Layouts.page>
<.breadcrumbs>
<:crumb navigate={~p"/"}>registry</:crumb>
<:crumb navigate={TarakanWeb.RepositoryPaths.repository_path(@task.repository)}>
{@task.repository.owner}/{@task.repository.name}
</:crumb>
<:crumb>job/{@task.id}</:crumb>
</.breadcrumbs>
<main id="review-task" class="border-2 border-strong">
<header class="border-b-2 border-strong bg-panel px-5 py-5 sm:px-8 sm:py-6">
<div class="flex flex-wrap items-center gap-2 font-mono text-[10px] uppercase tracking-[0.12em] text-ink-faint">
<span id="review-task-status" class="border border-rule px-2 py-1 text-ink-muted">
{task_status(@task)}
</span>
<span
:if={@task.linked_review_id && @task.linked_review}
id="review-task-linked-review"
class="border border-phosphor/40 px-2 py-1 text-phosphor"
>
Review #{@task.linked_review.id} · {@task.linked_review.findings_count} findings · {@task.linked_review.review_status}
</span>
<span
:if={
@task.status in ["submitted", "accepted"] and is_nil(@task.linked_review_id) and
@task.kind != "write_fix"
}
id="review-task-historical-badge"
class="border border-rule px-2 py-1 text-ink-faint"
>
Historical notes only - no structured Review
</span>
<span
:if={@task.status in ["submitted", "accepted"] and @task.kind == "write_fix"}
id="review-task-fix-badge"
class="border border-phosphor/40 px-2 py-1 text-phosphor"
>
Patch proposal
</span>
<span
:if={@task.status == "accepted"}
id="review-task-visibility"
class="border border-rule px-2 py-1 text-ink-muted"
>
{visibility_label(@task.visibility)}
</span>
<span>{kind_label(@task.kind)}</span>
<span>·</span>
<span>{provenance_label(@task.capability)} required</span>
<span>·</span>
<span title={@task.commit_sha}>{short_sha(@task.commit_sha)}</span>
</div>
<h1
id="review-task-title"
class="mt-3 font-display text-2xl uppercase leading-tight tracking-[0.02em] text-ink sm:text-3xl"
>
{@task.title}
</h1>
<p
:if={String.trim(@task.description || "") != ""}
class="mt-3 max-w-3xl whitespace-pre-line text-sm leading-6 text-ink-muted"
phx-no-format
>{String.trim(@task.description)}</p>
<p class="mt-3 font-mono text-[11px] text-ink-faint">
Opened by <span class="text-ink-muted">@{@task.created_by.handle}</span>
· commit <span title={@task.commit_sha}>{short_sha(@task.commit_sha)}</span>
</p>
</header>
<section
:if={@task.kind == "verify_findings"}
id="review-task-target-report"
class="border-b border-rule bg-ground px-5 py-5 sm:px-8"
>
<div class="flex flex-wrap items-center gap-2 font-mono text-[10px] uppercase tracking-[0.12em] text-ink-faint">
<span>Target report #{@task.target_review_id}</span>
<%= if @visible_target_review do %>
<span>·</span>
<span>{@visible_target_review.findings_count} findings</span>
<span>·</span>
<span>{@visible_target_review.review_status}</span>
<span>·</span>
<span title={@visible_target_review.commit_sha}>{short_sha(
@visible_target_review.commit_sha
)}</span>
<% end %>
</div>
<p class="mt-2 text-sm leading-6 text-ink-muted">
Reproduce or dispute every visible finding against the target report's exact commit.
</p>
<ul
:if={@visible_target_review && @visible_target_review.details_visible}
class="mt-4 space-y-2"
>
<li
:for={finding <- @visible_target_review.findings}
class="border-l-2 border-rule pl-3 text-xs leading-5 text-ink-muted"
>
<span class="font-mono text-[10px] uppercase text-ink-faint">
{finding.severity} · {finding.file_path}:{finding.line_start}
</span>
<span class="ml-2 font-semibold text-ink">{finding.title}</span>
</li>
</ul>
<p
:if={is_nil(@visible_target_review) || !@visible_target_review.details_visible}
class="mt-3 text-xs leading-5 text-ink-faint"
>
Finding details are restricted until an authorized contributor claims the check.
</p>
<div
:if={@visible_target_review && @visible_target_review.confirmations != []}
id="review-task-target-confirmations"
class="mt-5 border-t border-rule pt-4"
>
<h3 class="text-sm font-semibold text-ink">
Recorded checks
</h3>
<article
:for={confirmation <- @visible_target_review.confirmations}
class="mt-3 border-l-2 border-phosphor pl-4"
>
<p class="font-mono text-[11px] text-ink-muted">
{confirmation.verdict} · {provenance_label(confirmation.provenance)}
<span :if={confirmation.account}> · @{confirmation.account.handle}</span>
</p>
<p
:if={confirmation.notes}
class="mt-2 whitespace-pre-line text-xs leading-5 text-ink-muted"
phx-no-format
>{confirmation.notes}</p>
<pre
:if={confirmation.evidence}
class="mt-2 overflow-x-auto whitespace-pre-wrap border border-rule bg-panel p-3 font-mono text-[11px] leading-5 text-ink"
>{confirmation.evidence}</pre>
</article>
</div>
</section>
<section
:if={@task.status == "proposed"}
id="review-task-proposal"
class="px-5 py-6 sm:px-8"
>
<%= cond do %>
<% can_publish?(@task, @current_scope) -> %>
<p class="mb-5 max-w-2xl text-sm leading-6 text-ink-muted">
Confirm the scope is safe and useful before it enters the public job queue.
Stewards and moderators who open jobs from a repository page usually skip this step -
it auto-publishes with a default reason.
</p>
<.form for={@decision_form} id="publish-review-task-form" phx-submit="publish">
<.input
field={@decision_form[:reason]}
type="textarea"
label="Approval reason (pre-filled - edit if you want)"
placeholder="Why is this job safe, scoped, and useful?"
/>
<button
id="publish-review-task-button"
type="submit"
class="clip-notch mt-5 bg-btn px-5 py-2.5 font-display text-xs uppercase tracking-[0.12em] text-btn-fg transition hover:opacity-90"
>
Publish job
</button>
</.form>
<% created_by_current_account?(@task, @current_scope) -> %>
<p id="review-task-proposed-notice" class="text-sm text-ink-muted">
Awaiting a steward or moderator to publish this job.
</p>
<% true -> %>
<p class="text-sm text-ink-muted">
This proposal is awaiting a steward or moderator.
</p>
<% end %>
</section>
<section
:if={@task.status in ["open", "claimed", "changes_requested"]}
id="review-task-actions"
class="px-5 py-6 sm:px-8"
>
<%= cond do %>
<% owns_claim?(@task, @current_scope) -> %>
<div class="mb-6 flex flex-wrap items-center justify-between gap-3 border-b border-rule pb-5">
<p class="text-sm text-ink-muted">
You hold this job until <span class="font-mono text-ink">{Calendar.strftime(@task.claim_expires_at, "%H:%M UTC")}</span>.
</p>
<button
id="release-review-task-button"
phx-click="release"
class="font-mono text-xs text-ink-faint transition hover:text-signal"
>
Release claim
</button>
</div>
<%= cond do %>
<% @task.kind == "verify_findings" and agent_primary_path?(@task) -> %>
<div id="review-task-agent-path" class="space-y-4">
<p class="text-sm leading-6 text-ink-muted">
Run an independent agent check against every finding in the target report.
</p>
<div class="border border-strong bg-panel px-4 py-4 font-mono text-xs leading-6 text-ink">
<p class="text-xs font-semibold text-ink-muted">CLI</p>
<pre class="mt-2 overflow-x-auto whitespace-pre-wrap">tarakan worker --agent grok --once --jobs-only</pre>
</div>
</div>
<% @task.kind == "write_fix" and agent_primary_path?(@task) -> %>
<div id="review-task-agent-path" class="space-y-4">
<p class="text-sm leading-6 text-ink-muted">
The autonomous worker will produce a reviewable unified diff and test plan without modifying the repository.
</p>
<div class="border border-strong bg-panel px-4 py-4 font-mono text-xs leading-6 text-ink">
<p class="text-xs font-semibold text-ink-muted">CLI</p>
<pre class="mt-2 overflow-x-auto whitespace-pre-wrap">tarakan worker --agent grok --once --jobs-only</pre>
</div>
</div>
<% agent_primary_path?(@task) -> %>
<div id="review-task-agent-path" class="space-y-4">
<p class="text-sm leading-6 text-ink-muted">
This job requires <span class="font-semibold text-ink">agent</span> work.
</p>
<div class="border border-strong bg-panel px-4 py-4 font-mono text-xs leading-6 text-ink">
<p class="text-xs font-semibold text-ink-muted">CLI</p>
<pre class="mt-2 overflow-x-auto whitespace-pre-wrap">tarakan report --agent grok --job {@task.id} --yes
# or: tarakan report --agent grok --job {@task.id} --interactive</pre>
</div>
<p class="text-xs leading-5 text-ink-faint">
Agents: <span class="font-mono text-ink">grok</span>, <span class="font-mono text-ink">codex</span>, <span class="font-mono text-ink">claude</span>, <span class="font-mono text-ink">kimi</span>.
</p>
</div>
<% @task.kind == "verify_findings" -> %>
<.form
for={@verification_form}
id="review-task-verification-form"
phx-submit="complete_verification"
>
<div class="grid gap-4">
<.input
field={@verification_form[:provenance]}
type="select"
label="How was this check produced?"
options={@provenance_options}
/>
<.input
field={@verification_form[:verdict]}
type="select"
label="Overall check"
options={[{"Confirmed", "confirmed"}, {"Disputed", "disputed"}]}
/>
<.input
field={@verification_form[:notes]}
type="textarea"
label="Check notes"
placeholder="Explain what was reproduced or why the report is incorrect."
/>
<.input
field={@verification_form[:evidence]}
type="textarea"
label="Proof of concept or counter-evidence"
placeholder="Commands, exact traces, observed output, and relevant file:line references"
/>
</div>
<button
id="complete-review-task-button"
type="submit"
class="clip-notch mt-5 bg-btn px-5 py-2.5 font-display text-xs uppercase tracking-[0.12em] text-btn-fg transition hover:opacity-90 phx-submit-loading:opacity-60"
>
Submit check
</button>
</.form>
<% true -> %>
<.form
for={@contribution_form}
id="review-task-completion-form"
phx-change="validate_contribution"
phx-submit="complete"
>
<div class="grid gap-4">
<.input
field={@contribution_form[:provenance]}
type="select"
label="How was this work produced?"
options={@provenance_options}
/>
<.input
field={@contribution_form[:summary]}
type="textarea"
label={
if @task.kind == "write_fix", do: "Fix summary", else: "Result summary"
}
placeholder={
if @task.kind == "write_fix",
do: "What does the proposed patch change and why?",
else: "What did you establish, reproduce, or refute?"
}
/>
<.input
field={@contribution_form[:evidence]}
type="textarea"
label={
if @task.kind == "write_fix",
do: "Proposed unified diff and test plan",
else: "Evidence and reproduction notes"
}
placeholder={
if @task.kind == "write_fix",
do: "Paste a unified diff followed by tests that should prove the fix",
else:
"File paths, test commands, observations, and remaining uncertainty"
}
/>
</div>
<button
id="complete-review-task-button"
type="submit"
class="clip-notch mt-5 bg-btn px-5 py-2.5 font-display text-xs uppercase tracking-[0.12em] text-btn-fg transition hover:opacity-90 phx-submit-loading:opacity-60"
>
Submit job result
</button>
</.form>
<% end %>
<% can_claim?(@task, @current_scope) -> %>
<p class="max-w-2xl text-sm leading-6 text-ink-muted">
Claiming reserves this job for two hours. Creators may claim and perform their own Jobs.
</p>
<button
id="claim-review-task-button"
phx-click="claim"
class="clip-notch mt-4 bg-btn px-5 py-2.5 font-display text-xs uppercase tracking-[0.12em] text-btn-fg transition hover:opacity-90"
>
Claim job
</button>
<% @task.claimed_by && ReviewTask.claim_active?(@task) -> %>
<p id="review-task-claimed-notice" class="text-sm text-ink-muted">
Claimed by <span class="font-semibold text-ink">@{@task.claimed_by.handle}</span>.
</p>
<% true -> %>
<.link
navigate={~p"/accounts/log-in"}
class="font-mono text-xs text-signal hover:underline"
>
Sign in to claim this task.
</.link>
<% end %>
</section>
<section
:if={@task.status == "submitted"}
id="review-task-review"
class="border-t border-rule px-5 py-6 sm:px-8"
>
<%= if can_review?(@task, @current_scope) do %>
<h2 class="font-display text-xl uppercase tracking-[0.02em] text-ink">
Independent review
</h2>
<.form
for={@decision_form}
id="review-task-decision-form"
phx-submit="review"
class="mt-5"
>
<div class="grid gap-4">
<.input field={@decision_form[:reason]} type="textarea" label="Decision reason" />
<.input
field={@decision_form[:evidence]}
type="textarea"
label="Independent evidence"
/>
</div>
<div class="mt-5 flex flex-wrap gap-3">
<button
id="accept-review-task-button"
type="submit"
name="action"
value="accept"
class="clip-notch bg-btn px-4 py-2 font-display text-xs uppercase tracking-[0.12em] text-btn-fg"
>
Close request
</button>
<button
id="request-changes-review-task-button"
type="submit"
name="action"
value="request_changes"
class="border border-rule px-4 py-2 font-display text-xs uppercase tracking-[0.12em] text-ink"
>
Request changes
</button>
<button
id="reject-review-task-button"
type="submit"
name="action"
value="reject"
class="border border-signal px-4 py-2 font-display text-xs uppercase tracking-[0.12em] text-signal"
>
Reject
</button>
</div>
</.form>
<% else %>
<p id="review-task-review-pending" class="text-sm text-ink-muted">
Evidence submitted. Awaiting independent review.
</p>
<% end %>
</section>
<section
:if={@task.status == "accepted"}
id="review-task-disclosure"
class="border-t border-rule px-5 py-6 sm:px-8"
>
<%= if can_disclose?(@task, @current_scope) do %>
<h2 class="font-display text-xl uppercase tracking-[0.02em] text-ink">
Disclosure decision
</h2>
<.form
for={@disclosure_form}
id="review-task-disclosure-form"
phx-submit="disclose"
class="mt-5"
>
<.input
field={@disclosure_form[:reason]}
type="textarea"
label="Disclosure reason"
placeholder="Why is this result safe and useful to disclose?"
/>
<div :if={full_disclosure_allowed?(@task)} class="mt-4">
<.input
field={@disclosure_form[:sensitive_data_reviewed]}
type="checkbox"
label="I checked the evidence for secrets, credentials, and personal data"
/>
</div>
<div class="mt-5 flex flex-wrap gap-3">
<button
id="publish-review-task-summary-button"
type="submit"
name="visibility"
value="public_summary"
class="clip-notch bg-btn px-4 py-2 font-display text-xs uppercase tracking-[0.12em] text-btn-fg"
>
Publish summary
</button>
<button
:if={full_disclosure_allowed?(@task)}
id="publish-review-task-full-button"
type="submit"
name="visibility"
value="public"
class="border border-rule px-4 py-2 font-display text-xs uppercase tracking-[0.12em] text-ink"
>
Publish full evidence
</button>
</div>
</.form>
<% else %>
<p id="review-task-disclosure-pending" class="text-sm text-ink-muted">
<%= if @task.visibility == "restricted" do %>
Accepted. Awaiting a disclosure decision.
<% else %>
This result has an attributable disclosure decision.
<% end %>
</p>
<% end %>
</section>
<section
:if={@task.linked_review}
id="review-task-linked-review-detail"
class="border-t border-rule px-5 py-6 sm:px-8 sm:py-8"
>
<div class="flex flex-wrap items-center gap-2 font-mono text-[10px] uppercase tracking-[0.12em] text-ink-faint">
<span>Linked Review #{@task.linked_review.id}</span>
<span>·</span>
<span>{@task.linked_review.review_status}</span>
<span>·</span>
<span>{provenance_label(@task.linked_review.provenance)}</span>
</div>
<h2 class="mt-5 font-display text-xl uppercase tracking-[0.02em] text-ink">
Structured findings
</h2>
<p class="mt-2 text-sm text-ink-muted">
Request submitted. The linked report remains separately {@task.linked_review.review_status} until its findings are independently checked - closing this request does not accept the report.
</p>
<ul class="mt-5 space-y-3">
<li
:for={finding <- @task.linked_review.findings || []}
class="border border-rule px-4 py-3"
>
<div class="font-mono text-[10px] uppercase tracking-[0.12em] text-ink-faint">
{finding.severity} · {finding.file_path}:{finding.line_start}
</div>
<div class="mt-1 text-sm font-semibold text-ink">{finding.title}</div>
<p class="mt-1 text-xs leading-5 text-ink-muted">{finding.description}</p>
</li>
</ul>
</section>
<section
:if={@task.contribution}
id="review-task-contribution"
class="border-t border-rule px-5 py-6 sm:px-8 sm:py-8"
>
<div class="flex flex-wrap items-center gap-2 font-mono text-[10px] uppercase tracking-[0.12em] text-ink-faint">
<span>
<%= cond do %>
<% @task.kind == "write_fix" -> %>
Patch proposal v{@task.contribution.version} by @{@task.contribution.account.handle}
<% @task.linked_review_id -> %>
Notes v{@task.contribution.version} by @{@task.contribution.account.handle}
<% true -> %>
Historical notes v{@task.contribution.version} by @{@task.contribution.account.handle}
<% end %>
</span>
<span>·</span>
<span>{provenance_label(@task.contribution.provenance)}</span>
</div>
<h2 class="mt-5 font-display text-xl uppercase tracking-[0.02em] text-ink">
<%= cond do %>
<% @task.kind == "write_fix" -> %>
Proposed fix
<% @task.linked_review_id -> %>
Result
<% true -> %>
Historical notes
<% end %>
</h2>
<p
:if={is_nil(@task.linked_review_id) and @task.kind != "write_fix"}
class="mt-2 text-xs text-ink-faint"
>
Free-text evidence only - no Findings on this submission.
</p>
<p :if={@task.kind == "write_fix"} class="mt-2 text-xs text-ink-faint">
Reviewable patch artifact only - Tarakan did not modify or push to the repository.
</p>
<p class="mt-3 whitespace-pre-line text-sm leading-6 text-ink-muted" phx-no-format>{@task.contribution.summary}</p>
<div :if={@task.contribution.evidence} class="mt-6 border-l-2 border-rule pl-5">
<h3 class="text-sm font-semibold text-ink">
{if @task.kind == "write_fix", do: "Proposed patch and tests", else: "Evidence"}
</h3>
<pre
class="mt-2 overflow-x-auto whitespace-pre-wrap font-mono text-xs leading-6 text-ink-muted"
phx-no-format
>{@task.contribution.evidence}</pre>
</div>
</section>
<section
:if={can_cancel?(@task, @current_scope)}
id="review-task-cancellation"
class="border-t border-rule px-5 py-6 sm:px-8"
>
<.form for={@decision_form} id="cancel-review-task-form" phx-submit="cancel">
<.input
field={@decision_form[:reason]}
type="textarea"
label="Cancellation reason"
placeholder="Explain why this work should be withdrawn."
/>
<button
id="cancel-review-task-button"
type="submit"
class="mt-4 font-mono text-xs text-signal hover:underline"
>
Cancel task
</button>
</.form>
</section>
</main>
</Layouts.page>
</Layouts.app>