oll.am · Reference / Standard · the frontend half of a Core client

The Core Client Kit

The reusable recipe a product frontend copies to talk to the live Core — a generated API client plus three tiny HTTP interceptors. Lifted verbatim from specview (already a proven Core client), so every product gets auth, payment, and a web-or-native API host for free.

Why a kit, not a rewrite

A product frontend should never hand-write fetch calls, token attachment, or 401-refresh logic. specview already solved all of it; the kit is those solved pieces, extracted. Adopting Core on a new frontend = copy four small files and point a generator at Core's OpenAPI. That's the whole job.

Three concerns, three interceptors, one generated client — each maps to a house skill so the reasoning is reusable, not just the code.

PieceJobSkill
Generated API client (src/app/api/)Typed functions + models from Core's openapi.yaml — never hand-written, regenerated on contract changecontract-first-api
base-url.interceptor.tsSame-origin /api/* on web; absolute origin on Capacitor nativeenv-injected-base-url
auth.interceptor.tsAttach the Bearer JWT; on 401, hand off to refresh/logoutjwt-bearer-spine
billing.interceptor.tsSurface paywall/entitlement responses to the upgrade flowstripe-billing-wiring + token-identity-not-entitlement

1 · The web-vs-native seam — base-url.interceptor.ts

The single trick that lets one build serve both web and a Capacitor app: on web the API is same-origin so apiBaseUrl is '' and the interceptor is a no-op; a native build swaps in an absolute origin and the interceptor prefixes every root-relative /api/* call. No service code changes between platforms.

export const baseUrlInterceptor: HttpInterceptorFn = (req, next) => {
  const base = environment.apiBaseUrl;
  // Only rewrite root-relative paths ("/api/..."). Absolute URLs
  // (http://, https://, capacitor://) pass through untouched.
  if (base && req.url.startsWith('/')) {
    return next(req.clone({ url: base.replace(/\/$/, '') + req.url }));
  }
  return next(req);
};
// environment.ts (web) — same-origin, interceptor is a no-op
export const environment = { production: false, apiBaseUrl: '' };
// environment.native.ts (Capacitor) — added via a build fileReplacement
export const environment = { production: true, apiBaseUrl: 'https://core.oll.am' };
Registration order matters: base-url is registered last so the auth/billing interceptors still match on the original root-relative URL before the host is prefixed.

2 · The auth seam — auth.interceptor.ts

Bearer header end to end (not cookies — that's the deliberate choice that fixes the Capacitor WebView cookie problem). The interceptor fetches the current token, attaches it, and on a 401 hands off to the token lifecycle (refresh or logout). Passwordless endpoints bypass it — there's no JWT yet when you request a magic link.

const PUBLIC_PATHS = ['/api/auth/magic-link', '/api/auth/verify', '/api/auth/refresh'];

export const authInterceptor: HttpInterceptorFn = (req, next) => {
  const lifecycle = inject(TokenLifecycleService);
  if (PUBLIC_PATHS.some(p => req.url.startsWith(p))) return next(req);

  return from(lifecycle.getToken()).pipe(
    switchMap(token => {
      const outgoing = token
        ? req.clone({ setHeaders: { Authorization: `Bearer ${token}` } })
        : req;
      return next(outgoing).pipe(
        catchError((err: unknown) => {
          if (err instanceof HttpErrorResponse && err.status === 401)
            lifecycle.handleAuthFailure();   // refresh or logout
          return throwError(() => err);
        })
      );
    })
  );
};
Entitlement is NOT in the token. The JWT carries identity only (sub + email). A product reads plan live from Core /me (cached stale-while-revalidate) and gates on that — so an upgrade takes effect without re-issuing a token. See token-identity-not-entitlement.

3 · The generated client + the nginx split

The src/app/api/ folder is generated by openapi-generator (typescript-angular) from Core's openapi.yaml — typed function-per-endpoint, never edited by hand. When Core's contract changes, regenerate; the CI drift-check fails the build if code and spec disagree (contract-first-api).

On the server side, the proven specview pattern is an nginx split so the SAME web app reaches Core for infrastructure and its own backend for product logic:

# nginx.core-split.conf — auth/billing/email → Core, product → local API
location ~ ^/api/(auth|billing|email)/ { proxy_pass https://core.oll.am; }
location ^~ /api/                       { proxy_pass http://api:3101/api/; }

Adopting it on a new product frontend

  1. Generate the client: point openapi-generator (typescript-angular) at core.oll.am's openapi.yamlsrc/app/api/.
  2. Copy the three interceptors from specview web-ng/src/app/interceptors/ and register them in app.config.ts (auth + billing first, base-url last).
  3. Add environment.apiBaseUrl: '' (web) and an environment.native.ts with the absolute Core origin + a build fileReplacement for native.
  4. Add the magic-link login flow (magic-link-auth) and the checkout return page (checkout-return-page) — both are copy-from-specview.
  5. Add the nginx split for the web deployment.
  6. Build the product's own feature as a lazy module (scaffold-feature-module) that calls the product backend; auth/billing/email all come from Core.
Net result: a new product's frontend carries zero bespoke auth/payment/host code. Everything that isn't the product itself is copied, generated, or proxied. That's the architecture paying rent.
oll.am · The Core Client Kit · grounded in specview/web-ng/src · Overnight Plan · foto-service · Design Language