Skip to content

feat(public-apps): SDK public (anonymous) mode [draft] - #645

Draft
Raina451 wants to merge 1 commit into
mainfrom
feat/public-coded-app-sdk-mode
Draft

feat(public-apps): SDK public (anonymous) mode [draft]#645
Raina451 wants to merge 1 commit into
mainfrom
feat/public-coded-app-sdk-mode

Conversation

@Raina451

@Raina451 Raina451 commented Aug 5, 2026

Copy link
Copy Markdown
Collaborator

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

  • Config / meta tags — reads uipath:runtime-auth-mode (anonymous) and uipath: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', no Authorization) calls to {baseUrl}/{org}/apps_/integrations/codedapp/{appId}/.... On a 401 it bootstraps a session (POST /session, single-flight) and retries once. This mirrors the design's "browser holds only the cookie" model.
  • Service wiringProcesses.start and Jobs.getOutput route through the gateway in public mode; the existing token-based path is untouched otherwise.

Scope / follow-ups

  • Covers the two integration methods that match the current server routes (start-process, read-output). Each additional method is a one-line delegation to 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).
  • Depends on deploy injecting the uipath:app-id + uipath:runtime-auth-mode meta 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).

throw ErrorFactory.createNetworkError(error);
}

if (!response.ok) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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:

Suggested change
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,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread src/core/uipath.ts
const publicMode = isPublicMode(config);

// Public mode carries no OAuth/secret — skip auth validation. Base fields are
// already guaranteed by the caller's gate.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Two issues here:

  1. Hardcoded 'anonymous'RUNTIME_AUTH_MODE_ANONYMOUS is defined in runtime.ts but can't be imported here without a circular dependency (runtime.tsPartialUiPathConfigsdk-config.ts). The constant should be moved to a more foundational location (e.g. src/utils/runtime/constants.ts where UiPathMetaTags already lives) so both sdk-config.ts and runtime.ts can import it without a cycle. Right now there are two sources of truth for the 'anonymous' string.

  2. Weak type guard return type — the predicate config is { runtimeAuthMode: string; appId: string } only narrows runtimeAuthMode to string, not the literal 'anonymous'. Prefer:

Suggested change
// 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 } {

Comment thread src/services/base.ts
/**
* 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.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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();

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Convention: no any — use vi.stubGlobal instead.

(globalThis as any).fetch = fetchMock violates the no-any rule. Vitest provides a type-safe alternative:

Suggested change
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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
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' });
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.startProcess resolves → returns [job]
  • Missing processKey (!request.processKey) → throws ValidationError
  • publicApp.startProcess rejects → 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.

@claude

claude Bot commented Aug 5, 2026

Copy link
Copy Markdown
Contributor

Review summary

8 issues found this pass. Two are bugs that should block merge:

Bugs:

  1. public-app-client.ts line 88 - request() catch block wraps ensureSession() HTTP errors (e.g. NotFoundError from POST /session 404) as NetworkError, hiding the real cause. Fix: separate the initial-fetch try/catch from the ensureSession call.
  2. uipath.ts line 92 - initialize() has no public-mode early-return guard. Calling it in public mode falls through to this.#authService.authenticate(), which tries to start PKCE on a config with no OAuth credentials.

Convention violations:
3. public-app-client.ts line 70 - Raw fetch() calls must add traceparent/x-uipath-traceparent-id headers per conventions.md: all raw-fetch callers that route to UiPath services must carry distributed-tracing headers.
4. sdk-config.ts line 34 - isPublicMode (a) hardcodes the string 'anonymous' when RUNTIME_AUTH_MODE_ANONYMOUS already exists (that constant needs to move to a shared file to break the circular dep), and (b) the return type should narrow to the literal 'anonymous', not string.
5. base.ts line 83 - Inserting the publicApp field between the pre-existing constructor JSDoc and the constructor declaration orphans the constructor docs.
6. tests/unit/core/public-app-client.test.ts line 15 - (globalThis as any) violates the no-any rule; use vi.stubGlobal instead.
7. tests/unit/core/public-app-client.test.ts line 68 - .rejects.toBeTruthy() is too permissive; use .rejects.toBeInstanceOf(ErrorType).
8. processes.ts line 79 - No unit tests for the public-mode code paths in processes.ts or jobs.ts (success, missing-key validation, and propagated service error).

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.
@Raina451
Raina451 force-pushed the feat/public-coded-app-sdk-mode branch from 489db7e to 827d83d Compare August 12, 2026 08:21

@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> {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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:

  1. Interface — update JobServiceModel.getOutput to reflect the public-mode variant, or use overloads so both modes are type-safe.
  2. Bound method guard — remove (or public-mode-bypass) the !jobData.folderId check, since that guard fires before the service can route through the gateway.

@claude

claude Bot commented Aug 12, 2026

Copy link
Copy Markdown
Contributor

Review summary (this pass): 1 new finding. The 8 threads from the previous pass remain open.

New: src/services/orchestrator/jobs/jobs.ts line 99 — Making folderId optional in the implementation while JobServiceModel still requires it creates two problems: (a) direct users of the concrete class lose compile-time safety in token mode, and (b) the bound method in jobs.models.ts guards on !jobData.folderId and throws before the service can route through the gateway, silently breaking job.getOutput() for public-mode callers whose gateway job response does not carry a folder ID.

@sonarqubecloud

Copy link
Copy Markdown

Quality Gate Failed Quality Gate failed

Failed conditions
74.0% Coverage on New Code (required ≥ 90%)

See analysis details on SonarQube Cloud

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant