refactor(account): tighten effect-based account flows (#17072)
This commit is contained in:
@@ -8,3 +8,37 @@
|
|||||||
- **Command**: `bun run db generate --name <slug>`.
|
- **Command**: `bun run db generate --name <slug>`.
|
||||||
- **Output**: creates `migration/<timestamp>_<slug>/migration.sql` and `snapshot.json`.
|
- **Output**: creates `migration/<timestamp>_<slug>/migration.sql` and `snapshot.json`.
|
||||||
- **Tests**: migration tests should read the per-folder layout (no `_journal.json`).
|
- **Tests**: migration tests should read the per-folder layout (no `_journal.json`).
|
||||||
|
|
||||||
|
# opencode Effect guide
|
||||||
|
|
||||||
|
Instructions to follow when writing Effect.
|
||||||
|
|
||||||
|
## Schemas
|
||||||
|
|
||||||
|
- Use `Schema.Class` for data types with multiple fields.
|
||||||
|
- Use branded schemas (`Schema.brand`) for single-value types.
|
||||||
|
|
||||||
|
## Services
|
||||||
|
|
||||||
|
- Services use `ServiceMap.Service<ServiceName, ServiceName.Service>()("@console/<Name>")`.
|
||||||
|
- In `Layer.effect`, always return service implementations with `ServiceName.of({ ... })`, never a plain object.
|
||||||
|
|
||||||
|
## Errors
|
||||||
|
|
||||||
|
- Use `Schema.TaggedErrorClass` for typed errors.
|
||||||
|
- For defect-like causes, use `Schema.Defect` instead of `unknown`.
|
||||||
|
- In `Effect.gen`, prefer `yield* new MyError(...)` over `yield* Effect.fail(new MyError(...))` for direct early-failure branches.
|
||||||
|
|
||||||
|
## Effects
|
||||||
|
|
||||||
|
- Use `Effect.gen(function* () { ... })` for composition.
|
||||||
|
- Use `Effect.fn("ServiceName.method")` for named/traced effects and `Effect.fnUntraced` for internal helpers.
|
||||||
|
- `Effect.fn` / `Effect.fnUntraced` accept pipeable operators as extra arguments, so avoid unnecessary `flow` or outer `.pipe()` wrappers.
|
||||||
|
|
||||||
|
## Time
|
||||||
|
|
||||||
|
- Prefer `DateTime.nowAsDate` over `new Date(yield* Clock.currentTimeMillis)` when you need a `Date`.
|
||||||
|
|
||||||
|
## Errors
|
||||||
|
|
||||||
|
- In `Effect.gen/fn`, prefer `yield* new MyError(...)` over `yield* Effect.fail(new MyError(...))` for direct early-failure branches.
|
||||||
|
|||||||
@@ -1,20 +1,24 @@
|
|||||||
import { sqliteTable, text, integer, primaryKey } from "drizzle-orm/sqlite-core"
|
import { sqliteTable, text, integer, primaryKey } from "drizzle-orm/sqlite-core"
|
||||||
|
|
||||||
|
import { type AccessToken, type AccountID, type OrgID, type RefreshToken } from "./schema"
|
||||||
import { Timestamps } from "../storage/schema.sql"
|
import { Timestamps } from "../storage/schema.sql"
|
||||||
|
|
||||||
export const AccountTable = sqliteTable("account", {
|
export const AccountTable = sqliteTable("account", {
|
||||||
id: text().primaryKey(),
|
id: text().$type<AccountID>().primaryKey(),
|
||||||
email: text().notNull(),
|
email: text().notNull(),
|
||||||
url: text().notNull(),
|
url: text().notNull(),
|
||||||
access_token: text().notNull(),
|
access_token: text().$type<AccessToken>().notNull(),
|
||||||
refresh_token: text().notNull(),
|
refresh_token: text().$type<RefreshToken>().notNull(),
|
||||||
token_expiry: integer(),
|
token_expiry: integer(),
|
||||||
...Timestamps,
|
...Timestamps,
|
||||||
})
|
})
|
||||||
|
|
||||||
export const AccountStateTable = sqliteTable("account_state", {
|
export const AccountStateTable = sqliteTable("account_state", {
|
||||||
id: integer().primaryKey(),
|
id: integer().primaryKey(),
|
||||||
active_account_id: text().references(() => AccountTable.id, { onDelete: "set null" }),
|
active_account_id: text()
|
||||||
active_org_id: text(),
|
.$type<AccountID>()
|
||||||
|
.references(() => AccountTable.id, { onDelete: "set null" }),
|
||||||
|
active_org_id: text().$type<OrgID>(),
|
||||||
})
|
})
|
||||||
|
|
||||||
// LEGACY
|
// LEGACY
|
||||||
@@ -23,8 +27,8 @@ export const ControlAccountTable = sqliteTable(
|
|||||||
{
|
{
|
||||||
email: text().notNull(),
|
email: text().notNull(),
|
||||||
url: text().notNull(),
|
url: text().notNull(),
|
||||||
access_token: text().notNull(),
|
access_token: text().$type<AccessToken>().notNull(),
|
||||||
refresh_token: text().notNull(),
|
refresh_token: text().$type<RefreshToken>().notNull(),
|
||||||
token_expiry: integer(),
|
token_expiry: integer(),
|
||||||
active: integer({ mode: "boolean" })
|
active: integer({ mode: "boolean" })
|
||||||
.notNull()
|
.notNull()
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Effect, Option, ServiceMap } from "effect"
|
import { Effect, Option } from "effect"
|
||||||
|
|
||||||
import {
|
import {
|
||||||
Account as AccountSchema,
|
Account as AccountSchema,
|
||||||
@@ -13,13 +13,11 @@ export { AccessToken, AccountID, OrgID } from "./service"
|
|||||||
|
|
||||||
import { runtime } from "@/effect/runtime"
|
import { runtime } from "@/effect/runtime"
|
||||||
|
|
||||||
type AccountServiceShape = ServiceMap.Service.Shape<typeof AccountService>
|
function runSync<A>(f: (service: AccountService.Service) => Effect.Effect<A, AccountError>) {
|
||||||
|
|
||||||
function runSync<A>(f: (service: AccountServiceShape) => Effect.Effect<A, AccountError>) {
|
|
||||||
return runtime.runSync(AccountService.use(f))
|
return runtime.runSync(AccountService.use(f))
|
||||||
}
|
}
|
||||||
|
|
||||||
function runPromise<A>(f: (service: AccountServiceShape) => Effect.Effect<A, AccountError>) {
|
function runPromise<A>(f: (service: AccountService.Service) => Effect.Effect<A, AccountError>) {
|
||||||
return runtime.runPromise(AccountService.use(f))
|
return runtime.runPromise(AccountService.use(f))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,43 +3,16 @@ import { Effect, Layer, Option, Schema, ServiceMap } from "effect"
|
|||||||
|
|
||||||
import { Database } from "@/storage/db"
|
import { Database } from "@/storage/db"
|
||||||
import { AccountStateTable, AccountTable } from "./account.sql"
|
import { AccountStateTable, AccountTable } from "./account.sql"
|
||||||
import { Account, AccountID, AccountRepoError, OrgID } from "./schema"
|
import { AccessToken, Account, AccountID, AccountRepoError, OrgID, RefreshToken } from "./schema"
|
||||||
|
|
||||||
export type AccountRow = (typeof AccountTable)["$inferSelect"]
|
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
|
type DbClient = Parameters<typeof Database.use>[0] extends (db: infer T) => unknown ? T : never
|
||||||
|
|
||||||
const ACCOUNT_STATE_ID = 1
|
const ACCOUNT_STATE_ID = 1
|
||||||
|
|
||||||
const db = <A>(run: (db: DbClient) => A) =>
|
export namespace AccountRepo {
|
||||||
Effect.try({
|
export interface Service {
|
||||||
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 active: () => Effect.Effect<Option.Option<Account>, AccountRepoError>
|
||||||
readonly list: () => Effect.Effect<Account[], AccountRepoError>
|
readonly list: () => Effect.Effect<Account[], AccountRepoError>
|
||||||
readonly remove: (accountID: AccountID) => Effect.Effect<void, AccountRepoError>
|
readonly remove: (accountID: AccountID) => Effect.Effect<void, AccountRepoError>
|
||||||
@@ -47,62 +20,96 @@ export class AccountRepo extends ServiceMap.Service<
|
|||||||
readonly getRow: (accountID: AccountID) => Effect.Effect<Option.Option<AccountRow>, AccountRepoError>
|
readonly getRow: (accountID: AccountID) => Effect.Effect<Option.Option<AccountRow>, AccountRepoError>
|
||||||
readonly persistToken: (input: {
|
readonly persistToken: (input: {
|
||||||
accountID: AccountID
|
accountID: AccountID
|
||||||
accessToken: string
|
accessToken: AccessToken
|
||||||
refreshToken: string
|
refreshToken: RefreshToken
|
||||||
expiry: Option.Option<number>
|
expiry: Option.Option<number>
|
||||||
}) => Effect.Effect<void, AccountRepoError>
|
}) => Effect.Effect<void, AccountRepoError>
|
||||||
readonly persistAccount: (input: {
|
readonly persistAccount: (input: {
|
||||||
id: AccountID
|
id: AccountID
|
||||||
email: string
|
email: string
|
||||||
url: string
|
url: string
|
||||||
accessToken: string
|
accessToken: AccessToken
|
||||||
refreshToken: string
|
refreshToken: RefreshToken
|
||||||
expiry: number
|
expiry: number
|
||||||
orgID: Option.Option<OrgID>
|
orgID: Option.Option<OrgID>
|
||||||
}) => Effect.Effect<void, AccountRepoError>
|
}) => 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")(() =>
|
export class AccountRepo extends ServiceMap.Service<AccountRepo, AccountRepo.Service>()("@opencode/AccountRepo") {
|
||||||
db((db) =>
|
static readonly layer: Layer.Layer<AccountRepo> = Layer.effect(
|
||||||
|
AccountRepo,
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const decode = Schema.decodeUnknownSync(Account)
|
||||||
|
|
||||||
|
const query = <A>(f: (db: DbClient) => A) =>
|
||||||
|
Effect.try({
|
||||||
|
try: () => Database.use(f),
|
||||||
|
catch: (cause) => new AccountRepoError({ message: "Database operation failed", cause }),
|
||||||
|
})
|
||||||
|
|
||||||
|
const tx = <A>(f: (db: DbClient) => A) =>
|
||||||
|
Effect.try({
|
||||||
|
try: () => Database.transaction(f),
|
||||||
|
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 state = (db: DbClient, accountID: AccountID, orgID: Option.Option<OrgID>) => {
|
||||||
|
const id = Option.getOrNull(orgID)
|
||||||
|
return db
|
||||||
|
.insert(AccountStateTable)
|
||||||
|
.values({ id: ACCOUNT_STATE_ID, active_account_id: accountID, active_org_id: id })
|
||||||
|
.onConflictDoUpdate({
|
||||||
|
target: AccountStateTable.id,
|
||||||
|
set: { active_account_id: accountID, active_org_id: id },
|
||||||
|
})
|
||||||
|
.run()
|
||||||
|
}
|
||||||
|
|
||||||
|
const active = Effect.fn("AccountRepo.active")(() =>
|
||||||
|
query((db) => current(db)).pipe(Effect.map((row) => (row ? Option.some(decode(row)) : Option.none()))),
|
||||||
|
)
|
||||||
|
|
||||||
|
const list = Effect.fn("AccountRepo.list")(() =>
|
||||||
|
query((db) =>
|
||||||
db
|
db
|
||||||
.select()
|
.select()
|
||||||
.from(AccountTable)
|
.from(AccountTable)
|
||||||
.all()
|
.all()
|
||||||
.map((row) => decodeAccount({ ...row, active_org_id: null })),
|
.map((row: AccountRow) => decode({ ...row, active_org_id: null })),
|
||||||
),
|
),
|
||||||
),
|
)
|
||||||
|
|
||||||
remove: Effect.fn("AccountRepo.remove")((accountID: AccountID) =>
|
const remove = Effect.fn("AccountRepo.remove")((accountID: AccountID) =>
|
||||||
db((db) =>
|
tx((db) => {
|
||||||
Database.transaction((tx) => {
|
db.update(AccountStateTable)
|
||||||
tx.update(AccountStateTable)
|
.set({ active_account_id: null, active_org_id: null })
|
||||||
.set({ active_account_id: null, active_org_id: null })
|
.where(eq(AccountStateTable.active_account_id, accountID))
|
||||||
.where(eq(AccountStateTable.active_account_id, accountID))
|
.run()
|
||||||
.run()
|
db.delete(AccountTable).where(eq(AccountTable.id, accountID)).run()
|
||||||
tx.delete(AccountTable).where(eq(AccountTable.id, accountID)).run()
|
}).pipe(Effect.asVoid),
|
||||||
}),
|
)
|
||||||
).pipe(Effect.asVoid),
|
|
||||||
),
|
|
||||||
|
|
||||||
use: Effect.fn("AccountRepo.use")((accountID: AccountID, orgID: Option.Option<OrgID>) =>
|
const use = Effect.fn("AccountRepo.use")((accountID: AccountID, orgID: Option.Option<OrgID>) =>
|
||||||
db((db) => setState(db, accountID, Option.getOrNull(orgID))).pipe(Effect.asVoid),
|
query((db) => state(db, accountID, orgID)).pipe(Effect.asVoid),
|
||||||
),
|
)
|
||||||
|
|
||||||
getRow: Effect.fn("AccountRepo.getRow")((accountID: AccountID) =>
|
const getRow = Effect.fn("AccountRepo.getRow")((accountID: AccountID) =>
|
||||||
db((db) => db.select().from(AccountTable).where(eq(AccountTable.id, accountID)).get()).pipe(
|
query((db) => db.select().from(AccountTable).where(eq(AccountTable.id, accountID)).get()).pipe(
|
||||||
Effect.map(Option.fromNullishOr),
|
Effect.map(Option.fromNullishOr),
|
||||||
),
|
),
|
||||||
),
|
)
|
||||||
|
|
||||||
persistToken: Effect.fn("AccountRepo.persistToken")((input) =>
|
const persistToken = Effect.fn("AccountRepo.persistToken")((input) =>
|
||||||
db((db) =>
|
query((db) =>
|
||||||
db
|
db
|
||||||
.update(AccountTable)
|
.update(AccountTable)
|
||||||
.set({
|
.set({
|
||||||
@@ -113,34 +120,41 @@ export class AccountRepo extends ServiceMap.Service<
|
|||||||
.where(eq(AccountTable.id, input.accountID))
|
.where(eq(AccountTable.id, input.accountID))
|
||||||
.run(),
|
.run(),
|
||||||
).pipe(Effect.asVoid),
|
).pipe(Effect.asVoid),
|
||||||
),
|
)
|
||||||
|
|
||||||
persistAccount: Effect.fn("AccountRepo.persistAccount")((input) => {
|
const persistAccount = Effect.fn("AccountRepo.persistAccount")((input) =>
|
||||||
const orgID = Option.getOrNull(input.orgID)
|
tx((db) => {
|
||||||
return db((db) =>
|
db.insert(AccountTable)
|
||||||
Database.transaction((tx) => {
|
.values({
|
||||||
tx.insert(AccountTable)
|
id: input.id,
|
||||||
.values({
|
email: input.email,
|
||||||
id: input.id,
|
url: input.url,
|
||||||
email: input.email,
|
access_token: input.accessToken,
|
||||||
url: input.url,
|
refresh_token: input.refreshToken,
|
||||||
|
token_expiry: input.expiry,
|
||||||
|
})
|
||||||
|
.onConflictDoUpdate({
|
||||||
|
target: AccountTable.id,
|
||||||
|
set: {
|
||||||
access_token: input.accessToken,
|
access_token: input.accessToken,
|
||||||
refresh_token: input.refreshToken,
|
refresh_token: input.refreshToken,
|
||||||
token_expiry: input.expiry,
|
token_expiry: input.expiry,
|
||||||
})
|
},
|
||||||
.onConflictDoUpdate({
|
})
|
||||||
target: AccountTable.id,
|
.run()
|
||||||
set: {
|
void state(db, input.id, input.orgID)
|
||||||
access_token: input.accessToken,
|
}).pipe(Effect.asVoid),
|
||||||
refresh_token: input.refreshToken,
|
)
|
||||||
token_expiry: input.expiry,
|
|
||||||
},
|
return AccountRepo.of({
|
||||||
})
|
active,
|
||||||
.run()
|
list,
|
||||||
setState(tx, input.id, orgID)
|
remove,
|
||||||
}),
|
use,
|
||||||
).pipe(Effect.asVoid)
|
getRow,
|
||||||
}),
|
persistToken,
|
||||||
|
persistAccount,
|
||||||
|
})
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,6 +20,24 @@ export const AccessToken = Schema.String.pipe(
|
|||||||
)
|
)
|
||||||
export type AccessToken = Schema.Schema.Type<typeof AccessToken>
|
export type AccessToken = Schema.Schema.Type<typeof AccessToken>
|
||||||
|
|
||||||
|
export const RefreshToken = Schema.String.pipe(
|
||||||
|
Schema.brand("RefreshToken"),
|
||||||
|
withStatics((s) => ({ make: (token: string) => s.makeUnsafe(token) })),
|
||||||
|
)
|
||||||
|
export type RefreshToken = Schema.Schema.Type<typeof RefreshToken>
|
||||||
|
|
||||||
|
export const DeviceCode = Schema.String.pipe(
|
||||||
|
Schema.brand("DeviceCode"),
|
||||||
|
withStatics((s) => ({ make: (code: string) => s.makeUnsafe(code) })),
|
||||||
|
)
|
||||||
|
export type DeviceCode = Schema.Schema.Type<typeof DeviceCode>
|
||||||
|
|
||||||
|
export const UserCode = Schema.String.pipe(
|
||||||
|
Schema.brand("UserCode"),
|
||||||
|
withStatics((s) => ({ make: (code: string) => s.makeUnsafe(code) })),
|
||||||
|
)
|
||||||
|
export type UserCode = Schema.Schema.Type<typeof UserCode>
|
||||||
|
|
||||||
export class Account extends Schema.Class<Account>("Account")({
|
export class Account extends Schema.Class<Account>("Account")({
|
||||||
id: AccountID,
|
id: AccountID,
|
||||||
email: Schema.String,
|
email: Schema.String,
|
||||||
@@ -45,12 +63,12 @@ export class AccountServiceError extends Schema.TaggedErrorClass<AccountServiceE
|
|||||||
export type AccountError = AccountRepoError | AccountServiceError
|
export type AccountError = AccountRepoError | AccountServiceError
|
||||||
|
|
||||||
export class Login extends Schema.Class<Login>("Login")({
|
export class Login extends Schema.Class<Login>("Login")({
|
||||||
code: Schema.String,
|
code: DeviceCode,
|
||||||
user: Schema.String,
|
user: UserCode,
|
||||||
url: Schema.String,
|
url: Schema.String,
|
||||||
server: Schema.String,
|
server: Schema.String,
|
||||||
expiry: Schema.Number,
|
expiry: Schema.Duration,
|
||||||
interval: Schema.Number,
|
interval: Schema.Duration,
|
||||||
}) {}
|
}) {}
|
||||||
|
|
||||||
export class PollSuccess extends Schema.TaggedClass<PollSuccess>()("PollSuccess", {
|
export class PollSuccess extends Schema.TaggedClass<PollSuccess>()("PollSuccess", {
|
||||||
|
|||||||
@@ -1,11 +1,5 @@
|
|||||||
import { Clock, Effect, Layer, Option, Schema, ServiceMap } from "effect"
|
import { Clock, Duration, Effect, Layer, Option, Schema, SchemaGetter, ServiceMap } from "effect"
|
||||||
import {
|
import { FetchHttpClient, HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
|
||||||
FetchHttpClient,
|
|
||||||
HttpClient,
|
|
||||||
HttpClientError,
|
|
||||||
HttpClientRequest,
|
|
||||||
HttpClientResponse,
|
|
||||||
} from "effect/unstable/http"
|
|
||||||
|
|
||||||
import { withTransientReadRetry } from "@/util/effect-http-client"
|
import { withTransientReadRetry } from "@/util/effect-http-client"
|
||||||
import { AccountRepo, type AccountRow } from "./repo"
|
import { AccountRepo, type AccountRow } from "./repo"
|
||||||
@@ -14,6 +8,8 @@ import {
|
|||||||
AccessToken,
|
AccessToken,
|
||||||
Account,
|
Account,
|
||||||
AccountID,
|
AccountID,
|
||||||
|
DeviceCode,
|
||||||
|
RefreshToken,
|
||||||
AccountServiceError,
|
AccountServiceError,
|
||||||
Login,
|
Login,
|
||||||
Org,
|
Org,
|
||||||
@@ -25,83 +21,101 @@ import {
|
|||||||
type PollResult,
|
type PollResult,
|
||||||
PollSlow,
|
PollSlow,
|
||||||
PollSuccess,
|
PollSuccess,
|
||||||
|
UserCode,
|
||||||
} from "./schema"
|
} from "./schema"
|
||||||
|
|
||||||
export * from "./schema"
|
export * from "./schema"
|
||||||
|
|
||||||
export type AccountOrgs = {
|
export type AccountOrgs = {
|
||||||
account: Account
|
account: Account
|
||||||
orgs: Org[]
|
orgs: readonly Org[]
|
||||||
}
|
}
|
||||||
|
|
||||||
const RemoteOrg = Schema.Struct({
|
class RemoteConfig extends Schema.Class<RemoteConfig>("RemoteConfig")({
|
||||||
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),
|
config: Schema.Record(Schema.String, Schema.Json),
|
||||||
})
|
}) {}
|
||||||
|
|
||||||
const TokenRefresh = Schema.Struct({
|
const DurationFromSeconds = Schema.Number.pipe(
|
||||||
access_token: Schema.String,
|
Schema.decodeTo(Schema.Duration, {
|
||||||
refresh_token: Schema.optional(Schema.String),
|
decode: SchemaGetter.transform((n) => Duration.seconds(n)),
|
||||||
expires_in: Schema.optional(Schema.Number),
|
encode: SchemaGetter.transform((d) => Duration.toSeconds(d)),
|
||||||
})
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
const DeviceCode = Schema.Struct({
|
class TokenRefresh extends Schema.Class<TokenRefresh>("TokenRefresh")({
|
||||||
device_code: Schema.String,
|
access_token: AccessToken,
|
||||||
user_code: Schema.String,
|
refresh_token: RefreshToken,
|
||||||
|
expires_in: DurationFromSeconds,
|
||||||
|
}) {}
|
||||||
|
|
||||||
|
class DeviceAuth extends Schema.Class<DeviceAuth>("DeviceAuth")({
|
||||||
|
device_code: DeviceCode,
|
||||||
|
user_code: UserCode,
|
||||||
verification_uri_complete: Schema.String,
|
verification_uri_complete: Schema.String,
|
||||||
expires_in: Schema.Number,
|
expires_in: DurationFromSeconds,
|
||||||
interval: Schema.Number,
|
interval: DurationFromSeconds,
|
||||||
})
|
}) {}
|
||||||
|
|
||||||
const DeviceToken = Schema.Struct({
|
class DeviceTokenSuccess extends Schema.Class<DeviceTokenSuccess>("DeviceTokenSuccess")({
|
||||||
access_token: Schema.optional(Schema.String),
|
access_token: AccessToken,
|
||||||
refresh_token: Schema.optional(Schema.String),
|
refresh_token: RefreshToken,
|
||||||
expires_in: Schema.optional(Schema.Number),
|
token_type: Schema.Literal("Bearer"),
|
||||||
error: Schema.optional(Schema.String),
|
expires_in: DurationFromSeconds,
|
||||||
error_description: Schema.optional(Schema.String),
|
}) {}
|
||||||
})
|
|
||||||
|
|
||||||
const User = Schema.Struct({
|
class DeviceTokenError extends Schema.Class<DeviceTokenError>("DeviceTokenError")({
|
||||||
id: Schema.optional(AccountID),
|
error: Schema.String,
|
||||||
email: Schema.optional(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 ClientId = Schema.Struct({ client_id: Schema.String })
|
const DeviceToken = Schema.Union([DeviceTokenSuccess, DeviceTokenError])
|
||||||
|
|
||||||
const DeviceTokenRequest = Schema.Struct({
|
class User extends Schema.Class<User>("User")({
|
||||||
|
id: AccountID,
|
||||||
|
email: Schema.String,
|
||||||
|
}) {}
|
||||||
|
|
||||||
|
class ClientId extends Schema.Class<ClientId>("ClientId")({ client_id: Schema.String }) {}
|
||||||
|
|
||||||
|
class DeviceTokenRequest extends Schema.Class<DeviceTokenRequest>("DeviceTokenRequest")({
|
||||||
grant_type: Schema.String,
|
grant_type: Schema.String,
|
||||||
device_code: Schema.String,
|
device_code: DeviceCode,
|
||||||
client_id: Schema.String,
|
client_id: Schema.String,
|
||||||
})
|
}) {}
|
||||||
|
|
||||||
|
class TokenRefreshRequest extends Schema.Class<TokenRefreshRequest>("TokenRefreshRequest")({
|
||||||
|
grant_type: Schema.String,
|
||||||
|
refresh_token: RefreshToken,
|
||||||
|
client_id: Schema.String,
|
||||||
|
}) {}
|
||||||
|
|
||||||
const clientId = "opencode-cli"
|
const clientId = "opencode-cli"
|
||||||
|
|
||||||
const toAccountServiceError = (message: string, cause?: unknown) => new AccountServiceError({ message, cause })
|
|
||||||
|
|
||||||
const mapAccountServiceError =
|
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> =>
|
<A, E, R>(effect: Effect.Effect<A, E, R>): Effect.Effect<A, AccountServiceError, R> =>
|
||||||
effect.pipe(
|
effect.pipe(
|
||||||
Effect.mapError((error) =>
|
Effect.mapError((cause) =>
|
||||||
error instanceof AccountServiceError ? error : toAccountServiceError(`${message} (${operation})`, error),
|
cause instanceof AccountServiceError ? cause : new AccountServiceError({ message, cause }),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
export class AccountService extends ServiceMap.Service<
|
export namespace AccountService {
|
||||||
AccountService,
|
export interface Service {
|
||||||
{
|
|
||||||
readonly active: () => Effect.Effect<Option.Option<Account>, AccountError>
|
readonly active: () => Effect.Effect<Option.Option<Account>, AccountError>
|
||||||
readonly list: () => Effect.Effect<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 remove: (accountID: AccountID) => Effect.Effect<void, AccountError>
|
||||||
readonly use: (accountID: AccountID, orgID: Option.Option<OrgID>) => 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: (
|
readonly config: (
|
||||||
accountID: AccountID,
|
accountID: AccountID,
|
||||||
orgID: OrgID,
|
orgID: OrgID,
|
||||||
@@ -110,80 +124,98 @@ export class AccountService extends ServiceMap.Service<
|
|||||||
readonly login: (url: string) => Effect.Effect<Login, AccountError>
|
readonly login: (url: string) => Effect.Effect<Login, AccountError>
|
||||||
readonly poll: (input: Login) => Effect.Effect<PollResult, 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(
|
static readonly layer: Layer.Layer<AccountService, never, AccountRepo | HttpClient.HttpClient> = Layer.effect(
|
||||||
AccountService,
|
AccountService,
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const repo = yield* AccountRepo
|
const repo = yield* AccountRepo
|
||||||
const http = yield* HttpClient.HttpClient
|
const http = yield* HttpClient.HttpClient
|
||||||
const httpRead = withTransientReadRetry(http)
|
const httpRead = withTransientReadRetry(http)
|
||||||
|
const httpOk = HttpClient.filterStatusOk(http)
|
||||||
|
const httpReadOk = HttpClient.filterStatusOk(httpRead)
|
||||||
|
|
||||||
const execute = (operation: string, request: HttpClientRequest.HttpClientRequest) =>
|
const executeRead = (request: HttpClientRequest.HttpClientRequest) =>
|
||||||
http.execute(request).pipe(mapAccountServiceError(operation, "HTTP request failed"))
|
httpRead.execute(request).pipe(mapAccountServiceError("HTTP request failed"))
|
||||||
|
|
||||||
const executeRead = (operation: string, request: HttpClientRequest.HttpClientRequest) =>
|
const executeReadOk = (request: HttpClientRequest.HttpClientRequest) =>
|
||||||
httpRead.execute(request).pipe(mapAccountServiceError(operation, "HTTP request failed"))
|
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(
|
request.pipe(
|
||||||
Effect.flatMap((req) => http.execute(req)),
|
Effect.flatMap((req) => httpOk.execute(req)),
|
||||||
mapAccountServiceError(operation, "HTTP request failed"),
|
mapAccountServiceError("HTTP request failed"),
|
||||||
)
|
)
|
||||||
|
|
||||||
const okOrNone = (operation: string, response: HttpClientResponse.HttpClientResponse) =>
|
// Returns a usable access token for a stored account row, refreshing and
|
||||||
HttpClientResponse.filterStatusOk(response).pipe(
|
// persisting it when the cached token has expired.
|
||||||
Effect.map(Option.some),
|
const resolveToken = Effect.fnUntraced(function* (row: AccountRow) {
|
||||||
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
|
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(
|
const response = yield* executeEffectOk(
|
||||||
"token.refresh",
|
HttpClientRequest.post(`${row.url}/auth/device/token`).pipe(
|
||||||
HttpClientRequest.post(`${found.url}/oauth/token`).pipe(
|
|
||||||
HttpClientRequest.acceptJson,
|
HttpClientRequest.acceptJson,
|
||||||
HttpClientRequest.bodyUrlParams({
|
HttpClientRequest.schemaBodyJson(TokenRefreshRequest)(
|
||||||
grant_type: "refresh_token",
|
new TokenRefreshRequest({
|
||||||
refresh_token: found.refresh_token,
|
grant_type: "refresh_token",
|
||||||
}),
|
refresh_token: row.refresh_token,
|
||||||
|
client_id: clientId,
|
||||||
|
}),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
const ok = yield* okOrNone("token.refresh", response)
|
const parsed = yield* HttpClientResponse.schemaBodyJson(TokenRefresh)(response).pipe(
|
||||||
if (Option.isNone(ok)) return Option.none()
|
mapAccountServiceError("Failed to decode response"),
|
||||||
|
|
||||||
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))
|
const expiry = Option.some(now + Duration.toMillis(parsed.expires_in))
|
||||||
|
|
||||||
yield* repo.persistToken({
|
yield* repo.persistToken({
|
||||||
accountID: AccountID.make(found.id),
|
accountID: row.id,
|
||||||
accessToken: parsed.access_token,
|
accessToken: parsed.access_token,
|
||||||
refreshToken: parsed.refresh_token ?? found.refresh_token,
|
refreshToken: parsed.refresh_token,
|
||||||
expiry,
|
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)
|
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 account = maybeAccount.value
|
||||||
const accessToken = yield* tokenForRow(account)
|
const accessToken = yield* resolveToken(account)
|
||||||
if (Option.isNone(accessToken)) return Option.none<{ account: AccountRow; accessToken: AccessToken }>()
|
return Option.some({ account, accessToken })
|
||||||
|
})
|
||||||
|
|
||||||
return Option.some({ account, accessToken: accessToken.value })
|
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 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) =>
|
const token = Effect.fn("AccountService.token")((accountID: AccountID) =>
|
||||||
@@ -211,23 +243,7 @@ export class AccountService extends ServiceMap.Service<
|
|||||||
|
|
||||||
const { account, accessToken } = resolved.value
|
const { account, accessToken } = resolved.value
|
||||||
|
|
||||||
const response = yield* executeRead(
|
return yield* fetchOrgs(account.url, accessToken)
|
||||||
"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 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 { account, accessToken } = resolved.value
|
||||||
|
|
||||||
const response = yield* executeRead(
|
const response = yield* executeRead(
|
||||||
"config",
|
|
||||||
HttpClientRequest.get(`${account.url}/api/config`).pipe(
|
HttpClientRequest.get(`${account.url}/api/config`).pipe(
|
||||||
HttpClientRequest.acceptJson,
|
HttpClientRequest.acceptJson,
|
||||||
HttpClientRequest.bearerToken(accessToken),
|
HttpClientRequest.bearerToken(accessToken),
|
||||||
@@ -245,32 +260,26 @@ export class AccountService extends ServiceMap.Service<
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
const ok = yield* okOrNone("config", response)
|
if (response.status === 404) return Option.none()
|
||||||
if (Option.isNone(ok)) return Option.none()
|
|
||||||
|
|
||||||
const parsed = yield* HttpClientResponse.schemaBodyJson(RemoteConfig)(ok.value).pipe(
|
const ok = yield* HttpClientResponse.filterStatusOk(response).pipe(mapAccountServiceError())
|
||||||
mapAccountServiceError("config", "Failed to decode response"),
|
|
||||||
|
const parsed = yield* HttpClientResponse.schemaBodyJson(RemoteConfig)(ok).pipe(
|
||||||
|
mapAccountServiceError("Failed to decode response"),
|
||||||
)
|
)
|
||||||
return Option.some(parsed.config)
|
return Option.some(parsed.config)
|
||||||
})
|
})
|
||||||
|
|
||||||
const login = Effect.fn("AccountService.login")(function* (server: string) {
|
const login = Effect.fn("AccountService.login")(function* (server: string) {
|
||||||
const response = yield* executeEffect(
|
const response = yield* executeEffectOk(
|
||||||
"login",
|
|
||||||
HttpClientRequest.post(`${server}/auth/device/code`).pipe(
|
HttpClientRequest.post(`${server}/auth/device/code`).pipe(
|
||||||
HttpClientRequest.acceptJson,
|
HttpClientRequest.acceptJson,
|
||||||
HttpClientRequest.schemaBodyJson(ClientId)({ client_id: clientId }),
|
HttpClientRequest.schemaBodyJson(ClientId)(new ClientId({ client_id: clientId })),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
const ok = yield* okOrNone("login", response)
|
const parsed = yield* HttpClientResponse.schemaBodyJson(DeviceAuth)(response).pipe(
|
||||||
if (Option.isNone(ok)) {
|
mapAccountServiceError("Failed to decode response"),
|
||||||
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({
|
return new Login({
|
||||||
code: parsed.device_code,
|
code: parsed.device_code,
|
||||||
@@ -283,91 +292,49 @@ export class AccountService extends ServiceMap.Service<
|
|||||||
})
|
})
|
||||||
|
|
||||||
const poll = Effect.fn("AccountService.poll")(function* (input: Login) {
|
const poll = Effect.fn("AccountService.poll")(function* (input: Login) {
|
||||||
const response = yield* executeEffect(
|
const response = yield* executeEffectOk(
|
||||||
"poll",
|
|
||||||
HttpClientRequest.post(`${input.server}/auth/device/token`).pipe(
|
HttpClientRequest.post(`${input.server}/auth/device/token`).pipe(
|
||||||
HttpClientRequest.acceptJson,
|
HttpClientRequest.acceptJson,
|
||||||
HttpClientRequest.schemaBodyJson(DeviceTokenRequest)({
|
HttpClientRequest.schemaBodyJson(DeviceTokenRequest)(
|
||||||
grant_type: "urn:ietf:params:oauth:grant-type:device_code",
|
new DeviceTokenRequest({
|
||||||
device_code: input.code,
|
grant_type: "urn:ietf:params:oauth:grant-type:device_code",
|
||||||
client_id: clientId,
|
device_code: input.code,
|
||||||
}),
|
client_id: clientId,
|
||||||
|
}),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
const parsed = yield* HttpClientResponse.schemaBodyJson(DeviceToken)(response).pipe(
|
const parsed = yield* HttpClientResponse.schemaBodyJson(DeviceToken)(response).pipe(
|
||||||
mapAccountServiceError("poll", "Failed to decode response"),
|
mapAccountServiceError("Failed to decode response"),
|
||||||
)
|
)
|
||||||
|
|
||||||
if (!parsed.access_token) {
|
if (parsed instanceof DeviceTokenError) return parsed.toPollResult()
|
||||||
if (parsed.error === "authorization_pending") return new PollPending()
|
const accessToken = parsed.access_token
|
||||||
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 user = fetchUser(input.server, accessToken)
|
||||||
|
const orgs = fetchOrgs(input.server, accessToken)
|
||||||
|
|
||||||
const fetchUser = executeRead(
|
const [account, remoteOrgs] = yield* Effect.all([user, orgs], { concurrency: 2 })
|
||||||
"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(
|
// TODO: When there are multiple orgs, let the user choose
|
||||||
"poll.orgs",
|
const firstOrgID = remoteOrgs.length > 0 ? Option.some(remoteOrgs[0].id) : Option.none<OrgID>()
|
||||||
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 now = yield* Clock.currentTimeMillis
|
||||||
const expiry = now + (parsed.expires_in ?? 0) * 1000
|
const expiry = now + Duration.toMillis(parsed.expires_in)
|
||||||
const refresh = parsed.refresh_token ?? ""
|
const refreshToken = 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({
|
yield* repo.persistAccount({
|
||||||
id: userId,
|
id: account.id,
|
||||||
email: userEmail,
|
email: account.email,
|
||||||
url: input.server,
|
url: input.server,
|
||||||
accessToken: access,
|
accessToken,
|
||||||
refreshToken: refresh,
|
refreshToken,
|
||||||
expiry,
|
expiry,
|
||||||
orgID: firstOrgID,
|
orgID: firstOrgID,
|
||||||
})
|
})
|
||||||
|
|
||||||
return new PollSuccess({ email: userEmail })
|
return new PollSuccess({ email: account.email })
|
||||||
})
|
})
|
||||||
|
|
||||||
return AccountService.of({
|
return AccountService.of({
|
||||||
|
|||||||
@@ -24,17 +24,17 @@ const loginEffect = Effect.fn("login")(function* (url: string) {
|
|||||||
const s = Prompt.spinner()
|
const s = Prompt.spinner()
|
||||||
yield* s.start("Waiting for authorization...")
|
yield* s.start("Waiting for authorization...")
|
||||||
|
|
||||||
const poll = (wait: number): Effect.Effect<PollResult, AccountError> =>
|
const poll = (wait: Duration.Duration): Effect.Effect<PollResult, AccountError> =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
yield* Effect.sleep(wait)
|
yield* Effect.sleep(wait)
|
||||||
const result = yield* service.poll(login)
|
const result = yield* service.poll(login)
|
||||||
if (result._tag === "PollPending") return yield* poll(wait)
|
if (result._tag === "PollPending") return yield* poll(wait)
|
||||||
if (result._tag === "PollSlow") return yield* poll(wait + 5000)
|
if (result._tag === "PollSlow") return yield* poll(Duration.sum(wait, Duration.seconds(5)))
|
||||||
return result
|
return result
|
||||||
})
|
})
|
||||||
|
|
||||||
const result = yield* poll(login.interval * 1000).pipe(
|
const result = yield* poll(login.interval).pipe(
|
||||||
Effect.timeout(Duration.seconds(login.expiry)),
|
Effect.timeout(login.expiry),
|
||||||
Effect.catchTag("TimeoutError", () => Effect.succeed(new PollExpired())),
|
Effect.catchTag("TimeoutError", () => Effect.succeed(new PollExpired())),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { expect } from "bun:test"
|
|||||||
import { Effect, Layer, Option } from "effect"
|
import { Effect, Layer, Option } from "effect"
|
||||||
|
|
||||||
import { AccountRepo } from "../../src/account/repo"
|
import { AccountRepo } from "../../src/account/repo"
|
||||||
import { AccountID, OrgID } from "../../src/account/schema"
|
import { AccessToken, AccountID, OrgID, RefreshToken } from "../../src/account/schema"
|
||||||
import { Database } from "../../src/storage/db"
|
import { Database } from "../../src/storage/db"
|
||||||
import { testEffect } from "../fixture/effect"
|
import { testEffect } from "../fixture/effect"
|
||||||
|
|
||||||
@@ -41,8 +41,8 @@ it.effect(
|
|||||||
id,
|
id,
|
||||||
email: "test@example.com",
|
email: "test@example.com",
|
||||||
url: "https://control.example.com",
|
url: "https://control.example.com",
|
||||||
accessToken: "at_123",
|
accessToken: AccessToken.make("at_123"),
|
||||||
refreshToken: "rt_456",
|
refreshToken: RefreshToken.make("rt_456"),
|
||||||
expiry: Date.now() + 3600_000,
|
expiry: Date.now() + 3600_000,
|
||||||
orgID: Option.some(OrgID.make("org-1")),
|
orgID: Option.some(OrgID.make("org-1")),
|
||||||
}),
|
}),
|
||||||
@@ -51,7 +51,7 @@ it.effect(
|
|||||||
const row = yield* AccountRepo.use((r) => r.getRow(id))
|
const row = yield* AccountRepo.use((r) => r.getRow(id))
|
||||||
expect(Option.isSome(row)).toBe(true)
|
expect(Option.isSome(row)).toBe(true)
|
||||||
const value = Option.getOrThrow(row)
|
const value = Option.getOrThrow(row)
|
||||||
expect(value.id).toBe("user-1")
|
expect(value.id).toBe(AccountID.make("user-1"))
|
||||||
expect(value.email).toBe("test@example.com")
|
expect(value.email).toBe("test@example.com")
|
||||||
|
|
||||||
const active = yield* AccountRepo.use((r) => r.active())
|
const active = yield* AccountRepo.use((r) => r.active())
|
||||||
@@ -70,8 +70,8 @@ it.effect(
|
|||||||
id: id1,
|
id: id1,
|
||||||
email: "first@example.com",
|
email: "first@example.com",
|
||||||
url: "https://control.example.com",
|
url: "https://control.example.com",
|
||||||
accessToken: "at_1",
|
accessToken: AccessToken.make("at_1"),
|
||||||
refreshToken: "rt_1",
|
refreshToken: RefreshToken.make("rt_1"),
|
||||||
expiry: Date.now() + 3600_000,
|
expiry: Date.now() + 3600_000,
|
||||||
orgID: Option.some(OrgID.make("org-1")),
|
orgID: Option.some(OrgID.make("org-1")),
|
||||||
}),
|
}),
|
||||||
@@ -82,8 +82,8 @@ it.effect(
|
|||||||
id: id2,
|
id: id2,
|
||||||
email: "second@example.com",
|
email: "second@example.com",
|
||||||
url: "https://control.example.com",
|
url: "https://control.example.com",
|
||||||
accessToken: "at_2",
|
accessToken: AccessToken.make("at_2"),
|
||||||
refreshToken: "rt_2",
|
refreshToken: RefreshToken.make("rt_2"),
|
||||||
expiry: Date.now() + 3600_000,
|
expiry: Date.now() + 3600_000,
|
||||||
orgID: Option.some(OrgID.make("org-2")),
|
orgID: Option.some(OrgID.make("org-2")),
|
||||||
}),
|
}),
|
||||||
@@ -108,8 +108,8 @@ it.effect(
|
|||||||
id: id1,
|
id: id1,
|
||||||
email: "a@example.com",
|
email: "a@example.com",
|
||||||
url: "https://control.example.com",
|
url: "https://control.example.com",
|
||||||
accessToken: "at_1",
|
accessToken: AccessToken.make("at_1"),
|
||||||
refreshToken: "rt_1",
|
refreshToken: RefreshToken.make("rt_1"),
|
||||||
expiry: Date.now() + 3600_000,
|
expiry: Date.now() + 3600_000,
|
||||||
orgID: Option.none(),
|
orgID: Option.none(),
|
||||||
}),
|
}),
|
||||||
@@ -120,8 +120,8 @@ it.effect(
|
|||||||
id: id2,
|
id: id2,
|
||||||
email: "b@example.com",
|
email: "b@example.com",
|
||||||
url: "https://control.example.com",
|
url: "https://control.example.com",
|
||||||
accessToken: "at_2",
|
accessToken: AccessToken.make("at_2"),
|
||||||
refreshToken: "rt_2",
|
refreshToken: RefreshToken.make("rt_2"),
|
||||||
expiry: Date.now() + 3600_000,
|
expiry: Date.now() + 3600_000,
|
||||||
orgID: Option.some(OrgID.make("org-1")),
|
orgID: Option.some(OrgID.make("org-1")),
|
||||||
}),
|
}),
|
||||||
@@ -143,8 +143,8 @@ it.effect(
|
|||||||
id,
|
id,
|
||||||
email: "test@example.com",
|
email: "test@example.com",
|
||||||
url: "https://control.example.com",
|
url: "https://control.example.com",
|
||||||
accessToken: "at_1",
|
accessToken: AccessToken.make("at_1"),
|
||||||
refreshToken: "rt_1",
|
refreshToken: RefreshToken.make("rt_1"),
|
||||||
expiry: Date.now() + 3600_000,
|
expiry: Date.now() + 3600_000,
|
||||||
orgID: Option.none(),
|
orgID: Option.none(),
|
||||||
}),
|
}),
|
||||||
@@ -168,8 +168,8 @@ it.effect(
|
|||||||
id: id1,
|
id: id1,
|
||||||
email: "first@example.com",
|
email: "first@example.com",
|
||||||
url: "https://control.example.com",
|
url: "https://control.example.com",
|
||||||
accessToken: "at_1",
|
accessToken: AccessToken.make("at_1"),
|
||||||
refreshToken: "rt_1",
|
refreshToken: RefreshToken.make("rt_1"),
|
||||||
expiry: Date.now() + 3600_000,
|
expiry: Date.now() + 3600_000,
|
||||||
orgID: Option.none(),
|
orgID: Option.none(),
|
||||||
}),
|
}),
|
||||||
@@ -180,8 +180,8 @@ it.effect(
|
|||||||
id: id2,
|
id: id2,
|
||||||
email: "second@example.com",
|
email: "second@example.com",
|
||||||
url: "https://control.example.com",
|
url: "https://control.example.com",
|
||||||
accessToken: "at_2",
|
accessToken: AccessToken.make("at_2"),
|
||||||
refreshToken: "rt_2",
|
refreshToken: RefreshToken.make("rt_2"),
|
||||||
expiry: Date.now() + 3600_000,
|
expiry: Date.now() + 3600_000,
|
||||||
orgID: Option.none(),
|
orgID: Option.none(),
|
||||||
}),
|
}),
|
||||||
@@ -208,8 +208,8 @@ it.effect(
|
|||||||
id,
|
id,
|
||||||
email: "test@example.com",
|
email: "test@example.com",
|
||||||
url: "https://control.example.com",
|
url: "https://control.example.com",
|
||||||
accessToken: "old_token",
|
accessToken: AccessToken.make("old_token"),
|
||||||
refreshToken: "old_refresh",
|
refreshToken: RefreshToken.make("old_refresh"),
|
||||||
expiry: 1000,
|
expiry: 1000,
|
||||||
orgID: Option.none(),
|
orgID: Option.none(),
|
||||||
}),
|
}),
|
||||||
@@ -219,16 +219,16 @@ it.effect(
|
|||||||
yield* AccountRepo.use((r) =>
|
yield* AccountRepo.use((r) =>
|
||||||
r.persistToken({
|
r.persistToken({
|
||||||
accountID: id,
|
accountID: id,
|
||||||
accessToken: "new_token",
|
accessToken: AccessToken.make("new_token"),
|
||||||
refreshToken: "new_refresh",
|
refreshToken: RefreshToken.make("new_refresh"),
|
||||||
expiry: Option.some(expiry),
|
expiry: Option.some(expiry),
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
const row = yield* AccountRepo.use((r) => r.getRow(id))
|
const row = yield* AccountRepo.use((r) => r.getRow(id))
|
||||||
const value = Option.getOrThrow(row)
|
const value = Option.getOrThrow(row)
|
||||||
expect(value.access_token).toBe("new_token")
|
expect(value.access_token).toBe(AccessToken.make("new_token"))
|
||||||
expect(value.refresh_token).toBe("new_refresh")
|
expect(value.refresh_token).toBe(RefreshToken.make("new_refresh"))
|
||||||
expect(value.token_expiry).toBe(expiry)
|
expect(value.token_expiry).toBe(expiry)
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
@@ -243,8 +243,8 @@ it.effect(
|
|||||||
id,
|
id,
|
||||||
email: "test@example.com",
|
email: "test@example.com",
|
||||||
url: "https://control.example.com",
|
url: "https://control.example.com",
|
||||||
accessToken: "old_token",
|
accessToken: AccessToken.make("old_token"),
|
||||||
refreshToken: "old_refresh",
|
refreshToken: RefreshToken.make("old_refresh"),
|
||||||
expiry: 1000,
|
expiry: 1000,
|
||||||
orgID: Option.none(),
|
orgID: Option.none(),
|
||||||
}),
|
}),
|
||||||
@@ -253,8 +253,8 @@ it.effect(
|
|||||||
yield* AccountRepo.use((r) =>
|
yield* AccountRepo.use((r) =>
|
||||||
r.persistToken({
|
r.persistToken({
|
||||||
accountID: id,
|
accountID: id,
|
||||||
accessToken: "new_token",
|
accessToken: AccessToken.make("new_token"),
|
||||||
refreshToken: "new_refresh",
|
refreshToken: RefreshToken.make("new_refresh"),
|
||||||
expiry: Option.none(),
|
expiry: Option.none(),
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
@@ -274,8 +274,8 @@ it.effect(
|
|||||||
id,
|
id,
|
||||||
email: "test@example.com",
|
email: "test@example.com",
|
||||||
url: "https://control.example.com",
|
url: "https://control.example.com",
|
||||||
accessToken: "at_v1",
|
accessToken: AccessToken.make("at_v1"),
|
||||||
refreshToken: "rt_v1",
|
refreshToken: RefreshToken.make("rt_v1"),
|
||||||
expiry: 1000,
|
expiry: 1000,
|
||||||
orgID: Option.some(OrgID.make("org-1")),
|
orgID: Option.some(OrgID.make("org-1")),
|
||||||
}),
|
}),
|
||||||
@@ -286,8 +286,8 @@ it.effect(
|
|||||||
id,
|
id,
|
||||||
email: "test@example.com",
|
email: "test@example.com",
|
||||||
url: "https://control.example.com",
|
url: "https://control.example.com",
|
||||||
accessToken: "at_v2",
|
accessToken: AccessToken.make("at_v2"),
|
||||||
refreshToken: "rt_v2",
|
refreshToken: RefreshToken.make("rt_v2"),
|
||||||
expiry: 2000,
|
expiry: 2000,
|
||||||
orgID: Option.some(OrgID.make("org-2")),
|
orgID: Option.some(OrgID.make("org-2")),
|
||||||
}),
|
}),
|
||||||
@@ -298,7 +298,7 @@ it.effect(
|
|||||||
|
|
||||||
const row = yield* AccountRepo.use((r) => r.getRow(id))
|
const row = yield* AccountRepo.use((r) => r.getRow(id))
|
||||||
const value = Option.getOrThrow(row)
|
const value = Option.getOrThrow(row)
|
||||||
expect(value.access_token).toBe("at_v2")
|
expect(value.access_token).toBe(AccessToken.make("at_v2"))
|
||||||
|
|
||||||
const active = yield* AccountRepo.use((r) => r.active())
|
const active = yield* AccountRepo.use((r) => r.active())
|
||||||
expect(Option.getOrThrow(active).active_org_id).toBe(OrgID.make("org-2"))
|
expect(Option.getOrThrow(active).active_org_id).toBe(OrgID.make("org-2"))
|
||||||
@@ -315,8 +315,8 @@ it.effect(
|
|||||||
id,
|
id,
|
||||||
email: "test@example.com",
|
email: "test@example.com",
|
||||||
url: "https://control.example.com",
|
url: "https://control.example.com",
|
||||||
accessToken: "at_1",
|
accessToken: AccessToken.make("at_1"),
|
||||||
refreshToken: "rt_1",
|
refreshToken: RefreshToken.make("rt_1"),
|
||||||
expiry: Date.now() + 3600_000,
|
expiry: Date.now() + 3600_000,
|
||||||
orgID: Option.some(OrgID.make("org-1")),
|
orgID: Option.some(OrgID.make("org-1")),
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
import { expect } from "bun:test"
|
import { expect } from "bun:test"
|
||||||
import { Effect, Layer, Option, Ref, Schema } from "effect"
|
import { Duration, Effect, Layer, Option, Ref, Schema } from "effect"
|
||||||
import { HttpClient, HttpClientResponse } from "effect/unstable/http"
|
import { HttpClient, HttpClientResponse } from "effect/unstable/http"
|
||||||
|
|
||||||
import { AccountRepo } from "../../src/account/repo"
|
import { AccountRepo } from "../../src/account/repo"
|
||||||
import { AccountService } from "../../src/account/service"
|
import { AccountService } from "../../src/account/service"
|
||||||
import { AccountID, Login, Org, OrgID } from "../../src/account/schema"
|
import { AccessToken, AccountID, DeviceCode, Login, Org, OrgID, RefreshToken, UserCode } from "../../src/account/schema"
|
||||||
import { Database } from "../../src/storage/db"
|
import { Database } from "../../src/storage/db"
|
||||||
import { testEffect } from "../fixture/effect"
|
import { testEffect } from "../fixture/effect"
|
||||||
|
|
||||||
@@ -42,8 +42,8 @@ it.effect(
|
|||||||
id: AccountID.make("user-1"),
|
id: AccountID.make("user-1"),
|
||||||
email: "one@example.com",
|
email: "one@example.com",
|
||||||
url: "https://one.example.com",
|
url: "https://one.example.com",
|
||||||
accessToken: "at_1",
|
accessToken: AccessToken.make("at_1"),
|
||||||
refreshToken: "rt_1",
|
refreshToken: RefreshToken.make("rt_1"),
|
||||||
expiry: Date.now() + 60_000,
|
expiry: Date.now() + 60_000,
|
||||||
orgID: Option.none(),
|
orgID: Option.none(),
|
||||||
}),
|
}),
|
||||||
@@ -54,8 +54,8 @@ it.effect(
|
|||||||
id: AccountID.make("user-2"),
|
id: AccountID.make("user-2"),
|
||||||
email: "two@example.com",
|
email: "two@example.com",
|
||||||
url: "https://two.example.com",
|
url: "https://two.example.com",
|
||||||
accessToken: "at_2",
|
accessToken: AccessToken.make("at_2"),
|
||||||
refreshToken: "rt_2",
|
refreshToken: RefreshToken.make("rt_2"),
|
||||||
expiry: Date.now() + 60_000,
|
expiry: Date.now() + 60_000,
|
||||||
orgID: Option.none(),
|
orgID: Option.none(),
|
||||||
}),
|
}),
|
||||||
@@ -101,8 +101,8 @@ it.effect(
|
|||||||
id,
|
id,
|
||||||
email: "user@example.com",
|
email: "user@example.com",
|
||||||
url: "https://one.example.com",
|
url: "https://one.example.com",
|
||||||
accessToken: "at_old",
|
accessToken: AccessToken.make("at_old"),
|
||||||
refreshToken: "rt_old",
|
refreshToken: RefreshToken.make("rt_old"),
|
||||||
expiry: Date.now() - 1_000,
|
expiry: Date.now() - 1_000,
|
||||||
orgID: Option.none(),
|
orgID: Option.none(),
|
||||||
}),
|
}),
|
||||||
@@ -110,7 +110,7 @@ it.effect(
|
|||||||
|
|
||||||
const client = HttpClient.make((req) =>
|
const client = HttpClient.make((req) =>
|
||||||
Effect.succeed(
|
Effect.succeed(
|
||||||
req.url === "https://one.example.com/oauth/token"
|
req.url === "https://one.example.com/auth/device/token"
|
||||||
? json(req, {
|
? json(req, {
|
||||||
access_token: "at_new",
|
access_token: "at_new",
|
||||||
refresh_token: "rt_new",
|
refresh_token: "rt_new",
|
||||||
@@ -127,8 +127,8 @@ it.effect(
|
|||||||
|
|
||||||
const row = yield* AccountRepo.use((r) => r.getRow(id))
|
const row = yield* AccountRepo.use((r) => r.getRow(id))
|
||||||
const value = Option.getOrThrow(row)
|
const value = Option.getOrThrow(row)
|
||||||
expect(value.access_token).toBe("at_new")
|
expect(value.access_token).toBe(AccessToken.make("at_new"))
|
||||||
expect(value.refresh_token).toBe("rt_new")
|
expect(value.refresh_token).toBe(RefreshToken.make("rt_new"))
|
||||||
expect(value.token_expiry).toBeGreaterThan(Date.now())
|
expect(value.token_expiry).toBeGreaterThan(Date.now())
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
@@ -143,8 +143,8 @@ it.effect(
|
|||||||
id,
|
id,
|
||||||
email: "user@example.com",
|
email: "user@example.com",
|
||||||
url: "https://one.example.com",
|
url: "https://one.example.com",
|
||||||
accessToken: "at_1",
|
accessToken: AccessToken.make("at_1"),
|
||||||
refreshToken: "rt_1",
|
refreshToken: RefreshToken.make("rt_1"),
|
||||||
expiry: Date.now() + 60_000,
|
expiry: Date.now() + 60_000,
|
||||||
orgID: Option.none(),
|
orgID: Option.none(),
|
||||||
}),
|
}),
|
||||||
@@ -180,12 +180,12 @@ it.effect(
|
|||||||
"poll stores the account and first org on success",
|
"poll stores the account and first org on success",
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const login = new Login({
|
const login = new Login({
|
||||||
code: "device-code",
|
code: DeviceCode.make("device-code"),
|
||||||
user: "user-code",
|
user: UserCode.make("user-code"),
|
||||||
url: "https://one.example.com/verify",
|
url: "https://one.example.com/verify",
|
||||||
server: "https://one.example.com",
|
server: "https://one.example.com",
|
||||||
expiry: 600,
|
expiry: Duration.seconds(600),
|
||||||
interval: 5,
|
interval: Duration.seconds(5),
|
||||||
})
|
})
|
||||||
|
|
||||||
const client = HttpClient.make((req) =>
|
const client = HttpClient.make((req) =>
|
||||||
@@ -194,6 +194,7 @@ it.effect(
|
|||||||
? json(req, {
|
? json(req, {
|
||||||
access_token: "at_1",
|
access_token: "at_1",
|
||||||
refresh_token: "rt_1",
|
refresh_token: "rt_1",
|
||||||
|
token_type: "Bearer",
|
||||||
expires_in: 60,
|
expires_in: 60,
|
||||||
})
|
})
|
||||||
: req.url === "https://one.example.com/api/user"
|
: req.url === "https://one.example.com/api/user"
|
||||||
|
|||||||
Reference in New Issue
Block a user