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.
| Piece | Job | Skill |
|---|---|---|
Generated API client (src/app/api/) | Typed functions + models from Core's openapi.yaml — never hand-written, regenerated on contract change | contract-first-api |
base-url.interceptor.ts | Same-origin /api/* on web; absolute origin on Capacitor native | env-injected-base-url |
auth.interceptor.ts | Attach the Bearer JWT; on 401, hand off to refresh/logout | jwt-bearer-spine |
billing.interceptor.ts | Surface paywall/entitlement responses to the upgrade flow | stripe-billing-wiring + token-identity-not-entitlement |
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' };
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);
})
);
})
);
};
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.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/; }
openapi-generator (typescript-angular) at core.oll.am's openapi.yaml → src/app/api/.web-ng/src/app/interceptors/ and register them in app.config.ts (auth + billing first, base-url last).environment.apiBaseUrl: '' (web) and an environment.native.ts with the absolute Core origin + a build fileReplacement for native.magic-link-auth) and the checkout return page (checkout-return-page) — both are copy-from-specview.scaffold-feature-module) that calls the product backend; auth/billing/email all come from Core.