Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
@@ -1,6 +1,18 @@
# Facility platform — development environment
# Copy to .env and adjust. NEVER commit .env.

# --- Web (apps/web) ---
# Avatar delivery for assignees and the signed-in topbar. "proxy" (default)
# serves every avatar from the web app's own /api/avatars routes: browsers
# never contact GitHub, and no referrer or origin leaks to it. "off" draws
# initial letters only — zero remote requests. There is no browser-direct
# mode; deployments that must not reach GitHub at all can additionally
# firewall the server's egress, and avatars degrade to letters.
# In "proxy" mode the routes serve signed-in viewers only, cache what they
# fetch, and cap how much any one viewer can fetch, so the deployment's
# outbound avatar traffic does not grow with inbound request volume.
NEXT_PUBLIC_FACILITY_AVATARS=proxy

# --- Core ---
DATABASE_URL=postgres://facility:facility@localhost:5461/facility
# 32 bytes, base64. Generate: openssl rand -base64 32
Expand Down
11 changes: 11 additions & 0 deletions apps/docs/docs/self-host/production.md
Original file line number Diff line number Diff line change
Expand Up @@ -174,6 +174,17 @@ order, and end-to-end verification.
- Gateway reachable from sandboxes and CI only (it holds no read endpoints,
but it is the money path).
- Sandboxes on an isolated network segment; egress per profile.
- Avatars never load from third-party hosts in the browser: they are proxied
through this deployment's `/api/avatars` routes, and the server-side fetch
carries no cookies or referrer. Set `NEXT_PUBLIC_FACILITY_AVATARS=off` to
draw initial letters only; a deployment whose server egress to GitHub is
firewalled degrades to letters on its own.
- The avatar routes need a session the control plane recognises, so nobody who
can merely reach the deployment can make it call GitHub. Answers are cached
in the web process, and each signed-in viewer has a ceiling on how many
avatars they can make it fetch, so the traffic those routes send to GitHub
is bounded by the people who have accounts rather than by request volume.
Both bounds are per web process; run more than one and each holds its own.
- Backups: Postgres PITR + object-store lifecycle; audit retention per your
compliance window.
- Keep `node packages/cli/bin/facility.mjs doctor --url https://<api-host> --key
Expand Down
19 changes: 17 additions & 2 deletions apps/web/app/(app)/projects/[projectId]/stories/[number]/page.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { Eyebrow, PillTag, StatusDot } from "@facility/ui";
import { Avatar, Eyebrow, PillTag, StatusDot } from "@facility/ui";
import Link from "next/link";
import { notFound } from "next/navigation";
import { CiStatusLink } from "@/components/ci-status";
Expand All @@ -9,7 +9,8 @@ import { PullRequestLinks } from "@/components/story/pull-request-links";
import { StoryTimeline } from "@/components/story/timeline";
import { StoryTriggerButtons } from "@/components/story/trigger-buttons";
import { api } from "@/lib/api";
import { pipelineStories } from "@/lib/pipeline";
import { avatarSrcFor } from "@/lib/avatar-policy";
import { avatarInitial, pipelineStories, storyOwner } from "@/lib/pipeline";
import {
detachablePullRequests,
linkableIssues,
Expand Down Expand Up @@ -103,6 +104,7 @@ export default async function StoryPage({
stageLabels,
});
const stage = story.stage;
const owner = storyOwner(story.assignees);

const prLinks = new Map<number, string>();
for (const pr of story.prs) prLinks.set(pr.number, pr.url);
Expand Down Expand Up @@ -167,6 +169,19 @@ export default async function StoryPage({
{label}
</span>
))}
{owner ? (
<span className="inline-flex items-center gap-1.5 font-mono text-[11px] text-(--dim)">
<Avatar
size={16}
src={avatarSrcFor(owner.login) ?? undefined}
initial={avatarInitial(owner.login)}
/>
<span>
@{owner.login}
{owner.extra > 0 ? ` +${owner.extra}` : ""}
</span>
</span>
) : null}
<a
href={story.htmlUrl}
target="_blank"
Expand Down
169 changes: 116 additions & 53 deletions apps/web/app/(app)/projects/[projectId]/stories/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,14 @@ import { StageSection } from "@/components/project/stage-section";
import { LiveRefresh } from "@/components/shell/live-refresh";
import { api } from "@/lib/api";
import type { PipelineStageKey, PipelineStageKind, PipelineStageState } from "@/lib/pipeline";
import { pipelineStageStateLabel, pipelineStories } from "@/lib/pipeline";
import {
boardHref,
type MeOutcome,
mineFilterState,
ownedBy,
pipelineStageStateLabel,
pipelineStories,
} from "@/lib/pipeline";

export const metadata = { title: "stories" };

Expand All @@ -35,32 +42,53 @@ export default async function ProjectStoriesPage({
searchParams,
}: {
params: Promise<{ projectId: string }>;
searchParams: Promise<{ stage?: string; status?: string }>;
searchParams: Promise<{ stage?: string; status?: string; mine?: string }>;
}) {
const [{ projectId }, { stage, status }] = await Promise.all([params, searchParams]);
const [pipelineResult, me, project] = await Promise.all([
const [{ projectId }, { stage, status, mine }] = await Promise.all([params, searchParams]);
const [pipelineResult, meResult, project] = await Promise.all([
api.pipeline(projectId),
api.me(),
api.project(projectId),
]);

if (!pipelineResult.ok && pipelineResult.offline) return <Offline />;

const permissions = me.ok ? me.data.permissions : [];
// The identity request is allowed to fail independently of the pipeline: a
// partial failure must not silently downgrade `?mine=1` into "show
// everything", so the outcome — not just the login — feeds the filter.
const me: MeOutcome = meResult.ok
? { ok: true, githubLogin: meResult.data.principal.githubLogin }
: { ok: false, message: meResult.message };
const permissions = me.ok && meResult.ok ? meResult.data.permissions : [];
const canTrigger = hasPermission(permissions, "runs:trigger");
const canSync = hasPermission(permissions, "repos:write");
const viewerLogin = me.ok ? me.githubLogin : undefined;
const mineState = mineFilterState(mine, me);
const stages = pipelineResult.ok ? pipelineResult.data.stages : [];
const stageKeys = new Set(stages.map((candidate) => candidate.key));
const activeStage =
stage && stageKeys.has(stage as PipelineStageKey) ? (stage as PipelineStageKey) : null;
// Validated against every story, not just the mine-scoped set, so a status filter
// never silently drops out of the URL when "mine" empties the board.
const items = pipelineResult.ok ? pipelineStories(pipelineResult.data) : [];
const stageStates = new Set(items.map((story) => story.stageState));
const activeStatus =
activeStage && status && stageStates.has(status as PipelineStageState)
? (status as PipelineStageState)
: null;
const counts = [...stages].reverse();
const activeOpenStoryCount = items.filter((story) => story.state === "open").length;
const scoped =
mineState.kind === "on"
? stages.map((s) => ({
...s,
stories: s.stories.filter((story) => ownedBy(story.assignees, mineState.login)),
}))
: stages;
const mineOn = mineState.kind === "on";
const counts = [...scoped].reverse();
const scopedTotal = scoped.reduce((total, s) => total + s.stories.length, 0);
const activeOpenStoryCount = scoped
.flatMap((s) => s.stories)
.filter((story) => story.state === "open").length;

const stageFiltered = activeStage
? counts.filter((candidate) => candidate.key === activeStage)
Expand All @@ -80,6 +108,50 @@ export default async function ProjectStoriesPage({
)
: null;

const boardBody = () => (
<div className="flex flex-col gap-6">
{visibleStages.map((s) => {
const stageItems = s.stories;
return (
<StageSection
key={s.key}
label={s.label}
sub={s.sub}
kind={s.kind}
total={stageItems.length}
liveCount={stageItems.filter((story) => story.runState === "live").length}
failedCount={
stageItems.filter(
(story) => story.runState === "failed" || story.ciState === "failure",
).length
}
defaultOpen={activeStage !== null || s.key !== "shipped"}
>
{stageItems.length === 0 ? (
<p className="border border-(--line) px-5 py-3.5 text-[12.5px] text-(--dim)">
Nothing here right now.
</p>
) : (
<div className="flex flex-col border border-(--line)">
{stageItems.map((story) => (
<IssueRow
key={story.key}
projectId={projectId}
story={story}
canTrigger={canTrigger}
builderPlanRequired={
!project.ok || project.data.builderPlanPolicy === "required"
}
/>
))}
</div>
)}
</StageSection>
);
})}
</div>
);

return (
<div className="flex flex-col gap-8">
<LiveRefresh seconds={30} />
Expand All @@ -99,7 +171,7 @@ export default async function ProjectStoriesPage({

<div className="flex flex-wrap items-center gap-2">
<Link
href={`/projects/${projectId}/stories`}
href={boardHref(projectId, { mine: mineOn })}
className={cx(
"border px-3 py-1.5 text-[12px] font-medium transition-colors",
!activeStage
Expand All @@ -112,7 +184,7 @@ export default async function ProjectStoriesPage({
{counts.map((s) => (
<Link
key={s.key}
href={`/projects/${projectId}/stories?stage=${s.key}`}
href={boardHref(projectId, { stage: s.key, mine: mineOn })}
className={cx(
"inline-flex items-center gap-2 border px-3 py-1.5 text-[12px] font-medium transition-colors",
activeStage === s.key
Expand All @@ -125,10 +197,10 @@ export default async function ProjectStoriesPage({
<span
className={cx(
"font-mono text-[11px]",
s.count > 0 ? FILTER_COUNT_TONE[s.kind] : "text-(--dim)",
s.stories.length > 0 ? FILTER_COUNT_TONE[s.kind] : "text-(--dim)",
)}
>
{s.count}
{s.stories.length}
</span>
</Link>
))}
Expand All @@ -138,7 +210,7 @@ export default async function ProjectStoriesPage({
<span className="inline-flex items-center gap-2 border border-(--line-strong) px-3 py-1.5 text-[12px] font-medium text-(--ink)">
{activeStatusLabel}
<Link
href={`/projects/${projectId}/stories?stage=${activeStage}`}
href={boardHref(projectId, { stage: activeStage, mine: mineOn })}
aria-label="clear status filter"
className="text-(--dim) hover:text-(--ink)"
>
Expand All @@ -147,6 +219,19 @@ export default async function ProjectStoriesPage({
</span>
</>
) : null}
{viewerLogin ? (
<Link
href={boardHref(projectId, { stage: activeStage, status: activeStatus, mine: !mineOn })}
className={cx(
"border px-3 py-1.5 text-[12px] font-medium transition-colors",
mineOn
? "border-(--line-strong) text-(--ink)"
: "border-(--line) text-(--mut) hover:text-(--ink)",
)}
>
mine
</Link>
) : null}
</div>

{!pipelineResult.ok ? (
Expand All @@ -157,53 +242,31 @@ export default async function ProjectStoriesPage({
: `Couldn't load stories — ${pipelineResult.message}`
}
/>
) : mineState.kind === "blocked" ? (
<ErrorNotice
message={`Couldn't apply the "mine" filter — couldn't confirm who you are (${mineState.reason}). Reload to try again.`}
/>
) : mineState.kind === "on" && items.length > 0 && scopedTotal === 0 ? (
<div className="flex flex-col items-start gap-3 border border-(--line) bg-(--bg-subtle) p-8">
<p className="max-w-lg text-sm leading-relaxed text-(--mut)">
Nothing is assigned to{" "}
<span className="font-mono text-[12.5px]">@{mineState.login}</span> right now. Stories
you're assigned to in GitHub will appear here after the next sync.
</p>
<Link
href={boardHref(projectId, { stage: activeStage, status: activeStatus })}
className="border border-(--line-strong) px-3 py-1.5 text-[12px] font-medium text-(--ink) transition-colors hover:bg-(--bg-subtle)"
>
show all stories
</Link>
</div>
) : items.length === 0 ? (
<p className="max-w-lg text-sm leading-relaxed text-(--dim)">
No active stories right now. Closed and merged stories leave Shipped after seven days;
sync refreshes the GitHub mirror.
</p>
) : (
<div className="flex flex-col gap-6">
{visibleStages.map((s) => {
const stageItems = s.stories;
return (
<StageSection
key={s.key}
label={s.label}
sub={s.sub}
kind={s.kind}
total={stageItems.length}
liveCount={stageItems.filter((story) => story.runState === "live").length}
failedCount={
stageItems.filter(
(story) => story.runState === "failed" || story.ciState === "failure",
).length
}
defaultOpen={activeStage !== null || s.key !== "shipped"}
>
{stageItems.length === 0 ? (
<p className="border border-(--line) px-5 py-3.5 text-[12.5px] text-(--dim)">
Nothing here right now.
</p>
) : (
<div className="flex flex-col border border-(--line)">
{stageItems.map((story) => (
<IssueRow
key={story.key}
projectId={projectId}
story={story}
canTrigger={canTrigger}
builderPlanRequired={
!project.ok || project.data.builderPlanPolicy === "required"
}
/>
))}
</div>
)}
</StageSection>
);
})}
</div>
boardBody()
)}
</div>
);
Expand Down
Loading