App Architecture
Bkper platform apps use one Worker with an npm workspace for the client UI, typed /api/* routes, and /events handlers.
Bkper platform apps use one Worker bundle per app and environment. The same Worker serves the browser client, app-defined /api/* routes, and Bkper event ingress at /events.
Treat /api/* as the reusable surface for app behavior. The bundled web client is one consumer; scripts, external clients, and agents can call the same routes with bearer authentication.
Structure
my-app/├── client/│ ├── index.html│ ├── package.json│ ├── vite.config.ts│ └── src/│ ├── api/│ ├── auth/│ ├── components/│ └── services/├── server/│ ├── package.json│ └── src/│ ├── api/│ ├── events/│ ├── services/│ └── index.ts├── scripts/├── bkper.yaml├── env.d.ts├── package.json├── package-lock.json└── tsconfig.jsonThe root npm workspace orchestrates development, tests, builds, and deployment. The template keeps browser dependencies in client/ and Worker dependencies in server/. Add a shared package only when both sides actually need one.
Client
The client uses:
- Lit for components and rendering.
- Web Awesome for UI components.
@bkper/web-designfor Bkper design tokens.- Vite for development and production builds, configured in
client/vite.config.ts.
Client code has two data paths. Choose based on who owns the behavior:
- Direct Bkper calls use
bkper-jsfor generic Bkper data needed only by the browser UI. - App API calls use the generated typed client in
client/src/api/withauth.authenticatedFetch()for app-owned behavior, especially when it needs server-only capabilities or more than one caller.
Keep app-owned behavior in one place. Do not implement the same behavior separately in the UI and the app API.
For stateful feature components, co-locate view, controller, and CSS files in one folder under components/. Simple presentational components can remain in one file.
Client authentication
The client authenticates users with @bkper/web-auth. OAuth is preconfigured on the platform, so there are no client IDs, redirect URIs, or consent screens to configure.
import { Bkper } from 'bkper-js';import { BkperAuth } from '@bkper/web-auth';
const isLocalDev = ['localhost', '127.0.0.1'].includes(window.location.hostname);const auth = new BkperAuth({ baseUrl: isLocalDev ? window.location.origin : undefined, onLoginSuccess: () => initializeApp(), onLoginRequired: () => showLoginButton(),});await auth.init();
const bkper = new Bkper({ oauthTokenProvider: async () => auth.getAccessToken(),});@bkper/web-auth handles login, redirects, and token refresh. The template keeps this behavior behind client/src/auth/auth-session.ts.
See the @bkper/web-auth API Reference for the full SDK documentation.
Server Worker
The server runs on Cloudflare Workers and uses Hono with typed OpenAPI routes. It handles:
- app API routes under
/api/*; - Bkper event ingress under
/events; - platform services such as KV and secrets through
c.env; - static client assets through the
ASSETSbinding.
The Worker entry point composes those concerns while routes delegate business behavior to services:
import { OpenAPIHono } from '@hono/zod-openapi';import { registerApiRoutes } from './api/routes.js';import { registerEventRoutes } from './events/routes.js';import { appContextMiddleware, type AppEnv } from './app-context.js';
const app = new OpenAPIHono<AppEnv>();
app.use('/api/*', appContextMiddleware());app.use('/events', appContextMiddleware());registerApiRoutes(app);registerEventRoutes(app);
app.get('*', c => c.env.ASSETS.fetch(c.req.raw));
export default app;App API contract
The default template publishes versioned routes under /api/v1/* and exposes their OpenAPI contract at /openapi.json.
| Concern | Location |
|---|---|
| OpenAPI metadata | server/src/api/openapi.ts |
| Request and response schemas | server/src/api/schemas.ts |
| Thin route handlers | server/src/api/routes.ts |
| Business behavior | server/src/services/ |
| Generated client types | client/src/api/generated/types.d.ts |
| Typed client wrapper | client/src/api/app-api.ts |
| Contract snapshot | server/test/api/openapi.snapshot.json |
When changing the API:
- Update schemas, services, routes, and focused unit tests.
- Run
npm run apito regenerate client types. - Review the OpenAPI snapshot when the public contract changes.
- Run
npm run checkbefore release.
Keep existing /api/v1/* contracts backward compatible. Additive fields and routes can remain in v1; breaking changes belong in a new namespace such as /api/v2/*.
Reuse Bkper API types
When an app API returns payloads from the Bkper REST API, reference the canonical types from @bkper/bkper-api-types instead of recreating their fields in the app. The template’s balances endpoint demonstrates this with bkper.Book:
export const BookSchema = z .custom<bkper.Book>(value => value !== undefined) .openapi('Book', { type: 'object', additionalProperties: true, 'x-typescript-type': 'bkper.Book', });The template’s API generator recognizes x-typescript-type, imports @bkper/bkper-api-types, and emits the canonical reference in client/src/api/generated/types.d.ts:
Book: bkper.Book;Both the server and client packages include @bkper/bkper-api-types for local typechecking. Run npm run api after adding or changing these schemas.
This bridge provides compile-time types but does not validate payload fields at runtime. Use it directly for trusted Bkper-owned responses. Request bodies, especially those used to create or modify Book resources, still require concrete Zod validation.
URLs
Production API: https://{appId}.bkper.app/api/*Preview API: https://{appId}-preview.bkper.app/api/*Local API: http://localhost:8787/api/*
Production spec: https://{appId}.bkper.app/openapi.jsonPreview spec: https://{appId}-preview.bkper.app/openapi.jsonLocal spec: http://localhost:8787/openapi.jsonExample script call:
TOKEN="$(bkper auth token)"
curl \ -H "Authorization: Bearer ${TOKEN}" \ "https://my-app.bkper.app/api/v1/ping"Replace my-app with the app id from bkper.yaml.
Server API authentication
Deployed /api/* routes require a Bkper OAuth bearer token. The template client uses authenticatedFetch() so token attachment and refresh stay inside @bkper/web-auth:
const response = await auth.authenticatedFetch('/api/v1/ping');Dispatch validates the incoming bearer token and strips the Authorization header before the Worker runs. Server code should not read or forward the token.
When a route calls Bkper, create the SDK without a token provider:
import { Bkper } from 'bkper-js';
const bkper = new Bkper();const books = await bkper.getBooks();Platform outbound authentication injects the validated user’s OAuth token on Bkper API requests.
Authorize app operations
Platform authentication identifies the Bkper user and provides outbound authentication for server-side Bkper requests. Your app must still decide which authenticated users may perform each operation. Protect sensitive data and actions in the server API; client-side checks may improve the UI, but they are not an authorization boundary.
Restrict an internal app by user domain
For an app intended only for people in one organization, authorize the authenticated user’s hosted domain:
const ALLOWED_DOMAIN = 'example.com';
const user = await context.bkper.getUser();const domain = user.getHostedDomain()?.toLowerCase();
if (domain !== ALLOWED_DOMAIN) { return c.json(buildApiError('FORBIDDEN', 'This app is restricted to your organization'), 403);}Authorize a Book-backed operation
When an operation acts on a Book, use an explicit permission allowlist appropriate to that operation. For an operation that requires edit access:
import { Permission } from 'bkper-js';
const EDIT_PERMISSIONS: readonly Permission[] = [Permission.EDITOR, Permission.OWNER];
const book = await context.bkper.getBook(bookId);
if (!EDIT_PERMISSIONS.includes(book.getPermission())) { return c.json( buildApiError('FORBIDDEN', 'Editor or owner permission required for this operation'), 403 );}Read, posting, and other operations may require different policies. Choose the minimum authorization appropriate to the behavior instead of treating every authenticated user as authorized.
Require app installation
Having permission to access a Book does not mean the app is installed in that Book. If an app is only supposed to be used with Books where it is installed, verify installation:
const APP_ID = 'my-app';
const book = await context.bkper.getBook(bookId);const installedApps = await book.getApps();const isInstalled = installedApps.some(app => app.getId() === APP_ID);
if (!isInstalled) { return c.json(buildApiError('FORBIDDEN', 'This app is not installed in this Book'), 403);}Event handlers
Platform event deliveries reach /events on the same Worker. Event adapters live in server/src/events/, while reusable business behavior belongs in server/src/services/.
Event code uses server-side new Bkper() and must not read bkper-oauth-token, bkper-agent-id, or Authorization headers. Dispatch and platform outbound authentication handle the event token and app agent identity.
See Event Handlers for routing, responses, loop prevention, and event types. Self-hosted handlers process event authentication directly because the platform outbound layer is not involved.
App shapes
The platform supports different shapes:
- Full app — Client UI,
/api/*backend behavior, and/eventsautomation in one Worker. This is the default template. - Event-only app — Keep
server/and omitdeployment.client. - UI-only app — Keep a minimal Worker for static assets when behavior is truly browser-only. Add
/api/*when scripts, integrations, or agents should reuse that behavior.