feat(public-apps): SDK public (anonymous) mode [draft] - #645
Conversation
| throw ErrorFactory.createNetworkError(error); | ||
| } | ||
|
|
||
| if (!response.ok) { |
There was a problem hiding this comment.
Bug: session bootstrap errors mis-classified as NetworkError.
ensureSession() throws SDK-typed errors (e.g. NotFoundError for a 404 from POST /session). Because that await sits inside the same try block that catches raw fetch() failures, those errors get swallowed and re-wrapped as NetworkError, hiding the real cause.
Fix: move the initial fetch into its own try/catch and let ensureSession errors propagate unmodified:
| if (!response.ok) { | |
| let response: Response; | |
| try { | |
| response = await doFetch(); | |
| } catch (error) { | |
| throw ErrorFactory.createNetworkError(error); | |
| } | |
| if (response.status === 401) { | |
| // No session yet, or it expired/was revoked — bootstrap once and retry. | |
| await this.ensureSession(); | |
| try { | |
| response = await doFetch(); | |
| } catch (error) { | |
| throw ErrorFactory.createNetworkError(error); | |
| } | |
| } |
| private async request(method: string, subPath: string, body?: unknown): Promise<unknown> { | ||
| const doFetch = () => | ||
| fetch(`${this.gatewayBase}${subPath}`, { | ||
| method, |
There was a problem hiding this comment.
Convention: raw fetch() callers must add distributed-tracing headers.
Per conventions.md: "Service methods that bypass ApiClient.request() and use raw fetch() must manually add distributed-tracing headers — ApiClient normally injects traceparent and x-uipath-traceparent-id on every request; raw fetch() callers are invisible to the platform's tracing infrastructure without them. Pattern: generate trace IDs with crypto.randomUUID() and set both TRACEPARENT and UIPATH_TRACEPARENT_ID headers (constants already exported from src/utils/constants/headers.ts)."
PublicAppClient routes to UiPath services (via the Apps gateway) and uses raw fetch() throughout, so all three calls (doFetch, /session) need these headers. Without them, public-mode requests are invisible in tracing dashboards.
| const publicMode = isPublicMode(config); | ||
|
|
||
| // Public mode carries no OAuth/secret — skip auth validation. Base fields are | ||
| // already guaranteed by the caller's gate. |
There was a problem hiding this comment.
Bug: initialize() has no public-mode guard.
The constructor auto-initializes correctly in public mode, but initialize() isn't updated. In public mode hasSecretConfig is false, so initialize() falls through to await this.#authService!.authenticate(this.#config!), which will try to start a PKCE flow on a config with no OAuth credentials.
initialize() needs a matching early-return, e.g.:
if (hasSecretConfig(this.#config!) || isPublicMode(this.#config!)) {
return;
}Or, set this.#initialized = true inside #initializeWithConfig for the public mode branch so the code path documents the intent and prevents callers from accidentally triggering OAuth.
| export type PartialUiPathConfig = Partial<BaseConfig & OAuthFields & { secret: string }>; | ||
| export type PartialUiPathConfig = Partial<BaseConfig & OAuthFields & { secret: string } & PublicModeFields>; | ||
|
|
||
| // Type guard: is the app running in public (anonymous) mode? Requires the appId |
There was a problem hiding this comment.
Two issues here:
-
Hardcoded
'anonymous'—RUNTIME_AUTH_MODE_ANONYMOUSis defined inruntime.tsbut can't be imported here without a circular dependency (runtime.ts→PartialUiPathConfig→sdk-config.ts). The constant should be moved to a more foundational location (e.g.src/utils/runtime/constants.tswhereUiPathMetaTagsalready lives) so bothsdk-config.tsandruntime.tscan import it without a cycle. Right now there are two sources of truth for the'anonymous'string. -
Weak type guard return type — the predicate
config is { runtimeAuthMode: string; appId: string }only narrowsruntimeAuthModetostring, not the literal'anonymous'. Prefer:
| // Type guard: is the app running in public (anonymous) mode? Requires the appId | |
| export function isPublicMode(config: { runtimeAuthMode?: string; appId?: string }): config is { runtimeAuthMode: 'anonymous'; appId: string } { |
| /** | ||
| * Apps-gateway client, present only when the SDK runs in public (anonymous) mode. | ||
| * Services that support public mode route through this instead of the token-based | ||
| * ApiClient. Shared across services so the anonymous session is bootstrapped once. |
There was a problem hiding this comment.
Orphaned constructor JSDoc.
Inserting the publicApp field (with its own JSDoc block) between the pre-existing constructor JSDoc and the constructor declaration detaches the constructor comment — TypeScript tooling attaches each JSDoc block to the next declaration, so the old constructor docs now land on publicApp (which has its own JSDoc), and the constructor ends up with no JSDoc at all.
Fix: move the publicApp field declaration above the constructor's JSDoc block (fields are normally declared at the top of the class body alongside the other protected/private fields), or merge the two JSDoc blocks into one.
| let client: PublicAppClient; | ||
|
|
||
| beforeEach(() => { | ||
| fetchMock = vi.fn(); |
There was a problem hiding this comment.
Convention: no any — use vi.stubGlobal instead.
(globalThis as any).fetch = fetchMock violates the no-any rule. Vitest provides a type-safe alternative:
| fetchMock = vi.fn(); | |
| vi.stubGlobal('fetch', fetchMock); |
vi.stubGlobal is automatically restored by vi.restoreAllMocks() / vi.unstubAllGlobals() in afterEach, so no other change is needed.
| fetchMock | ||
| .mockResolvedValueOnce(new Response(null, { status: 401 })) | ||
| .mockResolvedValueOnce(new Response(null, { status: 404 })); // /session denied | ||
|
|
There was a problem hiding this comment.
Convention: use .rejects.toBeInstanceOf(ErrorType) for async error assertions.
Per rules.md: "Use .rejects.toBeInstanceOf(ErrorType) consistently for async error assertions — the toBeInstanceOf form is more precise (it tests the error class, not just the message) and matches the SDK-wide pattern."
.rejects.toBeTruthy() passes for any rejection — even an empty string. Same applies to line 73.
| await expect(client.startProcess('proc-1')).rejects.toBeInstanceOf(Error); |
If the bug in the request() catch-block (session bootstrap errors mis-classified as NetworkError) is fixed first, the correct assertion here will be something like toBeInstanceOf(NotFoundError), which would have caught that mis-classification automatically.
| if (this.publicApp) { | ||
| if (!request.processKey) { | ||
| throw new ValidationError({ message: 'processKey is required to start a process in public mode' }); | ||
| } |
There was a problem hiding this comment.
Missing unit tests for the public-mode code path.
Per rules.md: "Test both success and error scenarios for every public method." The start() public-mode branch (this if (this.publicApp) block) is exercised only at the PublicAppClient level — there are no processes.test.ts cases that mock this.publicApp and verify:
- Success:
publicApp.startProcessresolves → returns[job] - Missing processKey (
!request.processKey) → throwsValidationError publicApp.startProcessrejects → error propagates
Same gap exists in jobs.ts for getOutput's public-mode branch. These paths need their own entries in the respective service test files.
|
Review summary 8 issues found this pass. Two are bugs that should block merge: Bugs:
Convention violations: |
Adds a public-mode path so a deployed anonymous coded app can call UiPath APIs
as the app's own identity, with no user login and no token in the browser.
- Config: read uipath:runtime-auth-mode + uipath:app-id meta tags; isPublicMode
guard; public mode is a complete config without OAuth/secret.
- PublicAppClient: same-origin, cookie-credentialed calls to the Apps gateway
({baseUrl}/{org}/apps_/integrations/codedapp/{appId}/...), with single-flight
session bootstrap + one retry on 401. No Authorization header.
- Wire ProcessService.start and JobService.getOutput to route through the gateway
in public mode; token-based path unchanged when not public.
Draft: covers the two integration methods matching the current server routes
(start-process, job-output). Onboarding more methods = one delegation each.
489db7e to
827d83d
Compare
|
|
||
| @track('Jobs.GetOutput') | ||
| async getOutput(jobKey: string, folderId: number): Promise<Record<string, unknown> | null> { | ||
| async getOutput(jobKey: string, folderId?: number): Promise<Record<string, unknown> | null> { |
There was a problem hiding this comment.
Type-safety gap: implementation is now looser than the interface.
JobServiceModel still declares getOutput(jobKey: string, folderId: number) — required. The implementation now accepts folderId?: number, which is valid TypeScript (impls may be more permissive), so it compiles. But direct users of the concrete JobService class (not through the interface) lose the compile-time guarantee and instead hit a runtime ValidationError if they forget folderId in token mode.
There's also a subtle risk with the bound method in jobs.models.ts:
async getOutput(): Promise<Record<string, unknown> | null> {
if (!jobData.folderId) throw new Error('Job folderId is undefined'); // ← throws before service dispatch
return service.getOutput(jobData.key, jobData.folderId);
}If the gateway's startProcess response doesn't include a folderId (the gateway strips folder context in public mode), job.getOutput() throws on the guard before ever reaching the this.publicApp branch in the service — so the bound convenience method is silently broken for public-mode callers.
Two things to fix before this ships:
- Interface — update
JobServiceModel.getOutputto reflect the public-mode variant, or use overloads so both modes are type-safe. - Bound method guard — remove (or public-mode-bypass) the
!jobData.folderIdcheck, since that guard fires before the service can route through the gateway.
|
Review summary (this pass): 1 new finding. The 8 threads from the previous pass remain open. New: |
|


Draft — the SDK half of public (anonymous) coded apps. Pairs with the server-side session + integration and the edge-forwarding change (separate repos). Not for merge yet.
Why
A public coded app serves anonymous visitors with no UiPath login. The SDK can't do PKCE and holds no token. Instead, in public mode the SDK calls its own backend same-origin through the Apps gateway, which resolves the visitor's session, mints the app's identity server-side, and forwards downstream. The token never touches the browser.
What this adds
uipath:runtime-auth-mode(anonymous) anduipath:app-id, injected at deploy.isPublicMode()gates the behavior; public mode is a complete config even without OAuth/secret (there are none).PublicAppClient— same-origin, cookie-credentialed (credentials: 'include', noAuthorization) calls to{baseUrl}/{org}/apps_/integrations/codedapp/{appId}/.... On a401it bootstraps a session (POST /session, single-flight) and retries once. This mirrors the design's "browser holds only the cookie" model.Processes.startandJobs.getOutputroute through the gateway in public mode; the existing token-based path is untouched otherwise.Scope / follow-ups
PublicAppClient— deliberately not done here.inputArguments: the SDK models it as a JSON string while the gateway currently expects an object — the two need to agree (flagging, not fixed here).uipath:app-id+uipath:runtime-auth-modemeta tags, and on Gate 1 (resource fence) landing server-side.Tests
tests/unit/core/public-app-client.test.ts— URL shape, credentialed fetch, 401→bootstrap→retry, and fail-closed on denied session / unowned job (5 passing). Existing core/base/orchestrator suites still green (162 tests).