@louise-toolkit/astro 0.1.1 → 0.2.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 CHANGED
@@ -39,6 +39,14 @@ import { createLouiseMiddleware } from "@louise-toolkit/astro";
39
39
  export const onRequest = createLouiseMiddleware({/* ... */});
40
40
  ```
41
41
 
42
+ Pass `apiGate: true` to deny the editor API by default: every request under
43
+ `/api/louise` must come from a signed-in editor before any route runs, with
44
+ writes and WebSocket upgrades origin-checked. This is the same gate as
45
+ `composeWorker({ gate })` (ADR 0012), for routes mounted as Astro API routes.
46
+ Middleware runs before Astro knows which route file answers, so a public route
47
+ is declared by path: the toolkit's form and vitals routes are exempt at their
48
+ default paths, and `apiGate: { isPublic: (path) => … }` adds your own.
49
+
42
50
  **Actions** — the editor write path as Astro Actions, so a save is a typed call
43
51
  rather than a hand-rolled endpoint. `louiseSaveAction`, `louiseSaveDraftAction`
44
52
  and `louiseSettingsAction` wrap the same primitives the framework exposes, which
package/dist/index.d.ts CHANGED
@@ -2,4 +2,5 @@ export { type ActionErrorCtor, type EditorActionContext, type EditorActionDeps,
2
2
  export { type CatalogLoaderConfig, defineCatalogLoader } from "./catalog.js";
3
3
  export { collectionToAstroSchema, louiseLoader, type LouiseLoaderConfig, type LouiseRow, } from "./content-loader.js";
4
4
  export { formToAstroSchema } from "./form-schema.js";
5
+ export { type ResumeReadSession, resumeReadSession } from "./resume.js";
5
6
  export { createLouiseMiddleware, type LouiseMiddlewareConfig, type LouiseMiddlewareRateLimit, } from "./middleware.js";
package/dist/index.js CHANGED
@@ -8,4 +8,5 @@ export { louiseSaveAction, louiseSaveDraftAction, louiseSettingsAction, } from "
8
8
  export { defineCatalogLoader } from "./catalog.js";
9
9
  export { collectionToAstroSchema, louiseLoader, } from "./content-loader.js";
10
10
  export { formToAstroSchema } from "./form-schema.js";
11
+ export { resumeReadSession } from "./resume.js";
11
12
  export { createLouiseMiddleware, } from "./middleware.js";
@@ -13,6 +13,20 @@ export interface LouiseMiddlewareRateLimit {
13
13
  */
14
14
  kv: RateLimitBackend | (() => RateLimitBackend | undefined);
15
15
  }
16
+ export interface LouiseMiddlewareApiGate {
17
+ /** Path the gate protects. Default `/api/louise`, on a segment boundary. */
18
+ prefix?: string;
19
+ /**
20
+ * Paths under the prefix an anonymous request may still reach, on top of the
21
+ * toolkit's own public routes at their default mounts (forms and vitals — see
22
+ * `isLouisePublicPath`).
23
+ *
24
+ * A path, not a mark on the route, because middleware runs before it knows
25
+ * which route file will answer: a route can't declare itself public here the
26
+ * way `publicRoute` does for `composeWorker`.
27
+ */
28
+ isPublic?: (pathname: string) => boolean;
29
+ }
16
30
  export interface LouiseMiddlewareConfig<TEditor = unknown> {
17
31
  /**
18
32
  * Resolve the editor session for a request — the site wraps its own auth,
@@ -35,6 +49,12 @@ export interface LouiseMiddlewareConfig<TEditor = unknown> {
35
49
  /** Apply {@link louiseSecurityHeaders} (HSTS, nosniff, referrer, …) to the
36
50
  * response. Default `true`. */
37
51
  securityHeaders?: boolean;
52
+ /**
53
+ * Hosts to keep out of search indexes — sent as `X-Robots-Tag: noindex`.
54
+ * E.g. `(host) => isNoindexHost(host, { prefixes: ["preview."] })`. Set here
55
+ * rather than in a page, because a streamed page's headers are already gone.
56
+ */
57
+ noindex?: (hostname: string) => boolean;
38
58
  /**
39
59
  * Extra per-request work after editor resolution, before `next()` — e.g.
40
60
  * resolve a second session (a shop customer) onto `locals`. Runs inside the
@@ -80,6 +100,19 @@ export interface LouiseMiddlewareConfig<TEditor = unknown> {
80
100
  * rendered from it stay correct.
81
101
  */
82
102
  rewrite?: (context: APIContext) => string | undefined | Promise<string | undefined>;
103
+ /**
104
+ * Deny-by-default gate for the editor API (ADR 0012), for routes mounted as
105
+ * framework API routes (`runEditorRoute`) rather than `composeWorker` routes.
106
+ * `true` — or an object to change the prefix or add public paths — and every
107
+ * request under `/api/louise` must resolve to an editor, with writes and
108
+ * WebSocket upgrades origin-checked, before any route runs. Gated responses
109
+ * get `Cache-Control: no-store` unless the route set its own. Omit to skip.
110
+ *
111
+ * Behind `composeWorker({ gate })` this is a second check on requests the
112
+ * worker already let through, and costs nothing extra: the editor is
113
+ * resolved on every request regardless.
114
+ */
115
+ apiGate?: boolean | LouiseMiddlewareApiGate;
83
116
  /** Edit-mode cookie name. Default {@link LOUISE_EDIT_COOKIE} (`"louise_edit"`).
84
117
  * Change it and the `withEdgeCache` bypass predicate must be told too, or an
85
118
  * editor gets served the cached public page. */
@@ -19,7 +19,7 @@
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 { LOUISE_EDIT_COOKIE } from "louise-toolkit/worker";
22
+ import { isLouisePublicPath, LOUISE_API_PREFIX, LOUISE_EDIT_COOKIE, louiseApiGate, 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
@@ -31,6 +31,8 @@ export function createLouiseMiddleware(config) {
31
31
  // The default comes from the same constant `isEditRequest` reads, so the
32
32
  // cookie this sets and the predicate that looks for it cannot drift apart.
33
33
  const editCookie = config.editCookie ?? LOUISE_EDIT_COOKIE;
34
+ const apiGate = config.apiGate === true ? {} : config.apiGate || undefined;
35
+ const apiPrefix = apiGate?.prefix ?? LOUISE_API_PREFIX;
34
36
  return async (context, next) => {
35
37
  // Rate-limit the public, unauthenticated POST surfaces before any other
36
38
  // work. Keyed by client IP via a KV counter; `rateLimit` fails open on a KV
@@ -90,6 +92,23 @@ export function createLouiseMiddleware(config) {
90
92
  // SESSION_SECRET) → public rendering. Auth degrading is fine; what it
91
93
  // must NOT do is cancel anything else.
92
94
  }
95
+ // The API gate, before extend: it needs only the editor, and a refused
96
+ // request shouldn't pay for the site's extra work. A `resolveEditor` that
97
+ // threw left `locals.editor` null above, so where pages degrade to public
98
+ // the API fails closed — refused, not served anonymously.
99
+ const pathname = context.url.pathname;
100
+ const gatedApi = apiGate !== undefined &&
101
+ underPrefix(pathname, apiPrefix) &&
102
+ !isLouisePublicPath(pathname) &&
103
+ !apiGate.isPublic?.(pathname);
104
+ if (gatedApi) {
105
+ const denied = await louiseApiGate(context.request, undefined, {
106
+ resolveEditor: () => locals.editor,
107
+ prefix: apiPrefix,
108
+ });
109
+ if (denied)
110
+ return finish(context, denied);
111
+ }
93
112
  // extend gets its OWN catch, deliberately separate from auth's. When these
94
113
  // shared one, `resolveEditor` throwing (a sentinel SESSION_SECRET — the
95
114
  // dormant-until-provisioned state every module is supposed to survive)
@@ -118,6 +137,16 @@ export function createLouiseMiddleware(config) {
118
137
  // for host dispatch is another tenant's page.
119
138
  const rewrite = await config.rewrite?.(context);
120
139
  const response = rewrite === undefined ? await next() : await next(rewrite);
140
+ // An editor's JSON must not land in a shared cache. A route that chose its
141
+ // own policy keeps it.
142
+ if (gatedApi && !response.headers.has("cache-control")) {
143
+ response.headers.set("Cache-Control", "no-store");
144
+ }
145
+ return finish(context, response);
146
+ };
147
+ /** The response-side work every answer gets, a gate refusal included. */
148
+ function finish(context, response) {
149
+ const locals = context.locals;
121
150
  // content freshness: cached HTML would hide editor edits. Edit-mode pages are
122
151
  // per-editor and must be live (`no-store`); public HTML `no-cache` so edits
123
152
  // appear without a manual purge. Only HTML — hashed `/_astro/*` assets keep
@@ -131,9 +160,13 @@ export function createLouiseMiddleware(config) {
131
160
  // every edit surface), so guarantee the CSP permits data: fonts — no-op
132
161
  // without a CSP header or when already allowed. Saves consumers a font-src edit.
133
162
  allowCspDataFonts(response);
163
+ const noindex = config.noindex?.(context.url.hostname) ?? false;
134
164
  if (config.securityHeaders !== false) {
135
- louiseSecurityHeaders(response, { hostname: context.url.hostname });
165
+ louiseSecurityHeaders(response, { hostname: context.url.hostname, noindex });
166
+ }
167
+ else if (noindex) {
168
+ response.headers.set("X-Robots-Tag", "noindex");
136
169
  }
137
170
  return response;
138
- };
171
+ }
139
172
  }
@@ -0,0 +1,20 @@
1
+ import type { AstroCookies } from "astro";
2
+ import { type D1Client } from "louise-toolkit/db";
3
+ export interface ResumeReadSession {
4
+ /** Pass to `resumeDraft` (or any read that must see the editor's writes). */
5
+ client: D1Client;
6
+ /** Persist the bookmark the reads advanced to. Call once, after them. */
7
+ commit: () => void;
8
+ }
9
+ /**
10
+ * Open a D1 session anchored at the editor's persisted bookmark, for the
11
+ * edit-mode resume read. On a database without read replication — or a
12
+ * runtime without the Sessions API — `client` is the raw binding and nothing
13
+ * changes, so this is safe to wire before replication is on.
14
+ *
15
+ * Edit mode only: a view-mode render should stay session-free and cookie-free,
16
+ * so public pages remain cacheable.
17
+ */
18
+ export declare function resumeReadSession(DB: D1Database, cookies: AstroCookies, options?: {
19
+ maxAgeSeconds?: number;
20
+ }): ResumeReadSession;
package/dist/resume.js ADDED
@@ -0,0 +1,36 @@
1
+ // Copyright (c) 2026 BowenLabs. Louise Toolkit is MIT licensed.
2
+ //
3
+ // The edit-mode resume read's D1 session, over Astro's cookie API. The draft
4
+ // WRITE (auto-save) persists its D1 bookmark in the `louise_d1_bookmark` cookie;
5
+ // the page load that follows must read at or past that bookmark, or behind read
6
+ // replication the draft just saved can be missing ("my edit vanished").
7
+ import { D1_BOOKMARK_COOKIE, D1_BOOKMARK_MAX_AGE, d1Bookmark, openD1Session, } from "louise-toolkit/db";
8
+ /**
9
+ * Open a D1 session anchored at the editor's persisted bookmark, for the
10
+ * edit-mode resume read. On a database without read replication — or a
11
+ * runtime without the Sessions API — `client` is the raw binding and nothing
12
+ * changes, so this is safe to wire before replication is on.
13
+ *
14
+ * Edit mode only: a view-mode render should stay session-free and cookie-free,
15
+ * so public pages remain cacheable.
16
+ */
17
+ export function resumeReadSession(DB, cookies, options = {}) {
18
+ const bookmark = cookies.get(D1_BOOKMARK_COOKIE)?.value ?? null;
19
+ const client = openD1Session(DB, bookmark ?? "first-unconstrained");
20
+ return {
21
+ client,
22
+ commit() {
23
+ const next = d1Bookmark(client);
24
+ if (next && next !== bookmark) {
25
+ // The same attributes `serializeD1BookmarkCookie` writes on the save path.
26
+ cookies.set(D1_BOOKMARK_COOKIE, next, {
27
+ path: "/",
28
+ httpOnly: true,
29
+ sameSite: "lax",
30
+ secure: true,
31
+ maxAge: options.maxAgeSeconds ?? D1_BOOKMARK_MAX_AGE,
32
+ });
33
+ }
34
+ },
35
+ };
36
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@louise-toolkit/astro",
3
- "version": "0.1.1",
3
+ "version": "0.2.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.28.0"
38
+ "louise-toolkit": "0.30.0"
39
39
  },
40
40
  "devDependencies": {
41
41
  "@cloudflare/workers-types": "^5.20260829.1",