Skip to content

Commit 756b97d

Browse files
Apply PR #28071: feat: add well-known auth service
2 parents d7c3d94 + 5b1085d commit 756b97d

14 files changed

Lines changed: 697 additions & 373 deletions

File tree

Lines changed: 242 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,242 @@
1+
export * as AuthWellKnown from "./auth-well-known"
2+
3+
import path from "path"
4+
import { Context, Effect, Layer, Option, Schema, SynchronizedRef } from "effect"
5+
import { FetchHttpClient, HttpClient, HttpClientRequest } from "effect/unstable/http"
6+
import { AppFileSystem } from "./filesystem"
7+
import { Global } from "./global"
8+
import { Substitution } from "./substitution"
9+
10+
export class Entry extends Schema.Class<Entry>("AuthWellKnown.Entry")({
11+
key: Schema.String,
12+
token: Schema.String,
13+
}) {}
14+
15+
export class FileWriteError extends Schema.TaggedErrorClass<FileWriteError>()("AuthWellKnown.FileWriteError", {
16+
operation: Schema.Union([Schema.Literal("migrate"), Schema.Literal("write")]),
17+
cause: Schema.Defect,
18+
}) {}
19+
20+
export class RemoteConfigError extends Schema.TaggedErrorClass<RemoteConfigError>()("AuthWellKnown.RemoteConfigError", {
21+
url: Schema.String,
22+
status: Schema.Number.pipe(Schema.optional),
23+
cause: Schema.Defect.pipe(Schema.optional),
24+
}) {}
25+
26+
export type Error = FileWriteError | RemoteConfigError
27+
28+
const RemoteConfig = Schema.Struct({
29+
url: Schema.String,
30+
headers: Schema.Record(Schema.String, Schema.String).pipe(Schema.optional),
31+
})
32+
33+
export class Metadata extends Schema.Class<Metadata>("AuthWellKnown.Metadata")({
34+
auth: Schema.Struct({
35+
command: Schema.Array(Schema.String),
36+
env: Schema.String,
37+
}).pipe(Schema.optional),
38+
config: Schema.Record(Schema.String, Schema.Unknown).pipe(Schema.optional),
39+
remote_config: RemoteConfig.pipe(Schema.optional),
40+
}) {}
41+
42+
export type ConfigDocument = {
43+
url: string
44+
source: string
45+
dir: string
46+
content: unknown
47+
}
48+
49+
export interface Interface {
50+
readonly all: () => Effect.Effect<Record<string, Entry>, Error>
51+
readonly get: (url: string) => Effect.Effect<Entry | undefined, Error>
52+
readonly set: (url: string, entry: Entry) => Effect.Effect<void, Error>
53+
readonly remove: (url: string) => Effect.Effect<void, Error>
54+
readonly metadata: (url: string) => Effect.Effect<Metadata, Error>
55+
readonly configs: () => Effect.Effect<ConfigDocument[], Error>
56+
}
57+
58+
export class Service extends Context.Service<Service, Interface>()("@opencode/v2/AuthWellKnown") {}
59+
const decodeMetadata = Schema.decodeUnknownEffect(Metadata)
60+
const decodeRemoteConfig = Schema.decodeUnknownEffect(RemoteConfig)
61+
62+
function loadLegacyAuth(input: {
63+
fsys: AppFileSystem.Interface
64+
dataDir: string
65+
write: (data: Record<string, Entry>) => Effect.Effect<void, Error>
66+
}) {
67+
return Effect.gen(function* () {
68+
const decodeLegacy = Schema.decodeUnknownOption(Schema.Record(Schema.String, Schema.Unknown))
69+
const decodeLegacyCredential = Schema.decodeUnknownOption(
70+
Schema.Struct({
71+
type: Schema.Literal("wellknown"),
72+
key: Schema.String,
73+
token: Schema.String,
74+
}),
75+
)
76+
const legacy = Object.fromEntries(
77+
Object.entries(
78+
Option.getOrElse(
79+
decodeLegacy(
80+
yield* input.fsys.readJson(path.join(input.dataDir, "auth.json")).pipe(Effect.orElseSucceed(() => null)),
81+
),
82+
() => ({}),
83+
),
84+
).flatMap(([url, value]) => {
85+
const decoded = Option.getOrUndefined(decodeLegacyCredential(value))
86+
return decoded ? [[url.replace(/\/+$/, ""), new Entry({ key: decoded.key, token: decoded.token })]] : []
87+
}),
88+
)
89+
if (Object.keys(legacy).length > 0) yield* input.write(legacy).pipe(Effect.ignore)
90+
return legacy
91+
})
92+
}
93+
94+
export const layer = Layer.effect(
95+
Service,
96+
Effect.gen(function* () {
97+
const fsys = yield* AppFileSystem.Service
98+
const global = yield* Global.Service
99+
const http = yield* HttpClient.HttpClient
100+
const substitution = yield* Substitution.Service
101+
const file = path.join(global.data, "well-known.json")
102+
const decodeEntries = Schema.decodeUnknownOption(Schema.Record(Schema.String, Entry))
103+
const normalizeUrl = (url: string) => url.replace(/\/+$/, "")
104+
105+
const write = (operation: "migrate" | "write", data: Record<string, Entry>) =>
106+
fsys.writeJson(file, data, 0o600).pipe(Effect.mapError((cause) => new FileWriteError({ operation, cause })))
107+
108+
const load: () => Effect.Effect<Record<string, Entry>> = Effect.fnUntraced(function* () {
109+
const current = yield* fsys.readJson(file).pipe(Effect.orElseSucceed(() => null))
110+
if (current && typeof current === "object")
111+
return Option.getOrElse(decodeEntries(current), () => ({}) as Record<string, Entry>)
112+
return yield* loadLegacyAuth({ fsys, dataDir: global.data, write: (data) => write("migrate", data) })
113+
})
114+
115+
const state = SynchronizedRef.makeUnsafe<Record<string, Entry>>(yield* load())
116+
117+
const metadata = Effect.fn("AuthWellKnown.metadata")(function* (url: string) {
118+
const normalized = normalizeUrl(url)
119+
const source = `${normalized}/.well-known/opencode`
120+
const response = yield* HttpClientRequest.get(source).pipe(
121+
HttpClientRequest.acceptJson,
122+
http.execute,
123+
Effect.mapError((cause) => new RemoteConfigError({ url: source, cause })),
124+
)
125+
if (response.status < 200 || response.status >= 300) {
126+
return yield* new RemoteConfigError({ url: source, status: response.status })
127+
}
128+
const metadata = yield* response.json.pipe(
129+
Effect.flatMap(decodeMetadata),
130+
Effect.mapError((cause) => new RemoteConfigError({ url: source, cause })),
131+
)
132+
return { url: normalized, source, dir: path.dirname(source), metadata }
133+
})
134+
135+
const remote = Effect.fn("AuthWellKnown.remote")(function* (input: { url: string; headers?: Record<string, string> }) {
136+
const response = yield* HttpClientRequest.get(input.url).pipe(
137+
HttpClientRequest.acceptJson,
138+
input.headers ? HttpClientRequest.setHeaders(input.headers) : (request) => request,
139+
http.execute,
140+
Effect.mapError((cause) => new RemoteConfigError({ url: input.url, cause })),
141+
)
142+
if (response.status < 200 || response.status >= 300) {
143+
return yield* new RemoteConfigError({ url: input.url, status: response.status })
144+
}
145+
return yield* response.json.pipe(Effect.mapError((cause) => new RemoteConfigError({ url: input.url, cause })))
146+
})
147+
148+
return Service.of({
149+
all: Effect.fn("AuthWellKnown.all")(function* () {
150+
return yield* SynchronizedRef.get(state)
151+
}),
152+
153+
get: Effect.fn("AuthWellKnown.get")(function* (url) {
154+
return (yield* SynchronizedRef.get(state))[normalizeUrl(url)]
155+
}),
156+
157+
set: Effect.fn("AuthWellKnown.set")(function* (url, entry) {
158+
yield* SynchronizedRef.updateEffect(
159+
state,
160+
Effect.fnUntraced(function* (data) {
161+
const next = { ...data, [normalizeUrl(url)]: entry }
162+
yield* write("write", next)
163+
return next
164+
}),
165+
)
166+
}),
167+
168+
remove: Effect.fn("AuthWellKnown.remove")(function* (url) {
169+
yield* SynchronizedRef.updateEffect(
170+
state,
171+
Effect.fnUntraced(function* (data) {
172+
const next = { ...data }
173+
delete next[url]
174+
delete next[normalizeUrl(url)]
175+
yield* write("write", next)
176+
return next
177+
}),
178+
)
179+
}),
180+
181+
metadata: Effect.fn("AuthWellKnown.metadata.public")(function* (url) {
182+
return (yield* metadata(url)).metadata
183+
}),
184+
185+
configs: Effect.fn("AuthWellKnown.configs")(function* () {
186+
const documents = yield* Effect.all(
187+
Object.entries(yield* SynchronizedRef.get(state)).map(([url, entry]) =>
188+
Effect.gen(function* () {
189+
const configs: ConfigDocument[] = []
190+
const response = yield* metadata(url)
191+
const env = { [entry.key]: entry.token }
192+
if (response.metadata.config) {
193+
configs.push({
194+
url: response.url,
195+
source: response.source,
196+
dir: response.dir,
197+
content: response.metadata.config,
198+
})
199+
}
200+
if (response.metadata.remote_config) {
201+
const remoteConfig = yield* substitution
202+
.substitute({
203+
text: JSON.stringify(response.metadata.remote_config),
204+
type: "virtual",
205+
dir: response.url,
206+
source: response.source,
207+
env,
208+
})
209+
.pipe(
210+
Effect.flatMap((text) =>
211+
Effect.try({
212+
try: () => JSON.parse(text) as unknown,
213+
catch: (cause) => new RemoteConfigError({ url: response.source, cause }),
214+
}),
215+
),
216+
Effect.flatMap(decodeRemoteConfig),
217+
Effect.mapError((cause) => new RemoteConfigError({ url: response.source, cause })),
218+
)
219+
configs.push({
220+
url: remoteConfig.url,
221+
source: remoteConfig.url,
222+
dir: path.dirname(remoteConfig.url),
223+
content: yield* remote({ url: remoteConfig.url, headers: remoteConfig.headers }),
224+
})
225+
}
226+
return configs
227+
}),
228+
),
229+
{ concurrency: "unbounded" },
230+
)
231+
return documents.flat()
232+
}),
233+
})
234+
}),
235+
)
236+
237+
export const defaultLayer = layer.pipe(
238+
Layer.provide(AppFileSystem.defaultLayer),
239+
Layer.provide(Global.defaultLayer),
240+
Layer.provide(FetchHttpClient.layer),
241+
Layer.provide(Substitution.defaultLayer),
242+
)

