11// Cross-target: connection health checks, the feature that answers "has this
2- // credential expired?" (the Google 7-day dev-token case) in one declared probe.
3- // Entirely through the typed client:
2+ // credential expired?" (the Google 7-day dev-token case) and "whose account is
3+ // this?" in one declared probe. Entirely through the typed client:
44//
55// 1. register an OpenAPI integration whose `GET /me` is auth-gated,
66// 2. CONFIGURE a health check by picking that operation (the same flow the
77// user drives in the editor: list candidates, ranked GET-first, then set),
8- // 3. CHECK a SAVED connection and watch its status flip healthy -> expired
9- // when the stored key stops working,
10- // 4. confirm a connection with no configured check reports `unknown`.
8+ // 3. VALIDATE a pasted key without saving it (the key-first connect flow) and
9+ // watch the probe derive the connection identity from the live response,
10+ // 4. CHECK a SAVED connection and watch its status flip healthy -> expired
11+ // when the stored key stops working.
1112//
1213// The upstream API is a real node:http server started inside the scenario on
1314// 127.0.0.1 that gates `GET /me` on a bearer token: a generic "bring your own
@@ -30,14 +31,14 @@ const api = composePluginApi([openApiHttpPlugin()] as const);
3031type Client = HttpApiClient . ForApi < typeof api > ;
3132
3233const TEMPLATE = AuthTemplateSlug . make ( "apiKey" ) ;
33- const ACCOUNT_EMAIL = "alice@example.com" ;
34+ const IDENTITY = "alice@example.com" ;
3435
3536const newSlug = ( prefix : string ) =>
3637 IntegrationSlug . make ( `${ prefix } -${ randomBytes ( 4 ) . toString ( "hex" ) } ` ) ;
3738
38- /** OpenAPI 3 spec with an auth-gated GET (`/me`, the obvious health check) plus a
39- * destructive POST so the candidate ranking has something to sort the GET ahead
40- * of. */
39+ /** OpenAPI 3 spec with an auth-gated identity GET (`/me`, the obvious health
40+ * check) plus a destructive POST so the candidate ranking has something to sort
41+ * the GET ahead of. */
4142const identitySpec = ( baseUrl : string ) : string =>
4243 JSON . stringify ( {
4344 openapi : "3.0.3" ,
@@ -73,6 +74,72 @@ const identitySpec = (baseUrl: string): string =>
7374 } ,
7475 } ) ;
7576
77+ /** OpenAPI 3 spec whose `GET /me` response mirrors Vercel's `getAuthUser`: the
78+ * account is a `oneOf` of two object variants, the obvious identity scalars
79+ * (`email`, `id`) sit behind a large nested object, and one field (`limited`)
80+ * exists only on the second variant. A naive walker that follows only the first
81+ * union branch (and descends the nested object until a field cap) drops both
82+ * `user.email` and `user.limited`; the projector must merge branches and emit
83+ * shallow fields first. No live server needed (candidate projection is static). */
84+ const discriminatedUnionSpec = ( baseUrl : string ) : string => {
85+ // 60 nested scalars, listed before `email`, so a depth-first walk blows the
86+ // field cap inside `profile` before it ever reaches the top-level identity.
87+ const profileProps : Record < string , unknown > = { } ;
88+ for ( let i = 0 ; i < 60 ; i ++ ) profileProps [ `field${ i } ` ] = { type : "string" } ;
89+ return JSON . stringify ( {
90+ openapi : "3.0.3" ,
91+ info : { title : "Union Identity API" , version : "1.0.0" } ,
92+ servers : [ { url : baseUrl } ] ,
93+ paths : {
94+ "/me" : {
95+ get : {
96+ operationId : "getAuthUser" ,
97+ summary : "The current account" ,
98+ responses : {
99+ "200" : {
100+ description : "The authenticated account" ,
101+ content : {
102+ "application/json" : {
103+ schema : {
104+ type : "object" ,
105+ properties : {
106+ user : {
107+ oneOf : [
108+ { $ref : "#/components/schemas/AccountFull" } ,
109+ { $ref : "#/components/schemas/AccountLimited" } ,
110+ ] ,
111+ } ,
112+ } ,
113+ } ,
114+ } ,
115+ } ,
116+ } ,
117+ } ,
118+ } ,
119+ } ,
120+ } ,
121+ components : {
122+ schemas : {
123+ AccountFull : {
124+ type : "object" ,
125+ properties : {
126+ profile : { type : "object" , properties : profileProps } ,
127+ email : { type : "string" } ,
128+ id : { type : "string" } ,
129+ } ,
130+ } ,
131+ AccountLimited : {
132+ type : "object" ,
133+ properties : {
134+ email : { type : "string" } ,
135+ limited : { type : "boolean" } ,
136+ } ,
137+ } ,
138+ } ,
139+ } ,
140+ } ) ;
141+ } ;
142+
76143/** A real node:http identity API on 127.0.0.1. `GET /me` returns the account
77144 * JSON only when the bearer token matches `validToken`; any other token is a
78145 * 401 (the "the dev token got revoked" case the health check classifies as
@@ -89,7 +156,7 @@ const serveIdentityApi = (validToken: string) =>
89156 return ;
90157 }
91158 response . writeHead ( 200 , { "content-type" : "application/json" } ) ;
92- response . end ( JSON . stringify ( { email : ACCOUNT_EMAIL , login : "alice" } ) ) ;
159+ response . end ( JSON . stringify ( { email : IDENTITY , login : "alice" } ) ) ;
93160 return ;
94161 }
95162 response . writeHead ( 404 , { "content-type" : "application/json" } ) ;
@@ -130,9 +197,9 @@ const registerIdentityIntegration = (client: Client, slug: IntegrationSlug, base
130197 } ,
131198 } ) ;
132199
133- /** The stored operation name for the GET probe (openapi prefixes it by tag, e.g.
134- * `me.getMe`), discovered the same way the editor does: from the ranked
135- * candidate list. */
200+ /** The stored operation name for the GET identity probe (openapi prefixes it by
201+ * tag, e.g. `me.getMe`), discovered the same way the editor does: from the
202+ * ranked candidate list. */
136203const getMeOperation = ( client : Client , slug : IntegrationSlug ) =>
137204 Effect . gen ( function * ( ) {
138205 const candidates = yield * client . integrations . healthCheckCandidates ( { params : { slug } } ) ;
@@ -142,7 +209,7 @@ const getMeOperation = (client: Client, slug: IntegrationSlug) =>
142209 } ) ;
143210
144211scenario (
145- "Health checks · the editor ranks the non-destructive GET ahead of the destructive POST " ,
212+ "Health checks · configuring a check, then validating a key derives the connection identity " ,
146213 { } ,
147214 Effect . scoped (
148215 Effect . gen ( function * ( ) {
@@ -152,14 +219,14 @@ scenario(
152219 const client = yield * makeClient ( api , identity ) ;
153220 const goodToken = `gk_${ randomBytes ( 8 ) . toString ( "hex" ) } ` ;
154221 const server = yield * serveIdentityApi ( goodToken ) ;
155- const slug = newSlug ( "hc-rank " ) ;
222+ const slug = newSlug ( "hc-validate " ) ;
156223
157224 yield * Effect . ensuring (
158225 Effect . gen ( function * ( ) {
159226 yield * registerIdentityIntegration ( client , slug , server . url ) ;
160227
161228 // The editor offers the integration's operations, ranked so the
162- // non-destructive GET endpoint floats to the top.
229+ // non-destructive GET identity endpoint floats to the top.
163230 const candidates = yield * client . integrations . healthCheckCandidates ( {
164231 params : { slug } ,
165232 } ) ;
@@ -169,26 +236,45 @@ scenario(
169236 return yield * Effect . die ( "identity spec should expose a GET and a POST candidate" ) ;
170237 }
171238 // Operations are stored tag-prefixed (e.g. `me.getMe`); match the suffix.
172- expect ( get . operation . split ( "." ) . at ( - 1 ) , "the GET is offered" ) . toBe ( "getMe" ) ;
239+ expect ( get . operation . split ( "." ) . at ( - 1 ) , "the identity GET is offered" ) . toBe ( "getMe" ) ;
173240 expect ( post . operation . split ( "." ) . at ( - 1 ) , "the destructive POST is offered" ) . toBe (
174241 "sendMessage" ,
175242 ) ;
176243 expect (
177244 candidates [ 0 ] ?. operation ,
178245 "the non-destructive GET ranks ahead of the destructive POST" ,
179246 ) . toBe ( get . operation ) ;
180- expect ( get . destructive , "the GET probe is non-destructive" ) . toBe ( false ) ;
247+ expect ( get . destructive , "the GET identity probe is non-destructive" ) . toBe ( false ) ;
181248 expect ( post . destructive , "the POST is flagged destructive" ) . toBe ( true ) ;
249+ const operation = get . operation ;
182250
183- // Pick it: just the operation (a pure liveness probe) .
251+ // Pick it: the operation plus the dot-path to the identity field .
184252 yield * client . integrations . healthCheckSet ( {
185253 params : { slug } ,
186- payload : { spec : { operation : get . operation } } ,
254+ payload : { spec : { operation, identityField : "email" } } ,
187255 } ) ;
188256 const stored = yield * client . integrations . healthCheckGet ( { params : { slug } } ) ;
189257 expect ( stored , "the chosen health check round-trips" ) . toEqual ( {
190- operation : get . operation ,
258+ operation,
259+ identityField : "email" ,
191260 } ) ;
261+
262+ // Key-first connect: a pasted key is probed WITHOUT saving, and the
263+ // probe surfaces the identity the UI fills the connection name from.
264+ const healthy = yield * client . connections . validate ( {
265+ payload : { owner : "org" , integration : slug , template : TEMPLATE , value : goodToken } ,
266+ } ) ;
267+ expect ( healthy . status , "a live key validates as healthy" ) . toBe ( "healthy" ) ;
268+ expect ( healthy . httpStatus , "the probe saw the 200" ) . toBe ( 200 ) ;
269+ expect ( healthy . identity , "the identity is derived from the response body" ) . toBe ( IDENTITY ) ;
270+
271+ // A revoked / wrong key validates as expired, with no identity.
272+ const expired = yield * client . connections . validate ( {
273+ payload : { owner : "org" , integration : slug , template : TEMPLATE , value : "wrong-key" } ,
274+ } ) ;
275+ expect ( expired . status , "a rejected key validates as expired" ) . toBe ( "expired" ) ;
276+ expect ( expired . httpStatus , "the probe saw the 401" ) . toBe ( 401 ) ;
277+ expect ( expired . identity , "no identity is surfaced for a rejected key" ) . toBeUndefined ( ) ;
192278 } ) ,
193279 client . openapi . removeSpec ( { params : { slug } } ) . pipe ( Effect . ignore ) ,
194280 ) ;
@@ -216,10 +302,10 @@ scenario(
216302 const operation = yield * getMeOperation ( client , slug ) ;
217303 yield * client . integrations . healthCheckSet ( {
218304 params : { slug } ,
219- payload : { spec : { operation } } ,
305+ payload : { spec : { operation, identityField : "email" } } ,
220306 } ) ;
221307
222- // A connection holding the live key checks out healthy.
308+ // A connection holding the live key checks out healthy, identity and all .
223309 yield * client . connections . create ( {
224310 payload : {
225311 owner : "org" ,
@@ -234,6 +320,7 @@ scenario(
234320 } ) ;
235321 expect ( healthy . status , "the saved connection's live key is healthy" ) . toBe ( "healthy" ) ;
236322 expect ( healthy . httpStatus , "the saved probe saw the 200" ) . toBe ( 200 ) ;
323+ expect ( healthy . identity , "the saved probe derives the account identity" ) . toBe ( IDENTITY ) ;
237324
238325 // Re-creating the same (owner, integration, name) replaces the stored
239326 // key in place: now the connection holds a key the server rejects.
@@ -251,6 +338,7 @@ scenario(
251338 } ) ;
252339 expect ( expired . status , "the same connection now reads as expired" ) . toBe ( "expired" ) ;
253340 expect ( expired . httpStatus , "the saved probe saw the 401" ) . toBe ( 401 ) ;
341+ expect ( expired . identity , "an expired connection surfaces no identity" ) . toBeUndefined ( ) ;
254342 } ) ,
255343 Effect . gen ( function * ( ) {
256344 yield * client . connections
@@ -263,6 +351,56 @@ scenario(
263351 ) ,
264352) ;
265353
354+ scenario (
355+ "Health checks · the identity picker surfaces shallow fields across a discriminated union" ,
356+ { } ,
357+ Effect . scoped (
358+ Effect . gen ( function * ( ) {
359+ const target = yield * Target ;
360+ const { client : makeClient } = yield * Api ;
361+ const identity = yield * target . newIdentity ( ) ;
362+ const client = yield * makeClient ( api , identity ) ;
363+ const slug = newSlug ( "hc-union" ) ;
364+
365+ yield * Effect . ensuring (
366+ Effect . gen ( function * ( ) {
367+ yield * client . openapi . addSpec ( {
368+ payload : {
369+ spec : { kind : "blob" , value : discriminatedUnionSpec ( "https://union.example.com" ) } ,
370+ slug,
371+ baseUrl : "https://union.example.com" ,
372+ authenticationTemplate : [
373+ {
374+ slug : "apiKey" ,
375+ type : "apiKey" ,
376+ headers : { authorization : [ "Bearer " , { type : "variable" , name : "token" } ] } ,
377+ } ,
378+ ] ,
379+ } ,
380+ } ) ;
381+
382+ // The identity picker is fed by the GET candidate's projected response
383+ // fields. They must include the shallow identity scalar even though it
384+ // sits behind a 60-field nested object...
385+ const candidates = yield * client . integrations . healthCheckCandidates ( { params : { slug } } ) ;
386+ const get = candidates . find ( ( candidate ) => candidate . method === "get" ) ;
387+ if ( ! get ) return yield * Effect . die ( "union spec exposed no GET candidate" ) ;
388+ const paths = ( get . responseFields ?? [ ] ) . map ( ( field ) => field . path ) ;
389+ expect ( paths , "the shallow identity scalar is offered, not starved by nesting" ) . toContain (
390+ "user.email" ,
391+ ) ;
392+ // ...and the field that exists ONLY on the second union variant, proving
393+ // every branch contributes (not just the first).
394+ expect ( paths , "a field unique to the second union branch is offered" ) . toContain (
395+ "user.limited" ,
396+ ) ;
397+ } ) ,
398+ client . openapi . removeSpec ( { params : { slug } } ) . pipe ( Effect . ignore ) ,
399+ ) ;
400+ } ) ,
401+ ) ,
402+ ) ;
403+
266404scenario (
267405 "Health checks · a connection with no configured check reports unknown, not a failure" ,
268406 { } ,
0 commit comments