@louise-toolkit/astro 0.4.0 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +10 -0
- package/dist/form-schema.d.ts +1 -1
- package/dist/form-schema.js +19 -7
- package/dist/middleware.d.ts +26 -1
- package/dist/middleware.js +18 -4
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -43,9 +43,19 @@ Pass `apiGate: true` to deny the editor API by default: every request under
|
|
|
43
43
|
`/api/louise` must come from a signed-in editor before any route runs, with
|
|
44
44
|
writes and WebSocket upgrades origin-checked. This is the same gate as
|
|
45
45
|
`composeWorker({ gate })` (ADR 0012), for routes mounted as Astro API routes.
|
|
46
|
+
|
|
47
|
+
An error a page, an endpoint, or the middleware throws is reported as an
|
|
48
|
+
incident, then re-thrown, so Astro still renders its error page. Astro catches
|
|
49
|
+
the error outside every middleware, so `composeWorker` never sees a throw; this
|
|
50
|
+
is how it still reaches the sinks you gave `composeWorker`'s `onIncident`
|
|
51
|
+
(ADR 0022). Pass `reportErrors: false` to turn it off.
|
|
46
52
|
Middleware runs before Astro knows which route file answers, so a public route
|
|
47
53
|
is declared by path: the toolkit's form, vitals, and status routes are exempt
|
|
48
54
|
at their default paths, and `apiGate: { isPublic: (path) => … }` adds your own.
|
|
55
|
+
An MCP endpoint that takes agent tokens is declared the same way:
|
|
56
|
+
`apiGate: { takesBearer: (path) => path === LOUISE_MCP_PATH }` lets a request
|
|
57
|
+
with `Authorization: Bearer` through to that path, and only that path, for the
|
|
58
|
+
route to verify the token itself. It's off unless you set it.
|
|
49
59
|
|
|
50
60
|
**Actions**: the editor write path as Astro Actions, so a save is a typed call
|
|
51
61
|
rather than a hand-rolled endpoint. `louiseSaveAction`, `louiseSaveDraftAction`,
|
package/dist/form-schema.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { z } from "astro/zod";
|
|
2
|
-
import type
|
|
2
|
+
import { type FormConfig } from "louise-toolkit/forms";
|
|
3
3
|
/**
|
|
4
4
|
* Build a Zod schema from a `defineForm` definition's fields—the form is the
|
|
5
5
|
* single source of truth for its Astro Action `input`, so the handler receives a
|
package/dist/form-schema.js
CHANGED
|
@@ -19,18 +19,30 @@
|
|
|
19
19
|
// Zod builder from `astro/zod` (an optional peer), so the framework-agnostic
|
|
20
20
|
// core never takes a Zod dependency.
|
|
21
21
|
import { z } from "astro/zod";
|
|
22
|
+
import { coerceFormValue } from "louise-toolkit/forms";
|
|
23
|
+
/** Coerce the way `formRoute` does, first, so an Action accepts what the route
|
|
24
|
+
* accepts: a URL without a scheme, a number with the locale's separators. A
|
|
25
|
+
* blank value becomes `undefined`, which an optional field allows. */
|
|
26
|
+
function forgiving(field, locale, schema) {
|
|
27
|
+
return z.preprocess((raw) => coerceFormValue(field, raw, { locale }) ?? undefined, schema);
|
|
28
|
+
}
|
|
22
29
|
/** Map one form field to its Zod type, including the type's built-in format
|
|
23
30
|
* check (email/url) and coercion (number/date/checkbox). */
|
|
24
|
-
function formFieldToZod(field) {
|
|
31
|
+
function formFieldToZod(field, locale) {
|
|
25
32
|
const required = field.required ?? false;
|
|
26
33
|
switch (field.type) {
|
|
27
34
|
case "email":
|
|
28
35
|
return required ? z.email() : z.email().optional();
|
|
29
|
-
case "url":
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
36
|
+
case "url": {
|
|
37
|
+
const url = z.url({ error: "Enter a web address, like example.com." });
|
|
38
|
+
return forgiving(field, locale, required ? url : url.optional());
|
|
39
|
+
}
|
|
40
|
+
case "number": {
|
|
41
|
+
// Form values arrive as strings; the shared coercion turns "1,200" into
|
|
42
|
+
// 1200 under the form's locale, and leaves anything else a string.
|
|
43
|
+
const number = z.number({ error: "Enter a number, like 1200." });
|
|
44
|
+
return forgiving(field, locale, required ? number : number.optional());
|
|
45
|
+
}
|
|
34
46
|
case "date":
|
|
35
47
|
// Accepts an ISO string, an epoch number, or a Date.
|
|
36
48
|
return required ? z.coerce.date() : z.coerce.date().optional();
|
|
@@ -69,7 +81,7 @@ function formFieldToZod(field) {
|
|
|
69
81
|
export function formToAstroSchema(form) {
|
|
70
82
|
const shape = {};
|
|
71
83
|
for (const [key, field] of Object.entries(form.fields)) {
|
|
72
|
-
shape[key] = formFieldToZod(field);
|
|
84
|
+
shape[key] = formFieldToZod(field, form.locale);
|
|
73
85
|
}
|
|
74
86
|
return z.object(shape);
|
|
75
87
|
}
|
package/dist/middleware.d.ts
CHANGED
|
@@ -26,6 +26,18 @@ export interface LouiseMiddlewareApiGate {
|
|
|
26
26
|
* way `publicRoute` does for `composeWorker`.
|
|
27
27
|
*/
|
|
28
28
|
isPublic?: (pathname: string) => boolean;
|
|
29
|
+
/**
|
|
30
|
+
* Paths under the prefix whose route checks a bearer token itself—in
|
|
31
|
+
* practice, an `mcpRoute` given `resolveAgent`, at `LOUISE_MCP_PATH`. A
|
|
32
|
+
* request that carries `Authorization: Bearer` skips the gate on these paths
|
|
33
|
+
* and nowhere else; one without a bearer token is gated as usual.
|
|
34
|
+
*
|
|
35
|
+
* Off unless you set it: the gate can't see whether the route at a path
|
|
36
|
+
* checks tokens, and a route that relies on the gate alone would be open to
|
|
37
|
+
* any request with a bearer header. `composeWorker` needs none of this; it
|
|
38
|
+
* reads `bearerRoute`'s mark instead.
|
|
39
|
+
*/
|
|
40
|
+
takesBearer?: (pathname: string) => boolean;
|
|
29
41
|
}
|
|
30
42
|
export interface LouiseMiddlewareConfig<TEditor = unknown> {
|
|
31
43
|
/**
|
|
@@ -132,6 +144,18 @@ export interface LouiseMiddlewareConfig<TEditor = unknown> {
|
|
|
132
144
|
location: string;
|
|
133
145
|
status: number;
|
|
134
146
|
} | null>;
|
|
147
|
+
/**
|
|
148
|
+
* Report an error a page, an endpoint, or this middleware throws as an
|
|
149
|
+
* incident (ADR 0022), then re-throw it, so Astro still renders its error
|
|
150
|
+
* page. Astro catches the error outside every middleware and answers with
|
|
151
|
+
* its 500, so `composeWorker` never sees a throw; this is how the error still
|
|
152
|
+
* reaches `composeWorker`'s `onIncident` sinks. Without `onIncident` it does
|
|
153
|
+
* nothing. Default `true`.
|
|
154
|
+
*
|
|
155
|
+
* An error a streamed page throws after its first bytes are sent happens
|
|
156
|
+
* outside every middleware, so no middleware can report it.
|
|
157
|
+
*/
|
|
158
|
+
reportErrors?: boolean;
|
|
135
159
|
/** Edit-mode cookie name. Default {@link LOUISE_EDIT_COOKIE} (`"louise_edit"`).
|
|
136
160
|
* Change it and the `withEdgeCache` bypass predicate must be told too, or an
|
|
137
161
|
* editor gets served the cached public page. */
|
|
@@ -140,7 +164,8 @@ export interface LouiseMiddlewareConfig<TEditor = unknown> {
|
|
|
140
164
|
/**
|
|
141
165
|
* Build the shared Louise Astro middleware: rate-limit → resolve the editor
|
|
142
166
|
* session + sticky `?louise` edit mode → `next()` → content-freshness cache headers
|
|
143
|
-
* + CSP `style-src` rewrite + transport security headers.
|
|
167
|
+
* + CSP `style-src` rewrite + transport security headers. A thrown error is
|
|
168
|
+
* reported as an incident and re-thrown (see {@link LouiseMiddlewareConfig.reportErrors}). Sites supply the bits
|
|
144
169
|
* that vary via {@link LouiseMiddlewareConfig} and export the result as
|
|
145
170
|
* `onRequest`.
|
|
146
171
|
*/
|
package/dist/middleware.js
CHANGED
|
@@ -19,11 +19,12 @@
|
|
|
19
19
|
// This subpath is the ONE place Louise touches Astro's types—`astro` is an
|
|
20
20
|
// optional peer, pulled in only by sites that import `louise-toolkit/astro`.
|
|
21
21
|
import { allowCspDataFonts, louiseSecurityHeaders, matchRateRule, rateLimit, rewriteCspStyleSrc, } from "louise-toolkit/security";
|
|
22
|
-
import { isLouisePublicPath, LOUISE_API_PREFIX, LOUISE_EDIT_COOKIE, louiseApiGate, underPrefix, } from "louise-toolkit/worker";
|
|
22
|
+
import { hasBearerCredential, isLouisePublicPath, LOUISE_API_PREFIX, LOUISE_EDIT_COOKIE, louiseApiGate, reportIncident, underPrefix, } from "louise-toolkit/worker";
|
|
23
23
|
/**
|
|
24
24
|
* Build the shared Louise Astro middleware: rate-limit → resolve the editor
|
|
25
25
|
* session + sticky `?louise` edit mode → `next()` → content-freshness cache headers
|
|
26
|
-
* + CSP `style-src` rewrite + transport security headers.
|
|
26
|
+
* + CSP `style-src` rewrite + transport security headers. A thrown error is
|
|
27
|
+
* reported as an incident and re-thrown (see {@link LouiseMiddlewareConfig.reportErrors}). Sites supply the bits
|
|
27
28
|
* that vary via {@link LouiseMiddlewareConfig} and export the result as
|
|
28
29
|
* `onRequest`.
|
|
29
30
|
*/
|
|
@@ -33,7 +34,7 @@ export function createLouiseMiddleware(config) {
|
|
|
33
34
|
const editCookie = config.editCookie ?? LOUISE_EDIT_COOKIE;
|
|
34
35
|
const apiGate = config.apiGate === true ? {} : config.apiGate || undefined;
|
|
35
36
|
const apiPrefix = apiGate?.prefix ?? LOUISE_API_PREFIX;
|
|
36
|
-
|
|
37
|
+
const handle = async (context, next) => {
|
|
37
38
|
// Rate-limit the public, unauthenticated POST surfaces before any other
|
|
38
39
|
// work. Keyed by client IP via a KV counter; `rateLimit` fails open on a KV
|
|
39
40
|
// error so a limiter outage never takes down sign-in or the contact form.
|
|
@@ -100,7 +101,8 @@ export function createLouiseMiddleware(config) {
|
|
|
100
101
|
const gatedApi = apiGate !== undefined &&
|
|
101
102
|
underPrefix(pathname, apiPrefix) &&
|
|
102
103
|
!isLouisePublicPath(pathname) &&
|
|
103
|
-
!apiGate.isPublic?.(pathname)
|
|
104
|
+
!apiGate.isPublic?.(pathname) &&
|
|
105
|
+
!(apiGate.takesBearer?.(pathname) && hasBearerCredential(context.request));
|
|
104
106
|
if (gatedApi) {
|
|
105
107
|
const denied = await louiseApiGate(context.request, undefined, {
|
|
106
108
|
resolveEditor: () => locals.editor,
|
|
@@ -159,6 +161,18 @@ export function createLouiseMiddleware(config) {
|
|
|
159
161
|
}
|
|
160
162
|
return finish(context, response);
|
|
161
163
|
};
|
|
164
|
+
if (config.reportErrors === false)
|
|
165
|
+
return handle;
|
|
166
|
+
const reporting = async (context, next) => {
|
|
167
|
+
try {
|
|
168
|
+
return await handle(context, next);
|
|
169
|
+
}
|
|
170
|
+
catch (err) {
|
|
171
|
+
reportIncident({ kind: "fetch", cause: err, request: context.request });
|
|
172
|
+
throw err;
|
|
173
|
+
}
|
|
174
|
+
};
|
|
175
|
+
return reporting;
|
|
162
176
|
/** The response-side work every answer gets, a gate refusal included. */
|
|
163
177
|
function finish(context, response) {
|
|
164
178
|
const locals = context.locals;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@louise-toolkit/astro",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.6.0",
|
|
4
4
|
"description": "Astro adapter for louise-toolkit: middleware, Actions, content-layer loaders, and the forms schema bridge.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"astro",
|
|
@@ -35,7 +35,7 @@
|
|
|
35
35
|
"access": "public"
|
|
36
36
|
},
|
|
37
37
|
"dependencies": {
|
|
38
|
-
"louise-toolkit": "0.
|
|
38
|
+
"louise-toolkit": "0.37.0"
|
|
39
39
|
},
|
|
40
40
|
"devDependencies": {
|
|
41
41
|
"@cloudflare/workers-types": "^5.20260829.1",
|