packages/core/src/substitution.ts

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
export * as Substitution from "./substitution"
2+
3+
import os from "os"
4+
import path from "path"
5+
import { Context, Effect, Layer, Schema } from "effect"
6+
import { AppFileSystem } from "./filesystem"
7+
8+
type Source =
9+
| {
10+
type: "path"
11+
path: string
12+
}
13+
| {
14+
type: "virtual"
15+
source: string
16+
dir: string
17+
}
18+
19+
export type Input = Source & {
20+
text: string
21+
missing?: "error" | "empty"
22+
env?: Record<string, string | undefined>
23+
}
24+
25+
export class FileReferenceError extends Schema.TaggedErrorClass<FileReferenceError>()("Substitution.FileReferenceError", {
26+
source: Schema.String,
27+
token: Schema.String,
28+
resolved: Schema.String,
29+
cause: Schema.Defect,
30+
}) {}
31+
32+
export type Error = FileReferenceError
33+
34+
export interface Interface {
35+
readonly substitute: (input: Input) => Effect.Effect<string, Error>
36+
}
37+
38+
export class Service extends Context.Service<Service, Interface>()("@opencode/v2/Substitution") {}
39+
40+
export const layer = Layer.effect(
41+
Service,
42+
Effect.gen(function* () {
43+
const fs = yield* AppFileSystem.Service
44+
45+
return Service.of({
46+
substitute: Effect.fn("Substitution.substitute")(function* (input) {
47+
const missing = input.missing ?? "error"
48+
const text = input.text.replace(/\{env:([^}]+)\}/g, (_, varName) => {
49+
return input.env?.[varName] ?? process.env[varName] ?? ""
50+
})
51+
52+
const fileMatches = Array.from(text.matchAll(/\{file:[^}]+\}/g))
53+
if (!fileMatches.length) return text
54+
55+
const configDir = input.type === "path" ? path.dirname(input.path) : input.dir
56+
const configSource = input.type === "path" ? input.path : input.source
57+
let out = ""
58+
let cursor = 0
59+
60+
for (const match of fileMatches) {
61+
const token = match[0]
62+
const index = match.index!
63+
out += text.slice(cursor, index)
64+
65+
const lineStart = text.lastIndexOf("\n", index - 1) + 1
66+
const prefix = text.slice(lineStart, index).trimStart()
67+
if (prefix.startsWith("//")) {
68+
out += token
69+
cursor = index + token.length
70+
continue
71+
}
72+
73+
const reference = token.replace(/^\{file:/, "").replace(/\}$/, "")
74+
const filepath = reference.startsWith("~/") ? path.join(os.homedir(), reference.slice(2)) : reference
75+
const resolved = path.isAbsolute(filepath) ? filepath : path.resolve(configDir, filepath)
76+
const content = yield* fs.readFileString(resolved).pipe(
77+
Effect.catch((cause) => {
78+
if (missing === "empty") return Effect.succeed("")
79+
return Effect.fail(new FileReferenceError({ source: configSource, token, resolved, cause }))
80+
}),
81+
)
82+
83+
out += JSON.stringify(content.trim()).slice(1, -1)
84+
cursor = index + token.length
85+
}
86+
87+
out += text.slice(cursor)
88+
return out
89+
}),
90+
})
91+
}),
92+
)
93+
94+
export const defaultLayer = layer.pipe(Layer.provide(AppFileSystem.defaultLayer))

0 commit comments

Comments
 (0)