diff --git a/apps/cluster/src/AccountLinking.ts b/apps/cluster/src/AccountLinking.ts index 6374d421..acaa0b0e 100644 --- a/apps/cluster/src/AccountLinking.ts +++ b/apps/cluster/src/AccountLinking.ts @@ -1,4 +1,9 @@ import { + type AuthorizationExpired, + AuthorizationRejected, +} from "@janitor/domain/GitHub/Authorization" +import { + type AccountAlreadyConnected, type LinkedAccount, type LinkingAvailability, type LinkPlatform, @@ -14,7 +19,7 @@ import type * as Redacted from "effect/Redacted" import type * as HttpClient from "effect/http/HttpClient" import * as GitHubLink from "./Linking/GitHub.ts" import type { LinkProofFailed, ProvenAccount } from "./Linking/Proof.ts" -import { TeammateError, Teammates } from "./Teammates.ts" +import { Teammates } from "./Teammates.ts" /** * The account-linking round trip: start an attempt bound to the signed-in @@ -67,15 +72,15 @@ export class AccountLinking extends Context.Service< { readonly availability: LinkingAvailability /** Returns the platform URL the browser should open. */ - readonly start: ( - teammateId: TeammateId, - platform: LinkPlatform, - ) => Effect.Effect + readonly start: (teammateId: TeammateId, platform: LinkPlatform) => Effect.Effect readonly complete: ( teammateId: TeammateId, platform: LinkPlatform, request: LinkReturnRequest, - ) => Effect.Effect + ) => Effect.Effect< + LinkedAccount, + AuthorizationExpired | AuthorizationRejected | AccountAlreadyConnected + > } >()("@janitor/cluster/AccountLinking", { make: Effect.gen(function* () { @@ -97,7 +102,7 @@ export class AccountLinking extends Context.Service< const providerFor = (platform: LinkPlatform) => Option.match(maybeGithub, { - onNone: () => Effect.fail(unavailable(platform)), + onNone: () => Effect.die(`${platform} account linking is not configured`), onSome: Effect.succeed, }) @@ -119,7 +124,7 @@ export class AccountLinking extends Context.Service< Effect.annotateLogs({ platform, teammateId }), ), ), - Effect.mapError(proofFailed), + Effect.mapError(() => new AuthorizationRejected()), ) return yield* teammates.link(teammateId, { platform, ...proven }) }) @@ -134,15 +139,6 @@ export class AccountLinking extends Context.Service< > = Layer.effect(this, this.make) } -const unavailable = (_platform: LinkPlatform) => - new TeammateError({ - reason: "unavailable", - message: "GitHub account linking is not configured for this deployment.", - }) - -const proofFailed = (error: LinkProofFailed) => - new TeammateError({ reason: "rejected", message: error.message }) - interface Provider { readonly authorizeUrl: (state: string, nonce: string) => string readonly prove: (code: string, nonce: string) => Effect.Effect diff --git a/apps/cluster/src/Ingress/Account.ts b/apps/cluster/src/Ingress/Account.ts index 67199424..b16e6269 100644 --- a/apps/cluster/src/Ingress/Account.ts +++ b/apps/cluster/src/Ingress/Account.ts @@ -1,96 +1,45 @@ -import { - AccountView, - LinkedAccount, - LinkPlatform, - LinkReturnRequest, -} from "@janitor/domain/Team/Account" +import { JanitorApi } from "@janitor/domain/Api" import * as Effect from "effect/Effect" -import * as Layer from "effect/Layer" -import * as Schema from "effect/Schema" -import * as HttpRouter from "effect/http/HttpRouter" -import * as Request from "effect/http/HttpServerRequest" -import * as Response from "effect/http/HttpServerResponse" +import * as HttpApiBuilder from "effect/http-api/HttpApiBuilder" import { AccountLinking } from "../AccountLinking.ts" -import { - isTeammateError, - type TeammateError, - type TeammateErrorReason, - Teammates, -} from "../Teammates.ts" -import { CurrentTeammate, SameOriginMiddleware } from "./Middleware.ts" +import { Teammates } from "../Teammates.ts" +import { CurrentTeammate } from "./Middleware.ts" /** - * Account screens: the signed-in teammate's identity and connected accounts, - * and the admin-only team roster. Roles are enforced by `Teammates`; these - * routes only translate outcomes into responses. + * The account page: the signed-in teammate's identity and connected accounts. + * `Teammates` and `AccountLinking` fail with the domain errors the endpoints + * declare, so the handlers only pick the teammate and call through. */ - -const respondMessage = Response.schemaJson(Schema.Struct({ message: Schema.String })) - -const STATUS_BY_REASON: Record = { - "not-found": 404, - conflict: 409, - expired: 410, - rejected: 422, - unavailable: 503, -} - -/** Teammate outcomes become responses; request-shape errors keep their own status. */ -const handled = (effect: Effect.Effect) => - effect.pipe( - Effect.catchIf( - (error: E | TeammateError): error is TeammateError => isTeammateError(error), - (error) => - respondMessage({ message: error.message }, { status: STATUS_BY_REASON[error.reason] }), +export const AccountGroupLayer = HttpApiBuilder.group(JanitorApi, "account", (handlers) => + handlers + .handle("get", () => + Effect.gen(function* () { + const teammate = yield* CurrentTeammate + const linking = yield* AccountLinking + return yield* Teammates.use((teammates) => + teammates.account(teammate.teammateId, linking.availability), + ) + }), + ) + .handle("startLink", ({ params }) => + Effect.gen(function* () { + const teammate = yield* CurrentTeammate + const url = yield* AccountLinking.use((linking) => + linking.start(teammate.teammateId, params.platform), + ) + return { url } + }), + ) + .handle("completeLink", ({ params, payload }) => + Effect.flatMap(CurrentTeammate, (teammate) => + AccountLinking.use((linking) => + linking.complete(teammate.teammateId, params.platform, payload), + ), + ), + ) + .handle("unlink", ({ params }) => + Effect.flatMap(CurrentTeammate, (teammate) => + Teammates.use((teammates) => teammates.disconnect(teammate.teammateId, params.linkId)), + ), ), - ) - -const PlatformPath = Schema.Struct({ platform: LinkPlatform }) -const LinkPath = Schema.Struct({ linkId: Schema.String }) - -const reads = HttpRouter.add( - "GET", - "/account", - Effect.gen(function* () { - const teammate = yield* CurrentTeammate - const linking = yield* AccountLinking - const view = yield* (yield* Teammates).account(teammate.teammateId, linking.availability) - return yield* Response.schemaJson(AccountView)(view) - }).pipe(handled), ) - -const writes = HttpRouter.addAll([ - HttpRouter.route( - "POST", - "/account/links/:platform/start", - Effect.gen(function* () { - const { platform } = yield* HttpRouter.schemaPathParams(PlatformPath) - const teammate = yield* CurrentTeammate - const url = yield* (yield* AccountLinking).start(teammate.teammateId, platform) - return yield* Response.schemaJson(Schema.Struct({ url: Schema.String }))({ url }) - }).pipe(handled), - ), - HttpRouter.route( - "POST", - "/account/links/:platform/return", - Effect.gen(function* () { - const { platform } = yield* HttpRouter.schemaPathParams(PlatformPath) - const body = yield* Request.schemaBodyJson(LinkReturnRequest) - const teammate = yield* CurrentTeammate - const linked = yield* (yield* AccountLinking).complete(teammate.teammateId, platform, body) - return yield* Response.schemaJson(LinkedAccount)(linked) - }).pipe(handled), - ), - HttpRouter.route( - "DELETE", - "/account/links/:linkId", - Effect.gen(function* () { - const { linkId } = yield* HttpRouter.schemaPathParams(LinkPath) - const teammate = yield* CurrentTeammate - yield* (yield* Teammates).disconnect(teammate.teammateId, linkId) - return Response.empty({ status: 204 }) - }).pipe(handled), - ), -]).pipe(Layer.provide(SameOriginMiddleware)) - -export const AccountRoutesLayer = Layer.mergeAll(reads, writes) diff --git a/apps/cluster/src/Ingress/Middleware.ts b/apps/cluster/src/Ingress/Middleware.ts index c68f8981..0b8277be 100644 --- a/apps/cluster/src/Ingress/Middleware.ts +++ b/apps/cluster/src/Ingress/Middleware.ts @@ -186,7 +186,7 @@ export const MembershipMiddleware = HttpRouter.middleware<{ provides: CurrentTea if (Option.isNone(teammates)) { return yield* Effect.die("Teammates service missing from the request context") } - const teammate = yield* teammates.value.admit(identity).pipe(Effect.orDie) + const teammate = yield* teammates.value.admit(identity) return yield* app.pipe( Effect.provideService(CurrentTeammate, teammate), Effect.annotateLogs({ teammateId: teammate.teammateId }), diff --git a/apps/cluster/src/Ingress/Routes.ts b/apps/cluster/src/Ingress/Routes.ts index 53662a56..b9ede9ee 100644 --- a/apps/cluster/src/Ingress/Routes.ts +++ b/apps/cluster/src/Ingress/Routes.ts @@ -12,7 +12,7 @@ import { makeAuthenticatedMiddlewareLayer, RateLimitMiddlewareLayer, } from "./Middleware.ts" -import { AccountRoutesLayer } from "./Account.ts" +import { AccountGroupLayer } from "./Account.ts" import { RulesRoutesLayer } from "./Rules.ts" import { ConnectionRoutesLayer } from "./Connections.ts" import { SyncGroupLayer } from "./Sync.ts" @@ -32,7 +32,7 @@ const ApiRouterLayer = Layer.effect( */ const makeHttpApiLayer = (middleware: AccessMiddlewareOptions) => HttpApiBuilder.layer(JanitorApi).pipe( - Layer.provide(SyncGroupLayer), + Layer.provide(Layer.mergeAll(SyncGroupLayer, AccountGroupLayer)), Layer.provide(makeApiMiddlewareLayer(middleware)), ) @@ -51,7 +51,6 @@ export const makeRoutesLayer = (secrets: IngressSecrets, middleware: AccessMiddl RulesRoutesLayer, PullRequestReviewRoutesLayer, ConnectionRoutesLayer, - AccountRoutesLayer, ).pipe(Layer.provide(makeAuthenticatedMiddlewareLayer(middleware))), ReadinessRoutesLayer.pipe(Layer.provide(makeAccessMiddlewareLayer(middleware))), ).pipe(Layer.provide(ApiRouterLayer)), diff --git a/apps/cluster/src/Linking/GitHub.ts b/apps/cluster/src/Linking/GitHub.ts index 1d2b483f..9359ad11 100644 --- a/apps/cluster/src/Linking/GitHub.ts +++ b/apps/cluster/src/Linking/GitHub.ts @@ -116,7 +116,7 @@ export const make = Effect.fnUntraced(function* (config: GitHubLinkConfig) { .pipe( Effect.flatMap(HttpClientResponse.filterStatusOk), Effect.flatMap(decodeUser), - Effect.mapError(failed("lookup-failed", "GitHub did not identify the authorized user.")), + Effect.orDie, Effect.map((response): ProvenAccount => ({ workspaceId: GITHUB_WORKSPACE_ID, accountId: response.body.id, diff --git a/apps/cluster/src/Linking/Proof.ts b/apps/cluster/src/Linking/Proof.ts index a0ba6651..7dc81dac 100644 --- a/apps/cluster/src/Linking/Proof.ts +++ b/apps/cluster/src/Linking/Proof.ts @@ -13,7 +13,6 @@ export type LinkProofReason = | "token-rejected" | "nonce-mismatch" | "wrong-workspace" - | "lookup-failed" /** The platform did not prove ownership. Never carries tokens or codes. */ export class LinkProofFailed extends Data.TaggedError("LinkProofFailed")<{ diff --git a/apps/cluster/src/Teammates.ts b/apps/cluster/src/Teammates.ts index 23b3eef6..21f21cdb 100644 --- a/apps/cluster/src/Teammates.ts +++ b/apps/cluster/src/Teammates.ts @@ -1,5 +1,8 @@ +import { AuthorizationExpired } from "@janitor/domain/GitHub/Authorization" import { + AccountAlreadyConnected, type AccountView, + ConnectedAccountNotFound, LinkId, type LinkedAccount, type LinkPlatform, @@ -14,7 +17,6 @@ import * as Layer from "effect/Layer" import * as Schema from "effect/Schema" import * as SqlClient from "effect/sql/SqlClient" import * as SqlError from "effect/sql/SqlError" -import { describeError } from "./SqlErrors.ts" /** * The identity behind Access sign-in. Access decides who may use Janitor; @@ -23,23 +25,6 @@ import { describeError } from "./SqlErrors.ts" * at; Access only proves who is at the browser right now. */ -export const TeammateErrorReason = Schema.Literals([ - "not-found", - "conflict", - "expired", - "rejected", - "unavailable", -]) -export type TeammateErrorReason = typeof TeammateErrorReason.Type - -export class TeammateError extends Schema.TaggedError()("TeammateError", { - reason: TeammateErrorReason, - message: Schema.String, -}) {} - -export const isTeammateError = (error: unknown): error is TeammateError => - error instanceof TeammateError - export interface SignInIdentity { readonly issuer: string readonly subject: string @@ -116,51 +101,37 @@ const isUniqueViolation = (error: unknown): boolean => { ) } -/** Anything the database refuses becomes `unavailable`, except an ownership race. */ -const wrap = ( - effect: Effect.Effect, -) => - effect.pipe( - Effect.mapError((error) => - error._tag === "TeammateError" - ? error - : isUniqueViolation(error) - ? new TeammateError({ - reason: "conflict", - message: "That account was just connected by another teammate.", - }) - : new TeammateError({ reason: "unavailable", message: describeError(error) }), - ), - ) +/** Admission already resolved the teammate, so a missing row is a defect. */ +const unknownTeammate = Effect.die("Unknown teammate") export class Teammates extends Context.Service< Teammates, { /** Resolves or creates the teammate for a verified Access identity. */ - readonly admit: (identity: SignInIdentity) => Effect.Effect + readonly admit: (identity: SignInIdentity) => Effect.Effect readonly account: ( teammateId: TeammateId, linking: LinkingAvailability, - ) => Effect.Effect + ) => Effect.Effect readonly disconnect: ( teammateId: TeammateId, - linkId: string, - ) => Effect.Effect + linkId: LinkId, + ) => Effect.Effect readonly beginLink: ( teammateId: TeammateId, platform: LinkPlatform, - ) => Effect.Effect + ) => Effect.Effect /** Single use: a second call with the same state fails as expired. */ readonly consumeLinkAttempt: ( teammateId: TeammateId, platform: LinkPlatform, state: string, - ) => Effect.Effect<{ readonly nonce: string }, TeammateError> + ) => Effect.Effect<{ readonly nonce: string }, AuthorizationExpired> /** Records a proven account. Replaces the teammate's earlier link in that workspace. */ readonly link: ( teammateId: TeammateId, proof: LinkProof, - ) => Effect.Effect + ) => Effect.Effect } >()("@janitor/cluster/Teammates", { make: Effect.gen(function* () { @@ -238,20 +209,18 @@ export class Teammates extends Context.Service< ) .pipe(Effect.flatMap(decodeTeammates)) return summary(rows[0]!) - }).pipe(wrap) + }).pipe(Effect.orDie) const account = (teammateId: TeammateId, linking: LinkingAvailability) => Effect.gen(function* () { const row = yield* loadTeammate(teammateId) - if (row === undefined) { - return yield* new TeammateError({ reason: "not-found", message: "Unknown teammate." }) - } + if (row === undefined) return yield* unknownTeammate const links = (yield* linksOf([teammateId])).map(linkedAccount) const view: AccountView = { teammate: summary(row), links, linking } return view - }).pipe(wrap) + }).pipe(Effect.orDie) - const disconnect = (teammateId: TeammateId, linkId: string) => + const disconnect = (teammateId: TeammateId, linkId: LinkId) => sql .withTransaction( Effect.gen(function* () { @@ -260,30 +229,23 @@ export class Teammates extends Context.Service< yield* sql`UPDATE teammate_link SET status = 'disconnected', ended_at = now() WHERE link_id::text = ${linkId} AND teammate_id::text = ${teammateId} AND status = 'active' RETURNING link_id` - if (rows.length === 0) { - return yield* new TeammateError({ - reason: "not-found", - message: "That connected account is not yours or is already disconnected.", - }) - } + if (rows.length === 0) return yield* new ConnectedAccountNotFound({ linkId }) yield* advanceIdentityRevision(teammateId) yield* audit(teammateId, teammateId, "disconnect", { linkId }) }), ) - .pipe(wrap) + .pipe(Effect.catchTag(["SqlError", "SchemaError"], Effect.die)) const beginLink = (teammateId: TeammateId, platform: LinkPlatform) => Effect.gen(function* () { const row = yield* loadTeammate(teammateId) - if (row === undefined) { - return yield* new TeammateError({ reason: "not-found", message: "Unknown teammate." }) - } + if (row === undefined) return yield* unknownTeammate const [attempt] = yield* sql<{ state: string; nonce: string }>` INSERT INTO teammate_link_attempt (teammate_id, platform) VALUES (${teammateId}::uuid, ${platform}) RETURNING state::text, nonce::text` yield* sql`DELETE FROM teammate_link_attempt WHERE expires_at < now()` return { state: attempt!.state, nonce: attempt!.nonce } - }).pipe(wrap) + }).pipe(Effect.orDie) const consumeLinkAttempt = (teammateId: TeammateId, platform: LinkPlatform, state: string) => Effect.gen(function* () { @@ -293,23 +255,16 @@ export class Teammates extends Context.Service< WHERE state::text = ${state} AND teammate_id::text = ${teammateId} AND platform = ${platform} AND expires_at > now() RETURNING nonce::text` - if (rows.length === 0) { - return yield* new TeammateError({ - reason: "expired", - message: "This connection attempt expired or was already used. Start again.", - }) - } + if (rows.length === 0) return yield* new AuthorizationExpired() return { nonce: rows[0]!.nonce } - }).pipe(wrap) + }).pipe(Effect.catchTag("SqlError", Effect.die)) const link = (teammateId: TeammateId, proof: LinkProof) => sql .withTransaction( Effect.gen(function* () { const teammate = yield* lockTeammate(teammateId) - if (teammate === undefined) { - return yield* new TeammateError({ reason: "not-found", message: "Unknown teammate." }) - } + if (teammate === undefined) return yield* unknownTeammate const owners = yield* sql .unsafe( `SELECT ${LINK_COLUMNS} FROM teammate_link @@ -320,10 +275,7 @@ export class Teammates extends Context.Service< .pipe(Effect.flatMap(decodeLinks)) const owner = owners[0] if (owner !== undefined && owner.teammate_id !== teammateId) { - return yield* new TeammateError({ - reason: "conflict", - message: "That account is connected to another teammate.", - }) + return yield* new AccountAlreadyConnected() } if (owner !== undefined) { // Fresh proof of the same account: keep the link, note the re-proof. @@ -357,7 +309,11 @@ export class Teammates extends Context.Service< return linkedAccount(inserted[0]!) }), ) - .pipe(wrap) + .pipe( + // A concurrent link of the same account loses on the unique index. + Effect.catchIf(isUniqueViolation, () => Effect.fail(new AccountAlreadyConnected())), + Effect.catchTag(["SqlError", "SchemaError"], Effect.die), + ) return { admit, diff --git a/apps/cluster/test/AccountLinking.test.ts b/apps/cluster/test/AccountLinking.test.ts index 90aa2398..1727ef89 100644 --- a/apps/cluster/test/AccountLinking.test.ts +++ b/apps/cluster/test/AccountLinking.test.ts @@ -1,6 +1,8 @@ import { assert, layer } from "@effect/vitest" +import * as Cause from "effect/Cause" import * as Context from "effect/Context" import * as Effect from "effect/Effect" +import * as Exit from "effect/Exit" import * as Layer from "effect/Layer" import * as Option from "effect/Option" import * as Redacted from "effect/Redacted" @@ -99,11 +101,11 @@ layer(Services, { timeout: "2 minutes" })("Account linking", (it) => { const foreign = yield* Effect.flip( linking.complete(other.teammateId, "github", { state, code: "github-code" }), ) - assert.strictEqual(foreign.reason, "expired") + assert.strictEqual(foreign._tag, "AuthorizationExpired") const refused = yield* Effect.flip( linking.complete(admin.teammateId, "github", { state, code: "bad-code" }), ) - assert.strictEqual(refused.reason, "rejected") + assert.strictEqual(refused._tag, "AuthorizationRejected") const fresh = new URL(yield* linking.start(admin.teammateId, "github")) platforms.seen.length = 0 const freshState = fresh.searchParams.get("state")! @@ -115,7 +117,7 @@ layer(Services, { timeout: "2 minutes" })("Account linking", (it) => { const replay = yield* Effect.flip( linking.complete(admin.teammateId, "github", { state: freshState, code: "github-code" }), ) - assert.strictEqual(replay.reason, "expired") + assert.strictEqual(replay._tag, "AuthorizationExpired") assert.deepStrictEqual( [linked.platform, linked.workspaceId, linked.accountId, linked.displayName], ["github", "github.com", "4242", "octocat"], @@ -140,8 +142,8 @@ layer(Services, { timeout: "2 minutes" })("Account linking", (it) => { Effect.provideService(HttpClient.HttpClient, fakePlatforms(platforms)), ) assert.deepStrictEqual(unconfigured.availability, { github: false }) - const unavailable = yield* Effect.flip(unconfigured.start(admin.teammateId, "github")) - assert.strictEqual(unavailable.reason, "unavailable") + const unavailable = yield* Effect.exit(unconfigured.start(admin.teammateId, "github")) + assert.isTrue(Exit.isFailure(unavailable) && Cause.hasDies(unavailable.cause)) }), ) }) diff --git a/apps/cluster/test/Teammates.test.ts b/apps/cluster/test/Teammates.test.ts index 22b20490..970db274 100644 --- a/apps/cluster/test/Teammates.test.ts +++ b/apps/cluster/test/Teammates.test.ts @@ -60,7 +60,7 @@ layer(Services, { timeout: "2 minutes" })("Teammates", (it) => { const conflict = yield* Effect.flip( teammates.link(other.teammateId, { ...github, accountId: "100", displayName: "impostor" }), ) - assert.strictEqual(conflict.reason, "conflict") + assert.strictEqual(conflict._tag, "AccountAlreadyConnected") // One GitHub account per teammate; a second one replaces the first. const replacement = yield* teammates.link(owner.teammateId, { ...github, @@ -87,7 +87,8 @@ layer(Services, { timeout: "2 minutes" })("Teammates", (it) => { assert.strictEqual(released[0]?.status, "disconnected") // Only the owner can disconnect a link. const foreign = yield* Effect.flip(teammates.disconnect(owner.teammateId, freed.linkId)) - assert.strictEqual(foreign.reason, "not-found") + assert.strictEqual(foreign._tag, "ConnectedAccountNotFound") + assert.strictEqual(foreign.linkId, freed.linkId) }), ) @@ -127,7 +128,7 @@ layer(Services, { timeout: "2 minutes" })("Teammates", (it) => { const wrongTeammate = yield* Effect.flip( teammates.consumeLinkAttempt(other.teammateId, "github", attempt.state), ) - assert.strictEqual(wrongTeammate.reason, "expired") + assert.strictEqual(wrongTeammate._tag, "AuthorizationExpired") const consumed = yield* teammates.consumeLinkAttempt( owner.teammateId, "github", @@ -137,17 +138,17 @@ layer(Services, { timeout: "2 minutes" })("Teammates", (it) => { const replay = yield* Effect.flip( teammates.consumeLinkAttempt(owner.teammateId, "github", attempt.state), ) - assert.strictEqual(replay.reason, "expired") + assert.strictEqual(replay._tag, "AuthorizationExpired") const stale = yield* teammates.beginLink(owner.teammateId, "github") yield* sql`UPDATE teammate_link_attempt SET expires_at = now() - interval '1 minute' WHERE state::text = ${stale.state}` const expired = yield* Effect.flip( teammates.consumeLinkAttempt(owner.teammateId, "github", stale.state), ) - assert.strictEqual(expired.reason, "expired") + assert.strictEqual(expired._tag, "AuthorizationExpired") const garbage = yield* Effect.flip( teammates.consumeLinkAttempt(owner.teammateId, "github", "not-a-state"), ) - assert.strictEqual(garbage.reason, "expired") + assert.strictEqual(garbage._tag, "AuthorizationExpired") }), ) }) diff --git a/apps/web/src/components/account.ts b/apps/web/src/components/account.ts index 61abc4bd..e0d29c26 100644 --- a/apps/web/src/components/account.ts +++ b/apps/web/src/components/account.ts @@ -1,6 +1,7 @@ import { AccountView, LinkedAccount, + LinkId, LinkPlatform, type TeammateSummary, } from "@janitor/domain/Team/Account" @@ -9,8 +10,6 @@ import * as Effect from "effect/Effect" import * as Option from "effect/Option" import * as Schema from "effect/Schema" import * as Stream from "effect/Stream" -import * as HttpClient from "effect/http/HttpClient" -import * as HttpIncomingMessage from "effect/http/HttpIncomingMessage" import * as Command from "foldkit/command" import type { Html, HtmlBuilder } from "foldkit/html" import { defineMessageUnion } from "foldkit/message" @@ -23,7 +22,7 @@ import { platformMark } from "@/components/ui/mark" import { panel } from "@/components/ui/panel" import { rack } from "@/components/ui/rack" import { sign } from "@/components/ui/sign" -import { readFailure, reasonOf, request } from "@/lib/api" +import { ApiClient } from "@/lib/api-client" import * as Routes from "@/routes" /** @@ -70,7 +69,7 @@ export const Message = defineMessageUnion({ LoadFailed: { reason: Schema.String, requestId: Schema.Int }, ClickedConnect: { platform: LinkPlatform }, GotPlatformUrl: { url: Schema.String, operationId: Schema.Int }, - ClickedDisconnect: { linkId: Schema.String }, + ClickedDisconnect: { linkId: LinkId }, Changed: { operationId: Schema.Int, notice: Schema.String }, Returned: { operationId: Schema.Int, linked: LinkedAccount }, Failed: { reason: Schema.String, operationId: Schema.Int }, @@ -91,23 +90,20 @@ export interface ReturnParams { readonly code: string } -const base = "/api/v1/account" -const failed = (error: unknown, operationId: number) => - Message.Failed({ operationId, reason: reasonOf(error) }) +const failed = (reason: string, operationId: number) => Message.Failed({ operationId, reason }) +const retry = (operationId: number) => failed("The request failed. Please retry.", operationId) export const Load = Command.define("LoadAccount", { args: { requestId: Schema.Int }, messages: [Message.Loaded, Message.LoadFailed], execute: ({ requestId }) => - HttpClient.get(base).pipe( - Effect.flatMap((response) => - response.status === 200 - ? HttpIncomingMessage.schemaBodyJson(AccountView)(response) - : readFailure(response, "Could not load your account. Retry to continue."), - ), + ApiClient.use((api) => api.account.get()).pipe( Effect.map((view) => Message.Loaded({ view, requestId })), - Effect.catch((error) => - Effect.succeed(Message.LoadFailed({ reason: reasonOf(error), requestId })), + Effect.orElseSucceed(() => + Message.LoadFailed({ + reason: "Could not load your account. Retry to continue.", + requestId, + }), ), ), }) @@ -122,10 +118,9 @@ const Start = Command.define("StartAccountLink", { args: { platform: LinkPlatform, operationId: Schema.Int }, messages: [Message.GotPlatformUrl, Message.Failed], execute: ({ platform, operationId }) => - request("POST", `${base}/links/${platform}/start`, {}).pipe( - Effect.flatMap(HttpIncomingMessage.schemaBodyJson(Schema.Struct({ url: Schema.String }))), + ApiClient.use((api) => api.account.startLink({ params: { platform } })).pipe( Effect.map(({ url }) => Message.GotPlatformUrl({ url, operationId })), - Effect.catch((error) => Effect.succeed(failed(error, operationId))), + Effect.orElseSucceed(() => retry(operationId)), ), }) @@ -138,24 +133,48 @@ const CompleteLink = Command.define("CompleteAccountLink", { }, messages: [Message.Returned, Message.Failed], execute: ({ platform, state, code, operationId }) => - request("POST", `${base}/links/${platform}/return`, { state, code }).pipe( - Effect.flatMap(HttpIncomingMessage.schemaBodyJson(LinkedAccount)), + ApiClient.use((api) => + api.account.completeLink({ params: { platform }, payload: { state, code } }), + ).pipe( Effect.map((linked) => Message.Returned({ linked, operationId })), - Effect.catch((error) => Effect.succeed(failed(error, operationId))), + Effect.catchTags({ + AuthorizationExpired: () => + Effect.succeed( + failed( + "This connection attempt expired or was already used. Start again.", + operationId, + ), + ), + AuthorizationRejected: () => + Effect.succeed( + failed( + `${platformNames[platform]} did not accept the authorization. Try again.`, + operationId, + ), + ), + AccountAlreadyConnected: () => + Effect.succeed(failed("That account is connected to another teammate.", operationId)), + }), + Effect.orElseSucceed(() => retry(operationId)), ), }) const Disconnect = Command.define("DisconnectAccountLink", { - args: { linkId: Schema.String, operationId: Schema.Int }, + args: { linkId: LinkId, operationId: Schema.Int }, messages: [Message.Changed, Message.Failed], execute: ({ linkId, operationId }) => - request("DELETE", `${base}/links/${encodeURIComponent(linkId)}`, {}).pipe( + ApiClient.use((api) => api.account.unlink({ params: { linkId } })).pipe( Effect.as(Message.Changed({ operationId, notice: "Account disconnected." })), - Effect.catch((error) => Effect.succeed(failed(error, operationId))), + Effect.catchTag("ConnectedAccountNotFound", () => + Effect.succeed( + failed("That connected account is not yours or is already disconnected.", operationId), + ), + ), + Effect.orElseSucceed(() => retry(operationId)), ), }) -type Step = Update.ReturnWithOutMessage +type Step = Update.ReturnWithOutMessage const isBusy = (model: Model): boolean => Option.isSome(model.pending) const matchesOperation = (model: Model, operationId: number) => diff --git a/packages/domain/src/Api.ts b/packages/domain/src/Api.ts index f62d1f61..c91e7b15 100644 --- a/packages/domain/src/Api.ts +++ b/packages/domain/src/Api.ts @@ -1,8 +1,12 @@ import * as HttpApi from "effect/http-api/HttpApi" +import { AccountGroup } from "./Api/Account.ts" import { SyncGroup } from "./Api/Sync.ts" /** * Janitor's HTTP API, shared by the cluster's handlers and the web app's * derived client. Middleware stays on the router, outside the definition. */ -export class JanitorApi extends HttpApi.make("janitor").add(SyncGroup).prefix("/api/v1") {} +export class JanitorApi extends HttpApi.make("janitor") + .add(SyncGroup) + .add(AccountGroup) + .prefix("/api/v1") {} diff --git a/packages/domain/src/Api/Account.ts b/packages/domain/src/Api/Account.ts new file mode 100644 index 00000000..1a90f08a --- /dev/null +++ b/packages/domain/src/Api/Account.ts @@ -0,0 +1,37 @@ +import * as Schema from "effect/Schema" +import * as HttpApiEndpoint from "effect/http-api/HttpApiEndpoint" +import * as HttpApiGroup from "effect/http-api/HttpApiGroup" +import { AuthorizationExpired, AuthorizationRejected } from "../GitHub/Authorization.ts" +import { + AccountAlreadyConnected, + AccountView, + ConnectedAccountNotFound, + LinkedAccount, + LinkId, + LinkPlatform, + LinkReturnRequest, +} from "../Team/Account.ts" + +/** The account page: the signed-in teammate's view, and proving or dropping a platform account. */ +export class AccountGroup extends HttpApiGroup.make("account") + .add(HttpApiEndpoint.get("get", "/account", { success: AccountView })) + .add( + HttpApiEndpoint.post("startLink", "/account/links/:platform/start", { + params: { platform: LinkPlatform }, + success: Schema.Struct({ url: Schema.String }), + }), + ) + .add( + HttpApiEndpoint.post("completeLink", "/account/links/:platform/return", { + params: { platform: LinkPlatform }, + payload: LinkReturnRequest, + success: LinkedAccount, + error: [AuthorizationExpired, AuthorizationRejected, AccountAlreadyConnected], + }), + ) + .add( + HttpApiEndpoint.delete("unlink", "/account/links/:linkId", { + params: { linkId: LinkId }, + error: ConnectedAccountNotFound, + }), + ) {} diff --git a/packages/domain/src/GitHub/Authorization.ts b/packages/domain/src/GitHub/Authorization.ts new file mode 100644 index 00000000..a1803a8c --- /dev/null +++ b/packages/domain/src/GitHub/Authorization.ts @@ -0,0 +1,15 @@ +import * as Schema from "effect/Schema" + +/** The authorization attempt is unknown, already used, expired, or belongs to someone else. */ +export class AuthorizationExpired extends Schema.TaggedError()( + "AuthorizationExpired", + {}, + { httpApiStatus: 410 }, +) {} + +/** GitHub did not accept the authorization code it sent back. */ +export class AuthorizationRejected extends Schema.TaggedError()( + "AuthorizationRejected", + {}, + { httpApiStatus: 422 }, +) {} diff --git a/packages/domain/src/Team/Account.ts b/packages/domain/src/Team/Account.ts index 866ce61a..bb0a8e79 100644 --- a/packages/domain/src/Team/Account.ts +++ b/packages/domain/src/Team/Account.ts @@ -68,3 +68,17 @@ export const LinkReturnRequest = Schema.Struct({ code: Schema.String, }) export type LinkReturnRequest = typeof LinkReturnRequest.Type + +/** No active link with this id belongs to the teammate. */ +export class ConnectedAccountNotFound extends Schema.TaggedError()( + "ConnectedAccountNotFound", + { linkId: LinkId }, + { httpApiStatus: 404 }, +) {} + +/** The platform account is already connected to another teammate. */ +export class AccountAlreadyConnected extends Schema.TaggedError()( + "AccountAlreadyConnected", + {}, + { httpApiStatus: 409 }, +) {}