feat(10-01): serve the embedded admin SPA at backend.uri with cookie login

- backend.uri prefix (default /backend) mounts the admin API at {prefix}/api/v1
  and the embedded SPA shell at {prefix} with an api/ JSON 404 fallback
- cookie transport: an X-Requested-With login sets the HttpOnly summer_admin
  cookie and returns no token; the backend guard reads the cookie after Bearer
- CSRF wrapper refuses cookie-only POST/PUT/DELETE without X-Requested-With
- boardwalk package embeds boardwalk/dist, rewrites index.html once per prefix
  and sets cache and security headers
- framework admin OpenAPI pipeline (swag, swagger2openapi, openapi-typescript)
  with prefix-relative paths and typed envelopes for the tracer routes
- admin/ Vite SPA: login, plugin rail, section panel and read-only list
  through the openapi-fetch client typed by the generated schema
This commit is contained in:
Jakub Zych
2026-09-27 15:21:48 +02:00
parent 8c3e131111
commit 5f9353841b
79 changed files with 10745 additions and 147 deletions

44
admin/src/api/client.ts Normal file
View File

@@ -0,0 +1,44 @@
// The only HTTP client of the SPA: openapi-fetch typed by the generated
// paths (D-15). Every request carries X-Requested-With (the CSRF header the
// admin API requires on state-changing cookie requests, D-19) and same-origin
// credentials; the JWT lives in an HttpOnly cookie the SPA never reads.
import createClient, { type Middleware } from 'openapi-fetch'
import type { paths } from './schema'
import { runtime } from '../app/runtime'
export const REQUESTED_WITH = 'XMLHttpRequest'
type UnauthorizedHandler = () => void
let unauthorizedHandler: UnauthorizedHandler | null = null
/** Registers what happens when an API call other than login returns 401. */
export function onUnauthorized(handler: UnauthorizedHandler | null): void {
unauthorizedHandler = handler
}
function isLoginRequest(request: Request): boolean {
return new URL(request.url, 'http://local').pathname.endsWith('/auth/login')
}
export const transport: Middleware = {
onRequest({ request }) {
request.headers.set('X-Requested-With', REQUESTED_WITH)
return request
},
onResponse({ request, response }) {
if (response.status === 401 && !isLoginRequest(request)) {
unauthorizedHandler?.()
}
return response
},
}
export const api = createClient<paths>({
baseUrl: runtime.api,
credentials: 'same-origin',
// Resolve fetch per call so tests can replace globalThis.fetch.
fetch: (request: Request) => globalThis.fetch(request),
})
api.use(transport)

1532
admin/src/api/schema.d.ts vendored Normal file

File diff suppressed because it is too large Load Diff

16
admin/src/api/types.ts Normal file
View File

@@ -0,0 +1,16 @@
// Aliases onto the generated OpenAPI schema (D-15, D-16). No API shape is
// written by hand: every type here points at components['schemas'].
import type { components } from './schema'
type Schemas = components['schemas']
export type AdminLoginData = Schemas['cabana.AdminLoginData']
export type AdminLoginRequest = Schemas['cabana.AdminLoginRequest']
export type AdminProfile = Schemas['cabana.AdminProfile']
export type NavigationEntry = Schemas['cabana.NavigationEntry']
export type ListSchema = Schemas['cabana.ListSchema']
export type ListColumn = Schemas['cabana.ListColumn']
export type ListMeta = Schemas['cabana.ListMeta']
export type ErrorEnvelope = Schemas['cabana.ErrorEnvelope']
/** One record: a string-keyed map read through its list or form schema. */
export type AdminRecord = Schemas['cabana.ListEnvelope-array_cabana_AdminRecord']['data'][number]