@starkeep/app-client
v0.9.0
Published
The platform-provided client library every local Starkeep app uses to talk to the local-data-server. Owns three things so apps don't have to reimplement them:
Readme
@starkeep/app-client
The platform-provided client library every local Starkeep app uses to talk to the local-data-server. Owns three things so apps don't have to reimplement them:
- Loading the app's installed credentials.
- HMAC-signing requests to the local-data-server.
- Same-origin proxying for browser-driven apps so the HMAC secret stays server-side.
0.9.0
Renames the last three exported names that claimed a framework the platform no longer uses. All four apps now build with Vite and serve with Hono, so nothing in this package has a Next.js call site left to accommodate. Every change is a compile error rather than a behavior change; no runtime behavior moved.
createNextProxyHandleris nowcreateDataProxyHandler. Same function, same signature, sameendUserAuthcontract. The old name described the mount point of a framework no app uses; the new one describes what the handler does, which is proxy to the data server.MinimalNextRequestis nowMinimalRequest. The type never referenced Next — it is the narrow slice of the webRequestinterface this package reads, and it types the whole@starkeep/app-client/authsurface as well as the proxy.NextProxyOptionsandNextProxyParamsare nowDataProxyOptionsandDataProxyParams.- No aliases. An app upgrading from 0.8.0 changes the import and the call, and the compiler names every site.
0.8.0
Retires the migration aids the four apps needed while they still built with Next.js. Two breaking changes, both a compile error rather than a behavior change, and neither reached by any app in this codebase.
immutablePathsis required oncreateWebAppHandler. The old default named/_immutable/*and/_next/static/*together so a half-migrated tree cached correctly on every app at once. No app emits the second prefix now, and a default that decides an app's cache headers is the wrong shape regardless: a path cached forever by mistake stays wrong at the edge until its TTL expires. Pass["/_immutable/*"], or[]for a build that hashes nothing.createAuthGateMiddlewareis gone.createOriginGateis the same function under the name it should always have had. The alias existed for Nextmiddleware.tscall sites, and there are none./_immutable/*is the only forever-cached prefix. The platform's CloudFront distribution no longer carries an ordered cache behavior for/apps/*/_next/static/*. A cloud install therefore needs its app redeployed onto a/_immutable/*build before the distribution is redeployed, or that app's assets stop being cached at the edge.
0.7.1
One fix, found by driving a real app's packaged Lambda: honoOriginGate
defaults basePath to appBasePath().
createOriginGate uses basePath for two jobs — stripping the mount off the
pathname it matches, and prefixing the sign-in path in the Location it
redirects to. Under honoUpstream the first job is already done, which made the
option look unnecessary; the second is not. A gate mounted without it sent a
signed-out browser to /sign-in at the distribution root, outside the app.
Passing basePath explicitly still overrides the default.
Reaching this needs a document navigation to an undeclared path that carries no
sk_session — CloudFront's signed-out-redirect function answers most of those
before the gateway is consulted, which is why it survived a tier-3 run.
0.7.0
Carries the platform work that precedes the four apps leaving Next.js behind. Nothing here is a breaking change; the old names are aliases.
@starkeep/app-client/hono—honoUpstream,honoOriginGateandappBasePath. See "The server half: Hono" below. The mount prefix stops being each app's problem.createOriginGatereplacescreateAuthGateMiddleware, which stays as an alias. Same function; the name no longer claims a framework.createWebAppHandlergainsshellPaths— a declared client route is answered withindex.htmlfrom the assets directory, so a bundler-built SPA's shell is a file on disk rather than a response the app renders./_immutable/*is the platform's prefix for content-addressed build output.DEFAULT_IMMUTABLE_PATHScarries it alongside/_next/static/*for the length of the migration; passimmutablePathsexplicitly rather than relying on either.STARKEEP_APP_BASE_PATHis now set on an installed app's Lambda, which is whatappBasePath()reads. An app needs a cloud reinstall to pick it up.
Install
pnpm add @starkeep/app-clientPublished to npm. Apps inside starkeep-core take it as workspace:*;
starkeep-apps and memo pin a released version.
The HMAC contract
The local-data-server authenticates every per-app request by HMAC-SHA256 over
the request body, keyed on the app's hmacSecret. The wire contract that every
local app must implement byte-for-byte (a timingSafeEqual mismatch returns
401) is:
| Header | Value |
|---|---|
| X-Starkeep-App-Id | Your appId. |
| X-Starkeep-App-Sig | hex(hmac_sha256(hmacSecret, "<appId>:" ++ body)). |
The signature input is the bytes appId, then a literal : byte, then the raw
request body bytes. For GET and HEAD requests, the body is the empty string
(zero bytes appended after the colon). For POST / PATCH / PUT / DELETE
the body is the exact bytes that hit the wire — text bodies are signed as UTF-8
encoded bytes, binary bodies are signed as their raw bytes. Don't introduce a
string detour for binary content; signing through a Latin-1 round-trip happens
to work for ASCII but disagrees with the server on non-ASCII bytes.
Loopback-gated routes (/health, /config, /auth/*, /admin/*, /watches/*,
/events) and file-URL routes (token-in-URL) don't use this scheme. Per-app
data routes (/data/*, /app-data/*, /files/presign) all do.
Credentials file
Credentials live at $STARKEEP_DIR/app-creds/<appId>.json (default
~/.starkeep/app-creds/), written at mode 0o600 by admin-web at install
time. Shape:
{ "appId": "my-app", "hmacSecret": "<hex>", "dataServerUrl": "http://127.0.0.1:9820" }loadAppCredentials(appId) reads and caches this; the file is rewritten only
on uninstall+reinstall (which restarts your app process), so the in-process
cache is safe.
API
import {
loadAppCredentials,
signRequest,
signedFetch,
createDataProxyHandler,
createRuntimeConfigHandler,
} from "@starkeep/app-client";loadAppCredentials(appId): AppCredentials | null— server-side only. Returnsnullif the app isn't installed locally.signRequest({ appId, hmacSecret, body? }): { headers }— pure; produces the two HMAC headers. Body may bestring | Buffer | Uint8Array | undefined.signedFetch(creds, path, init?): Promise<Response>—fetchwrapper that adds the headers and resolvespathagainstcreds.dataServerUrl.createDataProxyHandler({ appId, endUserAuth })— returns a handler of(Request, { params }) => Promise<Response>. Mount it on one catch-all route for every verb (app.all("/api/local-data/*", …)) to give the browser a same-origin URL with HMAC added server-side.endUserAuthis required, and is either{ auth: "session", verifySession }or{ auth: "anonymous", justification }. It is required because this handler holds the app's HMAC credential and will sign whatever reaches it: on the cloud surface nothing upstream checks who the caller is (the data plane authenticates the app, and a browser navigation cannot carry a bearer token), so if the app does not check, nobody does. In local mode the session check is skipped by default — on-device data belongs to the person at the keyboard, and a sign-in gate there would break local-first — whichallowAnonymousLocal: falseoverrides. The documented way to answer it for a cloud app issessionAuth(), which wires in this package's own cookie-session verifier. The explicitverifySessionform stays for an unusual verifier; every app in this codebase wants whatsessionAuth()returns.createRuntimeConfigHandler()— returns a() => Responsethat serves the cloud-config env vars (STARKEEP_API_GATEWAY_URL,STARKEEP_USER_POOL_ID, etc.) as JSON. Mount it on any GET route. It reads the environment on every call, so a Node server needs no build-time opt-out.
The server half: Hono
A Starkeep app's server half is a Hono app, and
@starkeep/app-client/hono holds the joint between Hono and the platform.
import { honoUpstream, honoOriginGate, appBasePath } from "@starkeep/app-client/hono";honoUpstream(app)— adapts a Hono app tocreateWebAppHandler'srequestUpstream.honoOriginGate(opts)—createOriginGateas Hono middleware, forapp.use("*", ...).appBasePath()— the app's mount prefix, read fromSTARKEEP_APP_BASE_PATH.
The mount prefix never reaches app code. The platform mounts an installed
app at /apps/<appId>, and the Lambda sees that prefix on every path. A router
matches on the request's own pathname, so something has to reconcile the two.
honoUpstream rewrites the request's URL to the app-relative path before
calling app.fetch, which means an app route is written /api/records and
matches unchanged on the local surface and in the cloud. The alternative —
every app calling app.basePath(process.env.STARKEEP_APP_BASE_PATH) — puts a
copy of the platform's own mount choice in each app repository, so it is not
the convention here.
An app that genuinely needs the origin-facing URL, to build an absolute
redirect or print a link, calls appBasePath(). That is the single place the
mount is stated, and nothing derives it from a request.
Neither the platform nor this module imports Hono: the two shapes it touches —
an app with a fetch, and a middleware (c, next) — are structural, so the
integration adds nothing to an app's Lambda bundle.
Cross-target apps
Apps with targets: ["local", "cloud"] in their manifest use this package on
both sides: the same proxy mount serves both surfaces, and the package
decides server-side whether to forward to the loopback local-data-server or,
under STARKEEP_APP_CLIENT_MODE=cloud, to the shared API Gateway with the
HMAC secret fetched from SSM. The browser calls one same-origin path either
way; keep that behind a single data-source resolver in your client (see
Photos's data-client.ts).
Because the mount is shared, the cloud surface inherits whatever the local one
does — which is exactly how a proxy written for loopback ended up answering the
internet. endUserAuth is the field that forces the two surfaces to be
considered separately.
The session layer
A cloud app gets sign-in, sign-out, refresh and an origin gate from this package rather than writing them. The division is deliberate: the app owns the sign-in page — its route, markup, copy and styling — and the platform owns everything the page talks to, because cookie names, flags, path scoping, the Cognito flow and token verification are properties of the deployment rather than of any app. The operational rule is that this package ships no React and names no app; a component appearing in it means the boundary has been crossed.
Three entry points:
@starkeep/app-client/edge—createOriginGate({ publicPaths, signInPath, basePath }), one function ofRequest -> Response | undefined. It is deny-by-default: a path the manifest has not declared public is refused, so a route added later is gated until someone says otherwise. PasspublicPathsfrom the manifest itself, never as a second hand-maintained copy. Dependency-free, so it mounts anywhere:honoOriginGatewraps it as Hono middleware. The gate is a cloud gate — its first line returnsundefinedunlessSTARKEEP_APP_CLIENT_MODEiscloud.@starkeep/app-client/auth—createSessionRoutes({ appId }), mounted atapp/api/session/[[...action]]/route.ts. It servessign-in,new-password,refresh,sign-out, aGETprobe, andGET tokenfor the one case a cookie cannot serve (a call made directly against the gateway, where a bearer token is required). Also exportsverifyIdToken,requireSessionand the cookie helpers.sessionAuth()from the root entry, for the proxy'sendUserAuth.
Two cookies, both HttpOnly; Secure; SameSite=Lax; Path=/apps/<appId>:
sk_session holds the Cognito refresh token and sk_token holds a minted ID
token, re-minted from sk_session as it nears expiry. The browser holds no
Cognito credential at any point, so an XSS on the page has nothing durable to
take.
This package does not depend on @aws-sdk/client-cognito-identity-provider
and must not grow that dependency. InitiateAuth and RespondToAuthChallenge
are unauthenticated operations that need no SigV4, so they are a plain fetch —
which is also what keeps the verifier loadable in an edge runtime, where the
origin gate can be deployed.
