| 1 | import { |
| 2 | SharedAccount, |
| 3 | SharedAccountCredentials, |
| 4 | SharedAccountError, |
| 5 | type SharedAccountRequest, |
| 6 | type SharedAccountResponse, |
| 7 | } from "@t3tools/contracts"; |
| 8 | import * as Clock from "effect/Clock"; |
| 9 | import * as Context from "effect/Context"; |
| 10 | import * as Crypto from "effect/Crypto"; |
| 11 | import * as Effect from "effect/Effect"; |
| 12 | import * as Redacted from "effect/Redacted"; |
| 13 | import * as Layer from "effect/Layer"; |
| 14 | import * as Option from "effect/Option"; |
| 15 | import * as Schema from "effect/Schema"; |
| 16 | import * as Semaphore from "effect/Semaphore"; |
| 17 | import * as ServerSecretStore from "../auth/ServerSecretStore.ts"; |
| 18 | |
| 19 | const StoredAccount = Schema.Struct({ |
| 20 | ...SharedAccount.fields, |
| 21 | credentials: SharedAccountCredentials, |
| 22 | lease: Schema.NullOr(Schema.Struct({ token: Schema.String, expiresAt: Schema.Finite })), |
| 23 | }); |
| 24 | const StoredAccounts = Schema.fromJsonString(Schema.Array(StoredAccount)); |
| 25 | const decodeAccounts = Schema.decodeEffect(StoredAccounts); |
| 26 | const encodeAccounts = Schema.encodeEffect(StoredAccounts); |
| 27 | const sameCredentials = Schema.toEquivalence(SharedAccountCredentials); |
| 28 | |
| 29 | export class SharedAccounts extends Context.Service< |
| 30 | SharedAccounts, |
| 31 | { |
| 32 | readonly execute: ( |
| 33 | request: SharedAccountRequest, |
| 34 | ) => Effect.Effect<SharedAccountResponse, SharedAccountError>; |
| 35 | } |
| 36 | >()("t3/provider/SharedAccounts") {} |
| 37 | |
| 38 | const make = Effect.gen(function* () { |
| 39 | const secrets = yield* ServerSecretStore.ServerSecretStore; |
| 40 | const crypto = yield* Crypto.Crypto; |
| 41 | const lock = yield* Semaphore.make(1); |
| 42 | |
| 43 | const read = secrets.get("shared-provider-accounts").pipe( |
| 44 | Effect.flatMap((bytes) => |
| 45 | Option.isNone(bytes) |
| 46 | ? Effect.succeed<ReadonlyArray<typeof StoredAccount.Type>>([]) |
| 47 | : decodeAccounts(new TextDecoder().decode(bytes.value)), |
| 48 | ), |
| 49 | Effect.mapError( |
| 50 | (cause) => new SharedAccountError({ reason: "storage", cause: Redacted.make(cause) }), |
| 51 | ), |
| 52 | ); |
| 53 | |
| 54 | const write = (accounts: ReadonlyArray<typeof StoredAccount.Type>) => |
| 55 | encodeAccounts(accounts).pipe( |
| 56 | Effect.flatMap((json) => |
| 57 | secrets.set("shared-provider-accounts", new TextEncoder().encode(json)), |
| 58 | ), |
| 59 | Effect.mapError( |
| 60 | (cause) => new SharedAccountError({ reason: "storage", cause: Redacted.make(cause) }), |
| 61 | ), |
| 62 | ); |
| 63 | |
| 64 | const publicAccount = ({ id, label, driver, revision }: typeof StoredAccount.Type) => ({ |
| 65 | id, |
| 66 | label, |
| 67 | driver, |
| 68 | revision, |
| 69 | }); |
| 70 | const access = (account: typeof StoredAccount.Type): SharedAccountResponse => ({ |
| 71 | type: "access", |
| 72 | account: publicAccount(account), |
| 73 | credentials: |
| 74 | account.credentials.driver === "codex" |
| 75 | ? { |
| 76 | driver: "codex", |
| 77 | accessToken: account.credentials.accessToken, |
| 78 | idToken: account.credentials.idToken, |
| 79 | accountId: account.credentials.accountId, |
| 80 | expiresAt: account.credentials.expiresAt, |
| 81 | } |
| 82 | : account.credentials, |
| 83 | }); |
| 84 | |
| 85 | const execute = Effect.fn("SharedAccounts.execute")(function* (request: SharedAccountRequest) { |
| 86 | return yield* lock |
| 87 | .withPermits(1)( |
| 88 | Effect.gen(function* (): Effect.fn.Return<SharedAccountResponse, SharedAccountError> { |
| 89 | const accounts = [...(yield* read)]; |
| 90 | if (request.type === "list") { |
| 91 | return { type: "list", accounts: accounts.map(publicAccount) }; |
| 92 | } |
| 93 | if (request.type === "import") { |
| 94 | if (request.replaceId) { |
| 95 | const index = accounts.findIndex((account) => account.id === request.replaceId); |
| 96 | const previous = accounts[index]; |
| 97 | if (!previous) return yield* new SharedAccountError({ reason: "missing" }); |
| 98 | if ( |
| 99 | previous.driver !== request.credentials.driver || |
| 100 | (previous.credentials.driver === "codex" && |
| 101 | request.credentials.driver === "codex" && |
| 102 | previous.credentials.accountId !== request.credentials.accountId) |
| 103 | ) |
| 104 | return yield* new SharedAccountError({ reason: "identity" }); |
| 105 | if (sameCredentials(previous.credentials, request.credentials)) |
| 106 | return { type: "imported", account: publicAccount(previous) }; |
| 107 | const updated = { |
| 108 | ...previous, |
| 109 | credentials: request.credentials, |
| 110 | revision: previous.revision + 1, |
| 111 | lease: null, |
| 112 | }; |
| 113 | accounts[index] = updated; |
| 114 | yield* write(accounts); |
| 115 | return { type: "imported", account: publicAccount(updated) }; |
| 116 | } |
| 117 | const existing = accounts.find(({ credentials }) => |
| 118 | credentials.driver === "codex" && request.credentials.driver === "codex" |
| 119 | ? credentials.accountId === request.credentials.accountId |
| 120 | : credentials.driver === "claudeAgent" && |
| 121 | request.credentials.driver === "claudeAgent" && |
| 122 | credentials.accessToken === request.credentials.accessToken, |
| 123 | ); |
| 124 | if (existing) return { type: "imported", account: publicAccount(existing) }; |
| 125 | |
| 126 | const id = yield* crypto |
| 127 | .randomUUIDv4() |
| 128 | .pipe( |
| 129 | Effect.mapError( |
| 130 | (cause) => |
| 131 | new SharedAccountError({ reason: "storage", cause: Redacted.make(cause) }), |
| 132 | ), |
| 133 | ); |
| 134 | const account: typeof StoredAccount.Type = { |
| 135 | id, |
| 136 | label: request.label, |
| 137 | driver: request.credentials.driver, |
| 138 | revision: 1, |
| 139 | credentials: request.credentials, |
| 140 | lease: null, |
| 141 | }; |
| 142 | accounts.push(account); |
| 143 | yield* write(accounts); |
| 144 | return { type: "imported", account: publicAccount(account) }; |
| 145 | } |
| 146 | |
| 147 | const index = accounts.findIndex((entry) => entry.id === request.id); |
| 148 | const account = accounts[index]; |
| 149 | if (!account) return yield* new SharedAccountError({ reason: "missing" }); |
| 150 | |
| 151 | if (request.type === "remove") { |
| 152 | accounts.splice(index, 1); |
| 153 | yield* write(accounts); |
| 154 | return { type: "removed" }; |
| 155 | } |
| 156 | if (request.type === "commit") { |
| 157 | if ( |
| 158 | account.revision === request.revision + 1 && |
| 159 | sameCredentials(account.credentials, request.credentials) |
| 160 | ) |
| 161 | return access(account); |
| 162 | |
| 163 | if ( |
| 164 | account.revision !== request.revision || |
| 165 | account.lease?.token !== request.leaseToken |
| 166 | ) { |
| 167 | return yield* new SharedAccountError({ reason: "stale" }); |
| 168 | } |
| 169 | if ( |
| 170 | account.credentials.driver !== "codex" || |
| 171 | account.credentials.accountId !== request.credentials.accountId |
| 172 | ) { |
| 173 | return yield* new SharedAccountError({ reason: "identity" }); |
| 174 | } |
| 175 | const updated = { |
| 176 | ...account, |
| 177 | credentials: request.credentials, |
| 178 | revision: account.revision + 1, |
| 179 | lease: null, |
| 180 | }; |
| 181 | accounts[index] = updated; |
| 182 | yield* write(accounts); |
| 183 | return access(updated); |
| 184 | } |
| 185 | |
| 186 | const now = yield* Clock.currentTimeMillis; |
| 187 | if ( |
| 188 | account.credentials.driver === "claudeAgent" || |
| 189 | (account.credentials.expiresAt > now + 300_000 && |
| 190 | request.refreshRevision !== account.revision) |
| 191 | ) { |
| 192 | return access(account); |
| 193 | } |
| 194 | if (account.lease && account.lease.expiresAt > now) { |
| 195 | return { type: "busy", retryAt: account.lease.expiresAt }; |
| 196 | } |
| 197 | const token = yield* crypto |
| 198 | .randomUUIDv4() |
| 199 | .pipe( |
| 200 | Effect.mapError( |
| 201 | (cause) => |
| 202 | new SharedAccountError({ reason: "storage", cause: Redacted.make(cause) }), |
| 203 | ), |
| 204 | ); |
| 205 | const lease = { token, expiresAt: now + 30_000 }; |
| 206 | accounts[index] = { ...account, lease }; |
| 207 | yield* write(accounts); |
| 208 | return { |
| 209 | type: "refresh", |
| 210 | account: publicAccount(account), |
| 211 | leaseToken: token, |
| 212 | credentials: account.credentials, |
| 213 | expiresAt: lease.expiresAt, |
| 214 | }; |
| 215 | }), |
| 216 | ) |
| 217 | .pipe(Effect.uninterruptible); |
| 218 | }); |
| 219 | |
| 220 | return SharedAccounts.of({ execute }); |
| 221 | }); |
| 222 | |
| 223 | export const layer = Layer.effect(SharedAccounts, make); |