|
|
@@ -1,11 +1,5 @@
|
|
|
-import { Clock, Effect, Layer, Option, Schema, ServiceMap } from "effect"
|
|
|
-import {
|
|
|
- FetchHttpClient,
|
|
|
- HttpClient,
|
|
|
- HttpClientError,
|
|
|
- HttpClientRequest,
|
|
|
- HttpClientResponse,
|
|
|
-} from "effect/unstable/http"
|
|
|
+import { Clock, Duration, Effect, Layer, Option, Schema, SchemaGetter, ServiceMap } from "effect"
|
|
|
+import { FetchHttpClient, HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
|
|
|
|
|
|
import { withTransientReadRetry } from "@/util/effect-http-client"
|
|
|
import { AccountRepo, type AccountRow } from "./repo"
|
|
|
@@ -14,6 +8,8 @@ import {
|
|
|
AccessToken,
|
|
|
Account,
|
|
|
AccountID,
|
|
|
+ DeviceCode,
|
|
|
+ RefreshToken,
|
|
|
AccountServiceError,
|
|
|
Login,
|
|
|
Org,
|
|
|
@@ -25,83 +21,101 @@ import {
|
|
|
type PollResult,
|
|
|
PollSlow,
|
|
|
PollSuccess,
|
|
|
+ UserCode,
|
|
|
} from "./schema"
|
|
|
|
|
|
export * from "./schema"
|
|
|
|
|
|
export type AccountOrgs = {
|
|
|
account: Account
|
|
|
- orgs: Org[]
|
|
|
+ orgs: readonly Org[]
|
|
|
}
|
|
|
|
|
|
-const RemoteOrg = Schema.Struct({
|
|
|
- id: Schema.optional(OrgID),
|
|
|
- name: Schema.optional(Schema.String),
|
|
|
-})
|
|
|
+class RemoteConfig extends Schema.Class<RemoteConfig>("RemoteConfig")({
|
|
|
+ config: Schema.Record(Schema.String, Schema.Json),
|
|
|
+}) {}
|
|
|
+
|
|
|
+const DurationFromSeconds = Schema.Number.pipe(
|
|
|
+ Schema.decodeTo(Schema.Duration, {
|
|
|
+ decode: SchemaGetter.transform((n) => Duration.seconds(n)),
|
|
|
+ encode: SchemaGetter.transform((d) => Duration.toSeconds(d)),
|
|
|
+ }),
|
|
|
+)
|
|
|
+
|
|
|
+class TokenRefresh extends Schema.Class<TokenRefresh>("TokenRefresh")({
|
|
|
+ access_token: AccessToken,
|
|
|
+ refresh_token: RefreshToken,
|
|
|
+ expires_in: DurationFromSeconds,
|
|
|
+}) {}
|
|
|
+
|
|
|
+class DeviceAuth extends Schema.Class<DeviceAuth>("DeviceAuth")({
|
|
|
+ device_code: DeviceCode,
|
|
|
+ user_code: UserCode,
|
|
|
+ verification_uri_complete: Schema.String,
|
|
|
+ expires_in: DurationFromSeconds,
|
|
|
+ interval: DurationFromSeconds,
|
|
|
+}) {}
|
|
|
+
|
|
|
+class DeviceTokenSuccess extends Schema.Class<DeviceTokenSuccess>("DeviceTokenSuccess")({
|
|
|
+ access_token: AccessToken,
|
|
|
+ refresh_token: RefreshToken,
|
|
|
+ token_type: Schema.Literal("Bearer"),
|
|
|
+ expires_in: DurationFromSeconds,
|
|
|
+}) {}
|
|
|
+
|
|
|
+class DeviceTokenError extends Schema.Class<DeviceTokenError>("DeviceTokenError")({
|
|
|
+ error: Schema.String,
|
|
|
+ error_description: Schema.String,
|
|
|
+}) {
|
|
|
+ toPollResult(): PollResult {
|
|
|
+ if (this.error === "authorization_pending") return new PollPending()
|
|
|
+ if (this.error === "slow_down") return new PollSlow()
|
|
|
+ if (this.error === "expired_token") return new PollExpired()
|
|
|
+ if (this.error === "access_denied") return new PollDenied()
|
|
|
+ return new PollError({ cause: this.error })
|
|
|
+ }
|
|
|
+}
|
|
|
|
|
|
-const RemoteOrgs = Schema.Array(RemoteOrg)
|
|
|
+const DeviceToken = Schema.Union([DeviceTokenSuccess, DeviceTokenError])
|
|
|
|
|
|
-const RemoteConfig = Schema.Struct({
|
|
|
- config: Schema.Record(Schema.String, Schema.Json),
|
|
|
-})
|
|
|
+class User extends Schema.Class<User>("User")({
|
|
|
+ id: AccountID,
|
|
|
+ email: Schema.String,
|
|
|
+}) {}
|
|
|
|
|
|
-const TokenRefresh = Schema.Struct({
|
|
|
- access_token: Schema.String,
|
|
|
- refresh_token: Schema.optional(Schema.String),
|
|
|
- expires_in: Schema.optional(Schema.Number),
|
|
|
-})
|
|
|
+class ClientId extends Schema.Class<ClientId>("ClientId")({ client_id: Schema.String }) {}
|
|
|
|
|
|
-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({
|
|
|
+class DeviceTokenRequest extends Schema.Class<DeviceTokenRequest>("DeviceTokenRequest")({
|
|
|
grant_type: Schema.String,
|
|
|
- device_code: Schema.String,
|
|
|
+ device_code: DeviceCode,
|
|
|
client_id: Schema.String,
|
|
|
-})
|
|
|
+}) {}
|
|
|
|
|
|
-const clientId = "opencode-cli"
|
|
|
+class TokenRefreshRequest extends Schema.Class<TokenRefreshRequest>("TokenRefreshRequest")({
|
|
|
+ grant_type: Schema.String,
|
|
|
+ refresh_token: RefreshToken,
|
|
|
+ client_id: Schema.String,
|
|
|
+}) {}
|
|
|
|
|
|
-const toAccountServiceError = (message: string, cause?: unknown) => new AccountServiceError({ message, cause })
|
|
|
+const clientId = "opencode-cli"
|
|
|
|
|
|
const mapAccountServiceError =
|
|
|
- (operation: string, message = "Account service operation failed") =>
|
|
|
+ (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),
|
|
|
+ Effect.mapError((cause) =>
|
|
|
+ cause instanceof AccountServiceError ? cause : new AccountServiceError({ message, cause }),
|
|
|
),
|
|
|
)
|
|
|
|
|
|
-export class AccountService extends ServiceMap.Service<
|
|
|
- AccountService,
|
|
|
- {
|
|
|
+export namespace AccountService {
|
|
|
+ export interface Service {
|
|
|
readonly active: () => Effect.Effect<Option.Option<Account>, AccountError>
|
|
|
readonly list: () => Effect.Effect<Account[], AccountError>
|
|
|
- readonly orgsByAccount: () => Effect.Effect<AccountOrgs[], AccountError>
|
|
|
+ readonly orgsByAccount: () => Effect.Effect<readonly 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 orgs: (accountID: AccountID) => Effect.Effect<readonly Org[], AccountError>
|
|
|
readonly config: (
|
|
|
accountID: AccountID,
|
|
|
orgID: OrgID,
|
|
|
@@ -110,80 +124,98 @@ export class AccountService extends ServiceMap.Service<
|
|
|
readonly login: (url: string) => Effect.Effect<Login, AccountError>
|
|
|
readonly poll: (input: Login) => Effect.Effect<PollResult, AccountError>
|
|
|
}
|
|
|
->()("@opencode/Account") {
|
|
|
+}
|
|
|
+
|
|
|
+export class AccountService extends ServiceMap.Service<AccountService, AccountService.Service>()("@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 httpOk = HttpClient.filterStatusOk(http)
|
|
|
+ const httpReadOk = HttpClient.filterStatusOk(httpRead)
|
|
|
|
|
|
- const execute = (operation: string, request: HttpClientRequest.HttpClientRequest) =>
|
|
|
- http.execute(request).pipe(mapAccountServiceError(operation, "HTTP request failed"))
|
|
|
+ const executeRead = (request: HttpClientRequest.HttpClientRequest) =>
|
|
|
+ httpRead.execute(request).pipe(mapAccountServiceError("HTTP request failed"))
|
|
|
|
|
|
- const executeRead = (operation: string, request: HttpClientRequest.HttpClientRequest) =>
|
|
|
- httpRead.execute(request).pipe(mapAccountServiceError(operation, "HTTP request failed"))
|
|
|
+ const executeReadOk = (request: HttpClientRequest.HttpClientRequest) =>
|
|
|
+ httpReadOk.execute(request).pipe(mapAccountServiceError("HTTP request failed"))
|
|
|
|
|
|
- const executeEffect = <E>(operation: string, request: Effect.Effect<HttpClientRequest.HttpClientRequest, E>) =>
|
|
|
+ const executeEffectOk = <E>(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),
|
|
|
+ Effect.flatMap((req) => httpOk.execute(req)),
|
|
|
+ mapAccountServiceError("HTTP request failed"),
|
|
|
)
|
|
|
|
|
|
- const tokenForRow = Effect.fn("AccountService.tokenForRow")(function* (found: AccountRow) {
|
|
|
+ // Returns a usable access token for a stored account row, refreshing and
|
|
|
+ // persisting it when the cached token has expired.
|
|
|
+ const resolveToken = Effect.fnUntraced(function* (row: AccountRow) {
|
|
|
const now = yield* Clock.currentTimeMillis
|
|
|
- if (found.token_expiry && found.token_expiry > now) return Option.some(AccessToken.make(found.access_token))
|
|
|
+ if (row.token_expiry && row.token_expiry > now) return row.access_token
|
|
|
|
|
|
- const response = yield* execute(
|
|
|
- "token.refresh",
|
|
|
- HttpClientRequest.post(`${found.url}/oauth/token`).pipe(
|
|
|
+ const response = yield* executeEffectOk(
|
|
|
+ HttpClientRequest.post(`${row.url}/auth/device/token`).pipe(
|
|
|
HttpClientRequest.acceptJson,
|
|
|
- HttpClientRequest.bodyUrlParams({
|
|
|
- grant_type: "refresh_token",
|
|
|
- refresh_token: found.refresh_token,
|
|
|
- }),
|
|
|
+ HttpClientRequest.schemaBodyJson(TokenRefreshRequest)(
|
|
|
+ new TokenRefreshRequest({
|
|
|
+ grant_type: "refresh_token",
|
|
|
+ refresh_token: row.refresh_token,
|
|
|
+ client_id: clientId,
|
|
|
+ }),
|
|
|
+ ),
|
|
|
),
|
|
|
)
|
|
|
|
|
|
- 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 parsed = yield* HttpClientResponse.schemaBodyJson(TokenRefresh)(response).pipe(
|
|
|
+ mapAccountServiceError("Failed to decode response"),
|
|
|
)
|
|
|
|
|
|
- const expiry = Option.fromNullishOr(parsed.expires_in).pipe(Option.map((e) => now + e * 1000))
|
|
|
+ const expiry = Option.some(now + Duration.toMillis(parsed.expires_in))
|
|
|
|
|
|
yield* repo.persistToken({
|
|
|
- accountID: AccountID.make(found.id),
|
|
|
+ accountID: row.id,
|
|
|
accessToken: parsed.access_token,
|
|
|
- refreshToken: parsed.refresh_token ?? found.refresh_token,
|
|
|
+ refreshToken: parsed.refresh_token,
|
|
|
expiry,
|
|
|
})
|
|
|
|
|
|
- return Option.some(AccessToken.make(parsed.access_token))
|
|
|
+ return parsed.access_token
|
|
|
})
|
|
|
|
|
|
- const resolveAccess = Effect.fn("AccountService.resolveAccess")(function* (accountID: AccountID) {
|
|
|
+ const resolveAccess = Effect.fnUntraced(function* (accountID: AccountID) {
|
|
|
const maybeAccount = yield* repo.getRow(accountID)
|
|
|
- if (Option.isNone(maybeAccount)) return Option.none<{ account: AccountRow; accessToken: AccessToken }>()
|
|
|
+ if (Option.isNone(maybeAccount)) return Option.none()
|
|
|
|
|
|
const account = maybeAccount.value
|
|
|
- const accessToken = yield* tokenForRow(account)
|
|
|
- if (Option.isNone(accessToken)) return Option.none<{ account: AccountRow; accessToken: AccessToken }>()
|
|
|
+ const accessToken = yield* resolveToken(account)
|
|
|
+ return Option.some({ account, accessToken })
|
|
|
+ })
|
|
|
+
|
|
|
+ const fetchOrgs = Effect.fnUntraced(function* (url: string, accessToken: AccessToken) {
|
|
|
+ const response = yield* executeReadOk(
|
|
|
+ HttpClientRequest.get(`${url}/api/orgs`).pipe(
|
|
|
+ HttpClientRequest.acceptJson,
|
|
|
+ HttpClientRequest.bearerToken(accessToken),
|
|
|
+ ),
|
|
|
+ )
|
|
|
|
|
|
- return Option.some({ account, accessToken: accessToken.value })
|
|
|
+ return yield* HttpClientResponse.schemaBodyJson(Schema.Array(Org))(response).pipe(
|
|
|
+ mapAccountServiceError("Failed to decode response"),
|
|
|
+ )
|
|
|
+ })
|
|
|
+
|
|
|
+ const fetchUser = Effect.fnUntraced(function* (url: string, accessToken: AccessToken) {
|
|
|
+ const response = yield* executeReadOk(
|
|
|
+ HttpClientRequest.get(`${url}/api/user`).pipe(
|
|
|
+ HttpClientRequest.acceptJson,
|
|
|
+ HttpClientRequest.bearerToken(accessToken),
|
|
|
+ ),
|
|
|
+ )
|
|
|
+
|
|
|
+ return yield* HttpClientResponse.schemaBodyJson(User)(response).pipe(
|
|
|
+ mapAccountServiceError("Failed to decode response"),
|
|
|
+ )
|
|
|
})
|
|
|
|
|
|
const token = Effect.fn("AccountService.token")((accountID: AccountID) =>
|
|
|
@@ -211,23 +243,7 @@ export class AccountService extends ServiceMap.Service<
|
|
|
|
|
|
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! }))
|
|
|
+ return yield* fetchOrgs(account.url, accessToken)
|
|
|
})
|
|
|
|
|
|
const config = Effect.fn("AccountService.config")(function* (accountID: AccountID, orgID: OrgID) {
|
|
|
@@ -237,7 +253,6 @@ export class AccountService extends ServiceMap.Service<
|
|
|
const { account, accessToken } = resolved.value
|
|
|
|
|
|
const response = yield* executeRead(
|
|
|
- "config",
|
|
|
HttpClientRequest.get(`${account.url}/api/config`).pipe(
|
|
|
HttpClientRequest.acceptJson,
|
|
|
HttpClientRequest.bearerToken(accessToken),
|
|
|
@@ -245,32 +260,26 @@ export class AccountService extends ServiceMap.Service<
|
|
|
),
|
|
|
)
|
|
|
|
|
|
- const ok = yield* okOrNone("config", response)
|
|
|
- if (Option.isNone(ok)) return Option.none()
|
|
|
+ if (response.status === 404) return Option.none()
|
|
|
+
|
|
|
+ const ok = yield* HttpClientResponse.filterStatusOk(response).pipe(mapAccountServiceError())
|
|
|
|
|
|
- const parsed = yield* HttpClientResponse.schemaBodyJson(RemoteConfig)(ok.value).pipe(
|
|
|
- mapAccountServiceError("config", "Failed to decode response"),
|
|
|
+ const parsed = yield* HttpClientResponse.schemaBodyJson(RemoteConfig)(ok).pipe(
|
|
|
+ mapAccountServiceError("Failed to decode response"),
|
|
|
)
|
|
|
return Option.some(parsed.config)
|
|
|
})
|
|
|
|
|
|
const login = Effect.fn("AccountService.login")(function* (server: string) {
|
|
|
- const response = yield* executeEffect(
|
|
|
- "login",
|
|
|
+ const response = yield* executeEffectOk(
|
|
|
HttpClientRequest.post(`${server}/auth/device/code`).pipe(
|
|
|
HttpClientRequest.acceptJson,
|
|
|
- HttpClientRequest.schemaBodyJson(ClientId)({ client_id: clientId }),
|
|
|
+ HttpClientRequest.schemaBodyJson(ClientId)(new 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"),
|
|
|
+ const parsed = yield* HttpClientResponse.schemaBodyJson(DeviceAuth)(response).pipe(
|
|
|
+ mapAccountServiceError("Failed to decode response"),
|
|
|
)
|
|
|
return new Login({
|
|
|
code: parsed.device_code,
|
|
|
@@ -283,91 +292,49 @@ export class AccountService extends ServiceMap.Service<
|
|
|
})
|
|
|
|
|
|
const poll = Effect.fn("AccountService.poll")(function* (input: Login) {
|
|
|
- const response = yield* executeEffect(
|
|
|
- "poll",
|
|
|
+ const response = yield* executeEffectOk(
|
|
|
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"),
|
|
|
+ HttpClientRequest.schemaBodyJson(DeviceTokenRequest)(
|
|
|
+ new DeviceTokenRequest({
|
|
|
+ grant_type: "urn:ietf:params:oauth:grant-type:device_code",
|
|
|
+ device_code: input.code,
|
|
|
+ client_id: clientId,
|
|
|
+ }),
|
|
|
),
|
|
|
),
|
|
|
)
|
|
|
|
|
|
- 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 parsed = yield* HttpClientResponse.schemaBodyJson(DeviceToken)(response).pipe(
|
|
|
+ mapAccountServiceError("Failed to decode response"),
|
|
|
)
|
|
|
|
|
|
- const [user, remoteOrgs] = yield* Effect.all([fetchUser, fetchOrgs], { concurrency: 2 })
|
|
|
+ if (parsed instanceof DeviceTokenError) return parsed.toPollResult()
|
|
|
+ const accessToken = parsed.access_token
|
|
|
|
|
|
- const userId = user.id
|
|
|
- const userEmail = user.email
|
|
|
+ const user = fetchUser(input.server, accessToken)
|
|
|
+ const orgs = fetchOrgs(input.server, accessToken)
|
|
|
|
|
|
- if (!userId || !userEmail) {
|
|
|
- return new PollError({ cause: "No id or email in response" })
|
|
|
- }
|
|
|
+ const [account, remoteOrgs] = yield* Effect.all([user, orgs], { concurrency: 2 })
|
|
|
|
|
|
- const firstOrgID = remoteOrgs.length > 0 ? Option.fromNullishOr(remoteOrgs[0].id) : Option.none()
|
|
|
+ // TODO: When there are multiple orgs, let the user choose
|
|
|
+ const firstOrgID = remoteOrgs.length > 0 ? Option.some(remoteOrgs[0].id) : Option.none<OrgID>()
|
|
|
|
|
|
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",
|
|
|
- )
|
|
|
- }
|
|
|
+ const expiry = now + Duration.toMillis(parsed.expires_in)
|
|
|
+ const refreshToken = parsed.refresh_token
|
|
|
|
|
|
yield* repo.persistAccount({
|
|
|
- id: userId,
|
|
|
- email: userEmail,
|
|
|
+ id: account.id,
|
|
|
+ email: account.email,
|
|
|
url: input.server,
|
|
|
- accessToken: access,
|
|
|
- refreshToken: refresh,
|
|
|
+ accessToken,
|
|
|
+ refreshToken,
|
|
|
expiry,
|
|
|
orgID: firstOrgID,
|
|
|
})
|
|
|
|
|
|
- return new PollSuccess({ email: userEmail })
|
|
|
+ return new PollSuccess({ email: account.email })
|
|
|
})
|
|
|
|
|
|
return AccountService.of({
|