Skip to content

fetchWithAuth carries the whole request context, so route-to-route calls work - #279

Merged
netanelgilad merged 2 commits into
mainfrom
claude/fetch-with-auth-sdk-cyrgws
Sep 7, 2026
Merged

fetchWithAuth carries the whole request context, so route-to-route calls work#279
netanelgilad merged 2 commits into
mainfrom
claude/fetch-with-auth-sdk-cyrgws

Conversation

@netanelgilad

@netanelgilad netanelgilad commented Sep 7, 2026

Copy link
Copy Markdown
Collaborator

Follow-up to #277. fetchWithAuth shipped solving the browser half of the problem; this does the server half.

The problem

A sub-request carries nothing from the request that triggered it — a framework builds it from the caller's arguments alone — so a route reached by an in-process hop sees no Authorization and none of the platform headers Base44 sets. Its createClientFromRequest then throws on the missing Base44-App-Id, and service role is unavailable there.

Apps were papering over this by copying a hand-maintained list of header names off the inbound request:

const FORWARDED = ["authorization", "base44-app-id", "base44-api-url",
                   "base44-state", "x-data-env", "base44-functions-version"];
const forward = (request) => new Headers(FORWARDED.flatMap((h) => {  }));
const items = await fetch("/api/items", { headers: forward(request) });

That list belongs here. It is exactly the set createClientFromRequest reads, it goes stale in every app the moment the platform adds a header, and getting it wrong is a security bug rather than a broken feature — the guidance that produced the snippet above shipped with Base44-State missing (breaking IP-allowlisted apps) and then, on the fix, briefly copied the request wholesale (leaking the service credential).

The change

fetchWithAuth now sends whatever credentials the client holds, so a route reached this way rebuilds the client the caller is holding — same user, same data environment, same signed Base44-State, and service role:

const base44 = createClientFromRequest(event.req);
const items = await base44.fetchWithAuth("/api/items", { fetch }).then(r => r.json());

One method covers both situations because a client can only send what it has:

Client built by What goes out
createClient({ appId }) in a browser the user's Authorization only — unchanged
createClientFromRequest(event.req) in a route all seven headers that function reads

An optional init.fetch supplies the transport, since the global fetch cannot resolve a root-relative path on a server. In Nitro that is its own fetch, which dispatches a leading-slash path in-process with no network hop.

Why the service credential is included

It is minted per request for the app as a whole, not for one route — issue_virtual_service_token(app_id, client_id=…) signs app_id + client_id + scope + a TTL, with nothing request-specific. A handler in the same worker already runs with that authority, so reaching it through a route hop is the privilege it would have had by importing a shared function. Withholding it costs asServiceRole in the callee and buys nothing.

What must not happen is a credential leaving the app, and the relative-path rule is what prevents that: the trust boundary is the destination, not the header. An absolute URL, a protocol-relative //host, or anything else a URL parser would read as another origin throws before anything is sent.

host is deliberately never forwarded — h3's toRequest synthesizes an in-process sub-request's origin from it, so passing the inbound one would point the hop at another host.

Verification

  • tests/unit/fetch-with-auth.test.ts: 18 → 28 tests. 301 unit tests pass; lint, tsc --noEmit, and test:types all clean.
  • Two of the new tests exist to pin the reasoning rather than the behavior:
    • sends no service credential, having none — the browser case, which is the load-bearing fact that lets one method serve both. If browser clients ever gain a service token, this fails and the decision gets re-examined.
    • does not forward host, which would repoint the sub-request's origin.
  • Also covered: caller-set headers win (so a route can deliberately render as anonymous with Authorization: ""), headers absent from the inbound request stay absent rather than becoming empty strings, and init.fetch is not passed through as request init.

Nitro's in-process dispatch and h3's event/context wiring were read from the pinned nitro@3.0.260610-beta and h3@2.0.1-rc.22 packages, not from memory.

Notes for review

fetchWithAuth's released JSDoc promised "only the Authorization header is added", so that block is rewritten rather than appended to. An earlier revision of this description claimed that text is what Mintlify publishes — that was wrong, and I'm correcting it here rather than leaving it to mislead the review. I ran npm run create-docs on both this branch and the merged 0.8.47 code: Base44Client is not in types-to-expose.json, so no interface page is generated and fetchWithAuth appears nowhere in the output either way. The rewritten block's audience is editor intellisense and source readers, and no pipeline config change is needed.

The behavior change reaches only server-side callers, whose flow the old docs described as not working anyway ("a route that builds its own client from the incoming request also needs the platform's Base44-App-Id and Base44-Api-Url, which a request you construct yourself does not have").

Once this releases, the apper fullstack capability guide drops the FORWARDED block and restates the hop rule as destination-scoped.

🤖 Generated with Claude Code

https://claude.ai/code/session_014tTNp16fczTqi3KQ7mGEKS

A sub-request carries nothing from the request that triggered it — a
framework builds it from the caller's arguments alone — so a route reached
by an in-process hop sees no Authorization and none of the platform headers
Base44 sets. Its createClientFromRequest then throws on the missing
Base44-App-Id, and service role is unavailable there.

Apps were papering over this by copying a hand-maintained list of header
names off the inbound request. That list belongs in the SDK: it is exactly
the set createClientFromRequest reads, it goes stale in every app the moment
the platform adds one, and getting it wrong is a security bug rather than a
broken feature.

fetchWithAuth now sends whatever credentials the client holds, so the callee
rebuilds the client the caller is holding — same user, same data
environment, same signed Base44-State, and service role. One method covers
both situations because a client can only send what it has: a browser client
is built with no serviceToken, so there is no service credential for it to
send, and its behavior is unchanged.

The service credential is included deliberately. It is minted per request
for the app as a whole, not for one route, so a handler in the same worker
already runs with that authority — reaching it through a route hop is the
privilege it would have had by importing a shared function. What must not
happen is the credential leaving the app, and the relative-path rule is what
prevents that: the trust boundary is the destination, not the header.

An optional init.fetch supplies the transport, since the global fetch cannot
resolve a root-relative path on a server. host is deliberately never sent: a
runtime that routes a relative path in-process synthesizes the sub-request's
origin from it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014tTNp16fczTqi3KQ7mGEKS
@github-actions

github-actions Bot commented Sep 7, 2026

Copy link
Copy Markdown

🚀 Package Preview Available!


Install this PR's preview build with npm:

npm i @base44-preview/sdk@0.8.47-pr.279.2624371

Prefer not to change any import paths? Install using npm alias so your code still imports @base44/sdk:

npm i "@base44/sdk@npm:@base44-preview/sdk@0.8.47-pr.279.2624371"

Or add it to your package.json dependencies:

{
  "dependencies": {
    "@base44/sdk": "npm:@base44-preview/sdk@0.8.47-pr.279.2624371"
  }
}

Preview published to npm registry — try new features instantly!

FetchWithAuthInit is exported and named in fetchWithAuth's signature, so
referencing a non-exported alias from it gave consumers a type they could
not name.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014tTNp16fczTqi3KQ7mGEKS
@github-actions github-actions Bot added the docs-draft PR has auto-drafted documentation suggestions label Sep 7, 2026

@guyofeck guyofeck left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

LGTM

@netanelgilad
netanelgilad merged commit 3b80a15 into main Sep 7, 2026
7 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

docs-draft PR has auto-drafted documentation suggestions

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants