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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 13 additions & 17 deletions apps/cluster/src/AccountLinking.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,9 @@
import {
type AuthorizationExpired,
AuthorizationRejected,
} from "@janitor/domain/GitHub/Authorization"
import {
type AccountAlreadyConnected,
type LinkedAccount,
type LinkingAvailability,
type LinkPlatform,
Expand All @@ -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
Expand Down Expand Up @@ -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<string, TeammateError>
readonly start: (teammateId: TeammateId, platform: LinkPlatform) => Effect.Effect<string>
readonly complete: (
teammateId: TeammateId,
platform: LinkPlatform,
request: LinkReturnRequest,
) => Effect.Effect<LinkedAccount, TeammateError>
) => Effect.Effect<
LinkedAccount,
AuthorizationExpired | AuthorizationRejected | AccountAlreadyConnected
>
}
>()("@janitor/cluster/AccountLinking", {
make: Effect.gen(function* () {
Expand All @@ -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,
})

Expand All @@ -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 })
})
Expand All @@ -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<ProvenAccount, LinkProofFailed>
Expand Down
127 changes: 38 additions & 89 deletions apps/cluster/src/Ingress/Account.ts
Original file line number Diff line number Diff line change
@@ -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<TeammateErrorReason, number> = {
"not-found": 404,
conflict: 409,
expired: 410,
rejected: 422,
unavailable: 503,
}

/** Teammate outcomes become responses; request-shape errors keep their own status. */
const handled = <E, R>(effect: Effect.Effect<Response.HttpServerResponse, E | TeammateError, R>) =>
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)
2 changes: 1 addition & 1 deletion apps/cluster/src/Ingress/Middleware.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 }),
Expand Down
5 changes: 2 additions & 3 deletions apps/cluster/src/Ingress/Routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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)),
)

Expand All @@ -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)),
Expand Down
2 changes: 1 addition & 1 deletion apps/cluster/src/Linking/GitHub.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
1 change: 0 additions & 1 deletion apps/cluster/src/Linking/Proof.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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")<{
Expand Down
Loading