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

The OpenAPI document describes signing up and signing in: users, sessions and the first admin #5047

Merged opened this

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

Merged

scout/forge:scout/5037-the-openapi-document-describes-signing-u → main 2 commits fork 1 thread · 1 unresolved

  • Merged. scout/5037-the-openapi-document-describes-signing-u was merged into main as 9294aca.

Review

Sign in to review this change.
2 files changed +127 −0
src/contract/openapi.ts +82
@@ -842,6 +842,88 @@ export const ROUTES: readonly Route[] = [
842842 body: { source: "object", owner: "string", name: "string", visibility: "string", branch: "string", after: "string", crew: "object" },
843843 },
844844 { method: "get", path: "/api/v1/migrate/{job}", summary: "Read a migration job's stage", auth: true },
845 {
846 method: "post",
847 path: "/api/v1/users",
848 summary:
849 "Sign up: answers 201 with the new account { id, username, displayName, isAdmin, commitEmail }, never the admin; `email` is optional; `invite` is an invite link's token, `turnstile_token` the human check when the forge asks for one; refused from another origin with 403 forbidden",
850 auth: false,
851 status: 201,
852 body: { username: "string", email: "string", password: "string", invite: "string", turnstile_token: "string" },
853 refusals: {
854 rate_limited: "429, too many sign-ups; retry-after says when",
855 too_large: "413, the body is over the limit for a stranger's",
856 signup_closed: "403, this forge makes no new accounts (or the invite is not one)",
857 forbidden: "403, the name is not one this edge allows",
858 passkey_required: "422, this forge signs up with a passkey on the web page",
859 human_check: "422, the human check failed",
860 invalid_username: "422, not a name an account may have",
861 invalid_email: "422, not an email address",
862 weak_password: "422, the password is too short",
863 password_too_long: "422, the password is over the cap",
864 username_taken: "422, another account has the name",
865 username_reserved: "422, the name is reserved",
866 email_taken: "422, another account has the address",
867 },
868 },
869 {
870 method: "post",
871 path: "/api/v1/sessions",
872 summary:
873 "Sign in with a username and password: answers 201 with the account { id, username, displayName, isAdmin, commitEmail } and sets the HttpOnly forge_session cookie; refused from another origin with 403 forbidden",
874 auth: false,
875 status: 201,
876 body: { username: "string", password: "string" },
877 refusals: {
878 unauthorized: "401, the username or password is wrong",
879 too_many_attempts: "429, past the sign-in windows; retry-after says when",
880 too_large: "413, the body is over the limit for a stranger's",
881 },
882 },
883 {
884 method: "delete",
885 path: "/api/v1/sessions",
886 summary: "Sign out the session in the forge_session cookie: answers 200 with {} and clears the cookie; refused from another origin with 403 forbidden",
887 auth: false,
888 },
889 {
890 method: "post",
891 path: "/api/v1/first-admin",
892 summary:
893 "Make the forge's first admin with the operator's one-time code, no session or password: answers 201 with { user, scope, id, token, expiresAt, expiresIn }, the token shown once; scope is ro, rw or admin; expiresIn is whole seconds from 60 to a month (default a day); refused from another origin with 403 forbidden",
894 auth: false,
895 status: 201,
896 body: { code: "string", username: "string", email: "string", scope: "string", name: "string", expiresIn: "integer" },
897 refusals: {
898 too_large: "413, the body is over the limit for a stranger's",
899 invalid_scope: "422, scope is not ro, rw or admin",
900 invalid_expiry: "422, expiresIn is not whole seconds in range",
901 rate_limited: "429, too many attempts from here; retry-after says when",
902 refused: "403, the code does not work or this forge already has an admin",
903 invalid_username: "422, not a name an account may have",
904 invalid_email: "422, not an email address",
905 username_taken: "422, another account has the name",
906 email_taken: "422, another account has the address",
907 username_reserved: "422, the name is reserved",
908 unfinished: "422, the account could not be finished; send again",
909 },
910 },
911 {
912 method: "post",
913 path: "/api/v1/first-admin/recover",
914 summary:
915 "Get a stranded admin a new expiring token with the operator's recovery code; no account is made: answers 201 with { user, scope, id, token, expiresAt, expiresIn }; scope and expiresIn as for first-admin; refused from another origin with 403 forbidden",
916 auth: false,
917 status: 201,
918 body: { code: "string", scope: "string", name: "string", expiresIn: "integer" },
919 refusals: {
920 too_large: "413, the body is over the limit for a stranger's",
921 invalid_scope: "422, scope is not ro, rw or admin",
922 invalid_expiry: "422, expiresIn is not whole seconds in range",
923 rate_limited: "429, too many attempts from here; retry-after says when",
924 refused: "403, the code does not work",
925 },
926 },
845927 {
846928 method: "post",
847929 path: "/mcp",
test/openapi.test.ts +45
@@ -1,6 +1,11 @@
11 /** The OpenAPI document names every route the manifest names. */
22 import { expect, test } from "bun:test";
33 import { readFileSync } from "node:fs";
4 import { mkdtemp, rm } from "node:fs/promises";
5 import { tmpdir } from "node:os";
6 import { join } from "node:path";
7 import { createApp } from "../src/app.ts";
8 import { serve } from "../src/http/server.ts";
49 import { openapi, ROUTES } from "../src/contract/openapi.ts";
510
611 test("every http route the manifest names is described", () => {
@@ -116,6 +121,46 @@ test("making and listing apps is described with every field the route reads", ()
116121 expect(post.responses.default.description).toContain(`\`${code}\``);
117122 });
118123
124 test("signing up, signing in and the first admin are described", () => {
125 const doc = openapi("https://f.test", "1") as any;
126 const want: [string, string, number][] = [
127 ["post", "/api/v1/users", 201],
128 ["post", "/api/v1/sessions", 201],
129 ["delete", "/api/v1/sessions", 200],
130 ["post", "/api/v1/first-admin", 201],
131 ["post", "/api/v1/first-admin/recover", 201],
132 ];
133 for (const [method, path, status] of want) {
134 const op = doc.paths[path]?.[method];
135 expect(op, `${method} ${path}`).toBeDefined();
136 expect(op.responses[String(status)], `${method} ${path} ${status}`).toBeDefined();
137 expect(op.security, `${method} ${path}`).toBeUndefined();
138 }
139 const props = (p: string) => doc.paths[p].post.requestBody.content["application/json"].schema.properties;
140 expect(props("/api/v1/users").password.type).toBe("string");
141 expect(props("/api/v1/first-admin").expiresIn.type).toBe("integer");
142 });
143
144 test("the served document carries the sign-up and sign-in routes", async () => {
145 const dir = await mkdtemp(join(tmpdir(), "forge-openapi-"));
146 const app = createApp({ dataDir: dir, port: 0 });
147 const servers = await serve(app);
148 try {
149 const res = await fetch(`${servers.url}/contract/openapi.json`);
150 expect(res.status).toBe(200);
151 const doc = (await res.json()) as any;
152 expect(doc.paths["/api/v1/users"].post).toBeDefined();
153 expect(doc.paths["/api/v1/sessions"].post).toBeDefined();
154 expect(doc.paths["/api/v1/sessions"].delete).toBeDefined();
155 expect(doc.paths["/api/v1/first-admin"].post).toBeDefined();
156 expect(doc.paths["/api/v1/first-admin/recover"].post).toBeDefined();
157 } finally {
158 servers.stop();
159 app.close();
160 await rm(dir, { recursive: true, force: true });
161 }
162 });
163
119164 test("an app's routes are described, and env values are never read back", () => {
120165 const doc = openapi("https://f.test", "1") as any;
121166 const A = "/api/v1/repos/{owner}/{name}/app";

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs