mirror of
https://gitea.toothfairyai.com/ToothFairyAI/tf_code.git
synced 2026-04-07 01:08:58 +00:00
core: make account login upgrades safe while adding multi-account workspace auth (#15487)
Co-authored-by: Kit Langton <kit.langton@gmail.com> Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
35
packages/opencode/src/account/account.sql.ts
Normal file
35
packages/opencode/src/account/account.sql.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import { sqliteTable, text, integer, primaryKey } from "drizzle-orm/sqlite-core"
|
||||
import { Timestamps } from "../storage/schema.sql"
|
||||
|
||||
export const AccountTable = sqliteTable("account", {
|
||||
id: text().primaryKey(),
|
||||
email: text().notNull(),
|
||||
url: text().notNull(),
|
||||
access_token: text().notNull(),
|
||||
refresh_token: text().notNull(),
|
||||
token_expiry: integer(),
|
||||
...Timestamps,
|
||||
})
|
||||
|
||||
export const AccountStateTable = sqliteTable("account_state", {
|
||||
id: integer().primaryKey(),
|
||||
active_account_id: text().references(() => AccountTable.id, { onDelete: "set null" }),
|
||||
active_org_id: text(),
|
||||
})
|
||||
|
||||
// LEGACY
|
||||
export const ControlAccountTable = sqliteTable(
|
||||
"control_account",
|
||||
{
|
||||
email: text().notNull(),
|
||||
url: text().notNull(),
|
||||
access_token: text().notNull(),
|
||||
refresh_token: text().notNull(),
|
||||
token_expiry: integer(),
|
||||
active: integer({ mode: "boolean" })
|
||||
.notNull()
|
||||
.$default(() => false),
|
||||
...Timestamps,
|
||||
},
|
||||
(table) => [primaryKey({ columns: [table.email, table.url] })],
|
||||
)
|
||||
43
packages/opencode/src/account/index.ts
Normal file
43
packages/opencode/src/account/index.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import { Effect, Option, ServiceMap } from "effect"
|
||||
|
||||
import {
|
||||
Account as AccountSchema,
|
||||
type AccountError,
|
||||
type AccessToken,
|
||||
AccountID,
|
||||
AccountService,
|
||||
OrgID,
|
||||
} from "./service"
|
||||
|
||||
export { AccessToken, AccountID, OrgID } from "./service"
|
||||
|
||||
import { runtime } from "@/effect/runtime"
|
||||
|
||||
type AccountServiceShape = ServiceMap.Service.Shape<typeof AccountService>
|
||||
|
||||
function runSync<A>(f: (service: AccountServiceShape) => Effect.Effect<A, AccountError>) {
|
||||
return runtime.runSync(AccountService.use(f))
|
||||
}
|
||||
|
||||
function runPromise<A>(f: (service: AccountServiceShape) => Effect.Effect<A, AccountError>) {
|
||||
return runtime.runPromise(AccountService.use(f))
|
||||
}
|
||||
|
||||
export namespace Account {
|
||||
export const Account = AccountSchema
|
||||
export type Account = AccountSchema
|
||||
|
||||
export function active(): Account | undefined {
|
||||
return Option.getOrUndefined(runSync((service) => service.active()))
|
||||
}
|
||||
|
||||
export async function config(accountID: AccountID, orgID: OrgID): Promise<Record<string, unknown> | undefined> {
|
||||
const config = await runPromise((service) => service.config(accountID, orgID))
|
||||
return Option.getOrUndefined(config)
|
||||
}
|
||||
|
||||
export async function token(accountID: AccountID): Promise<AccessToken | undefined> {
|
||||
const token = await runPromise((service) => service.token(accountID))
|
||||
return Option.getOrUndefined(token)
|
||||
}
|
||||
}
|
||||
138
packages/opencode/src/account/repo.ts
Normal file
138
packages/opencode/src/account/repo.ts
Normal file
@@ -0,0 +1,138 @@
|
||||
import { eq } from "drizzle-orm"
|
||||
import { Effect, Layer, Option, Schema, ServiceMap } from "effect"
|
||||
|
||||
import { Database } from "@/storage/db"
|
||||
import { AccountStateTable, AccountTable } from "./account.sql"
|
||||
import { Account, AccountID, AccountRepoError, OrgID } from "./schema"
|
||||
|
||||
export type AccountRow = (typeof AccountTable)["$inferSelect"]
|
||||
|
||||
const decodeAccount = Schema.decodeUnknownSync(Account)
|
||||
|
||||
type DbClient = Parameters<typeof Database.use>[0] extends (db: infer T) => unknown ? T : never
|
||||
|
||||
const ACCOUNT_STATE_ID = 1
|
||||
|
||||
const db = <A>(run: (db: DbClient) => A) =>
|
||||
Effect.try({
|
||||
try: () => Database.use(run),
|
||||
catch: (cause) => new AccountRepoError({ message: "Database operation failed", cause }),
|
||||
})
|
||||
|
||||
const current = (db: DbClient) => {
|
||||
const state = db.select().from(AccountStateTable).where(eq(AccountStateTable.id, ACCOUNT_STATE_ID)).get()
|
||||
if (!state?.active_account_id) return
|
||||
const account = db.select().from(AccountTable).where(eq(AccountTable.id, state.active_account_id)).get()
|
||||
if (!account) return
|
||||
return { ...account, active_org_id: state.active_org_id ?? null }
|
||||
}
|
||||
|
||||
const setState = (db: DbClient, accountID: AccountID, orgID: string | null) =>
|
||||
db
|
||||
.insert(AccountStateTable)
|
||||
.values({ id: ACCOUNT_STATE_ID, active_account_id: accountID, active_org_id: orgID })
|
||||
.onConflictDoUpdate({
|
||||
target: AccountStateTable.id,
|
||||
set: { active_account_id: accountID, active_org_id: orgID },
|
||||
})
|
||||
.run()
|
||||
|
||||
export class AccountRepo extends ServiceMap.Service<
|
||||
AccountRepo,
|
||||
{
|
||||
readonly active: () => Effect.Effect<Option.Option<Account>, AccountRepoError>
|
||||
readonly list: () => Effect.Effect<Account[], AccountRepoError>
|
||||
readonly remove: (accountID: AccountID) => Effect.Effect<void, AccountRepoError>
|
||||
readonly use: (accountID: AccountID, orgID: Option.Option<OrgID>) => Effect.Effect<void, AccountRepoError>
|
||||
readonly getRow: (accountID: AccountID) => Effect.Effect<Option.Option<AccountRow>, AccountRepoError>
|
||||
readonly persistToken: (input: {
|
||||
accountID: AccountID
|
||||
accessToken: string
|
||||
refreshToken: string
|
||||
expiry: Option.Option<number>
|
||||
}) => Effect.Effect<void, AccountRepoError>
|
||||
readonly persistAccount: (input: {
|
||||
id: AccountID
|
||||
email: string
|
||||
url: string
|
||||
accessToken: string
|
||||
refreshToken: string
|
||||
expiry: number
|
||||
orgID: Option.Option<OrgID>
|
||||
}) => Effect.Effect<void, AccountRepoError>
|
||||
}
|
||||
>()("@opencode/AccountRepo") {
|
||||
static readonly layer: Layer.Layer<AccountRepo> = Layer.succeed(
|
||||
AccountRepo,
|
||||
AccountRepo.of({
|
||||
active: Effect.fn("AccountRepo.active")(() =>
|
||||
db((db) => current(db)).pipe(Effect.map((row) => (row ? Option.some(decodeAccount(row)) : Option.none()))),
|
||||
),
|
||||
|
||||
list: Effect.fn("AccountRepo.list")(() => db((db) => db.select().from(AccountTable).all().map((row) => decodeAccount({ ...row, active_org_id: null })))),
|
||||
|
||||
remove: Effect.fn("AccountRepo.remove")((accountID: AccountID) =>
|
||||
db((db) =>
|
||||
Database.transaction((tx) => {
|
||||
tx.update(AccountStateTable)
|
||||
.set({ active_account_id: null, active_org_id: null })
|
||||
.where(eq(AccountStateTable.active_account_id, accountID))
|
||||
.run()
|
||||
tx.delete(AccountTable).where(eq(AccountTable.id, accountID)).run()
|
||||
}),
|
||||
).pipe(Effect.asVoid),
|
||||
),
|
||||
|
||||
use: Effect.fn("AccountRepo.use")((accountID: AccountID, orgID: Option.Option<OrgID>) =>
|
||||
db((db) => setState(db, accountID, Option.getOrNull(orgID))).pipe(Effect.asVoid),
|
||||
),
|
||||
|
||||
getRow: Effect.fn("AccountRepo.getRow")((accountID: AccountID) =>
|
||||
db((db) => db.select().from(AccountTable).where(eq(AccountTable.id, accountID)).get()).pipe(
|
||||
Effect.map(Option.fromNullishOr),
|
||||
),
|
||||
),
|
||||
|
||||
persistToken: Effect.fn("AccountRepo.persistToken")((input) =>
|
||||
db((db) =>
|
||||
db
|
||||
.update(AccountTable)
|
||||
.set({
|
||||
access_token: input.accessToken,
|
||||
refresh_token: input.refreshToken,
|
||||
token_expiry: Option.getOrNull(input.expiry),
|
||||
})
|
||||
.where(eq(AccountTable.id, input.accountID))
|
||||
.run(),
|
||||
).pipe(Effect.asVoid),
|
||||
),
|
||||
|
||||
persistAccount: Effect.fn("AccountRepo.persistAccount")((input) => {
|
||||
const orgID = Option.getOrNull(input.orgID)
|
||||
return db((db) =>
|
||||
Database.transaction((tx) => {
|
||||
tx.insert(AccountTable)
|
||||
.values({
|
||||
id: input.id,
|
||||
email: input.email,
|
||||
url: input.url,
|
||||
access_token: input.accessToken,
|
||||
refresh_token: input.refreshToken,
|
||||
token_expiry: input.expiry,
|
||||
})
|
||||
.onConflictDoUpdate({
|
||||
target: AccountTable.id,
|
||||
set: {
|
||||
access_token: input.accessToken,
|
||||
refresh_token: input.refreshToken,
|
||||
token_expiry: input.expiry,
|
||||
},
|
||||
})
|
||||
.run()
|
||||
setState(tx, input.id, orgID)
|
||||
}),
|
||||
).pipe(Effect.asVoid)
|
||||
}),
|
||||
}),
|
||||
)
|
||||
}
|
||||
73
packages/opencode/src/account/schema.ts
Normal file
73
packages/opencode/src/account/schema.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
import { Schema } from "effect"
|
||||
|
||||
import { withStatics } from "@/util/schema"
|
||||
|
||||
export const AccountID = Schema.String.pipe(
|
||||
Schema.brand("AccountId"),
|
||||
withStatics((s) => ({ make: (id: string) => s.makeUnsafe(id) })),
|
||||
)
|
||||
export type AccountID = Schema.Schema.Type<typeof AccountID>
|
||||
|
||||
export const OrgID = Schema.String.pipe(
|
||||
Schema.brand("OrgId"),
|
||||
withStatics((s) => ({ make: (id: string) => s.makeUnsafe(id) })),
|
||||
)
|
||||
export type OrgID = Schema.Schema.Type<typeof OrgID>
|
||||
|
||||
export const AccessToken = Schema.String.pipe(
|
||||
Schema.brand("AccessToken"),
|
||||
withStatics((s) => ({ make: (token: string) => s.makeUnsafe(token) })),
|
||||
)
|
||||
export type AccessToken = Schema.Schema.Type<typeof AccessToken>
|
||||
|
||||
export class Account extends Schema.Class<Account>("Account")({
|
||||
id: AccountID,
|
||||
email: Schema.String,
|
||||
url: Schema.String,
|
||||
active_org_id: Schema.NullOr(OrgID),
|
||||
}) {}
|
||||
|
||||
export class Org extends Schema.Class<Org>("Org")({
|
||||
id: OrgID,
|
||||
name: Schema.String,
|
||||
}) {}
|
||||
|
||||
export class AccountRepoError extends Schema.TaggedErrorClass<AccountRepoError>()("AccountRepoError", {
|
||||
message: Schema.String,
|
||||
cause: Schema.optional(Schema.Defect),
|
||||
}) {}
|
||||
|
||||
export class AccountServiceError extends Schema.TaggedErrorClass<AccountServiceError>()("AccountServiceError", {
|
||||
message: Schema.String,
|
||||
cause: Schema.optional(Schema.Defect),
|
||||
}) {}
|
||||
|
||||
export type AccountError = AccountRepoError | AccountServiceError
|
||||
|
||||
export class Login extends Schema.Class<Login>("Login")({
|
||||
code: Schema.String,
|
||||
user: Schema.String,
|
||||
url: Schema.String,
|
||||
server: Schema.String,
|
||||
expiry: Schema.Number,
|
||||
interval: Schema.Number,
|
||||
}) {}
|
||||
|
||||
export class PollSuccess extends Schema.TaggedClass<PollSuccess>()("PollSuccess", {
|
||||
email: Schema.String,
|
||||
}) {}
|
||||
|
||||
export class PollPending extends Schema.TaggedClass<PollPending>()("PollPending", {}) {}
|
||||
|
||||
export class PollSlow extends Schema.TaggedClass<PollSlow>()("PollSlow", {}) {}
|
||||
|
||||
export class PollExpired extends Schema.TaggedClass<PollExpired>()("PollExpired", {}) {}
|
||||
|
||||
export class PollDenied extends Schema.TaggedClass<PollDenied>()("PollDenied", {}) {}
|
||||
|
||||
export class PollError extends Schema.TaggedClass<PollError>()("PollError", {
|
||||
cause: Schema.Defect,
|
||||
}) {}
|
||||
|
||||
export const PollResult = Schema.Union([PollSuccess, PollPending, PollSlow, PollExpired, PollDenied, PollError])
|
||||
export type PollResult = Schema.Schema.Type<typeof PollResult>
|
||||
384
packages/opencode/src/account/service.ts
Normal file
384
packages/opencode/src/account/service.ts
Normal file
@@ -0,0 +1,384 @@
|
||||
import { Clock, Effect, Layer, Option, Schema, ServiceMap } from "effect"
|
||||
import {
|
||||
FetchHttpClient,
|
||||
HttpClient,
|
||||
HttpClientError,
|
||||
HttpClientRequest,
|
||||
HttpClientResponse,
|
||||
} from "effect/unstable/http"
|
||||
|
||||
import { withTransientReadRetry } from "@/util/effect-http-client"
|
||||
import { AccountRepo, type AccountRow } from "./repo"
|
||||
import {
|
||||
type AccountError,
|
||||
AccessToken,
|
||||
Account,
|
||||
AccountID,
|
||||
AccountServiceError,
|
||||
Login,
|
||||
Org,
|
||||
OrgID,
|
||||
PollDenied,
|
||||
PollError,
|
||||
PollExpired,
|
||||
PollPending,
|
||||
type PollResult,
|
||||
PollSlow,
|
||||
PollSuccess,
|
||||
} from "./schema"
|
||||
|
||||
export * from "./schema"
|
||||
|
||||
export type AccountOrgs = {
|
||||
account: Account
|
||||
orgs: Org[]
|
||||
}
|
||||
|
||||
const RemoteOrg = Schema.Struct({
|
||||
id: Schema.optional(OrgID),
|
||||
name: Schema.optional(Schema.String),
|
||||
})
|
||||
|
||||
const RemoteOrgs = Schema.Array(RemoteOrg)
|
||||
|
||||
const RemoteConfig = Schema.Struct({
|
||||
config: Schema.Record(Schema.String, Schema.Json),
|
||||
})
|
||||
|
||||
const TokenRefresh = Schema.Struct({
|
||||
access_token: Schema.String,
|
||||
refresh_token: Schema.optional(Schema.String),
|
||||
expires_in: Schema.optional(Schema.Number),
|
||||
})
|
||||
|
||||
const DeviceCode = Schema.Struct({
|
||||
device_code: Schema.String,
|
||||
user_code: Schema.String,
|
||||
verification_uri_complete: Schema.String,
|
||||
expires_in: Schema.Number,
|
||||
interval: Schema.Number,
|
||||
})
|
||||
|
||||
const DeviceToken = Schema.Struct({
|
||||
access_token: Schema.optional(Schema.String),
|
||||
refresh_token: Schema.optional(Schema.String),
|
||||
expires_in: Schema.optional(Schema.Number),
|
||||
error: Schema.optional(Schema.String),
|
||||
error_description: Schema.optional(Schema.String),
|
||||
})
|
||||
|
||||
const User = Schema.Struct({
|
||||
id: Schema.optional(AccountID),
|
||||
email: Schema.optional(Schema.String),
|
||||
})
|
||||
|
||||
const ClientId = Schema.Struct({ client_id: Schema.String })
|
||||
|
||||
const DeviceTokenRequest = Schema.Struct({
|
||||
grant_type: Schema.String,
|
||||
device_code: Schema.String,
|
||||
client_id: Schema.String,
|
||||
})
|
||||
|
||||
const clientId = "opencode-cli"
|
||||
|
||||
const toAccountServiceError = (message: string, cause?: unknown) => new AccountServiceError({ message, cause })
|
||||
|
||||
const mapAccountServiceError =
|
||||
(operation: string, message = "Account service operation failed") =>
|
||||
<A, E, R>(effect: Effect.Effect<A, E, R>): Effect.Effect<A, AccountServiceError, R> =>
|
||||
effect.pipe(
|
||||
Effect.mapError((error) =>
|
||||
error instanceof AccountServiceError ? error : toAccountServiceError(`${message} (${operation})`, error),
|
||||
),
|
||||
)
|
||||
|
||||
export class AccountService extends ServiceMap.Service<
|
||||
AccountService,
|
||||
{
|
||||
readonly active: () => Effect.Effect<Option.Option<Account>, AccountError>
|
||||
readonly list: () => Effect.Effect<Account[], AccountError>
|
||||
readonly orgsByAccount: () => Effect.Effect<AccountOrgs[], AccountError>
|
||||
readonly remove: (accountID: AccountID) => Effect.Effect<void, AccountError>
|
||||
readonly use: (accountID: AccountID, orgID: Option.Option<OrgID>) => Effect.Effect<void, AccountError>
|
||||
readonly orgs: (accountID: AccountID) => Effect.Effect<Org[], AccountError>
|
||||
readonly config: (
|
||||
accountID: AccountID,
|
||||
orgID: OrgID,
|
||||
) => Effect.Effect<Option.Option<Record<string, unknown>>, AccountError>
|
||||
readonly token: (accountID: AccountID) => Effect.Effect<Option.Option<AccessToken>, AccountError>
|
||||
readonly login: (url: string) => Effect.Effect<Login, AccountError>
|
||||
readonly poll: (input: Login) => Effect.Effect<PollResult, AccountError>
|
||||
}
|
||||
>()("@opencode/Account") {
|
||||
static readonly layer: Layer.Layer<AccountService, never, AccountRepo | HttpClient.HttpClient> = Layer.effect(
|
||||
AccountService,
|
||||
Effect.gen(function* () {
|
||||
const repo = yield* AccountRepo
|
||||
const http = yield* HttpClient.HttpClient
|
||||
const httpRead = withTransientReadRetry(http)
|
||||
|
||||
const execute = (operation: string, request: HttpClientRequest.HttpClientRequest) =>
|
||||
http.execute(request).pipe(mapAccountServiceError(operation, "HTTP request failed"))
|
||||
|
||||
const executeRead = (operation: string, request: HttpClientRequest.HttpClientRequest) =>
|
||||
httpRead.execute(request).pipe(mapAccountServiceError(operation, "HTTP request failed"))
|
||||
|
||||
const executeEffect = <E>(operation: string, request: Effect.Effect<HttpClientRequest.HttpClientRequest, E>) =>
|
||||
request.pipe(
|
||||
Effect.flatMap((req) => http.execute(req)),
|
||||
mapAccountServiceError(operation, "HTTP request failed"),
|
||||
)
|
||||
|
||||
const okOrNone = (operation: string, response: HttpClientResponse.HttpClientResponse) =>
|
||||
HttpClientResponse.filterStatusOk(response).pipe(
|
||||
Effect.map(Option.some),
|
||||
Effect.catch((error) =>
|
||||
HttpClientError.isHttpClientError(error) && error.reason._tag === "StatusCodeError"
|
||||
? Effect.succeed(Option.none<HttpClientResponse.HttpClientResponse>())
|
||||
: Effect.fail(error),
|
||||
),
|
||||
mapAccountServiceError(operation),
|
||||
)
|
||||
|
||||
const tokenForRow = Effect.fn("AccountService.tokenForRow")(function* (found: AccountRow) {
|
||||
const now = yield* Clock.currentTimeMillis
|
||||
if (found.token_expiry && found.token_expiry > now) return Option.some(AccessToken.make(found.access_token))
|
||||
|
||||
const response = yield* execute(
|
||||
"token.refresh",
|
||||
HttpClientRequest.post(`${found.url}/oauth/token`).pipe(
|
||||
HttpClientRequest.acceptJson,
|
||||
HttpClientRequest.bodyUrlParams({
|
||||
grant_type: "refresh_token",
|
||||
refresh_token: found.refresh_token,
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
const ok = yield* okOrNone("token.refresh", response)
|
||||
if (Option.isNone(ok)) return Option.none()
|
||||
|
||||
const parsed = yield* HttpClientResponse.schemaBodyJson(TokenRefresh)(ok.value).pipe(
|
||||
mapAccountServiceError("token.refresh", "Failed to decode response"),
|
||||
)
|
||||
|
||||
const expiry = Option.fromNullishOr(parsed.expires_in).pipe(Option.map((e) => now + e * 1000))
|
||||
|
||||
yield* repo.persistToken({
|
||||
accountID: AccountID.make(found.id),
|
||||
accessToken: parsed.access_token,
|
||||
refreshToken: parsed.refresh_token ?? found.refresh_token,
|
||||
expiry,
|
||||
})
|
||||
|
||||
return Option.some(AccessToken.make(parsed.access_token))
|
||||
})
|
||||
|
||||
const resolveAccess = Effect.fn("AccountService.resolveAccess")(function* (accountID: AccountID) {
|
||||
const maybeAccount = yield* repo.getRow(accountID)
|
||||
if (Option.isNone(maybeAccount)) return Option.none<{ account: AccountRow; accessToken: AccessToken }>()
|
||||
|
||||
const account = maybeAccount.value
|
||||
const accessToken = yield* tokenForRow(account)
|
||||
if (Option.isNone(accessToken)) return Option.none<{ account: AccountRow; accessToken: AccessToken }>()
|
||||
|
||||
return Option.some({ account, accessToken: accessToken.value })
|
||||
})
|
||||
|
||||
const token = Effect.fn("AccountService.token")((accountID: AccountID) =>
|
||||
resolveAccess(accountID).pipe(Effect.map(Option.map((r) => r.accessToken))),
|
||||
)
|
||||
|
||||
const orgsByAccount = Effect.fn("AccountService.orgsByAccount")(function* () {
|
||||
const accounts = yield* repo.list()
|
||||
return yield* Effect.forEach(
|
||||
accounts,
|
||||
(account) => orgs(account.id).pipe(Effect.map((orgs) => ({ account, orgs }))),
|
||||
{ concurrency: 3 },
|
||||
)
|
||||
})
|
||||
|
||||
const orgs = Effect.fn("AccountService.orgs")(function* (accountID: AccountID) {
|
||||
const resolved = yield* resolveAccess(accountID)
|
||||
if (Option.isNone(resolved)) return []
|
||||
|
||||
const { account, accessToken } = resolved.value
|
||||
|
||||
const response = yield* executeRead(
|
||||
"orgs",
|
||||
HttpClientRequest.get(`${account.url}/api/orgs`).pipe(
|
||||
HttpClientRequest.acceptJson,
|
||||
HttpClientRequest.bearerToken(accessToken),
|
||||
),
|
||||
)
|
||||
|
||||
const ok = yield* okOrNone("orgs", response)
|
||||
if (Option.isNone(ok)) return []
|
||||
|
||||
const orgs = yield* HttpClientResponse.schemaBodyJson(RemoteOrgs)(ok.value).pipe(
|
||||
mapAccountServiceError("orgs", "Failed to decode response"),
|
||||
)
|
||||
return orgs
|
||||
.filter((org) => org.id !== undefined && org.name !== undefined)
|
||||
.map((org) => new Org({ id: org.id!, name: org.name! }))
|
||||
})
|
||||
|
||||
const config = Effect.fn("AccountService.config")(function* (accountID: AccountID, orgID: OrgID) {
|
||||
const resolved = yield* resolveAccess(accountID)
|
||||
if (Option.isNone(resolved)) return Option.none()
|
||||
|
||||
const { account, accessToken } = resolved.value
|
||||
|
||||
const response = yield* executeRead(
|
||||
"config",
|
||||
HttpClientRequest.get(`${account.url}/api/config`).pipe(
|
||||
HttpClientRequest.acceptJson,
|
||||
HttpClientRequest.bearerToken(accessToken),
|
||||
HttpClientRequest.setHeaders({ "x-org-id": orgID }),
|
||||
),
|
||||
)
|
||||
|
||||
const ok = yield* okOrNone("config", response)
|
||||
if (Option.isNone(ok)) return Option.none()
|
||||
|
||||
const parsed = yield* HttpClientResponse.schemaBodyJson(RemoteConfig)(ok.value).pipe(
|
||||
mapAccountServiceError("config", "Failed to decode response"),
|
||||
)
|
||||
return Option.some(parsed.config)
|
||||
})
|
||||
|
||||
const login = Effect.fn("AccountService.login")(function* (server: string) {
|
||||
const response = yield* executeEffect(
|
||||
"login",
|
||||
HttpClientRequest.post(`${server}/auth/device/code`).pipe(
|
||||
HttpClientRequest.acceptJson,
|
||||
HttpClientRequest.schemaBodyJson(ClientId)({ client_id: clientId }),
|
||||
),
|
||||
)
|
||||
|
||||
const ok = yield* okOrNone("login", response)
|
||||
if (Option.isNone(ok)) {
|
||||
const body = yield* response.text.pipe(Effect.orElseSucceed(() => ""))
|
||||
return yield* toAccountServiceError(`Failed to initiate device flow: ${body || response.status}`)
|
||||
}
|
||||
|
||||
const parsed = yield* HttpClientResponse.schemaBodyJson(DeviceCode)(ok.value).pipe(
|
||||
mapAccountServiceError("login", "Failed to decode response"),
|
||||
)
|
||||
return new Login({
|
||||
code: parsed.device_code,
|
||||
user: parsed.user_code,
|
||||
url: `${server}${parsed.verification_uri_complete}`,
|
||||
server,
|
||||
expiry: parsed.expires_in,
|
||||
interval: parsed.interval,
|
||||
})
|
||||
})
|
||||
|
||||
const poll = Effect.fn("AccountService.poll")(function* (input: Login) {
|
||||
const response = yield* executeEffect(
|
||||
"poll",
|
||||
HttpClientRequest.post(`${input.server}/auth/device/token`).pipe(
|
||||
HttpClientRequest.acceptJson,
|
||||
HttpClientRequest.schemaBodyJson(DeviceTokenRequest)({
|
||||
grant_type: "urn:ietf:params:oauth:grant-type:device_code",
|
||||
device_code: input.code,
|
||||
client_id: clientId,
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
const parsed = yield* HttpClientResponse.schemaBodyJson(DeviceToken)(response).pipe(
|
||||
mapAccountServiceError("poll", "Failed to decode response"),
|
||||
)
|
||||
|
||||
if (!parsed.access_token) {
|
||||
if (parsed.error === "authorization_pending") return new PollPending()
|
||||
if (parsed.error === "slow_down") return new PollSlow()
|
||||
if (parsed.error === "expired_token") return new PollExpired()
|
||||
if (parsed.error === "access_denied") return new PollDenied()
|
||||
return new PollError({ cause: parsed.error })
|
||||
}
|
||||
|
||||
const access = parsed.access_token
|
||||
|
||||
const fetchUser = executeRead(
|
||||
"poll.user",
|
||||
HttpClientRequest.get(`${input.server}/api/user`).pipe(
|
||||
HttpClientRequest.acceptJson,
|
||||
HttpClientRequest.bearerToken(access),
|
||||
),
|
||||
).pipe(
|
||||
Effect.flatMap((r) =>
|
||||
HttpClientResponse.schemaBodyJson(User)(r).pipe(
|
||||
mapAccountServiceError("poll.user", "Failed to decode response"),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
const fetchOrgs = executeRead(
|
||||
"poll.orgs",
|
||||
HttpClientRequest.get(`${input.server}/api/orgs`).pipe(
|
||||
HttpClientRequest.acceptJson,
|
||||
HttpClientRequest.bearerToken(access),
|
||||
),
|
||||
).pipe(
|
||||
Effect.flatMap((r) =>
|
||||
HttpClientResponse.schemaBodyJson(RemoteOrgs)(r).pipe(
|
||||
mapAccountServiceError("poll.orgs", "Failed to decode response"),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
const [user, remoteOrgs] = yield* Effect.all([fetchUser, fetchOrgs], { concurrency: 2 })
|
||||
|
||||
const userId = user.id
|
||||
const userEmail = user.email
|
||||
|
||||
if (!userId || !userEmail) {
|
||||
return new PollError({ cause: "No id or email in response" })
|
||||
}
|
||||
|
||||
const firstOrgID = remoteOrgs.length > 0 ? Option.fromNullishOr(remoteOrgs[0].id) : Option.none()
|
||||
|
||||
const now = yield* Clock.currentTimeMillis
|
||||
const expiry = now + (parsed.expires_in ?? 0) * 1000
|
||||
const refresh = parsed.refresh_token ?? ""
|
||||
if (!refresh) {
|
||||
yield* Effect.logWarning("Server did not return a refresh token — session may expire without ability to refresh")
|
||||
}
|
||||
|
||||
yield* repo.persistAccount({
|
||||
id: userId,
|
||||
email: userEmail,
|
||||
url: input.server,
|
||||
accessToken: access,
|
||||
refreshToken: refresh,
|
||||
expiry,
|
||||
orgID: firstOrgID,
|
||||
})
|
||||
|
||||
return new PollSuccess({ email: userEmail })
|
||||
})
|
||||
|
||||
return AccountService.of({
|
||||
active: repo.active,
|
||||
list: repo.list,
|
||||
orgsByAccount,
|
||||
remove: repo.remove,
|
||||
use: repo.use,
|
||||
orgs,
|
||||
config,
|
||||
token,
|
||||
login,
|
||||
poll,
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
static readonly defaultLayer = AccountService.layer.pipe(
|
||||
Layer.provide(AccountRepo.layer),
|
||||
Layer.provide(FetchHttpClient.layer),
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user