@astrale-os/cli 0.4.0-alpha.13

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (219) hide show
  1. package/.check-workspace.cjs +40 -0
  2. package/README.md +151 -0
  3. package/dist/astrale.js +59749 -0
  4. package/package.json +90 -0
  5. package/src/command.ts +40 -0
  6. package/src/commands/__tests__/admin-instance.test.ts +73 -0
  7. package/src/commands/__tests__/auth-login.test.ts +178 -0
  8. package/src/commands/__tests__/auth-token.test.ts +223 -0
  9. package/src/commands/__tests__/call.test.ts +72 -0
  10. package/src/commands/__tests__/domain-list.test.ts +74 -0
  11. package/src/commands/__tests__/help-contract.test.ts +136 -0
  12. package/src/commands/__tests__/install-identity-override.test.ts +65 -0
  13. package/src/commands/__tests__/instance-bookmark.test.ts +101 -0
  14. package/src/commands/__tests__/instance-create-hosts.test.ts +29 -0
  15. package/src/commands/__tests__/instance-list-rows.test.ts +63 -0
  16. package/src/commands/__tests__/logs.test.ts +117 -0
  17. package/src/commands/__tests__/ls.test.ts +25 -0
  18. package/src/commands/__tests__/setup-plan.test.ts +61 -0
  19. package/src/commands/admin/status.ts +61 -0
  20. package/src/commands/admin/use.ts +77 -0
  21. package/src/commands/auth/login.ts +82 -0
  22. package/src/commands/auth/logout.ts +50 -0
  23. package/src/commands/auth/status.ts +86 -0
  24. package/src/commands/auth/token.ts +162 -0
  25. package/src/commands/browser.ts +207 -0
  26. package/src/commands/call.ts +300 -0
  27. package/src/commands/describe.ts +182 -0
  28. package/src/commands/domain/install.ts +420 -0
  29. package/src/commands/domain/list.ts +154 -0
  30. package/src/commands/domain/publish.ts +155 -0
  31. package/src/commands/get.ts +60 -0
  32. package/src/commands/identity/create.ts +26 -0
  33. package/src/commands/identity/delete.ts +18 -0
  34. package/src/commands/identity/export.ts +66 -0
  35. package/src/commands/identity/import.ts +101 -0
  36. package/src/commands/identity/list.ts +56 -0
  37. package/src/commands/identity/register.ts +170 -0
  38. package/src/commands/identity/sync.ts +32 -0
  39. package/src/commands/identity/unsync.ts +24 -0
  40. package/src/commands/identity/use.ts +18 -0
  41. package/src/commands/identity/whoami.ts +34 -0
  42. package/src/commands/idp/add.ts +150 -0
  43. package/src/commands/idp/list.ts +57 -0
  44. package/src/commands/idp/refresh.ts +38 -0
  45. package/src/commands/idp/remove.ts +36 -0
  46. package/src/commands/idp/show.ts +29 -0
  47. package/src/commands/instance/active.ts +64 -0
  48. package/src/commands/instance/bookmark.ts +72 -0
  49. package/src/commands/instance/create.ts +69 -0
  50. package/src/commands/instance/delete.ts +72 -0
  51. package/src/commands/instance/forget.ts +26 -0
  52. package/src/commands/instance/list.ts +149 -0
  53. package/src/commands/instance/status.ts +42 -0
  54. package/src/commands/instance/use.ts +210 -0
  55. package/src/commands/logs.ts +347 -0
  56. package/src/commands/ls.ts +229 -0
  57. package/src/commands/query.ts +32 -0
  58. package/src/commands/setup.ts +54 -0
  59. package/src/commands/status.ts +60 -0
  60. package/src/commands/studio.ts +401 -0
  61. package/src/commands/token.ts +77 -0
  62. package/src/commands/update.ts +267 -0
  63. package/src/commands/use.ts +87 -0
  64. package/src/errors.ts +65 -0
  65. package/src/kernel/__tests__/auth.test.ts +77 -0
  66. package/src/kernel/__tests__/errors.test.ts +43 -0
  67. package/src/kernel/__tests__/remote-routing.test.ts +70 -0
  68. package/src/kernel/auth.ts +234 -0
  69. package/src/kernel/ca-fetch.ts +119 -0
  70. package/src/kernel/client.ts +191 -0
  71. package/src/kernel/errors.ts +280 -0
  72. package/src/kernel/expand.ts +217 -0
  73. package/src/kernel/index.ts +14 -0
  74. package/src/kernel/options.ts +22 -0
  75. package/src/kernel/remote-routing.ts +88 -0
  76. package/src/kernel/run.ts +63 -0
  77. package/src/kernel/types.ts +14 -0
  78. package/src/lib/__tests__/admin-target.test.ts +112 -0
  79. package/src/lib/__tests__/binary.test.ts +56 -0
  80. package/src/lib/__tests__/command-dx.test.ts +58 -0
  81. package/src/lib/__tests__/concurrency.test.ts +62 -0
  82. package/src/lib/__tests__/config.test.ts +53 -0
  83. package/src/lib/__tests__/design.test.ts +99 -0
  84. package/src/lib/__tests__/domain-identity.test.ts +60 -0
  85. package/src/lib/__tests__/format.test.ts +22 -0
  86. package/src/lib/__tests__/fs-atomic.test.ts +104 -0
  87. package/src/lib/__tests__/identity.test.ts +79 -0
  88. package/src/lib/__tests__/idp-session.driver.ts +53 -0
  89. package/src/lib/__tests__/idp-session.test.ts +357 -0
  90. package/src/lib/__tests__/idp.test.ts +385 -0
  91. package/src/lib/__tests__/instance-candidates.test.ts +73 -0
  92. package/src/lib/__tests__/instance-target.test.ts +183 -0
  93. package/src/lib/__tests__/instance.test.ts +136 -0
  94. package/src/lib/__tests__/keys.test.ts +129 -0
  95. package/src/lib/__tests__/local-status.test.ts +202 -0
  96. package/src/lib/__tests__/output.test.ts +150 -0
  97. package/src/lib/__tests__/panel.test.ts +40 -0
  98. package/src/lib/__tests__/port.test.ts +44 -0
  99. package/src/lib/__tests__/prompt.test.ts +25 -0
  100. package/src/lib/__tests__/sdk-deps.test.ts +68 -0
  101. package/src/lib/__tests__/self.test.ts +272 -0
  102. package/src/lib/__tests__/studio-server-deps.test.ts +74 -0
  103. package/src/lib/__tests__/table.test.ts +53 -0
  104. package/src/lib/__tests__/update.test.ts +246 -0
  105. package/src/lib/__tests__/use-target.test.ts +56 -0
  106. package/src/lib/__tests__/validation.test.ts +34 -0
  107. package/src/lib/admin-domain.ts +25 -0
  108. package/src/lib/admin-instance.ts +26 -0
  109. package/src/lib/admin-target.ts +217 -0
  110. package/src/lib/binary.ts +131 -0
  111. package/src/lib/browser.ts +150 -0
  112. package/src/lib/command-dx.ts +161 -0
  113. package/src/lib/concurrency.ts +31 -0
  114. package/src/lib/config.ts +45 -0
  115. package/src/lib/domain-identity.ts +49 -0
  116. package/src/lib/env.ts +49 -0
  117. package/src/lib/format.ts +4 -0
  118. package/src/lib/fs-atomic.ts +126 -0
  119. package/src/lib/identity.ts +256 -0
  120. package/src/lib/idp-session.ts +134 -0
  121. package/src/lib/idp.ts +876 -0
  122. package/src/lib/instance-candidates.ts +49 -0
  123. package/src/lib/instance-target.ts +182 -0
  124. package/src/lib/instance.ts +395 -0
  125. package/src/lib/keys.ts +294 -0
  126. package/src/lib/local-status.ts +152 -0
  127. package/src/lib/log.ts +116 -0
  128. package/src/lib/login-flow.ts +164 -0
  129. package/src/lib/meta.ts +86 -0
  130. package/src/lib/output.ts +222 -0
  131. package/src/lib/panel.ts +61 -0
  132. package/src/lib/paths.ts +11 -0
  133. package/src/lib/port.ts +41 -0
  134. package/src/lib/proc.ts +82 -0
  135. package/src/lib/prompt.ts +136 -0
  136. package/src/lib/provision-instance.ts +170 -0
  137. package/src/lib/sdk-deps.ts +104 -0
  138. package/src/lib/self.ts +166 -0
  139. package/src/lib/skills.ts +171 -0
  140. package/src/lib/table.ts +62 -0
  141. package/src/lib/update.ts +315 -0
  142. package/src/lib/use-target.ts +24 -0
  143. package/src/lib/validation.ts +59 -0
  144. package/src/program.ts +200 -0
  145. package/src/registry.ts +59 -0
  146. package/src/setup/__tests__/util.test.ts +29 -0
  147. package/src/setup/engine.ts +83 -0
  148. package/src/setup/render.ts +109 -0
  149. package/src/setup/steps/admin.ts +78 -0
  150. package/src/setup/steps/agent-browser.ts +81 -0
  151. package/src/setup/steps/auth.ts +54 -0
  152. package/src/setup/steps/domain.ts +68 -0
  153. package/src/setup/steps/index.ts +18 -0
  154. package/src/setup/steps/instance.ts +119 -0
  155. package/src/setup/steps/skills-bridge.ts +70 -0
  156. package/src/setup/steps/skills.ts +59 -0
  157. package/src/setup/types.ts +61 -0
  158. package/src/setup/util.ts +34 -0
  159. package/src/test-utils.ts +18 -0
  160. package/studio/client/dist/assets/index-DOwzZAEK.css +1 -0
  161. package/studio/client/dist/assets/index-wtU0Zxhy.js +183 -0
  162. package/studio/client/dist/index.html +13 -0
  163. package/studio/package.json +62 -0
  164. package/studio/server/agent/ask.ts +68 -0
  165. package/studio/server/agent/bridge-mcp.ts +182 -0
  166. package/studio/server/agent/bridge.ts +188 -0
  167. package/studio/server/agent/claude.ts +666 -0
  168. package/studio/server/agent/mock.ts +186 -0
  169. package/studio/server/agent/prompt.ts +202 -0
  170. package/studio/server/agent/registry.ts +29 -0
  171. package/studio/server/agent/runner.ts +484 -0
  172. package/studio/server/agent/schema-map.ts +112 -0
  173. package/studio/server/agent/types.ts +120 -0
  174. package/studio/server/api.ts +574 -0
  175. package/studio/server/cache.ts +138 -0
  176. package/studio/server/detect.ts +81 -0
  177. package/studio/server/domain.ts +70 -0
  178. package/studio/server/index.ts +136 -0
  179. package/studio/server/introspect/anatomy-extras.ts +398 -0
  180. package/studio/server/introspect/anatomy.ts +108 -0
  181. package/studio/server/introspect/bundle.ts +57 -0
  182. package/studio/server/introspect/core-extractor.ts +119 -0
  183. package/studio/server/introspect/core.ts +44 -0
  184. package/studio/server/introspect/diff.ts +133 -0
  185. package/studio/server/introspect/extractor.ts +102 -0
  186. package/studio/server/introspect/hash.ts +21 -0
  187. package/studio/server/introspect/overlay-tsmorph.ts +874 -0
  188. package/studio/server/introspect/overlay.ts +57 -0
  189. package/studio/server/introspect/runtime.ts +99 -0
  190. package/studio/server/introspect/schema-refs.ts +46 -0
  191. package/studio/server/lifecycle.ts +38 -0
  192. package/studio/server/sse.ts +57 -0
  193. package/studio/server/state/baseline.ts +211 -0
  194. package/studio/server/state/catalog.ts +117 -0
  195. package/studio/server/state/comments.ts +321 -0
  196. package/studio/server/state/context.ts +167 -0
  197. package/studio/server/state/copy.ts +156 -0
  198. package/studio/server/state/create.ts +156 -0
  199. package/studio/server/state/documents.ts +70 -0
  200. package/studio/server/state/env.ts +161 -0
  201. package/studio/server/state/git.ts +75 -0
  202. package/studio/server/state/handoff.ts +55 -0
  203. package/studio/server/state/harness-gateway.ts +181 -0
  204. package/studio/server/state/harness-token.ts +0 -0
  205. package/studio/server/state/instance.ts +244 -0
  206. package/studio/server/state/integrations.ts +55 -0
  207. package/studio/server/state/layout.ts +55 -0
  208. package/studio/server/state/settings.ts +39 -0
  209. package/studio/server/state/store.ts +97 -0
  210. package/studio/server/state/updates.ts +63 -0
  211. package/studio/server/state/usage.ts +37 -0
  212. package/studio/server/state/views.ts +138 -0
  213. package/studio/server/state/visibility.ts +33 -0
  214. package/studio/server/watch.ts +81 -0
  215. package/studio/server/workspace-state.ts +26 -0
  216. package/studio/server/workspace-watch.ts +101 -0
  217. package/studio/shared/types.ts +873 -0
  218. package/studio/tsconfig.json +23 -0
  219. package/tsconfig.json +14 -0
