Public: anyone can read this; the crew can change it.

The /api/v1/user routes move out of src/http/api.ts into src/http/api/user.ts, unchanged #7451

Merged opened this

Check · Review · Land · Live done — Landed and live in production.

Merged

mason/forge:mason/7444-the-api-v1-user-routes-move-out-of-src-h → main 1 commit fork

  • Merged. mason/7444-the-api-v1-user-routes-move-out-of-src-h was merged into main as 9f5f4a3.

Review

Sign in to review this change.
3 files changed +749 −746
src/contract/openapi/shared.ts +1 −1
@@ -51,7 +51,7 @@ export const BROWSE_REFUSALS = {
5151 origin_unreachable: "502: the origin could not be reached",
5252 };
5353
54 /** The refusals every live browse of an origin's code can answer (`sourceFailure` in `http/api.ts`); the connecting user only. */
54 /** The refusals every live browse of an origin's code can answer (`sourceFailure` in `http/api/user.ts`); the connecting user only. */
5555 export const BROWSE_CODE_REFUSALS = {
5656 ...BROWSE_REFUSALS,
5757 not_found: "404: no such origin of yours, or the origin has no such repository, ref or path",
src/http/api.ts +7 −745
@@ -13,7 +13,6 @@ import { homeTruth } from "../system/home_truth.ts";
1313 import { upstreamFacts } from "../actions/upstream_follow.ts";
1414 import { FAMILY_WORDS } from "../actions/family_words.ts";
1515 import { showProvenance } from "../actions/provenance.ts";
16 import { sshCloneUrl } from "../ssh/command.ts";
1716 import {
1817 APP_ALREADY_YOURS,
1918 ORIGIN_UNREACHABLE,
@@ -65,13 +64,9 @@ import {
6564 unsuspendAccount,
6665 verifyAccount,
6766 } from "../actions/accounts.ts";
68 import { resendVerification } from "../actions/verify.ts";
6967 import { PROVE_FIRST } from "../actions/verify_words.ts";
70 import { CAPPED_WORDS } from "../actions/mail.ts";
7168 import { REPORT_LINK_TITLE } from "../actions/report_words.ts";
72 import { VERIFY_WORDS } from "../actions/verify_words.ts";
7369 import { editMessage } from "../actions/edit_words.ts";
74 import { emailVerified } from "../core/verify.ts";
7570 import { readHealth } from "../actions/health.ts";
7671 import { isProbe } from "../kernel/probe.ts";
7772 import { PRIVATE_ADDRESS_REFUSED } from "../kernel/address_words.ts";
@@ -90,23 +85,8 @@ import {
9085 setIssueStateThrough,
9186 } from "../actions/issues.ts";
9287 import {
93 connectOrigin,
94 deleteOriginApp,
95 disconnectOrigin,
96 listOriginApps,
97 listPinned,
98 listRemoteRepos,
99 listUserOrigins,
100 mirrorJobFor,
101 pinRepo,
102 pollOAuthConnect,
10388 reattachMirror,
104 registerOriginApp,
105 startMirrorJob,
106 startOAuthConnect,
107 syncOriginNow,
10889 syncRepoNow,
109 unpinRepo,
11090 ORIGIN_ALREADY_CONNECTED,
11191 } from "../actions/origins.ts";
11292 import {
@@ -116,7 +96,6 @@ import {
11696 import { editFiles, type EditFile } from "../actions/contents.ts";
11797 import {
11898 contribute,
119 forkOrigin,
12099 type ContributeResult,
121100 } from "../actions/contribute.ts";
122101 import {
@@ -150,16 +129,12 @@ import {
150129 updateRepoSettings,
151130 } from "../actions/repos.ts";
152131 import {
153 addSshKey,
154 changePassword,
155132 createToken,
156 deleteSshKey,
157133 deleteToken,
158134 HUMAN_CHECK_MESSAGE,
159135 PASSKEY_REQUIRED_MESSAGE,
160136 signUp,
161137 SIGNUP_CLOSED_MESSAGE,
162 updateProfile,
163138 } from "../actions/users.ts";
164139 import { OFFSET_LIMIT_MAX, offsetPage, offsetQuery } from "./offset_page.ts";
165140 import { EVENTS_LIMIT_MAX, eventsLimit, eventsPage, validEventsCursor } from "../core/events_page.ts";
@@ -184,9 +159,7 @@ import {
184159 type Issue,
185160 type IssueState,
186161 } from "../core/issues.ts";
187 import { listKeys, publicKey } from "../core/keys.ts";
188162 import {
189 listOrgsForUser,
190163 soleOwnedOrgs,
191164 lastOwnerSays,
192165 } from "../core/orgs.ts";
@@ -324,7 +297,6 @@ import { isEnvName } from "../apps/data.ts";
324297 import { getAppByRepo } from "../core/apps.ts";
325298 import { crewRole, isCrew, type Agent } from "../core/agents.ts";
326299 import { listApps, type AppRecord } from "../core/apps.ts";
327 import { listAssignedTo } from "../core/issues.ts";
328300 import { closingFrom } from "../actions/issues.ts";
329301 import {
330302 checkSummary,
@@ -352,11 +324,6 @@ import { inviteLink, list as listInviteLinks, mint as mintInviteLink, revoke as
352324 import { originForRepo, type OriginRepoRow } from "../core/origins.ts";
353325 import { subscribe as subscribeJob, type MirrorJob } from "../origins/jobs.ts";
354326 import type { Origin } from "../origins/types.ts";
355 import {
356 sourceFor,
357 type RepoSource,
358 type SourceError,
359 } from "../view/source.ts";
360327 import { eventStream } from "./sse.ts";
361328 import {
362329 applyFleetOp,
@@ -414,6 +381,7 @@ import { agentRoutes } from "./api/agents.ts";
414381 import { boardRoutes } from "./api/boards.ts";
415382 import { followingRoutes } from "./api/following.ts";
416383 import { labelsRoutes } from "./api/labels.ts";
384 import { userRoutes } from "./api/user.ts";
417385 import { appNoteRoutes } from "./api/app_notes.ts";
418386 import { reportRoutes } from "./api/reports.ts";
419387 import { rollbackRoutes } from "./api/rollback.ts";
@@ -599,7 +567,7 @@ function repoJson(ctx: Ctx, repo: Repo) {
599567 * no origin id and the moment it froze — so a client can never read a
600568 * frozen copy as a quiet upstream. A native repository has none.
601569 */
602 function originFacts(repo: Repo) {
570 export function originFacts(repo: Repo) {
603571 const host = (url: string): string => URL.parse(url)?.host ?? "";
604572 if (repo.origin !== null)
605573 return {
@@ -640,37 +608,8 @@ export function originJson(origin: Origin) {
640608 };
641609 }
642610
643 /**
644 * Where a live browse is reading from — the banner's facts. The origin's
645 * credential and its id are ours; what a client gets is the forge, the name
646 * there, the page there, and whether we already hold a mirror of it.
647 */
648 function whereJson(source: RepoSource) {
649 const where = source.where;
650 if (where.kind === "local")
651 return {
652 source: "local" as const,
653 repo: { owner: where.repo.ownerName, name: where.repo.name },
654 };
655 return {
656 source: "origin" as const,
657 origin: {
658 id: where.origin.id,
659 kind: where.origin.kind,
660 webUrl: where.origin.webUrl,
661 login: where.origin.login,
662 },
663 fullName: where.fullName,
664 htmlUrl: where.htmlUrl,
665 mirroredAs:
666 where.mirroredAs === undefined
667 ? null
668 : { owner: where.mirroredAs.ownerName, name: where.mirroredAs.name },
669 };
670 }
671
672611 /** A mirror job, with the repo shaped like every other repo we return. */
673 function jobJson(ctx: Ctx, job: MirrorJob) {
612 export function jobJson(ctx: Ctx, job: MirrorJob) {
674613 return {
675614 id: job.id,
676615 originId: job.originId,
@@ -718,38 +657,6 @@ export function remoteRepoJson(ctx: Ctx, row: OriginRepoRow) {
718657 };
719658 }
720659
721 /**
722 * A live read that did not happen. `not_found` keeps its ambiguity; the rest
723 * name which side said no, so a client can tell "come back later" from
724 * "reconnect this origin".
725 */
726 function sourceFailure(error: SourceError): Response {
727 switch (error) {
728 case "not_found":
729 return notFound();
730 case "rate_limited":
731 return apiError(
732 "rate_limited",
733 429,
734 "the origin is rate-limiting us; try again shortly",
735 );
736 case "unauthorized":
737 return apiError(
738 "origin_unauthorized",
739 502,
740 "the origin refused this forge's credential; reconnect it",
741 );
742 case "too_large":
743 return apiError("too_large", 413, "that is too large to read here");
744 case "unreachable":
745 return apiError(
746 "origin_unreachable",
747 502,
748 ORIGIN_UNREACHABLE,
749 );
750 }
751 }
752
753660 /**
754661 * An action error from a path that may have crossed an origin. The three
755662 * origin outcomes get codes of their own so a client can tell "the forge
@@ -879,7 +786,7 @@ function reviewLines(raw: unknown): ReviewInput[] | undefined {
879786 return out;
880787 }
881788
882 function editFilesFrom(raw: unknown): EditFile[] {
789 export function editFilesFrom(raw: unknown): EditFile[] {
883790 if (!Array.isArray(raw)) return [];
884791 const files: EditFile[] = [];
885792 for (const entry of raw) {
@@ -909,7 +816,7 @@ function editFilesFrom(raw: unknown): EditFile[] {
909816 * `pull.local` is present only when we also mirror the upstream — the pull
910817 * belongs to the origin either way.
911818 */
912 function contributionJson(ctx: Ctx, result: ContributeResult) {
819 export function contributionJson(ctx: Ctx, result: ContributeResult) {
913820 return {
914821 via: result.via,
915822 repo: { owner: result.fork.ownerName, name: result.fork.name },
@@ -935,7 +842,7 @@ function contributionJson(ctx: Ctx, result: ContributeResult) {
935842 * that failed, the commit that landed anyway); the string ones are the
936843 * ordinary access and origin codes every other route already speaks.
937844 */
938 function contributeFailure(error: unknown): Response {
845 export function contributeFailure(error: unknown): Response {
939846 if (typeof error === "object" && error !== null && "kind" in error) {
940847 const detail = error as {
941848 kind: string;
@@ -1347,73 +1254,7 @@ export function apiRoutes(router: Router<Handler>): void {
13471254 });
13481255 });
13491256
1350 router.add("GET", "/api/v1/user", (ctx) => {
1351 notePhase(ctx.request, "session", takeSessionTime(ctx.request));
1352 const me = ctx.auth.user;
1353 if (!me) return unauthorized();
1354 const verified = timePhase(ctx.request, "user", () => emailVerified(ctx.app.db, me.id));
1355 return timePhase(ctx.request, "render", () => json({ ...publicUser(me), emailVerified: verified }));
1356 });
1357
1358 // Another verification link for the signed-in account; rate limited.
1359 router.add("POST", "/api/v1/user/email/verify", async (ctx) => {
1360 if (!ctx.auth.user) return unauthorized();
1361 const sent = await resendVerification(ctx.app, ctx.auth.user);
1362 if (sent.ok)
1363 return json({ outcome: sent.value }, sent.value === "sent" ? 202 : 200);
1364 if (sent.error === "unauthorized") return unauthorized();
1365 if (sent.error === "too_soon")
1366 return apiError("too_soon", 429, VERIFY_WORDS.too_soon);
1367 if (sent.error === "no_mail")
1368 return apiError("no_mail", 503, VERIFY_WORDS.no_mail);
1369 if (sent.error === "capped")
1370 return apiError("capped", 429, CAPPED_WORDS);
1371 return apiError("failed", 502, VERIFY_WORDS.failed);
1372 });
1373
1374 router.add("PATCH", "/api/v1/user", async (ctx) => {
1375 const denied = requireAccount(ctx, { adminToken: true });
1376 if (denied) return denied;
1377 const input = await body(ctx.request);
1378 if (
1379 input.commitEmail !== undefined &&
1380 input.commitEmail !== "noreply" &&
1381 input.commitEmail !== "account"
1382 )
1383 return json({ error: "invalid_commit_email" }, 422);
1384 const updated = updateProfile(ctx.app, ctx.auth.user, {
1385 ...(typeof input.displayName === "string"
1386 ? { displayName: input.displayName }
1387 : {}),
1388 ...(typeof input.email === "string" ? { email: input.email } : {}),
1389 ...(input.commitEmail === "noreply" || input.commitEmail === "account"
1390 ? { commitEmail: input.commitEmail }
1391 : {}),
1392 });
1393 if (!updated.ok) return respondAccess(updated.error, 422);
1394 return json(publicUser(updated.value));
1395 });
1396
1397 router.add("POST", "/api/v1/user/password", async (ctx) => {
1398 const denied = requireAccount(ctx, { adminToken: true });
1399 if (denied) return denied;
1400 const input = await body(ctx.request);
1401 const changed = await changePassword(ctx.app, ctx.auth.user, {
1402 current: str(input.current),
1403 next: str(input.next),
1404 source: clientAddress(ctx.request),
1405 });
1406 if (!changed.ok)
1407 return respondAccess(
1408 changed.error,
1409 changed.error === "bad_credentials"
1410 ? 403
1411 : changed.error === "throttled"
1412 ? 429
1413 : 422,
1414 );
1415 return json({}, 200);
1416 });
1257 userRoutes(router);
14171258
14181259 // ---- accounts: the owner's view of who joined (#660) ----
14191260 // Hidden from everyone but a forge admin: the door is not there.
@@ -1804,48 +1645,6 @@ export function apiRoutes(router: Router<Handler>): void {
18041645 return done.ok ? json({ removed: ctx.params.username }) : accountError(done.error);
18051646 });
18061647
1807 router.add("GET", "/api/v1/user/orgs", (ctx) => {
1808 notePhase(ctx.request, "session", takeSessionTime(ctx.request));
1809 if (!ctx.auth.user) return unauthorized();
1810 const user = ctx.auth.user;
1811 const window = keyedWindow(ctx);
1812 if (window instanceof Response) return window;
1813 const orgs = timePhase(ctx.request, "orgs", () =>
1814 listOrgsForUser(ctx.app.db, user.id).map(publicProfile),
1815 );
1816 return timePhase(ctx.request, "render", () => json(keyedPage(orgs, (o) => o.username, window)));
1817 });
1818
1819 // SSH keys — what the SSH listener authenticates against. Public keys are
1820 // public, so the list returns them whole.
1821 router.add("GET", "/api/v1/user/keys", (ctx) => {
1822 notePhase(ctx.request, "session", takeSessionTime(ctx.request));
1823 const user = ctx.auth.user;
1824 if (!user) return unauthorized();
1825 const window = keyedWindow(ctx);
1826 if (window instanceof Response) return window;
1827 const keys = timePhase(ctx.request, "keys", () => listKeys(ctx.app.db, user.id));
1828 return timePhase(ctx.request, "render", () => json(keyedPage(keys.map(publicKey), (k) => k.id, window)));
1829 });
1830 router.add("POST", "/api/v1/user/keys", async (ctx) => {
1831 const denied = requireScope(ctx, "rw");
1832 if (denied) return denied;
1833 const input = await body(ctx.request);
1834 const added = addSshKey(ctx.app, ctx.auth.user, {
1835 name: str(input.name),
1836 key: str(input.key),
1837 });
1838 if (!added.ok) return respondAccess(added.error, 422);
1839 return json(publicKey(added.value), 201);
1840 });
1841 router.add("DELETE", "/api/v1/user/keys/:id", (ctx) => {
1842 const denied = requireScope(ctx, "rw");
1843 if (denied) return denied;
1844 const removed = deleteSshKey(ctx.app, ctx.auth.user, ctx.params.id!);
1845 if (!removed.ok) return respondAccess(removed.error, 422);
1846 return new Response(null, { status: 204 });
1847 });
1848
18491648 // Tokens are minted, listed, and revoked with the account itself (a
18501649 // session or the password) — never with another token.
18511650 router.add("POST", "/api/v1/tokens", async (ctx) => {
@@ -4030,454 +3829,6 @@ export function apiRoutes(router: Router<Handler>): void {
40303829
40313830 hookRoutes(router);
40323831
4033 // ── origins ──────────────────────────────────────────────────────────
4034 //
4035 // An origin holds a credential for another forge, so every route here is
4036 // gated on the *account* — a same-origin session, the password, or an
4037 // explicit `admin` token. A leaked `rw` token must never be able to
4038 // enumerate, connect, or spend somebody's GitHub access.
4039
4040 /** The callback URL this forge tells an OAuth app to come back to. */
4041 const callbackUri = (ctx: Ctx): string =>
4042 oauthCallbackUri(ctx.url, ctx.app.config.host);
4043
4044 router.add("GET", "/api/v1/user/origins", (ctx) => {
4045 notePhase(ctx.request, "session", takeSessionTime(ctx.request));
4046 const denied = requireAccount(ctx, { adminToken: true });
4047 if (denied) return denied;
4048 const origins = timePhase(ctx.request, "origins", () => listUserOrigins(ctx.app, ctx.auth.user!).map(originJson));
4049 return timePhase(ctx.request, "render", () => json(origins));
4050 });
4051
4052 router.add("POST", "/api/v1/user/origins", async (ctx) => {
4053 const denied = requireAccount(ctx, { adminToken: true });
4054 if (denied) return denied;
4055 const input = await body(ctx.request);
4056 const kind = str(input.kind);
4057 const connected = await connectOrigin(ctx.app, ctx.auth.user, {
4058 host: str(input.host) || kind,
4059 ...(kind === "github" || kind === "forgejo" || kind === "gitea"
4060 ? { kind }
4061 : {}),
4062 token: str(input.token),
4063 });
4064 if (!connected.ok)
4065 return apiError(
4066 connected.error,
4067 connected.error === "rate_limited" ? 429 : 422,
4068 connectMessage(connected.error),
4069 );
4070 // The frozen mirrors connecting this account brought back, and the ones
4071 // it could not, each with its reason.
4072 return json(
4073 {
4074 ...originJson(connected.value),
4075 reattached: connected.value.reattached,
4076 },
4077 201,
4078 );
4079 });
4080
4081 // OAuth: one start route for both flows, because which flow you get is a
4082 // property of the forge (device on GitHub, code+PKCE on Forgejo) and not
4083 // something a client should have to know.
4084 router.add("POST", "/api/v1/user/origins/oauth", async (ctx) => {
4085 const denied = requireAccount(ctx, { adminToken: true });
4086 if (denied) return denied;
4087 const input = await body(ctx.request);
4088 const kind = str(input.kind);
4089 const started = await startOAuthConnect(ctx.app, ctx.auth.user, {
4090 host: str(input.host) || kind,
4091 ...(kind === "github" || kind === "forgejo" || kind === "gitea"
4092 ? { kind }
4093 : {}),
4094 redirectUri: callbackUri(ctx),
4095 });
4096 if (!started.ok)
4097 return apiError(
4098 started.error.error,
4099 started.error.error === "rate_limited" ? 429 : 422,
4100 started.error.message,
4101 );
4102 return json(started.value, 201);
4103 });
4104
4105 router.add("GET", "/api/v1/user/origins/oauth/:id", async (ctx) => {
4106 const denied = requireAccount(ctx, { adminToken: true });
4107 if (denied) return denied;
4108 notePhase(ctx.request, "session", takeSessionTime(ctx.request));
4109 const status = await timePhaseAsync(ctx.request, "origin", () =>
4110 pollOAuthConnect(ctx.app, ctx.auth.user, ctx.params.id!),
4111 );
4112 if (!status.ok) return respondAccess(status.error, 404);
4113 return json(status.value);
4114 });
4115
4116 // Registered OAuth applications. Every Forgejo instance needs its own, so
4117 // this is a per-user, per-host registration; GitHub can fall back to one
4118 // the operator configured for the whole forge.
4119 router.add("GET", "/api/v1/user/origin-apps", (ctx) => {
4120 notePhase(ctx.request, "session", takeSessionTime(ctx.request));
4121 const denied = requireAccount(ctx, { adminToken: true });
4122 if (denied) return denied;
4123 const apps = timePhase(ctx.request, "apps", () => listOriginApps(ctx.app, ctx.auth.user!));
4124 return timePhase(ctx.request, "render", () => json(apps));
4125 });
4126
4127 router.add("POST", "/api/v1/user/origin-apps", async (ctx) => {
4128 const denied = requireAccount(ctx, { adminToken: true });
4129 if (denied) return denied;
4130 const input = await body(ctx.request);
4131 const kind = str(input.kind);
4132 const saved = await registerOriginApp(ctx.app, ctx.auth.user, {
4133 host: str(input.host) || kind,
4134 ...(kind === "github" || kind === "forgejo" || kind === "gitea"
4135 ? { kind }
4136 : {}),
4137 clientId: str(input.clientId),
4138 ...(typeof input.clientSecret === "string"
4139 ? { clientSecret: input.clientSecret }
4140 : {}),
4141 });
4142 if (!saved.ok)
4143 return apiError(saved.error, 422, connectMessage(saved.error));
4144 return json(saved.value, 201);
4145 });
4146
4147 router.add("DELETE", "/api/v1/user/origin-apps/:id", (ctx) => {
4148 const denied = requireAccount(ctx, { adminToken: true });
4149 if (denied) return denied;
4150 const removed = deleteOriginApp(ctx.app, ctx.auth.user, ctx.params.id!);
4151 if (!removed.ok) return respondAccess(removed.error, 404);
4152 return new Response(null, { status: 204 });
4153 });
4154
4155 router.add("DELETE", "/api/v1/user/origins/:id", async (ctx) => {
4156 const denied = requireAccount(ctx, { adminToken: true });
4157 if (denied) return denied;
4158 const gone = await disconnectOrigin(ctx.app, ctx.auth.user, ctx.params.id!);
4159 if (!gone.ok) return respondAccess(gone.error, 404);
4160 return json(gone.value);
4161 });
4162
4163 router.add("POST", "/api/v1/user/origins/:id/sync", async (ctx) => {
4164 const denied = requireAccount(ctx, { adminToken: true });
4165 if (denied) return denied;
4166 const synced = await syncOriginNow(ctx.app, ctx.auth.user, ctx.params.id!);
4167 if (!synced.ok) return respondAccess(synced.error, 404);
4168 return json(synced.value);
4169 });
4170
4171 router.add("GET", "/api/v1/user/origins/:id/repos", async (ctx) => {
4172 notePhase(ctx.request, "session", takeSessionTime(ctx.request));
4173 const denied = requireAccount(ctx, { adminToken: true });
4174 if (denied) return denied;
4175 const listed = await timePhaseAsync(ctx.request, "origin", () =>
4176 listRemoteRepos(
4177 ctx.app,
4178 ctx.auth.user,
4179 ctx.params.id!,
4180 ctx.url.searchParams.get("refresh") === "1",
4181 ),
4182 );
4183 if (!listed.ok)
4184 return listed.error === "unreachable"
4185 ? apiError("origin_unreachable", 502, ORIGIN_UNREACHABLE)
4186 : respondAccess(listed.error, 404);
4187 return timePhase(ctx.request, "render", () =>
4188 json(listed.value.map((row) => remoteRepoJson(ctx, row))),
4189 );
4190 });
4191
4192 // ── browsing an origin, live ─────────────────────────────────────────
4193 //
4194 // Reading a repository the origin holds and we do not: the same shapes the
4195 // local read routes return, so a client renders one screen either way.
4196 // Everything runs through `sourceFor`, which is where the rule lives that
4197 // only the person who connected an origin may browse through it — the
4198 // request spends their credential on another forge.
4199
4200 const browse = (ctx: Ctx): RepoSource | Response => {
4201 notePhase(ctx.request, "session", takeSessionTime(ctx.request));
4202 const denied = requireAccount(ctx, { adminToken: true });
4203 if (denied) return denied;
4204 const source = sourceFor(ctx.app, ctx.auth.user, {
4205 originId: ctx.params.id!,
4206 fullName: `${ctx.params.owner!}/${ctx.params.name!}`,
4207 });
4208 return source.ok ? source.value : sourceFailure(source.error);
4209 };
4210
4211 /** Run one read against a live source and shape whatever comes back. */
4212 const read = async <T>(
4213 ctx: Ctx,
4214 run: (source: RepoSource) => Promise<Result<T, SourceError>>,
4215 shape: (value: T) => Response,
4216 ): Promise<Response> => {
4217 const source = browse(ctx);
4218 if (source instanceof Response) return source;
4219 const result = await timePhaseAsync(ctx.request, "origin", () => run(source));
4220 return result.ok
4221 ? timePhase(ctx.request, "render", () => shape(result.value))
4222 : sourceFailure(result.error);
4223 };
4224
4225 const BROWSE = "/api/v1/user/origins/:id/browse/:owner/:name";
4226
4227 // The landing read: what a repo page needs before it needs anything else.
4228 router.add("GET", BROWSE, async (ctx) => {
4229 const source = browse(ctx);
4230 if (source instanceof Response) return source;
4231 const facts = await timePhaseAsync(ctx.request, "origin", () => source.describe());
4232 if (!facts.ok) return sourceFailure(facts.error);
4233 const [branches, readme] = await timePhaseAsync(ctx.request, "origin", () =>
4234 Promise.all([source.branches(), source.readme(facts.value.defaultBranch)]),
4235 );
4236 return timePhase(ctx.request, "render", () =>
4237 json({
4238 ...whereJson(source),
4239 capabilities: source.capabilities,
4240 facts: facts.value,
4241 branches: branches.ok ? branches.value : [],
4242 readme: readme.ok ? readme.value : null,
4243 }),
4244 );
4245 });
4246
4247 router.add("GET", `${BROWSE}/branches`, (ctx) =>
4248 read(ctx, (s) => s.branches(), json),
4249 );
4250 router.add("GET", `${BROWSE}/tags`, (ctx) =>
4251 read(ctx, (s) => s.tags(), json),
4252 );
4253 router.add("GET", `${BROWSE}/labels`, (ctx) =>
4254 read(ctx, (s) => s.labels(), json),
4255 );
4256 router.add("GET", `${BROWSE}/paths/:ref`, (ctx) =>
4257 read(ctx, (s) => s.paths(ctx.params.ref!), json),
4258 );
4259
4260 const treeHandler = (ctx: Ctx): Promise<Response> => {
4261 const ref = ctx.params.ref!;
4262 const path = ctx.params.path ?? "";
4263 return read(
4264 ctx,
4265 (s) => s.tree(ref, path),
4266 (entries) => json({ type: "dir", ref, path, entries }),
4267 );
4268 };
4269 router.add("GET", `${BROWSE}/tree/:ref`, treeHandler);
4270 router.add("GET", `${BROWSE}/tree/:ref/*path`, treeHandler);
4271
4272 // Shaped exactly like the local contents endpoint, down to the 413.
4273 router.add("GET", `${BROWSE}/blob/:ref/*path`, (ctx) => {
4274 const ref = ctx.params.ref!;
4275 const path = ctx.params.path ?? "";
4276 return read(
4277 ctx,
4278 (s) => s.blob(ref, path),
4279 (blob) => {
4280 if (blob.kind === "too_large")
4281 return json({ type: "file", tooLarge: true, size: blob.size }, 413);
4282 const binary = blob.kind === "binary";
4283 return json({
4284 type: "file",
4285 ref,
4286 path,
4287 size: blob.size,
4288 encoding: binary ? "base64" : "utf-8",
4289 content: binary
4290 ? Buffer.from(blob.body).toString("base64")
4291 : new TextDecoder().decode(blob.body),
4292 });
4293 },
4294 );
4295 });
4296
4297 router.add("GET", `${BROWSE}/commits`, async (ctx) => {
4298 const source = browse(ctx);
4299 if (source instanceof Response) return source;
4300 // Only ask what the default branch is when the caller did not say — on
4301 // a live source that question is a request to another forge.
4302 let ref = ctx.url.searchParams.get("ref") ?? "";
4303 if (ref === "") {
4304 const facts = await timePhaseAsync(ctx.request, "origin", () => source.describe());
4305 if (!facts.ok) return sourceFailure(facts.error);
4306 ref = facts.value.defaultBranch || "HEAD";
4307 }
4308 const cursor = ctx.url.searchParams.get("cursor") ?? undefined;
4309 const page = await timePhaseAsync(ctx.request, "origin", () => source.commits(ref, cursor));
4310 if (!page.ok) return sourceFailure(page.error);
4311 return timePhase(ctx.request, "render", () =>
4312 json({ items: page.value.items, next_cursor: page.value.next }),
4313 );
4314 });
4315
4316 router.add("GET", `${BROWSE}/commits/:oid`, (ctx) => {
4317 const oid = ctx.params.oid!;
4318 return read(
4319 ctx,
4320 (s) => s.commit(oid),
4321 ({ commit, diff }) => json({ ...commit, diff }),
4322 );
4323 });
4324
4325 const conversations = (kind: "issue" | "pull") => async (ctx: Ctx) => {
4326 const q = ctx.url.searchParams;
4327 const stateParam = q.get("state");
4328 const label = q.get("label");
4329 const author = q.get("author");
4330 const search = q.get("q");
4331 const cursor = q.get("cursor");
4332 return read(
4333 ctx,
4334 (s) =>
4335 s.issues({
4336 kind,
4337 state:
4338 stateParam === "closed" || stateParam === "all"
4339 ? stateParam
4340 : "open",
4341 ...(label ? { label } : {}),
4342 ...(author ? { author } : {}),
4343 ...(search ? { query: search } : {}),
4344 ...(cursor ? { cursor } : {}),
4345 }),
4346 (page) => json({ items: page.items, next_cursor: page.next }),
4347 );
4348 };
4349 router.add("GET", `${BROWSE}/issues`, conversations("issue"));
4350 router.add("GET", `${BROWSE}/pulls`, conversations("pull"));
4351
4352 const conversation = (ctx: Ctx): Promise<Response> => {
4353 const number = Number(ctx.params.number);
4354 return read(
4355 ctx,
4356 (s) => s.issue(number),
4357 (detail) =>
4358 json({
4359 ...detail.issue,
4360 body: detail.body,
4361 comments: detail.comments,
4362 pull: detail.pull,
4363 }),
4364 );
4365 };
4366 router.add("GET", `${BROWSE}/issues/:number`, conversation);
4367 router.add("GET", `${BROWSE}/pulls/:number`, conversation);
4368
4369 // ── mirroring, as a job ──────────────────────────────────────────────
4370 //
4371 // 202, not 201: the repository does not exist yet. What comes back is
4372 // something to watch — poll it, or take the stream and render the stages.
4373
4374 router.add("POST", "/api/v1/user/origins/:id/mirror", async (ctx) => {
4375 const denied = requireAccount(ctx, { adminToken: true });
4376 if (denied) return denied;
4377 const input = await body(ctx.request);
4378 const started = startMirrorJob(
4379 ctx.app,
4380 ctx.auth.user,
4381 ctx.params.id!,
4382 str(input.fullName),
4383 str(input.name) || undefined,
4384 );
4385 if (!started.ok) return respondAccess(started.error, 404);
4386 return json({ job: jobJson(ctx, started.value) }, 202);
4387 });
4388
4389 // Fork at the origin. 202, like mirroring: the fork is asynchronous on
4390 // GitHub, and the local copy of it is a job to watch.
4391 router.add("POST", "/api/v1/user/origins/:id/fork", async (ctx) => {
4392 const denied = requireAccount(ctx, { adminToken: true });
4393 if (denied) return denied;
4394 const input = await body(ctx.request);
4395 const forked = await forkOrigin(
4396 ctx.app,
4397 ctx.auth.user,
4398 ctx.params.id!,
4399 str(input.fullName),
4400 );
4401 if (!forked.ok) return contributeFailure(forked.error);
4402 return json(
4403 {
4404 remote: forked.value.remote,
4405 existed: forked.value.existed,
4406 job:
4407 forked.value.job === undefined
4408 ? null
4409 : jobJson(ctx, forked.value.job),
4410 mirroredAs:
4411 forked.value.mirror === undefined
4412 ? null
4413 : {
4414 owner: forked.value.mirror.ownerName,
4415 name: forked.value.mirror.name,
4416 },
4417 },
4418 202,
4419 );
4420 });
4421
4422 // Contribute to a repository we are only *browsing* — the fork flow
4423 // straight from the picker, with no mirror of the upstream first.
4424 router.add("POST", `${BROWSE}/contribute`, async (ctx) => {
4425 const denied = requireAccount(ctx, { adminToken: true });
4426 if (denied) return denied;
4427 const input = await body(ctx.request);
4428 const result = await contribute(ctx.app, ctx.auth.user, {
4429 originId: ctx.params.id!,
4430 fullName: `${ctx.params.owner!}/${ctx.params.name!}`,
4431 branch: str(input.branch),
4432 ...(str(input.base) === "" ? {} : { base: str(input.base) }),
4433 ...(str(input.message) === "" ? {} : { message: str(input.message) }),
4434 files: editFilesFrom(input.files),
4435 ...(typeof input.openPull === "object" && input.openPull !== null
4436 ? {
4437 openPull: {
4438 title: str((input.openPull as Record<string, unknown>).title),
4439 body: str((input.openPull as Record<string, unknown>).body),
4440 },
4441 }
4442 : {}),
4443 });
4444 if (!result.ok) return contributeFailure(result.error);
4445 return json(contributionJson(ctx, result.value), 201);
4446 });
4447
4448 router.add("GET", "/api/v1/user/origins/:id/mirror/:job", (ctx) => {
4449 notePhase(ctx.request, "session", takeSessionTime(ctx.request));
4450 const denied = requireAccount(ctx, { adminToken: true });
4451 if (denied) return denied;
4452 const job = timePhase(ctx.request, "job", () =>
4453 mirrorJobFor(ctx.app, ctx.auth.user, ctx.params.id!, ctx.params.job!),
4454 );
4455 if (!job.ok) return respondAccess(job.error, 404);
4456 return timePhase(ctx.request, "render", () =>
4457 json({ job: jobJson(ctx, job.value) }),
4458 );
4459 });
4460
4461 // The same job as a live stream, so a mirror can be watched rather than
4462 // polled. It ends itself the moment the job reaches a terminal stage.
4463 router.add("GET", "/api/v1/user/origins/:id/mirror/:job/stream", (ctx) => {
4464 const denied = requireAccount(ctx, { adminToken: true });
4465 if (denied) return denied;
4466 const found = mirrorJobFor(
4467 ctx.app,
4468 ctx.auth.user,
4469 ctx.params.id!,
4470 ctx.params.job!,
4471 );
4472 if (!found.ok) return respondAccess(found.error, 404);
4473 return eventStream(ctx.app, ctx.request, (emit, close) =>
4474 subscribeJob(found.value.id, (job) => {
4475 emit(`mirror.${job.stage}`, jobJson(ctx, job), job.id);
4476 if (job.stage === "done" || job.stage === "failed") close();
4477 }),
4478 );
4479 });
4480
44813832 // Sync one mirror, or let a fork take its parent's updates. A mirror only
44823833 // pulls, so any writer may ask. A fork fast-forwards, or writes a merge
44833834 // commit authored as the caller when it has work of its own and the merge
@@ -4566,58 +3917,6 @@ export function apiRoutes(router: Router<Handler>): void {
45663917 return json(op.value, 201);
45673918 });
45683919
4569 // ── pins ─────────────────────────────────────────────────────────────
4570
4571 // The local client clones from this list, so each pin carries what a
4572 // clone needs: both URLs and where the repo came from.
4573 router.add("GET", "/api/v1/user/pins", (ctx) => {
4574 if (!ctx.auth.user) return unauthorized();
4575 notePhase(ctx.request, "session", takeSessionTime(ctx.request));
4576 const window = keyedWindow(ctx);
4577 if (window instanceof Response) return window;
4578 const user = ctx.auth.user;
4579 const { pins, repos } = timePhase(ctx.request, "pins", () => {
4580 const pins = listPinned(ctx.app, user);
4581 // One query for every pinned repo, not one per pin.
4582 const repos = getReposByIds(
4583 ctx.app.db,
4584 pins.map((pin) => pin.repoId),
4585 );
4586 return { pins, repos };
4587 });
4588 return timePhase(ctx.request, "render", () => {
4589 const items = pins.flatMap((pin) => {
4590 const repo = repos.get(pin.repoId);
4591 if (!repo) return [];
4592 const path = `${encodeURIComponent(repo.ownerName)}/${encodeURIComponent(repo.name)}`;
4593 return [
4594 {
4595 repoId: repo.id,
4596 owner: repo.ownerName,
4597 name: repo.name,
4598 defaultBranch: repo.defaultBranch,
4599 cloneUrl: `${ctx.url.origin}/${path}.git`,
4600 sshUrl:
4601 sshCloneUrl(ctx.app, repo.ownerName, repo.name) ?? null,
4602 origin: originFacts(repo),
4603 pinnedAt: pin.createdAt,
4604 },
4605 ];
4606 });
4607 return json(keyedPage(items, (p) => `${p.owner}/${p.name}`, window));
4608 });
4609 });
4610
4611 router.add("PUT", "/api/v1/user/pins/:owner/:name", (ctx) => {
4612 const denied = requireScope(ctx, "rw");
4613 if (denied) return denied;
4614 const repo = loadRepo(ctx);
4615 if (!repo) return notFound();
4616 const pinned = pinRepo(ctx.app, ctx.auth.user, repo);
4617 if (!pinned.ok) return respondAccess(pinned.error, 404);
4618 return json({ pinned: true });
4619 });
4620
46213920 // ---- apps, runs, and the crew (docs/APPS.md) ----
46223921
46233922 // How the forge is: anyone may ask (a monitor has no account), a stranger
@@ -5602,33 +4901,6 @@ export function apiRoutes(router: Router<Handler>): void {
56024901 );
56034902 }
56044903
5605 // What is on the caller: open issues and pulls assigned to them, anywhere.
5606 router.add("GET", "/api/v1/user/assigned", (ctx) => {
5607 notePhase(ctx.request, "session", takeSessionTime(ctx.request));
5608 const denied = requireScope(ctx, "ro");
5609 if (denied) return denied;
5610 const user = ctx.auth.user!;
5611 const items = timePhase(ctx.request, "assigned", () => {
5612 const issues = listAssignedTo(ctx.app.db, user.id);
5613 const repos = getReposByIds(
5614 ctx.app.db,
5615 issues.map((i) => i.repoId),
5616 );
5617 const out = [];
5618 for (const issue of issues) {
5619 const repo = repos.get(issue.repoId);
5620 if (!repo || !canRead(ctx.app.db, repo, user)) continue;
5621 if (!mayView(ctx.app.db, repo, issue, user)) continue;
5622 out.push({
5623 ...issue,
5624 repo: { owner: repo.ownerName, name: repo.name },
5625 });
5626 }
5627 return out;
5628 });
5629 return timePhase(ctx.request, "render", () => json({ items, next_cursor: null }));
5630 });
5631
56324904 agentRoutes(router);
56334905
56344906 router.add("GET", "/api/v1/repos/:owner/:name/crew", (ctx) => {
@@ -5671,16 +4943,6 @@ export function apiRoutes(router: Router<Handler>): void {
56714943 if (!removed.ok) return respondAccess(removed.error, 422);
56724944 return json({}, 200);
56734945 });
5674
5675 router.add("DELETE", "/api/v1/user/pins/:owner/:name", (ctx) => {
5676 const denied = requireScope(ctx, "rw");
5677 if (denied) return denied;
5678 const repo = loadRepo(ctx);
5679 if (!repo) return notFound();
5680 const removed = unpinRepo(ctx.app, ctx.auth.user, repo);
5681 if (!removed.ok) return respondAccess(removed.error, 404);
5682 return new Response(null, { status: 204 });
5683 });
56844946 }
56854947
56864948 /**
src/http/api/user.ts added +741
@@ -0,0 +1,741 @@
1 /**
2 * The signed-in account over the API: every `/api/v1/user…` route — the
3 * profile, password and email verification, its orgs and SSH keys, origins
4 * (connecting, OAuth, browsing live, mirroring and forking), pins, and what
5 * is assigned to the caller. Moved out of `api.ts` (#7444), unchanged.
6 */
7
8 import { contribute, forkOrigin } from "../../actions/contribute.ts";
9 import { CAPPED_WORDS } from "../../actions/mail.ts";
10 import { ORIGIN_UNREACHABLE } from "../../actions/origin_app_words.ts";
11 import { connectOrigin, deleteOriginApp, disconnectOrigin, listOriginApps, listPinned, listRemoteRepos, listUserOrigins, mirrorJobFor, pinRepo, pollOAuthConnect, registerOriginApp, startMirrorJob, startOAuthConnect, syncOriginNow, unpinRepo } from "../../actions/origins.ts";
12 import { addSshKey, changePassword, deleteSshKey, updateProfile } from "../../actions/users.ts";
13 import { VERIFY_WORDS } from "../../actions/verify_words.ts";
14 import { resendVerification } from "../../actions/verify.ts";
15 import { listAssignedTo } from "../../core/issues.ts";
16 import { listKeys, publicKey } from "../../core/keys.ts";
17 import { listOrgsForUser } from "../../core/orgs.ts";
18 import { canRead, getReposByIds } from "../../core/repos.ts";
19 import { notePhase, takeSessionTime, timePhase, timePhaseAsync } from "../../core/route_timing.ts";
20 import { mayView } from "../../core/sealed.ts";
21 import { emailVerified } from "../../core/verify.ts";
22 import type { Result } from "../../kernel/result.ts";
23 import { subscribe as subscribeJob } from "../../origins/jobs.ts";
24 import { sshCloneUrl } from "../../ssh/command.ts";
25 import { type RepoSource, type SourceError, sourceFor } from "../../view/source.ts";
26 import { connectMessage, contributeFailure, contributionJson, editFilesFrom, jobJson, keyedPage, keyedWindow, loadRepo, oauthCallbackUri, originFacts, originJson, remoteRepoJson } from "../api.ts";
27 import type { Ctx, Handler } from "../context.ts";
28 import { clientAddress } from "../peer.ts";
29 import { apiError, body, json, notFound, publicProfile, publicUser, requireAccount, requireScope, respondAccess, str, unauthorized } from "../respond.ts";
30 import type { Router } from "../router.ts";
31 import { eventStream } from "../sse.ts";
32
33 /**
34 * Where a live browse is reading from — the banner's facts. The origin's
35 * credential and its id are ours; what a client gets is the forge, the name
36 * there, the page there, and whether we already hold a mirror of it.
37 */
38 function whereJson(source: RepoSource) {
39 const where = source.where;
40 if (where.kind === "local")
41 return {
42 source: "local" as const,
43 repo: { owner: where.repo.ownerName, name: where.repo.name },
44 };
45 return {
46 source: "origin" as const,
47 origin: {
48 id: where.origin.id,
49 kind: where.origin.kind,
50 webUrl: where.origin.webUrl,
51 login: where.origin.login,
52 },
53 fullName: where.fullName,
54 htmlUrl: where.htmlUrl,
55 mirroredAs:
56 where.mirroredAs === undefined
57 ? null
58 : { owner: where.mirroredAs.ownerName, name: where.mirroredAs.name },
59 };
60 }
61
62 /**
63 * A live read that did not happen. `not_found` keeps its ambiguity; the rest
64 * name which side said no, so a client can tell "come back later" from
65 * "reconnect this origin".
66 */
67 function sourceFailure(error: SourceError): Response {
68 switch (error) {
69 case "not_found":
70 return notFound();
71 case "rate_limited":
72 return apiError(
73 "rate_limited",
74 429,
75 "the origin is rate-limiting us; try again shortly",
76 );
77 case "unauthorized":
78 return apiError(
79 "origin_unauthorized",
80 502,
81 "the origin refused this forge's credential; reconnect it",
82 );
83 case "too_large":
84 return apiError("too_large", 413, "that is too large to read here");
85 case "unreachable":
86 return apiError(
87 "origin_unreachable",
88 502,
89 ORIGIN_UNREACHABLE,
90 );
91 }
92 }
93
94 export function userRoutes(router: Router<Handler>): void {
95 router.add("GET", "/api/v1/user", (ctx) => {
96 notePhase(ctx.request, "session", takeSessionTime(ctx.request));
97 const me = ctx.auth.user;
98 if (!me) return unauthorized();
99 const verified = timePhase(ctx.request, "user", () => emailVerified(ctx.app.db, me.id));
100 return timePhase(ctx.request, "render", () => json({ ...publicUser(me), emailVerified: verified }));
101 });
102
103 // Another verification link for the signed-in account; rate limited.
104 router.add("POST", "/api/v1/user/email/verify", async (ctx) => {
105 if (!ctx.auth.user) return unauthorized();
106 const sent = await resendVerification(ctx.app, ctx.auth.user);
107 if (sent.ok)
108 return json({ outcome: sent.value }, sent.value === "sent" ? 202 : 200);
109 if (sent.error === "unauthorized") return unauthorized();
110 if (sent.error === "too_soon")
111 return apiError("too_soon", 429, VERIFY_WORDS.too_soon);
112 if (sent.error === "no_mail")
113 return apiError("no_mail", 503, VERIFY_WORDS.no_mail);
114 if (sent.error === "capped")
115 return apiError("capped", 429, CAPPED_WORDS);
116 return apiError("failed", 502, VERIFY_WORDS.failed);
117 });
118
119 router.add("PATCH", "/api/v1/user", async (ctx) => {
120 const denied = requireAccount(ctx, { adminToken: true });
121 if (denied) return denied;
122 const input = await body(ctx.request);
123 if (
124 input.commitEmail !== undefined &&
125 input.commitEmail !== "noreply" &&
126 input.commitEmail !== "account"
127 )
128 return json({ error: "invalid_commit_email" }, 422);
129 const updated = updateProfile(ctx.app, ctx.auth.user, {
130 ...(typeof input.displayName === "string"
131 ? { displayName: input.displayName }
132 : {}),
133 ...(typeof input.email === "string" ? { email: input.email } : {}),
134 ...(input.commitEmail === "noreply" || input.commitEmail === "account"
135 ? { commitEmail: input.commitEmail }
136 : {}),
137 });
138 if (!updated.ok) return respondAccess(updated.error, 422);
139 return json(publicUser(updated.value));
140 });
141
142 router.add("POST", "/api/v1/user/password", async (ctx) => {
143 const denied = requireAccount(ctx, { adminToken: true });
144 if (denied) return denied;
145 const input = await body(ctx.request);
146 const changed = await changePassword(ctx.app, ctx.auth.user, {
147 current: str(input.current),
148 next: str(input.next),
149 source: clientAddress(ctx.request),
150 });
151 if (!changed.ok)
152 return respondAccess(
153 changed.error,
154 changed.error === "bad_credentials"
155 ? 403
156 : changed.error === "throttled"
157 ? 429
158 : 422,
159 );
160 return json({}, 200);
161 });
162
163 router.add("GET", "/api/v1/user/orgs", (ctx) => {
164 notePhase(ctx.request, "session", takeSessionTime(ctx.request));
165 if (!ctx.auth.user) return unauthorized();
166 const user = ctx.auth.user;
167 const window = keyedWindow(ctx);
168 if (window instanceof Response) return window;
169 const orgs = timePhase(ctx.request, "orgs", () =>
170 listOrgsForUser(ctx.app.db, user.id).map(publicProfile),
171 );
172 return timePhase(ctx.request, "render", () => json(keyedPage(orgs, (o) => o.username, window)));
173 });
174
175 // SSH keys — what the SSH listener authenticates against. Public keys are
176 // public, so the list returns them whole.
177 router.add("GET", "/api/v1/user/keys", (ctx) => {
178 notePhase(ctx.request, "session", takeSessionTime(ctx.request));
179 const user = ctx.auth.user;
180 if (!user) return unauthorized();
181 const window = keyedWindow(ctx);
182 if (window instanceof Response) return window;
183 const keys = timePhase(ctx.request, "keys", () => listKeys(ctx.app.db, user.id));
184 return timePhase(ctx.request, "render", () => json(keyedPage(keys.map(publicKey), (k) => k.id, window)));
185 });
186 router.add("POST", "/api/v1/user/keys", async (ctx) => {
187 const denied = requireScope(ctx, "rw");
188 if (denied) return denied;
189 const input = await body(ctx.request);
190 const added = addSshKey(ctx.app, ctx.auth.user, {
191 name: str(input.name),
192 key: str(input.key),
193 });
194 if (!added.ok) return respondAccess(added.error, 422);
195 return json(publicKey(added.value), 201);
196 });
197 router.add("DELETE", "/api/v1/user/keys/:id", (ctx) => {
198 const denied = requireScope(ctx, "rw");
199 if (denied) return denied;
200 const removed = deleteSshKey(ctx.app, ctx.auth.user, ctx.params.id!);
201 if (!removed.ok) return respondAccess(removed.error, 422);
202 return new Response(null, { status: 204 });
203 });
204
205 // ── origins ──────────────────────────────────────────────────────────
206 //
207 // An origin holds a credential for another forge, so every route here is
208 // gated on the *account* — a same-origin session, the password, or an
209 // explicit `admin` token. A leaked `rw` token must never be able to
210 // enumerate, connect, or spend somebody's GitHub access.
211
212 /** The callback URL this forge tells an OAuth app to come back to. */
213 const callbackUri = (ctx: Ctx): string =>
214 oauthCallbackUri(ctx.url, ctx.app.config.host);
215
216 router.add("GET", "/api/v1/user/origins", (ctx) => {
217 notePhase(ctx.request, "session", takeSessionTime(ctx.request));
218 const denied = requireAccount(ctx, { adminToken: true });
219 if (denied) return denied;
220 const origins = timePhase(ctx.request, "origins", () => listUserOrigins(ctx.app, ctx.auth.user!).map(originJson));
221 return timePhase(ctx.request, "render", () => json(origins));
222 });
223
224 router.add("POST", "/api/v1/user/origins", async (ctx) => {
225 const denied = requireAccount(ctx, { adminToken: true });
226 if (denied) return denied;
227 const input = await body(ctx.request);
228 const kind = str(input.kind);
229 const connected = await connectOrigin(ctx.app, ctx.auth.user, {
230 host: str(input.host) || kind,
231 ...(kind === "github" || kind === "forgejo" || kind === "gitea"
232 ? { kind }
233 : {}),
234 token: str(input.token),
235 });
236 if (!connected.ok)
237 return apiError(
238 connected.error,
239 connected.error === "rate_limited" ? 429 : 422,
240 connectMessage(connected.error),
241 );
242 // The frozen mirrors connecting this account brought back, and the ones
243 // it could not, each with its reason.
244 return json(
245 {
246 ...originJson(connected.value),
247 reattached: connected.value.reattached,
248 },
249 201,
250 );
251 });
252
253 // OAuth: one start route for both flows, because which flow you get is a
254 // property of the forge (device on GitHub, code+PKCE on Forgejo) and not
255 // something a client should have to know.
256 router.add("POST", "/api/v1/user/origins/oauth", async (ctx) => {
257 const denied = requireAccount(ctx, { adminToken: true });
258 if (denied) return denied;
259 const input = await body(ctx.request);
260 const kind = str(input.kind);
261 const started = await startOAuthConnect(ctx.app, ctx.auth.user, {
262 host: str(input.host) || kind,
263 ...(kind === "github" || kind === "forgejo" || kind === "gitea"
264 ? { kind }
265 : {}),
266 redirectUri: callbackUri(ctx),
267 });
268 if (!started.ok)
269 return apiError(
270 started.error.error,
271 started.error.error === "rate_limited" ? 429 : 422,
272 started.error.message,
273 );
274 return json(started.value, 201);
275 });
276
277 router.add("GET", "/api/v1/user/origins/oauth/:id", async (ctx) => {
278 const denied = requireAccount(ctx, { adminToken: true });
279 if (denied) return denied;
280 notePhase(ctx.request, "session", takeSessionTime(ctx.request));
281 const status = await timePhaseAsync(ctx.request, "origin", () =>
282 pollOAuthConnect(ctx.app, ctx.auth.user, ctx.params.id!),
283 );
284 if (!status.ok) return respondAccess(status.error, 404);
285 return json(status.value);
286 });
287
288 // Registered OAuth applications. Every Forgejo instance needs its own, so
289 // this is a per-user, per-host registration; GitHub can fall back to one
290 // the operator configured for the whole forge.
291 router.add("GET", "/api/v1/user/origin-apps", (ctx) => {
292 notePhase(ctx.request, "session", takeSessionTime(ctx.request));
293 const denied = requireAccount(ctx, { adminToken: true });
294 if (denied) return denied;
295 const apps = timePhase(ctx.request, "apps", () => listOriginApps(ctx.app, ctx.auth.user!));
296 return timePhase(ctx.request, "render", () => json(apps));
297 });
298
299 router.add("POST", "/api/v1/user/origin-apps", async (ctx) => {
300 const denied = requireAccount(ctx, { adminToken: true });
301 if (denied) return denied;
302 const input = await body(ctx.request);
303 const kind = str(input.kind);
304 const saved = await registerOriginApp(ctx.app, ctx.auth.user, {
305 host: str(input.host) || kind,
306 ...(kind === "github" || kind === "forgejo" || kind === "gitea"
307 ? { kind }
308 : {}),
309 clientId: str(input.clientId),
310 ...(typeof input.clientSecret === "string"
311 ? { clientSecret: input.clientSecret }
312 : {}),
313 });
314 if (!saved.ok)
315 return apiError(saved.error, 422, connectMessage(saved.error));
316 return json(saved.value, 201);
317 });
318
319 router.add("DELETE", "/api/v1/user/origin-apps/:id", (ctx) => {
320 const denied = requireAccount(ctx, { adminToken: true });
321 if (denied) return denied;
322 const removed = deleteOriginApp(ctx.app, ctx.auth.user, ctx.params.id!);
323 if (!removed.ok) return respondAccess(removed.error, 404);
324 return new Response(null, { status: 204 });
325 });
326
327 router.add("DELETE", "/api/v1/user/origins/:id", async (ctx) => {
328 const denied = requireAccount(ctx, { adminToken: true });
329 if (denied) return denied;
330 const gone = await disconnectOrigin(ctx.app, ctx.auth.user, ctx.params.id!);
331 if (!gone.ok) return respondAccess(gone.error, 404);
332 return json(gone.value);
333 });
334
335 router.add("POST", "/api/v1/user/origins/:id/sync", async (ctx) => {
336 const denied = requireAccount(ctx, { adminToken: true });
337 if (denied) return denied;
338 const synced = await syncOriginNow(ctx.app, ctx.auth.user, ctx.params.id!);
339 if (!synced.ok) return respondAccess(synced.error, 404);
340 return json(synced.value);
341 });
342
343 router.add("GET", "/api/v1/user/origins/:id/repos", async (ctx) => {
344 notePhase(ctx.request, "session", takeSessionTime(ctx.request));
345 const denied = requireAccount(ctx, { adminToken: true });
346 if (denied) return denied;
347 const listed = await timePhaseAsync(ctx.request, "origin", () =>
348 listRemoteRepos(
349 ctx.app,
350 ctx.auth.user,
351 ctx.params.id!,
352 ctx.url.searchParams.get("refresh") === "1",
353 ),
354 );
355 if (!listed.ok)
356 return listed.error === "unreachable"
357 ? apiError("origin_unreachable", 502, ORIGIN_UNREACHABLE)
358 : respondAccess(listed.error, 404);
359 return timePhase(ctx.request, "render", () =>
360 json(listed.value.map((row) => remoteRepoJson(ctx, row))),
361 );
362 });
363
364 // ── browsing an origin, live ─────────────────────────────────────────
365 //
366 // Reading a repository the origin holds and we do not: the same shapes the
367 // local read routes return, so a client renders one screen either way.
368 // Everything runs through `sourceFor`, which is where the rule lives that
369 // only the person who connected an origin may browse through it — the
370 // request spends their credential on another forge.
371
372 const browse = (ctx: Ctx): RepoSource | Response => {
373 notePhase(ctx.request, "session", takeSessionTime(ctx.request));
374 const denied = requireAccount(ctx, { adminToken: true });
375 if (denied) return denied;
376 const source = sourceFor(ctx.app, ctx.auth.user, {
377 originId: ctx.params.id!,
378 fullName: `${ctx.params.owner!}/${ctx.params.name!}`,
379 });
380 return source.ok ? source.value : sourceFailure(source.error);
381 };
382
383 /** Run one read against a live source and shape whatever comes back. */
384 const read = async <T>(
385 ctx: Ctx,
386 run: (source: RepoSource) => Promise<Result<T, SourceError>>,
387 shape: (value: T) => Response,
388 ): Promise<Response> => {
389 const source = browse(ctx);
390 if (source instanceof Response) return source;
391 const result = await timePhaseAsync(ctx.request, "origin", () => run(source));
392 return result.ok
393 ? timePhase(ctx.request, "render", () => shape(result.value))
394 : sourceFailure(result.error);
395 };
396
397 const BROWSE = "/api/v1/user/origins/:id/browse/:owner/:name";
398
399 // The landing read: what a repo page needs before it needs anything else.
400 router.add("GET", BROWSE, async (ctx) => {
401 const source = browse(ctx);
402 if (source instanceof Response) return source;
403 const facts = await timePhaseAsync(ctx.request, "origin", () => source.describe());
404 if (!facts.ok) return sourceFailure(facts.error);
405 const [branches, readme] = await timePhaseAsync(ctx.request, "origin", () =>
406 Promise.all([source.branches(), source.readme(facts.value.defaultBranch)]),
407 );
408 return timePhase(ctx.request, "render", () =>
409 json({
410 ...whereJson(source),
411 capabilities: source.capabilities,
412 facts: facts.value,
413 branches: branches.ok ? branches.value : [],
414 readme: readme.ok ? readme.value : null,
415 }),
416 );
417 });
418
419 router.add("GET", `${BROWSE}/branches`, (ctx) =>
420 read(ctx, (s) => s.branches(), json),
421 );
422 router.add("GET", `${BROWSE}/tags`, (ctx) =>
423 read(ctx, (s) => s.tags(), json),
424 );
425 router.add("GET", `${BROWSE}/labels`, (ctx) =>
426 read(ctx, (s) => s.labels(), json),
427 );
428 router.add("GET", `${BROWSE}/paths/:ref`, (ctx) =>
429 read(ctx, (s) => s.paths(ctx.params.ref!), json),
430 );
431
432 const treeHandler = (ctx: Ctx): Promise<Response> => {
433 const ref = ctx.params.ref!;
434 const path = ctx.params.path ?? "";
435 return read(
436 ctx,
437 (s) => s.tree(ref, path),
438 (entries) => json({ type: "dir", ref, path, entries }),
439 );
440 };
441 router.add("GET", `${BROWSE}/tree/:ref`, treeHandler);
442 router.add("GET", `${BROWSE}/tree/:ref/*path`, treeHandler);
443
444 // Shaped exactly like the local contents endpoint, down to the 413.
445 router.add("GET", `${BROWSE}/blob/:ref/*path`, (ctx) => {
446 const ref = ctx.params.ref!;
447 const path = ctx.params.path ?? "";
448 return read(
449 ctx,
450 (s) => s.blob(ref, path),
451 (blob) => {
452 if (blob.kind === "too_large")
453 return json({ type: "file", tooLarge: true, size: blob.size }, 413);
454 const binary = blob.kind === "binary";
455 return json({
456 type: "file",
457 ref,
458 path,
459 size: blob.size,
460 encoding: binary ? "base64" : "utf-8",
461 content: binary
462 ? Buffer.from(blob.body).toString("base64")
463 : new TextDecoder().decode(blob.body),
464 });
465 },
466 );
467 });
468
469 router.add("GET", `${BROWSE}/commits`, async (ctx) => {
470 const source = browse(ctx);
471 if (source instanceof Response) return source;
472 // Only ask what the default branch is when the caller did not say — on
473 // a live source that question is a request to another forge.
474 let ref = ctx.url.searchParams.get("ref") ?? "";
475 if (ref === "") {
476 const facts = await timePhaseAsync(ctx.request, "origin", () => source.describe());
477 if (!facts.ok) return sourceFailure(facts.error);
478 ref = facts.value.defaultBranch || "HEAD";
479 }
480 const cursor = ctx.url.searchParams.get("cursor") ?? undefined;
481 const page = await timePhaseAsync(ctx.request, "origin", () => source.commits(ref, cursor));
482 if (!page.ok) return sourceFailure(page.error);
483 return timePhase(ctx.request, "render", () =>
484 json({ items: page.value.items, next_cursor: page.value.next }),
485 );
486 });
487
488 router.add("GET", `${BROWSE}/commits/:oid`, (ctx) => {
489 const oid = ctx.params.oid!;
490 return read(
491 ctx,
492 (s) => s.commit(oid),
493 ({ commit, diff }) => json({ ...commit, diff }),
494 );
495 });
496
497 const conversations = (kind: "issue" | "pull") => async (ctx: Ctx) => {
498 const q = ctx.url.searchParams;
499 const stateParam = q.get("state");
500 const label = q.get("label");
501 const author = q.get("author");
502 const search = q.get("q");
503 const cursor = q.get("cursor");
504 return read(
505 ctx,
506 (s) =>
507 s.issues({
508 kind,
509 state:
510 stateParam === "closed" || stateParam === "all"
511 ? stateParam
512 : "open",
513 ...(label ? { label } : {}),
514 ...(author ? { author } : {}),
515 ...(search ? { query: search } : {}),
516 ...(cursor ? { cursor } : {}),
517 }),
518 (page) => json({ items: page.items, next_cursor: page.next }),
519 );
520 };
521 router.add("GET", `${BROWSE}/issues`, conversations("issue"));
522 router.add("GET", `${BROWSE}/pulls`, conversations("pull"));
523
524 const conversation = (ctx: Ctx): Promise<Response> => {
525 const number = Number(ctx.params.number);
526 return read(
527 ctx,
528 (s) => s.issue(number),
529 (detail) =>
530 json({
531 ...detail.issue,
532 body: detail.body,
533 comments: detail.comments,
534 pull: detail.pull,
535 }),
536 );
537 };
538 router.add("GET", `${BROWSE}/issues/:number`, conversation);
539 router.add("GET", `${BROWSE}/pulls/:number`, conversation);
540
541 // ── mirroring, as a job ──────────────────────────────────────────────
542 //
543 // 202, not 201: the repository does not exist yet. What comes back is
544 // something to watch — poll it, or take the stream and render the stages.
545
546 router.add("POST", "/api/v1/user/origins/:id/mirror", async (ctx) => {
547 const denied = requireAccount(ctx, { adminToken: true });
548 if (denied) return denied;
549 const input = await body(ctx.request);
550 const started = startMirrorJob(
551 ctx.app,
552 ctx.auth.user,
553 ctx.params.id!,
554 str(input.fullName),
555 str(input.name) || undefined,
556 );
557 if (!started.ok) return respondAccess(started.error, 404);
558 return json({ job: jobJson(ctx, started.value) }, 202);
559 });
560
561 // Fork at the origin. 202, like mirroring: the fork is asynchronous on
562 // GitHub, and the local copy of it is a job to watch.
563 router.add("POST", "/api/v1/user/origins/:id/fork", async (ctx) => {
564 const denied = requireAccount(ctx, { adminToken: true });
565 if (denied) return denied;
566 const input = await body(ctx.request);
567 const forked = await forkOrigin(
568 ctx.app,
569 ctx.auth.user,
570 ctx.params.id!,
571 str(input.fullName),
572 );
573 if (!forked.ok) return contributeFailure(forked.error);
574 return json(
575 {
576 remote: forked.value.remote,
577 existed: forked.value.existed,
578 job:
579 forked.value.job === undefined
580 ? null
581 : jobJson(ctx, forked.value.job),
582 mirroredAs:
583 forked.value.mirror === undefined
584 ? null
585 : {
586 owner: forked.value.mirror.ownerName,
587 name: forked.value.mirror.name,
588 },
589 },
590 202,
591 );
592 });
593
594 // Contribute to a repository we are only *browsing* — the fork flow
595 // straight from the picker, with no mirror of the upstream first.
596 router.add("POST", `${BROWSE}/contribute`, async (ctx) => {
597 const denied = requireAccount(ctx, { adminToken: true });
598 if (denied) return denied;
599 const input = await body(ctx.request);
600 const result = await contribute(ctx.app, ctx.auth.user, {
601 originId: ctx.params.id!,
602 fullName: `${ctx.params.owner!}/${ctx.params.name!}`,
603 branch: str(input.branch),
604 ...(str(input.base) === "" ? {} : { base: str(input.base) }),
605 ...(str(input.message) === "" ? {} : { message: str(input.message) }),
606 files: editFilesFrom(input.files),
607 ...(typeof input.openPull === "object" && input.openPull !== null
608 ? {
609 openPull: {
610 title: str((input.openPull as Record<string, unknown>).title),
611 body: str((input.openPull as Record<string, unknown>).body),
612 },
613 }
614 : {}),
615 });
616 if (!result.ok) return contributeFailure(result.error);
617 return json(contributionJson(ctx, result.value), 201);
618 });
619
620 router.add("GET", "/api/v1/user/origins/:id/mirror/:job", (ctx) => {
621 notePhase(ctx.request, "session", takeSessionTime(ctx.request));
622 const denied = requireAccount(ctx, { adminToken: true });
623 if (denied) return denied;
624 const job = timePhase(ctx.request, "job", () =>
625 mirrorJobFor(ctx.app, ctx.auth.user, ctx.params.id!, ctx.params.job!),
626 );
627 if (!job.ok) return respondAccess(job.error, 404);
628 return timePhase(ctx.request, "render", () =>
629 json({ job: jobJson(ctx, job.value) }),
630 );
631 });
632
633 // The same job as a live stream, so a mirror can be watched rather than
634 // polled. It ends itself the moment the job reaches a terminal stage.
635 router.add("GET", "/api/v1/user/origins/:id/mirror/:job/stream", (ctx) => {
636 const denied = requireAccount(ctx, { adminToken: true });
637 if (denied) return denied;
638 const found = mirrorJobFor(
639 ctx.app,
640 ctx.auth.user,
641 ctx.params.id!,
642 ctx.params.job!,
643 );
644 if (!found.ok) return respondAccess(found.error, 404);
645 return eventStream(ctx.app, ctx.request, (emit, close) =>
646 subscribeJob(found.value.id, (job) => {
647 emit(`mirror.${job.stage}`, jobJson(ctx, job), job.id);
648 if (job.stage === "done" || job.stage === "failed") close();
649 }),
650 );
651 });
652
653 // ── pins ─────────────────────────────────────────────────────────────
654
655 // The local client clones from this list, so each pin carries what a
656 // clone needs: both URLs and where the repo came from.
657 router.add("GET", "/api/v1/user/pins", (ctx) => {
658 if (!ctx.auth.user) return unauthorized();
659 notePhase(ctx.request, "session", takeSessionTime(ctx.request));
660 const window = keyedWindow(ctx);
661 if (window instanceof Response) return window;
662 const user = ctx.auth.user;
663 const { pins, repos } = timePhase(ctx.request, "pins", () => {
664 const pins = listPinned(ctx.app, user);
665 // One query for every pinned repo, not one per pin.
666 const repos = getReposByIds(
667 ctx.app.db,
668 pins.map((pin) => pin.repoId),
669 );
670 return { pins, repos };
671 });
672 return timePhase(ctx.request, "render", () => {
673 const items = pins.flatMap((pin) => {
674 const repo = repos.get(pin.repoId);
675 if (!repo) return [];
676 const path = `${encodeURIComponent(repo.ownerName)}/${encodeURIComponent(repo.name)}`;
677 return [
678 {
679 repoId: repo.id,
680 owner: repo.ownerName,
681 name: repo.name,
682 defaultBranch: repo.defaultBranch,
683 cloneUrl: `${ctx.url.origin}/${path}.git`,
684 sshUrl:
685 sshCloneUrl(ctx.app, repo.ownerName, repo.name) ?? null,
686 origin: originFacts(repo),
687 pinnedAt: pin.createdAt,
688 },
689 ];
690 });
691 return json(keyedPage(items, (p) => `${p.owner}/${p.name}`, window));
692 });
693 });
694
695 router.add("PUT", "/api/v1/user/pins/:owner/:name", (ctx) => {
696 const denied = requireScope(ctx, "rw");
697 if (denied) return denied;
698 const repo = loadRepo(ctx);
699 if (!repo) return notFound();
700 const pinned = pinRepo(ctx.app, ctx.auth.user, repo);
701 if (!pinned.ok) return respondAccess(pinned.error, 404);
702 return json({ pinned: true });
703 });
704
705 // What is on the caller: open issues and pulls assigned to them, anywhere.
706 router.add("GET", "/api/v1/user/assigned", (ctx) => {
707 notePhase(ctx.request, "session", takeSessionTime(ctx.request));
708 const denied = requireScope(ctx, "ro");
709 if (denied) return denied;
710 const user = ctx.auth.user!;
711 const items = timePhase(ctx.request, "assigned", () => {
712 const issues = listAssignedTo(ctx.app.db, user.id);
713 const repos = getReposByIds(
714 ctx.app.db,
715 issues.map((i) => i.repoId),
716 );
717 const out = [];
718 for (const issue of issues) {
719 const repo = repos.get(issue.repoId);
720 if (!repo || !canRead(ctx.app.db, repo, user)) continue;
721 if (!mayView(ctx.app.db, repo, issue, user)) continue;
722 out.push({
723 ...issue,
724 repo: { owner: repo.ownerName, name: repo.name },
725 });
726 }
727 return out;
728 });
729 return timePhase(ctx.request, "render", () => json({ items, next_cursor: null }));
730 });
731
732 router.add("DELETE", "/api/v1/user/pins/:owner/:name", (ctx) => {
733 const denied = requireScope(ctx, "rw");
734 if (denied) return denied;
735 const repo = loadRepo(ctx);
736 if (!repo) return notFound();
737 const removed = unpinRepo(ctx.app, ctx.auth.user, repo);
738 if (!removed.ok) return respondAccess(removed.error, 404);
739 return new Response(null, { status: 204 });
740 });
741 }

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs