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:
44
admin/src/api/client.ts
Normal file
44
admin/src/api/client.ts
Normal 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
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
16
admin/src/api/types.ts
Normal 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]
|
||||
Reference in New Issue
Block a user