package/src/lib/idp.ts ADDED
@@ -0,0 +1,876 @@
1
+ import { decodeJwt, type JWTPayload } from 'jose'
2
+ import { mkdir, readFile, readdir, unlink } from 'node:fs/promises'
3
+ import { dirname, join } from 'node:path'
4
+ import { z } from 'zod'
5
+
6
+ import { paths } from './env'
7
+ import { atomicWrite } from './fs-atomic'
8
+ import { log } from './log'
9
+ import { IDPS_PATH, IDP_SESSIONS_DIR } from './paths'
10
+ import { validateName, validateUrl } from './validation'
11
+
12
+ /**
13
+ * Thrown when a refresh succeeds but the IdP mints an access token whose `aud`
14
+ * does not match the one the caller requested. Distinct from a transport/grant
15
+ * failure: the session is *healthy*, the IdP simply will not issue that
16
+ * audience (e.g. WorkOS AuthKit ignores the `audience` param and stamps a fixed
17
+ * API audience per environment). Re-login does not help — callers must target
18
+ * an instance whose audience equals `actual`, or reconfigure the IdP/bookmark.
19
+ */
20
+ export class IdpAudienceMismatchError extends Error {
21
+ readonly requested: string
22
+ readonly actual: string | undefined
23
+
24
+ constructor(requested: string, actual: string | undefined) {
25
+ super(
26
+ `IdP minted an access token for audience ${actual ?? '(none)'} but ${requested} was required`,
27
+ )
28
+ this.name = 'IdpAudienceMismatchError'
29
+ this.requested = requested
30
+ this.actual = actual
31
+ }
32
+ }
33
+
34
+ /**
35
+ * A token-endpoint request that the IdP answered with an OAuth error (or that
36
+ * failed at the HTTP layer). Carries the structured OAuth `error` code so
37
+ * callers can tell a definitively dead grant (`invalid_grant`) from a
38
+ * transient outage — the message string alone cannot.
39
+ */
40
+ export class OAuthTokenError extends Error {
41
+ /** OAuth `error` code, e.g. `invalid_grant`. */
42
+ readonly code?: string
43
+ /** OAuth `error_description`. */
44
+ readonly description?: string
45
+ /** HTTP status of the token response. */
46
+ readonly status?: number
47
+
48
+ constructor(args: { code?: string; description?: string; status?: number }) {
49
+ super(
50
+ `OAuth token request failed: ${args.description ?? args.code ?? `HTTP ${args.status ?? '?'}`}`,
51
+ )
52
+ this.name = 'OAuthTokenError'
53
+ this.code = args.code
54
+ this.description = args.description
55
+ this.status = args.status
56
+ }
57
+ }
58
+
59
+ export type RefreshFailureKind = 'session-ended' | 'org-rejected' | 'transient' | 'unknown'
60
+
61
+ /**
62
+ * Classify a `refreshSession` failure so callers only demand a re-login when
63
+ * the grant is actually dead. `org-rejected` = healthy session, wrong/stale
64
+ * org scope (re-login can't fix it); 5xx/429 and fetch-layer failures are
65
+ * transient — the cached session is likely still valid.
66
+ */
67
+ export function classifyRefreshFailure(e: unknown): RefreshFailureKind {
68
+ if (e instanceof OAuthTokenError) {
69
+ if (
70
+ /not a member of the organization|organization not found/i.test(e.description ?? e.message)
71
+ ) {
72
+ return 'org-rejected'
73
+ }
74
+ if (e.code === 'invalid_grant') return 'session-ended'
75
+ if (e.status !== undefined && (e.status >= 500 || e.status === 429)) return 'transient'
76
+ return 'unknown'
77
+ }
78
+ if (e instanceof Error) {
79
+ if (e.name === 'AbortError' || e.name === 'TimeoutError') return 'transient'
80
+ // Node's fetch rejects network-layer failures as TypeError.
81
+ if (e instanceof TypeError) return 'transient'
82
+ // Bun stamps codes like ConnectionRefused/ConnectionClosed/FailedToOpenSocket;
83
+ // Node uses the classic ECONN*/ETIMEDOUT family.
84
+ const code = (e as { code?: string }).code
85
+ if (
86
+ typeof code === 'string' &&
87
+ /^(ECONN|ETIMEDOUT|ENOTFOUND|EAI_AGAIN|ENETUNREACH|Connection|FailedToOpenSocket|Timeout)/.test(
88
+ code,
89
+ )
90
+ ) {
91
+ return 'transient'
92
+ }
93
+ }
94
+ return 'unknown'
95
+ }
96
+
97
+ export const OidcMetadataSchema = z
98
+ .object({
99
+ issuer: z.string().url(),
100
+ authorization_endpoint: z.string().url().optional(),
101
+ token_endpoint: z.string().url(),
102
+ jwks_uri: z.string().url(),
103
+ device_authorization_endpoint: z.string().url().optional(),
104
+ revocation_endpoint: z.string().url().optional(),
105
+ scopes_supported: z.array(z.string()).optional(),
106
+ grant_types_supported: z.array(z.string()).optional(),
107
+ response_types_supported: z.array(z.string()).optional(),
108
+ code_challenge_methods_supported: z.array(z.string()).optional(),
109
+ })
110
+ .passthrough()
111
+
112
+ export type OidcMetadata = z.infer<typeof OidcMetadataSchema>
113
+
114
+ export const IdpClientConfigSchema = z
115
+ .object({
116
+ client_id: z.string().optional(),
117
+ client_secret_env: z.string().optional(),
118
+ redirect_uris: z.array(z.string().url()).optional(),
119
+ scope: z.string().optional(),
120
+ public: z.boolean().optional(),
121
+ token_response: z.enum(['oauth', 'workos-authkit']).optional(),
122
+ token_request_format: z.enum(['form', 'json']).optional(),
123
+ workos_application_id: z.string().optional(),
124
+ workos_application_type: z.enum(['oauth', 'm2m']).optional(),
125
+ })
126
+ .passthrough()
127
+
128
+ export type IdpClientConfig = z.infer<typeof IdpClientConfigSchema>
129
+
130
+ export const IdpIndexEntrySchema = z.object({
131
+ issuer: z.string().url(),
132
+ builtIn: z.boolean().optional(),
133
+ createdAt: z.string(),
134
+ updatedAt: z.string(),
135
+ })
136
+
137
+ export const IdpStoreSchema = z.object({
138
+ idps: z.record(z.string(), IdpIndexEntrySchema),
139
+ })
140
+
141
+ export type IdpStore = z.infer<typeof IdpStoreSchema>
142
+ export type IdpIndexEntry = z.infer<typeof IdpIndexEntrySchema>
143
+
144
+ export const IdpSessionSchema = z
145
+ .object({
146
+ identity: z.string(),
147
+ idp: z.string(),
148
+ issuer: z.string().url(),
149
+ subject: z.string(),
150
+ audience: z.string().optional(),
151
+ organizationId: z.string().optional(),
152
+ access_token: z.string(),
153
+ id_token: z.string().optional(),
154
+ refresh_token: z.string().optional(),
155
+ token_type: z.string().optional(),
156
+ scope: z.string().optional(),
157
+ expires_at: z.string().optional(),
158
+ claims: z.record(z.string(), z.unknown()).optional(),
159
+ /**
160
+ * Access tokens by audience. WorkOS mints one `aud` per token, so a
161
+ * session juggling several instances would otherwise burn a (single-use)
162
+ * refresh-token rotation on every instance flip. The top-level
163
+ * `access_token`/`expires_at` stay the most recently minted token for
164
+ * back-compat with older CLI builds.
165
+ */
166
+ tokens: z
167
+ .record(
168
+ z.string(),
169
+ z.object({ access_token: z.string(), expires_at: z.string().optional() }).passthrough(),
170
+ )
171
+ .optional(),
172
+ updatedAt: z.string(),
173
+ })
174
+ .passthrough()
175
+
176
+ export type IdpSession = z.infer<typeof IdpSessionSchema>
177
+
178
+ export type TokenResponse = {
179
+ access_token?: string
180
+ accessToken?: string
181
+ id_token?: string
182
+ idToken?: string
183
+ refresh_token?: string
184
+ refreshToken?: string
185
+ token_type?: string
186
+ scope?: string
187
+ expires_in?: number
188
+ expiresIn?: number
189
+ user?: { id?: string; [key: string]: unknown }
190
+ organization_id?: string
191
+ authentication_method?: string
192
+ error?: string
193
+ error_description?: string
194
+ [key: string]: unknown
195
+ }
196
+
197
+ export type DeviceAuthorizationResponse = {
198
+ device_code: string
199
+ user_code?: string
200
+ verification_uri?: string
201
+ verification_uri_complete?: string
202
+ expires_in: number
203
+ interval?: number
204
+ message?: string
205
+ [key: string]: unknown
206
+ }
207
+
208
+ export type IdpConfig = {
209
+ name: string
210
+ entry: IdpIndexEntry
211
+ metadata: OidcMetadata
212
+ client: IdpClientConfig
213
+ }
214
+
215
+ export const BUILTIN_WORKOS_IDP_NAME = 'workos'
216
+ const DEFAULT_WORKOS_API_HOST = 'https://api.workos.com'
217
+ const DEFAULT_WORKOS_CLIENT_ID = 'client_01KC29HEGD7B40TV2C4QZ436BG'
218
+ const WORKOS_CLIENT_ID_ENV_NAMES = ['WORKOS_CLIENT_ID', 'VITE_WORKOS_CLIENT_ID'] as const
219
+
220
+ export function idpDir(name: string): string {
221
+ return paths.idpDir(name)
222
+ }
223
+
224
+ export function idpMetadataPath(name: string): string {
225
+ return join(idpDir(name), 'metadata.json')
226
+ }
227
+
228
+ export function idpClientPath(name: string): string {
229
+ return join(idpDir(name), 'client.json')
230
+ }
231
+
232
+ export function idpSessionPath(identityName: string): string {
233
+ validateName(identityName, 'Identity')
234
+ return paths.idpSession(identityName)
235
+ }
236
+
237
+ export async function readIdpStore(): Promise<IdpStore> {
238
+ try {
239
+ const raw = await readFile(IDPS_PATH, 'utf-8')
240
+ return IdpStoreSchema.parse(JSON.parse(raw))
241
+ } catch (e) {
242
+ if (e instanceof z.ZodError) {
243
+ log.warn(`Invalid IdP index at ${IDPS_PATH} — using empty registry`)
244
+ } else if ((e as { code?: string }).code !== 'ENOENT') {
245
+ log.warn(`Could not read IdP index at ${IDPS_PATH} — using empty registry`)
246
+ }
247
+ return { idps: {} }
248
+ }
249
+ }
250
+
251
+ export async function writeIdpStore(store: IdpStore): Promise<void> {
252
+ await mkdir(dirname(IDPS_PATH), { recursive: true })
253
+ await atomicWrite(IDPS_PATH, JSON.stringify(store, null, 2) + '\n')
254
+ }
255
+
256
+ export async function readIdpConfig(name: string): Promise<IdpConfig> {
257
+ validateName(name, 'IdP')
258
+ const store = await readIdpStore()
259
+ const entry = store.idps[name]
260
+ if (!entry)
261
+ throw new Error(`IdP "${name}" not found. Run: astrale idp add ${name} --issuer <url>`)
262
+ const [metadataRaw, clientRaw] = await Promise.all([
263
+ readFile(idpMetadataPath(name), 'utf-8'),
264
+ readFile(idpClientPath(name), 'utf-8').catch((e) => {
265
+ if ((e as { code?: string }).code === 'ENOENT') return '{}'
266
+ throw e
267
+ }),
268
+ ])
269
+ return {
270
+ name,
271
+ entry,
272
+ metadata: OidcMetadataSchema.parse(JSON.parse(metadataRaw)),
273
+ client: IdpClientConfigSchema.parse(JSON.parse(clientRaw)),
274
+ }
275
+ }
276
+
277
+ export async function readIdpConfigOrBuiltin(
278
+ name: string,
279
+ opts: { clientId?: string; persist?: boolean } = {},
280
+ ): Promise<IdpConfig> {
281
+ validateName(name, 'IdP')
282
+ const store = await readIdpStore()
283
+ if (store.idps[name]) {
284
+ return await readIdpConfig(name)
285
+ }
286
+
287
+ const builtin = builtinIdpConfig(name, opts.clientId)
288
+ if (!builtin) {
289
+ throw new Error(`IdP "${name}" not found. Run: astrale idp add ${name} --issuer <url>`)
290
+ }
291
+ if (!opts.persist) return builtin
292
+ return upsertIdpConfig({
293
+ name: builtin.name,
294
+ metadata: builtin.metadata,
295
+ client: builtin.client,
296
+ builtIn: true,
297
+ })
298
+ }
299
+
300
+ export async function listIdpConfigs(): Promise<IdpConfig[]> {
301
+ const store = await readIdpStore()
302
+ const entries = await Promise.all(
303
+ Object.keys(store.idps).map((name) => readIdpConfig(name).catch(() => undefined)),
304
+ )
305
+ const configs = entries.filter((entry): entry is IdpConfig => !!entry)
306
+ if (!store.idps[BUILTIN_WORKOS_IDP_NAME]) {
307
+ const workos = builtinIdpConfig(BUILTIN_WORKOS_IDP_NAME)
308
+ if (workos) configs.push(workos)
309
+ }
310
+ return configs
311
+ }
312
+
313
+ export async function upsertIdpConfig(args: {
314
+ name: string
315
+ metadata: OidcMetadata
316
+ client?: IdpClientConfig
317
+ builtIn?: boolean
318
+ }): Promise<IdpConfig> {
319
+ validateName(args.name, 'IdP')
320
+ const store = await readIdpStore()
321
+ const now = new Date().toISOString()
322
+ const existing = store.idps[args.name]
323
+ store.idps[args.name] = {
324
+ issuer: args.metadata.issuer,
325
+ builtIn: args.builtIn ?? existing?.builtIn,
326
+ createdAt: existing?.createdAt ?? now,
327
+ updatedAt: now,
328
+ }
329
+ await mkdir(idpDir(args.name), { recursive: true })
330
+ await Promise.all([
331
+ atomicWrite(idpMetadataPath(args.name), JSON.stringify(args.metadata, null, 2) + '\n'),
332
+ atomicWrite(idpClientPath(args.name), JSON.stringify(args.client ?? {}, null, 2) + '\n'),
333
+ writeIdpStore(store),
334
+ ])
335
+ return readIdpConfig(args.name)
336
+ }
337
+
338
+ export async function removeIdpConfig(name: string): Promise<void> {
339
+ validateName(name, 'IdP')
340
+ const store = await readIdpStore()
341
+ const entry = store.idps[name]
342
+ if (!entry) throw new Error(`IdP "${name}" not found`)
343
+ if (entry.builtIn) throw new Error(`Cannot remove built-in IdP "${name}"`)
344
+ delete store.idps[name]
345
+ await writeIdpStore(store)
346
+ await unlink(idpMetadataPath(name)).catch(() => undefined)
347
+ await unlink(idpClientPath(name)).catch(() => undefined)
348
+ }
349
+
350
+ export async function fetchOidcMetadata(issuer: string): Promise<OidcMetadata> {
351
+ validateUrl(issuer)
352
+ const discoveryUrl = new URL('/.well-known/openid-configuration', issuer)
353
+ const response = await fetch(discoveryUrl)
354
+ if (!response.ok) {
355
+ throw new Error(`OIDC discovery failed for ${issuer}: HTTP ${response.status}`)
356
+ }
357
+ const body = await response.json()
358
+ let metadata = OidcMetadataSchema.parse(body)
359
+ if (normalizeIssuer(metadata.issuer) !== normalizeIssuer(issuer)) {
360
+ throw new Error(
361
+ `OIDC issuer mismatch: discovery returned ${metadata.issuer}, expected ${issuer}`,
362
+ )
363
+ }
364
+ if (!metadata.device_authorization_endpoint) {
365
+ const oauthMetadata = await fetchOAuthAuthorizationServerMetadata(issuer).catch(() => undefined)
366
+ if (oauthMetadata) metadata = OidcMetadataSchema.parse({ ...metadata, ...oauthMetadata })
367
+ }
368
+ return metadata
369
+ }
370
+
371
+ export function workosAuthKitMetadata(
372
+ apiHost = 'https://api.workos.com',
373
+ clientId?: string,
374
+ ): OidcMetadata {
375
+ validateUrl(apiHost)
376
+ const base = apiHost.replace(/\/+$/, '')
377
+ return OidcMetadataSchema.parse({
378
+ issuer: base,
379
+ authorization_endpoint: `${base}/user_management/authorize`,
380
+ token_endpoint: `${base}/user_management/authenticate`,
381
+ device_authorization_endpoint: `${base}/user_management/authorize/device`,
382
+ jwks_uri: clientId ? `${base}/sso/jwks/${clientId}` : `${base}/sso/jwks`,
383
+ grant_types_supported: ['urn:ietf:params:oauth:grant-type:device_code'],
384
+ response_types_supported: ['code'],
385
+ })
386
+ }
387
+
388
+ export function builtinIdpConfig(
389
+ name: string,
390
+ clientIdOverride?: string,
391
+ env: NodeJS.ProcessEnv = process.env,
392
+ ): IdpConfig | null {
393
+ if (name !== BUILTIN_WORKOS_IDP_NAME) return null
394
+ const clientId = clientIdOverride ?? workosClientIdFromEnv(env)
395
+ if (!clientId) return null
396
+ const apiHost = env.WORKOS_API_HOSTNAME ?? DEFAULT_WORKOS_API_HOST
397
+ const now = new Date().toISOString()
398
+ return {
399
+ name: BUILTIN_WORKOS_IDP_NAME,
400
+ entry: {
401
+ issuer: apiHost.replace(/\/+$/, ''),
402
+ builtIn: true,
403
+ createdAt: now,
404
+ updatedAt: now,
405
+ },
406
+ metadata: workosAuthKitMetadata(apiHost, clientId),
407
+ client: {
408
+ client_id: clientId,
409
+ public: true,
410
+ token_response: 'workos-authkit',
411
+ token_request_format: 'json',
412
+ },
413
+ }
414
+ }
415
+
416
+ export function workosClientIdFromEnv(env: NodeJS.ProcessEnv = process.env): string | undefined {
417
+ return (
418
+ WORKOS_CLIENT_ID_ENV_NAMES.map((name) => env[name]).find(
419
+ (value): value is string => typeof value === 'string' && value.length > 0,
420
+ ) ?? DEFAULT_WORKOS_CLIENT_ID
421
+ )
422
+ }
423
+
424
+ async function fetchOAuthAuthorizationServerMetadata(
425
+ issuer: string,
426
+ ): Promise<Partial<OidcMetadata> | undefined> {
427
+ const discoveryUrl = new URL('/.well-known/oauth-authorization-server', issuer)
428
+ const response = await fetch(discoveryUrl)
429
+ if (!response.ok) return undefined
430
+ const body = (await response.json()) as Partial<OidcMetadata>
431
+ if (typeof body.issuer === 'string' && normalizeIssuer(body.issuer) !== normalizeIssuer(issuer)) {
432
+ return undefined
433
+ }
434
+ return body
435
+ }
436
+
437
+ export async function postForm(url: string, params: URLSearchParams): Promise<TokenResponse> {
438
+ const response = await fetch(url, {
439
+ method: 'POST',
440
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
441
+ body: params,
442
+ })
443
+ return tokenResponseFrom(response)
444
+ }
445
+
446
+ export async function postJson(
447
+ url: string,
448
+ bodyInput: Record<string, string>,
449
+ ): Promise<TokenResponse> {
450
+ const response = await fetch(url, {
451
+ method: 'POST',
452
+ headers: { 'Content-Type': 'application/json' },
453
+ body: JSON.stringify(bodyInput),
454
+ })
455
+ return tokenResponseFrom(response)
456
+ }
457
+
458
+ async function tokenResponseFrom(response: Response): Promise<TokenResponse> {
459
+ const text = await response.text()
460
+ let body: TokenResponse = {}
461
+ try {
462
+ body = text ? (JSON.parse(text) as TokenResponse) : {}
463
+ } catch {
464
+ // Non-JSON body (e.g. an HTML 502 from a proxy): surface the HTTP failure,
465
+ // keep a snippet for diagnostics.
466
+ throw new OAuthTokenError({
467
+ status: response.status,
468
+ description: `HTTP ${response.status} — non-JSON response: ${text.slice(0, 200)}`,
469
+ })
470
+ }
471
+ if (!response.ok || body.error) {
472
+ throw new OAuthTokenError({
473
+ code: body.error,
474
+ description: body.error_description,
475
+ status: response.status,
476
+ })
477
+ }
478
+ return body
479
+ }
480
+
481
+ export function tokenExpiresAt(token: TokenResponse): string | undefined {
482
+ const expiresIn =
483
+ typeof token.expires_in === 'number'
484
+ ? token.expires_in
485
+ : typeof token.expiresIn === 'number'
486
+ ? token.expiresIn
487
+ : undefined
488
+ if (typeof expiresIn === 'number') return new Date(Date.now() + expiresIn * 1000).toISOString()
489
+ return tokenExpiresAtFromJwt(token.access_token ?? token.accessToken ?? token.id_token)
490
+ }
491
+
492
+ export function decodeTokenClaims(token: string | undefined): JWTPayload | undefined {
493
+ if (!token) return undefined
494
+ try {
495
+ return decodeJwt(token)
496
+ } catch {
497
+ return undefined
498
+ }
499
+ }
500
+
501
+ export function tokenAudienceMatches(token: string | undefined, audience: string): boolean {
502
+ const actual = decodeTokenClaims(token)?.aud
503
+ if (Array.isArray(actual)) return actual.includes(audience)
504
+ return actual === audience
505
+ }
506
+
507
+ function tokenExpiresAtFromJwt(token: string | undefined): string | undefined {
508
+ const claims = decodeTokenClaims(token)
509
+ return typeof claims?.exp === 'number' ? new Date(claims.exp * 1000).toISOString() : undefined
510
+ }
511
+
512
+ export function subjectFromToken(token: TokenResponse, fallback: string): string {
513
+ const claims = decodeTokenClaims(token.id_token ?? token.access_token)
514
+ const sub = claims?.sub
515
+ return typeof sub === 'string' && sub ? sub : (token.user?.id ?? fallback)
516
+ }
517
+
518
+ export function issuerFromToken(token: TokenResponse, fallback: string): string {
519
+ const claims = decodeTokenClaims(token.id_token ?? token.access_token)
520
+ const iss = claims?.iss
521
+ return typeof iss === 'string' && iss ? iss : fallback
522
+ }
523
+
524
+ export function normalizeTokenResponse(token: TokenResponse): TokenResponse {
525
+ return {
526
+ ...token,
527
+ access_token: token.access_token ?? token.accessToken,
528
+ id_token: token.id_token ?? token.idToken,
529
+ refresh_token: token.refresh_token ?? token.refreshToken,
530
+ }
531
+ }
532
+
533
+ export function identityNameFromClaims(claims: JWTPayload | undefined, fallback: string): string {
534
+ const preferred = [claims?.email, claims?.preferred_username, claims?.sub, fallback].find(
535
+ (v) => typeof v === 'string' && v.length > 0,
536
+ ) as string
537
+ const normalized = preferred.replace(/[^a-zA-Z0-9_.-]+/g, '-').replace(/^-+|-+$/g, '')
538
+ return normalized || fallback
539
+ }
540
+
541
+ export async function saveIdpSession(session: IdpSession): Promise<void> {
542
+ const path = idpSessionPath(session.identity)
543
+ await mkdir(dirname(path), { recursive: true })
544
+ await atomicWrite(path, JSON.stringify(session, null, 2) + '\n')
545
+ }
546
+
547
+ export async function readIdpSession(identityName: string): Promise<IdpSession | null> {
548
+ try {
549
+ const raw = await readFile(idpSessionPath(identityName), 'utf-8')
550
+ return IdpSessionSchema.parse(JSON.parse(raw))
551
+ } catch (e) {
552
+ if ((e as { code?: string }).code === 'ENOENT') return null
553
+ if (e instanceof z.ZodError) {
554
+ throw new Error(`Invalid IdP session cache for "${identityName}"`)
555
+ }
556
+ throw e
557
+ }
558
+ }
559
+
560
+ export async function deleteIdpSession(identityName: string): Promise<void> {
561
+ await unlink(idpSessionPath(identityName)).catch(() => undefined)
562
+ }
563
+
564
+ export async function listIdpSessions(): Promise<IdpSession[]> {
565
+ const out: IdpSession[] = []
566
+ let entries: string[] = []
567
+ try {
568
+ entries = (await readdir(IDP_SESSIONS_DIR)).filter((entry) => entry.endsWith('.json'))
569
+ } catch {
570
+ return out
571
+ }
572
+ for (const entry of entries) {
573
+ const name = entry.replace(/\.json$/, '')
574
+ const session = await readIdpSession(name).catch(() => null)
575
+ if (session) out.push(session)
576
+ }
577
+ return out
578
+ }
579
+
580
+ export function isSessionExpired(
581
+ session: Pick<IdpSession, 'expires_at'> & { access_token?: string },
582
+ skewMs = 60_000,
583
+ ): boolean {
584
+ const expiresAt = session.expires_at ?? tokenExpiresAtFromJwt(session.access_token)
585
+ if (!expiresAt) return false
586
+ return new Date(expiresAt).getTime() <= Date.now() + skewMs
587
+ }
588
+
589
+ /**
590
+ * A still-fresh access token usable for `audience`, or undefined when a
591
+ * refresh is needed. Checks the per-audience `tokens` map first, then the
592
+ * top-level token's own `aud` claim. Without an `audience`, freshness of the
593
+ * top-level token is the only requirement.
594
+ */
595
+ export function accessTokenForAudience(session: IdpSession, audience?: string): string | undefined {
596
+ if (audience === undefined) {
597
+ return isSessionExpired(session) ? undefined : session.access_token
598
+ }
599
+ const entry = session.tokens?.[audience]
600
+ if (entry && !isSessionExpired(entry)) return entry.access_token
601
+ if (!isSessionExpired(session) && tokenAudienceMatches(session.access_token, audience)) {
602
+ return session.access_token
603
+ }
604
+ return undefined
605
+ }
606
+
607
+ /**
608
+ * Fold a freshly minted access token into the per-audience map under every
609
+ * `aud` it carries, dropping entries that have already expired.
610
+ */
611
+ export function withCachedToken(
612
+ tokens: IdpSession['tokens'],
613
+ accessToken: string,
614
+ expiresAt: string | undefined,
615
+ ): IdpSession['tokens'] {
616
+ const next: NonNullable<IdpSession['tokens']> = {}
617
+ for (const [aud, entry] of Object.entries(tokens ?? {})) {
618
+ if (!isSessionExpired(entry)) next[aud] = entry
619
+ }
620
+ const aud = decodeTokenClaims(accessToken)?.aud
621
+ const audiences = Array.isArray(aud) ? aud : typeof aud === 'string' ? [aud] : []
622
+ for (const audience of audiences) {
623
+ next[audience] = { access_token: accessToken, expires_at: expiresAt }
624
+ }
625
+ return Object.keys(next).length > 0 ? next : undefined
626
+ }
627
+
628
+ export function requireClientId(idp: IdpConfig, override?: string): string {
629
+ const clientId = override ?? idp.client.client_id
630
+ if (!clientId) {
631
+ throw new Error(`IdP "${idp.name}" has no client_id. Re-run idp add with --client-id.`)
632
+ }
633
+ return clientId
634
+ }
635
+
636
+ export function resolveClientSecret(idp: IdpConfig, overrideEnv?: string): string | undefined {
637
+ const envName = overrideEnv ?? idp.client.client_secret_env
638
+ if (!envName) return undefined
639
+ const secret = process.env[envName]
640
+ if (!secret) throw new Error(`Client secret env var ${envName} is not set`)
641
+ return secret
642
+ }
643
+
644
+ export async function refreshSession(
645
+ identityName: string,
646
+ session: IdpSession,
647
+ opts: { readonly audience?: string; readonly organizationId?: string } = {},
648
+ ): Promise<IdpSession> {
649
+ if (!session.refresh_token)
650
+ throw new Error(`IdP session for "${identityName}" has no refresh token`)
651
+ const idp = await readIdpConfig(session.idp)
652
+ const params = new URLSearchParams({
653
+ grant_type: 'refresh_token',
654
+ refresh_token: session.refresh_token,
655
+ client_id: requireClientId(idp),
656
+ })
657
+ const audience = opts.audience ?? session.audience
658
+ if (audience) params.set('audience', audience)
659
+ const organizationId = opts.organizationId ?? session.organizationId
660
+ if (organizationId) params.set('organization_id', organizationId)
661
+ if (process.env.ASTRALE_DEBUG_ORG)
662
+ console.error(
663
+ '[debug-org] refresh',
664
+ JSON.stringify({
665
+ audience,
666
+ organizationId,
667
+ sub: (session.claims as { sub?: string } | undefined)?.sub,
668
+ }),
669
+ )
670
+ const secret = resolveClientSecret(idp)
671
+ if (secret) params.set('client_secret', secret)
672
+ const token = normalizeTokenResponse(
673
+ idp.client.token_request_format === 'json'
674
+ ? await postJson(idp.metadata.token_endpoint, Object.fromEntries(params))
675
+ : await postForm(idp.metadata.token_endpoint, params),
676
+ )
677
+ if (!token.access_token) throw new Error('Refresh response did not include access_token')
678
+ const claims = decodeTokenClaims(token.id_token ?? token.access_token)
679
+ const expiresAt = tokenExpiresAt(token)
680
+ const next: IdpSession = {
681
+ ...session,
682
+ access_token: token.access_token,
683
+ id_token: token.id_token ?? session.id_token,
684
+ refresh_token: token.refresh_token ?? session.refresh_token,
685
+ token_type: token.token_type ?? session.token_type,
686
+ scope: token.scope ?? session.scope,
687
+ audience,
688
+ organizationId: organizationId ?? session.organizationId,
689
+ expires_at: expiresAt,
690
+ tokens: withCachedToken(session.tokens, token.access_token, expiresAt),
691
+ claims: claims ? (claims as Record<string, unknown>) : session.claims,
692
+ updatedAt: new Date().toISOString(),
693
+ }
694
+ // Persist the rotated session BEFORE the audience check. WorkOS refresh tokens
695
+ // are single-use: the token request above already exchanged the old one, so the
696
+ // new refresh_token must be saved even when the audience doesn't match — else
697
+ // the cache is stranded on the now-invalid old token and every subsequent
698
+ // refresh dies with "Refresh token already exchanged".
699
+ await saveIdpSession(next)
700
+ if (audience && !tokenAudienceMatches(next.access_token, audience)) {
701
+ const actual = decodeTokenClaims(next.access_token)?.aud
702
+ throw new IdpAudienceMismatchError(
703
+ audience,
704
+ Array.isArray(actual) ? actual.join(', ') : (actual as string | undefined),
705
+ )
706
+ }
707
+ return next
708
+ }
709
+
710
+ export async function requestClientCredentials(args: {
711
+ idp: IdpConfig
712
+ clientId?: string
713
+ clientSecretEnv?: string
714
+ scope?: string
715
+ audience?: string
716
+ }): Promise<TokenResponse> {
717
+ const clientId = requireClientId(args.idp, args.clientId)
718
+ const secret = resolveClientSecret(args.idp, args.clientSecretEnv)
719
+ if (!secret) throw new Error('Client credentials flow requires a client secret env var')
720
+ const params = new URLSearchParams({
721
+ grant_type: 'client_credentials',
722
+ client_id: clientId,
723
+ client_secret: secret,
724
+ })
725
+ if (args.scope) params.set('scope', args.scope)
726
+ if (args.audience) params.set('audience', args.audience)
727
+ return postForm(args.idp.metadata.token_endpoint, params)
728
+ }
729
+
730
+ export async function requestDeviceAuthorization(args: {
731
+ idp: IdpConfig
732
+ clientId?: string
733
+ scope?: string
734
+ audience?: string
735
+ }): Promise<DeviceAuthorizationResponse> {
736
+ const endpoint = args.idp.metadata.device_authorization_endpoint
737
+ if (!endpoint)
738
+ throw new Error(`IdP "${args.idp.name}" does not advertise device_authorization_endpoint`)
739
+ const params = new URLSearchParams({
740
+ client_id: requireClientId(args.idp, args.clientId),
741
+ })
742
+ if (args.scope) params.set('scope', args.scope)
743
+ if (args.audience) params.set('audience', args.audience)
744
+ const response =
745
+ args.idp.client.token_request_format === 'json'
746
+ ? await fetch(endpoint, {
747
+ method: 'POST',
748
+ headers: { 'Content-Type': 'application/json' },
749
+ body: JSON.stringify(Object.fromEntries(params)),
750
+ })
751
+ : await fetch(endpoint, {
752
+ method: 'POST',
753
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
754
+ body: params,
755
+ })
756
+ const body = (await response.json()) as DeviceAuthorizationResponse & {
757
+ error?: string
758
+ error_description?: string
759
+ }
760
+ if (!response.ok || body.error) {
761
+ throw new Error(
762
+ body.error_description ??
763
+ body.error ??
764
+ `Device authorization failed: HTTP ${response.status}`,
765
+ )
766
+ }
767
+ return body
768
+ }
769
+
770
+ export async function pollDeviceToken(args: {
771
+ idp: IdpConfig
772
+ deviceCode: string
773
+ clientId?: string
774
+ clientSecretEnv?: string
775
+ intervalSec?: number
776
+ expiresInSec: number
777
+ }): Promise<TokenResponse> {
778
+ const clientId = requireClientId(args.idp, args.clientId)
779
+ const secret = resolveClientSecret(args.idp, args.clientSecretEnv)
780
+ let intervalMs = Math.max(args.intervalSec ?? 5, 1) * 1000
781
+ const deadline = Date.now() + args.expiresInSec * 1000
782
+ while (Date.now() < deadline) {
783
+ await new Promise((r) => setTimeout(r, intervalMs))
784
+ const params = new URLSearchParams({
785
+ grant_type: 'urn:ietf:params:oauth:grant-type:device_code',
786
+ device_code: args.deviceCode,
787
+ client_id: clientId,
788
+ })
789
+ if (secret) params.set('client_secret', secret)
790
+ const response =
791
+ args.idp.client.token_request_format === 'json'
792
+ ? await fetch(args.idp.metadata.token_endpoint, {
793
+ method: 'POST',
794
+ headers: { 'Content-Type': 'application/json' },
795
+ body: JSON.stringify(Object.fromEntries(params)),
796
+ })
797
+ : await fetch(args.idp.metadata.token_endpoint, {
798
+ method: 'POST',
799
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
800
+ body: params,
801
+ })
802
+ const body = (await response.json()) as TokenResponse
803
+ const token = normalizeTokenResponse(body)
804
+ if (response.ok && token.access_token) return token
805
+ if (body.error === 'authorization_pending') continue
806
+ if (body.error === 'slow_down') {
807
+ intervalMs += 5_000
808
+ continue
809
+ }
810
+ throw new Error(
811
+ body.error_description ??
812
+ body.error ??
813
+ `Device token request failed: HTTP ${response.status}`,
814
+ )
815
+ }
816
+ throw new Error('Device authorization expired before completion')
817
+ }
818
+
819
+ export async function exchangeAuthorizationCode(args: {
820
+ idp: IdpConfig
821
+ code: string
822
+ redirectUri: string
823
+ codeVerifier?: string
824
+ clientId?: string
825
+ clientSecretEnv?: string
826
+ }): Promise<TokenResponse> {
827
+ const params = new URLSearchParams({
828
+ grant_type: 'authorization_code',
829
+ code: args.code,
830
+ redirect_uri: args.redirectUri,
831
+ client_id: requireClientId(args.idp, args.clientId),
832
+ })
833
+ if (args.codeVerifier) params.set('code_verifier', args.codeVerifier)
834
+ const secret = resolveClientSecret(args.idp, args.clientSecretEnv)
835
+ if (secret) params.set('client_secret', secret)
836
+ return postForm(args.idp.metadata.token_endpoint, params)
837
+ }
838
+
839
+ export type WorkosApplication = {
840
+ id: string
841
+ client_id?: string
842
+ name?: string
843
+ application_type?: 'oauth' | 'm2m'
844
+ scopes?: string[]
845
+ redirect_uris?: Array<{ uri?: string; default?: boolean }>
846
+ uses_pkce?: boolean
847
+ }
848
+
849
+ export async function fetchWorkosApplication(args: {
850
+ apiKeyEnv: string
851
+ app: string
852
+ }): Promise<WorkosApplication> {
853
+ const apiKey = process.env[args.apiKeyEnv]
854
+ if (!apiKey) throw new Error(`WorkOS API key env var ${args.apiKeyEnv} is not set`)
855
+ const response = await fetch(
856
+ `https://api.workos.com/connect/applications/${encodeURIComponent(args.app)}`,
857
+ {
858
+ headers: { Authorization: `Bearer ${apiKey}` },
859
+ },
860
+ )
861
+ const body = (await response.json()) as {
862
+ error?: string
863
+ message?: string
864
+ connect_application?: WorkosApplication
865
+ } & WorkosApplication
866
+ if (!response.ok) {
867
+ throw new Error(
868
+ body.message ?? body.error ?? `WorkOS application fetch failed: HTTP ${response.status}`,
869
+ )
870
+ }
871
+ return body.connect_application ?? body
872
+ }
873
+
874
+ function normalizeIssuer(value: string): string {
875
+ return value.replace(/\/+$/, '')
876
+ }