astroidjs 0.19.0 → 0.21.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/bin/astroid.mjs +32 -8
- package/dist/commerce/secrets.d.ts +7 -0
- package/dist/commerce/secrets.js +12 -3
- package/dist/config.d.ts +74 -2
- package/dist/config.js +84 -1
- package/dist/incidents/index.d.ts +2 -0
- package/dist/incidents/index.js +12 -0
- package/dist/incidents/names.d.ts +17 -0
- package/dist/incidents/names.js +55 -0
- package/dist/incidents/sentry.d.ts +20 -0
- package/dist/incidents/sentry.js +118 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +2 -0
- package/dist/pages/index.d.ts +19 -0
- package/dist/pages/index.js +34 -0
- package/dist/project/generate.js +109 -52
- package/dist/project/scaffold.js +41 -16
- package/dist/queues/index.d.ts +1 -1
- package/dist/queues/index.js +1 -1
- package/dist/queues/messages.d.ts +6 -0
- package/dist/queues/messages.js +17 -2
- package/dist/queues/scaffold.js +5 -1
- package/dist/schema/generate.js +67 -4
- package/dist/security/index.d.ts +1 -1
- package/dist/security/index.js +1 -1
- package/dist/security/rate-rules.d.ts +9 -2
- package/dist/security/rate-rules.js +46 -22
- package/dist/seo/routes.js +3 -2
- package/dist/shape.d.ts +6 -0
- package/dist/shape.js +14 -0
- package/dist/status.js +22 -10
- package/dist/worker/generate.js +232 -18
- package/package.json +10 -3
- package/src/components/Credit.astro +70 -0
|
@@ -16,6 +16,7 @@
|
|
|
16
16
|
// The contact form is also absent by design: it's a worker route with its own
|
|
17
17
|
// per-form limiter, and worker routes are matched before Astro's middleware ever
|
|
18
18
|
// runs, so a rule here would never fire.
|
|
19
|
+
import { astroidHasEditor } from "../shape.js";
|
|
19
20
|
/**
|
|
20
21
|
* Base path the customer/portal Better Auth instance mounts at—its own
|
|
21
22
|
* handler, separate from the editor's `/api/auth`. Fixed by Astroid so the rate
|
|
@@ -24,14 +25,21 @@
|
|
|
24
25
|
export const ASTROID_PORTAL_BASE_PATH = "/api/portal-auth";
|
|
25
26
|
/** Path the commerce module's checkout POSTs to. */
|
|
26
27
|
export const ASTROID_CHECKOUT_PATH = "/api/checkout";
|
|
28
|
+
/**
|
|
29
|
+
* Where an app with no editor (`editor: false`) serves its versioned JSON API.
|
|
30
|
+
* A web client and a later native client call the same routes, so a new
|
|
31
|
+
* version is a new prefix rather than a change to the one clients already use.
|
|
32
|
+
*/
|
|
33
|
+
export const ASTROID_API_PREFIX = "/api/v1";
|
|
27
34
|
/** Ten minutes. Every default budget uses this window—long enough that a
|
|
28
35
|
* burst can't wait it out, short enough that a false positive self-heals. */
|
|
29
36
|
const WINDOW = 600;
|
|
30
37
|
const exact = (path) => (p) => p === path;
|
|
31
38
|
/**
|
|
32
39
|
* The rule set for a project, derived from its config: the editor sign-in
|
|
33
|
-
* surface
|
|
34
|
-
* checkout when commerce is configured
|
|
40
|
+
* surface unless the project has no editor, the portal's credential surfaces
|
|
41
|
+
* when a portal is enabled, checkout when commerce is configured, and the
|
|
42
|
+
* versioned API for an app with no editor.
|
|
35
43
|
*
|
|
36
44
|
* Rules are matched first-wins, and `security.rateRules` from the config are
|
|
37
45
|
* placed FIRST—so a site tightens or loosens any default by declaring its own
|
|
@@ -39,26 +47,29 @@ const exact = (path) => (p) => p === path;
|
|
|
39
47
|
*/
|
|
40
48
|
export function astroidRateRules(config) {
|
|
41
49
|
const rules = [...(config.security?.rateRules ?? [])];
|
|
42
|
-
//
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
50
|
+
// The editor's sign-in, which an app with no editor doesn't serve.
|
|
51
|
+
if (astroidHasEditor(config)) {
|
|
52
|
+
// Magic-link sign-in is the email-bombing target: without a cap, anyone who
|
|
53
|
+
// knows an editor's address can trigger unbounded sign-in mail (their inbox,
|
|
54
|
+
// your Email + Worker spend). Tightest budget in the set.
|
|
55
|
+
rules.push({
|
|
56
|
+
name: "magic-link",
|
|
57
|
+
method: "POST",
|
|
58
|
+
match: exact("/api/auth/sign-in/magic-link"),
|
|
59
|
+
limit: 5,
|
|
60
|
+
windowSec: WINDOW,
|
|
61
|
+
});
|
|
62
|
+
// Everything else Better Auth serves (passkey challenges, sign-out, callbacks)
|
|
63
|
+
// behind a looser catch-all. Ordered after the specific rule above, which
|
|
64
|
+
// matters: `matchRateRule` takes the first match.
|
|
65
|
+
rules.push({
|
|
66
|
+
name: "auth",
|
|
67
|
+
method: "POST",
|
|
68
|
+
match: (p) => p.startsWith("/api/auth/"),
|
|
69
|
+
limit: 30,
|
|
70
|
+
windowSec: WINDOW,
|
|
71
|
+
});
|
|
72
|
+
}
|
|
62
73
|
if (config.portal?.enabled) {
|
|
63
74
|
// The portal's mount is configurable (a site may already ship a second
|
|
64
75
|
// instance at its own path), so the credential-surface rules must track the
|
|
@@ -106,5 +117,18 @@ export function astroidRateRules(config) {
|
|
|
106
117
|
windowSec: WINDOW,
|
|
107
118
|
});
|
|
108
119
|
}
|
|
120
|
+
if (!astroidHasEditor(config)) {
|
|
121
|
+
// An app's own JSON API: quotes, orders, account changes. Looser than
|
|
122
|
+
// checkout because a client posts here as a cart changes, not once per
|
|
123
|
+
// purchase, so this is abuse control rather than flow control. After the
|
|
124
|
+
// specific rules, so a tighter budget for one route wins.
|
|
125
|
+
rules.push({
|
|
126
|
+
name: "api",
|
|
127
|
+
method: "POST",
|
|
128
|
+
match: (p) => p.startsWith(`${ASTROID_API_PREFIX}/`),
|
|
129
|
+
limit: 120,
|
|
130
|
+
windowSec: WINDOW,
|
|
131
|
+
});
|
|
132
|
+
}
|
|
109
133
|
return rules;
|
|
110
134
|
}
|
package/dist/seo/routes.js
CHANGED
|
@@ -13,6 +13,7 @@
|
|
|
13
13
|
// rules are: which routes exist is a function of which modules are enabled, and
|
|
14
14
|
// a hand-maintained list drifts the moment someone turns a portal on.
|
|
15
15
|
import { ASTROID_PORTAL_BASE_PATH } from "../security/rate-rules.js";
|
|
16
|
+
import { astroidHasEditor } from "../shape.js";
|
|
16
17
|
/**
|
|
17
18
|
* Paths that must never be indexed, derived from the config: the editor and its
|
|
18
19
|
* API always, plus the portal's account + auth surfaces and checkout when those
|
|
@@ -25,8 +26,8 @@ export function astroidNoindexPaths(config) {
|
|
|
25
26
|
const paths = [
|
|
26
27
|
// Every worker route (editor CRUD, media, forms) and Better Auth.
|
|
27
28
|
"/api/",
|
|
28
|
-
// The editor entry point.
|
|
29
|
-
"/louise",
|
|
29
|
+
// The editor entry point, when there is an editor.
|
|
30
|
+
...(astroidHasEditor(config) ? ["/louise"] : []),
|
|
30
31
|
];
|
|
31
32
|
if (config.portal?.enabled) {
|
|
32
33
|
paths.push(ASTROID_PORTAL_BASE_PATH, "/account", "/login", "/register", "/reset-password");
|
package/dist/shape.d.ts
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import type { AstroidConfig } from "./config.js";
|
|
2
|
+
/**
|
|
3
|
+
* Whether the project has a Louise editor: its routes, its sign-in, and the
|
|
4
|
+
* content tables it edits. True unless the config sets `editor: false`.
|
|
5
|
+
*/
|
|
6
|
+
export declare function astroidHasEditor(config: AstroidConfig): boolean;
|
package/dist/shape.js
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
// Copyright (c) 2026 BowenLabs. Astroid is MIT licensed.
|
|
2
|
+
//
|
|
3
|
+
// The project's shape: a Louise-edited site, or an app with no pages to edit.
|
|
4
|
+
//
|
|
5
|
+
// Its own module, and dependency-free, because nearly every generator asks the
|
|
6
|
+
// question and `config.ts` imports some of them at runtime. A type-only import
|
|
7
|
+
// of the config keeps that from becoming a cycle.
|
|
8
|
+
/**
|
|
9
|
+
* Whether the project has a Louise editor: its routes, its sign-in, and the
|
|
10
|
+
* content tables it edits. True unless the config sets `editor: false`.
|
|
11
|
+
*/
|
|
12
|
+
export function astroidHasEditor(config) {
|
|
13
|
+
return config.editor !== false;
|
|
14
|
+
}
|
package/dist/status.js
CHANGED
|
@@ -19,6 +19,7 @@
|
|
|
19
19
|
import { commerceSecretNames, resolveCommerceStatus } from "./commerce/secrets.js";
|
|
20
20
|
import { ASTROID_VITALS_SECRET_NAMES } from "./analytics/index.js";
|
|
21
21
|
import { EMAIL_SECRET_NAMES, resolveMailerStatus } from "./email/send.js";
|
|
22
|
+
import { astroidHasEditor } from "./shape.js";
|
|
22
23
|
/**
|
|
23
24
|
* Secrets every Astroid site has, independent of which modules are on.
|
|
24
25
|
*
|
|
@@ -40,17 +41,25 @@ export const ASTROID_CORE_SECRET_NAMES = [
|
|
|
40
41
|
* and a marketing site shouldn't be told to provision a Square token.
|
|
41
42
|
*/
|
|
42
43
|
export function astroidSecretNames(config) {
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
44
|
+
// An app with no editor signs nobody in unless it has a portal, which needs
|
|
45
|
+
// the session secret and mail, not the editor's Turnstile pair.
|
|
46
|
+
const editor = astroidHasEditor(config);
|
|
47
|
+
const portal = Boolean(config.portal?.enabled);
|
|
48
|
+
const groups = {};
|
|
49
|
+
if (editor)
|
|
50
|
+
groups.core = [...ASTROID_CORE_SECRET_NAMES];
|
|
51
|
+
else if (portal)
|
|
52
|
+
groups.core = ["SESSION_SECRET"];
|
|
53
|
+
if (editor || portal)
|
|
54
|
+
groups.email = [...EMAIL_SECRET_NAMES];
|
|
47
55
|
const commerce = commerceSecretNames(config.commerce);
|
|
48
56
|
if (commerce.length > 0)
|
|
49
57
|
groups.commerce = commerce;
|
|
50
58
|
// The CWV read-back's API credentials. Collection needs none of this—only
|
|
51
59
|
// querying the p75 back out does, because the Analytics Engine SQL API is
|
|
52
|
-
// account-scoped and has no binding.
|
|
53
|
-
|
|
60
|
+
// account-scoped and has no binding. The editor's Health panel reads it.
|
|
61
|
+
if (editor)
|
|
62
|
+
groups.vitals = [...ASTROID_VITALS_SECRET_NAMES];
|
|
54
63
|
return groups;
|
|
55
64
|
}
|
|
56
65
|
/**
|
|
@@ -68,8 +77,11 @@ export async function astroidModuleStatus(config, env) {
|
|
|
68
77
|
resolveCommerceStatus(config.commerce, secretEnv),
|
|
69
78
|
resolveMailerStatus(env),
|
|
70
79
|
]);
|
|
71
|
-
const reports = [
|
|
72
|
-
|
|
80
|
+
const reports = [];
|
|
81
|
+
// Mail is for signing in: the editor's magic link, or a portal's password
|
|
82
|
+
// reset. An app with neither sends none, so there's nothing to report.
|
|
83
|
+
if (astroidHasEditor(config) || config.portal?.enabled) {
|
|
84
|
+
reports.push({
|
|
73
85
|
module: "email",
|
|
74
86
|
enabled: true,
|
|
75
87
|
configured: mailer.configured,
|
|
@@ -79,8 +91,8 @@ export async function astroidModuleStatus(config, env) {
|
|
|
79
91
|
: // Naming the log is the useful part: the magic link IS in the console,
|
|
80
92
|
// and someone who doesn't know that concludes sign-in is broken.
|
|
81
93
|
"dormant — messages are logged to the console (the magic link is in the log), not sent",
|
|
82
|
-
}
|
|
83
|
-
|
|
94
|
+
});
|
|
95
|
+
}
|
|
84
96
|
if (commerce.enabled) {
|
|
85
97
|
reports.push({
|
|
86
98
|
module: "commerce",
|
package/dist/worker/generate.js
CHANGED
|
@@ -12,12 +12,14 @@
|
|
|
12
12
|
// beforeChange hook, and pagesRoute (which takes no collection config) through
|
|
13
13
|
// the `astroidPagesWriteHooks` spread, so both write paths enforce one contract.
|
|
14
14
|
import { ASTROID_VITALS_BINDING, generateAstroidCwvQuery } from "../analytics/index.js";
|
|
15
|
+
import { ASTROID_INCIDENT_EVENTS_BINDING, ASTROID_SENTRY_DSN_BINDING, ASTROID_VERSION_METADATA_BINDING, } from "../incidents/names.js";
|
|
15
16
|
import { astroidEditorTable } from "../auth/index.js";
|
|
16
17
|
import { astroidPortal } from "../portal/config.js";
|
|
17
|
-
import { ASTROID_HEALTH_CRON, astroidCron, astroidUsesQueues } from "../queues/messages.js";
|
|
18
|
+
import { ASTROID_HEALTH_CRON, astroidCron, astroidQueueNames, astroidUsesQueues, } from "../queues/messages.js";
|
|
18
19
|
import { ASTROID_EDIT_SESSION_CLASS, ASTROID_REALTIME_BINDING, usesRealtime, } from "../realtime/scaffold.js";
|
|
19
20
|
import { capturesInquiries } from "../schema/framework.js";
|
|
20
21
|
import { astroidCspStyleSrc } from "../security/csp-origins.js";
|
|
22
|
+
import { astroidHasEditor } from "../shape.js";
|
|
21
23
|
import { ASTROID_REWRITE_EXCLUDE, ASTROID_TENANT_PREFIX } from "../tenancy/index.js";
|
|
22
24
|
import { astroidEditorRoutePlan } from "./routes.js";
|
|
23
25
|
// Astroid's default editable site_settings surface—the columns the Settings
|
|
@@ -52,6 +54,8 @@ export const ASTROID_SETTINGS_IMAGE_KEYS = ["logoUrl", "faviconUrl", "defaultOgI
|
|
|
52
54
|
* emitted only when a brand captures inquiries.
|
|
53
55
|
*/
|
|
54
56
|
export function generateAstroidWorker(config) {
|
|
57
|
+
if (!astroidHasEditor(config))
|
|
58
|
+
return generateAppWorker(config);
|
|
55
59
|
const inquiries = capturesInquiries(config);
|
|
56
60
|
const queues = astroidUsesQueues(config);
|
|
57
61
|
const cron = astroidCron(config);
|
|
@@ -205,6 +209,7 @@ export function generateAstroidWorker(config) {
|
|
|
205
209
|
p('import { cwvSqlQuery, parseCwvRows, summarizeCwv, vitalsRoute } from "louise-toolkit/analytics";');
|
|
206
210
|
if (inquiries)
|
|
207
211
|
p('import { defineForm } from "louise-toolkit/forms";');
|
|
212
|
+
p(incidentsImport(queues));
|
|
208
213
|
if (queues)
|
|
209
214
|
p('import { processBatch } from "louise-toolkit/queues";');
|
|
210
215
|
// Only when a route actually takes a runner—a project with no AI assists
|
|
@@ -227,6 +232,7 @@ export function generateAstroidWorker(config) {
|
|
|
227
232
|
"setAstroidMediaBase",
|
|
228
233
|
...(inquiries ? ["sendInquiryMail"] : []),
|
|
229
234
|
...(queues ? ["type AstroidQueueMessage"] : []),
|
|
235
|
+
...(config.incidents?.sentry ? ["sentryIncidents", "type SecretSource"] : []),
|
|
230
236
|
].sort();
|
|
231
237
|
p(`import { ${astroidImports.join(", ")} } from "astroidjs";`);
|
|
232
238
|
// The config lives at the PROJECT ROOT (create-astroid writes it there); this
|
|
@@ -472,6 +478,7 @@ export function generateAstroidWorker(config) {
|
|
|
472
478
|
p(" return new Response(obj.body, { headers });");
|
|
473
479
|
p("};");
|
|
474
480
|
p();
|
|
481
|
+
emitIncidentPreamble(p, config, queues);
|
|
475
482
|
// The queue message type parameter is what gives the `queue` consumer below a
|
|
476
483
|
// typed `MessageBatch` instead of `MessageBatch<unknown>`.
|
|
477
484
|
p(queues
|
|
@@ -507,13 +514,9 @@ export function generateAstroidWorker(config) {
|
|
|
507
514
|
p(" // An editor never reads from, and never writes to, the shared entry.");
|
|
508
515
|
p(" bypass: isEditRequest,");
|
|
509
516
|
p(" }),");
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
p
|
|
513
|
-
p(" // batch from acking; Cloudflare routes it to the DLQ once it exceeds");
|
|
514
|
-
p(" // max_retries (see wrangler.jsonc).");
|
|
515
|
-
p(" queue: (batch, env) => processBatch(batch, (message) => handleQueueMessage(env, message)),");
|
|
516
|
-
}
|
|
517
|
+
emitIncidentOption(p, config);
|
|
518
|
+
if (queues)
|
|
519
|
+
emitQueueOption(p, config);
|
|
517
520
|
// ONE scheduled handler for every cron, dispatching on `controller.cron`.
|
|
518
521
|
// Cloudflare gives no other way to tell them apart, and the strings here have
|
|
519
522
|
// to match `astroidCrons` exactly—which is why both read the same constants
|
|
@@ -564,6 +567,118 @@ export function generateAstroidWorker(config) {
|
|
|
564
567
|
}
|
|
565
568
|
return lines.join("\n");
|
|
566
569
|
}
|
|
570
|
+
/**
|
|
571
|
+
* The worker of an app with no editor (`editor: false`): the public status
|
|
572
|
+
* route, the SSR fallback, and whatever the config's modules add (a queue
|
|
573
|
+
* consumer, the crons), with no editor route and no auth seam.
|
|
574
|
+
*
|
|
575
|
+
* The gate stays, with a resolver that never finds an editor. Nothing under
|
|
576
|
+
* `/api/louise` belongs to one here, so it refuses everything there except the
|
|
577
|
+
* public status probe, and it gives route responses the security headers the
|
|
578
|
+
* middleware never sees.
|
|
579
|
+
*/
|
|
580
|
+
function generateAppWorker(config) {
|
|
581
|
+
const queues = astroidUsesQueues(config);
|
|
582
|
+
const cron = astroidCron(config);
|
|
583
|
+
const customCrons = config.crons ?? [];
|
|
584
|
+
const scheduled = cron !== null || customCrons.length > 0;
|
|
585
|
+
const lines = [];
|
|
586
|
+
const p = (s = "") => lines.push(s);
|
|
587
|
+
p("// Generated by astroidjs—do not hand-edit.");
|
|
588
|
+
p("// Source: your defineAstroid config. This app has no editor (`editor: false`),");
|
|
589
|
+
p("// so it serves no editor routes and resolves no editor session.");
|
|
590
|
+
p('import { handle } from "@astrojs/cloudflare/handler";');
|
|
591
|
+
p('import { d1Check, statusRoute } from "louise-toolkit/editor";');
|
|
592
|
+
p(incidentsImport(queues));
|
|
593
|
+
if (queues)
|
|
594
|
+
p('import { processBatch } from "louise-toolkit/queues";');
|
|
595
|
+
p('import { composeWorker, type WorkerRoute, withEdgeCache } from "louise-toolkit/worker";');
|
|
596
|
+
const astroidImports = [
|
|
597
|
+
...(queues ? ["type AstroidQueueMessage"] : []),
|
|
598
|
+
...(config.incidents?.sentry ? ["sentryIncidents", "type SecretSource"] : []),
|
|
599
|
+
].sort();
|
|
600
|
+
if (astroidImports.length > 0) {
|
|
601
|
+
const typeOnly = astroidImports.every((name) => name.startsWith("type "));
|
|
602
|
+
p(typeOnly
|
|
603
|
+
? `import type { ${astroidImports.map((name) => name.slice(5)).join(", ")} } from "astroidjs";`
|
|
604
|
+
: `import { ${astroidImports.join(", ")} } from "astroidjs";`);
|
|
605
|
+
}
|
|
606
|
+
if (config.status?.checks) {
|
|
607
|
+
p("// Your STATUS seam: the app's own checks for the public status route.");
|
|
608
|
+
p("// Scaffolded once and yours to edit.");
|
|
609
|
+
p('import { statusChecks } from "./status-checks.js";');
|
|
610
|
+
}
|
|
611
|
+
if (queues) {
|
|
612
|
+
p("// Your QUEUE seam: what each message actually does. Scaffolded once and");
|
|
613
|
+
p("// yours to edit.");
|
|
614
|
+
p('import { handleQueueMessage } from "./queue.js";');
|
|
615
|
+
}
|
|
616
|
+
p();
|
|
617
|
+
p("// --- public status ---------------------------------------------------------");
|
|
618
|
+
p("// What statusRoute answers an outside probe with: 200 when every check");
|
|
619
|
+
p("// passes, 503 when any fails, throws, or takes over two seconds. Anyone can");
|
|
620
|
+
p("// make these run, so each is one cheap read, and a burst of probes reuses one");
|
|
621
|
+
p("// result per isolate for STATUS_REUSE_MS.");
|
|
622
|
+
p("const STATUS_REUSE_MS = 10_000;");
|
|
623
|
+
p("const STATUS_CHECKS = {");
|
|
624
|
+
p(" // D1 answers `SELECT 1`.");
|
|
625
|
+
p(" d1: d1Check((env: CloudflareEnv) => env.DB),");
|
|
626
|
+
if (config.status?.checks) {
|
|
627
|
+
p(" // The app's own, from src/status-checks.ts. Spread last, so a check with");
|
|
628
|
+
p(" // the same name replaces Astroid's on purpose.");
|
|
629
|
+
p(" ...statusChecks,");
|
|
630
|
+
}
|
|
631
|
+
p("};");
|
|
632
|
+
p();
|
|
633
|
+
p("const routes: WorkerRoute<CloudflareEnv>[] = [");
|
|
634
|
+
p(" // Public status for an outside probe (GET/HEAD /api/louise/status). A");
|
|
635
|
+
p(" // publicRoute, so the gate below lets an anonymous probe through.");
|
|
636
|
+
p(" statusRoute({ checks: STATUS_CHECKS, reuseMs: STATUS_REUSE_MS }),");
|
|
637
|
+
p("];");
|
|
638
|
+
p();
|
|
639
|
+
emitIncidentPreamble(p, config, queues);
|
|
640
|
+
p(queues
|
|
641
|
+
? "export default composeWorker<CloudflareEnv, AstroidQueueMessage>({"
|
|
642
|
+
: "export default composeWorker<CloudflareEnv>({");
|
|
643
|
+
p(" routes,");
|
|
644
|
+
p(" // No editor, so no request under /api/louise resolves to one. The gate");
|
|
645
|
+
p(" // refuses everything there except the public status probe, and gives route");
|
|
646
|
+
p(" // responses the security headers the middleware never sees.");
|
|
647
|
+
p(" gate: { resolveEditor: () => null },");
|
|
648
|
+
p(" // The SSR fallback, in the Worker cache (ADR 0004). It stores only a");
|
|
649
|
+
p(" // response that opts in with `Astro.cache.set(...)`, and it strips the CDN");
|
|
650
|
+
p(" // directive from every other one, so Cloudflare's cookie-blind edge cache");
|
|
651
|
+
p(" // never stores a signed-in customer's page.");
|
|
652
|
+
p(" fetch: withEdgeCache((request, env, ctx) => handle(request, env, ctx)),");
|
|
653
|
+
emitIncidentOption(p, config);
|
|
654
|
+
if (queues)
|
|
655
|
+
emitQueueOption(p, config);
|
|
656
|
+
if (scheduled) {
|
|
657
|
+
p(" // Cron. Cloudflare fires this for EVERY trigger in wrangler.jsonc and");
|
|
658
|
+
p(" // identifies which by `controller.cron`, so dispatch on it.");
|
|
659
|
+
p(" scheduled: (controller, env, ctx) => {");
|
|
660
|
+
if (cron) {
|
|
661
|
+
p(` if (controller.cron === ${JSON.stringify(cron)}) {`);
|
|
662
|
+
p(" // Catalog safety net: a missed webhook leaves the catalog stale only");
|
|
663
|
+
p(" // until the next tick. Enqueued, so it takes the retry + DLQ path.");
|
|
664
|
+
p(' ctx.waitUntil(env.COMMERCE_QUEUE.send({ kind: "catalog_refresh" }));');
|
|
665
|
+
p(" return;");
|
|
666
|
+
p(" }");
|
|
667
|
+
}
|
|
668
|
+
for (const custom of customCrons) {
|
|
669
|
+
p(` if (controller.cron === ${JSON.stringify(custom.expression)}) {`);
|
|
670
|
+
p(" // Enqueued, not run inline: same retry + DLQ path as everything");
|
|
671
|
+
p(" // else, and a slow job can't hold the scheduled handler open.");
|
|
672
|
+
p(` ctx.waitUntil(env.COMMERCE_QUEUE.send(${JSON.stringify(custom.message)}));`);
|
|
673
|
+
p(" return;");
|
|
674
|
+
p(" }");
|
|
675
|
+
}
|
|
676
|
+
p(" },");
|
|
677
|
+
}
|
|
678
|
+
p("});");
|
|
679
|
+
p();
|
|
680
|
+
return lines.join("\n");
|
|
681
|
+
}
|
|
567
682
|
/**
|
|
568
683
|
* Generate the Astro middleware (`middleware.ts`) from an Astroid config: the
|
|
569
684
|
* shared Louise flow (rate-limit the unauthenticated POST surface → resolve editor
|
|
@@ -592,6 +707,8 @@ export function generateAstroidMiddleware(config) {
|
|
|
592
707
|
// origins (the Square SDK's stylesheet) are baked in here; `astroid build`
|
|
593
708
|
// regenerates this file, so a config change lands on the next deploy.
|
|
594
709
|
const cspStyleSrc = astroidCspStyleSrc(config);
|
|
710
|
+
// An app with no editor has no auth seam and no pages to redirect between.
|
|
711
|
+
const editor = astroidHasEditor(config);
|
|
595
712
|
const portal = astroidPortal(config);
|
|
596
713
|
const tenancy = config.tenancy;
|
|
597
714
|
const rewritePrefix = tenancy?.rewritePrefix ?? ASTROID_TENANT_PREFIX;
|
|
@@ -599,13 +716,17 @@ export function generateAstroidMiddleware(config) {
|
|
|
599
716
|
return [
|
|
600
717
|
"// Generated by astroidjs—do not hand-edit.",
|
|
601
718
|
"// The shared Louise middleware: rate-limit the unauthenticated POST surfaces,",
|
|
602
|
-
|
|
719
|
+
...(editor
|
|
720
|
+
? ["// then resolve the editor session + sticky ?louise edit mode, then apply"]
|
|
721
|
+
: ["// then (this app has no editor, so there's no session to resolve) apply"]),
|
|
603
722
|
"// content-freshness + transport-security headers, and rewrite the style-src of",
|
|
604
723
|
"// the CSP header Astro's security.csp emits so Louise's data-driven inline",
|
|
605
724
|
"// styles + inlined data: brand font are allowed.",
|
|
606
725
|
'import { env } from "cloudflare:workers";',
|
|
607
726
|
'import { createLouiseMiddleware } from "@louise-toolkit/astro";',
|
|
608
|
-
|
|
727
|
+
...(editor
|
|
728
|
+
? ['import { db, pageRedirects, resolvePageRedirect } from "louise-toolkit/db";']
|
|
729
|
+
: []),
|
|
609
730
|
// One `astroidjs` import, composed from what this config actually uses—two
|
|
610
731
|
// import statements for the same module is legal and reads as an
|
|
611
732
|
// oversight in a file nobody is supposed to hand-edit.
|
|
@@ -623,15 +744,21 @@ export function generateAstroidMiddleware(config) {
|
|
|
623
744
|
'import { resolveTenant } from "./tenancy.js";',
|
|
624
745
|
]
|
|
625
746
|
: []),
|
|
626
|
-
|
|
627
|
-
|
|
747
|
+
...(editor
|
|
748
|
+
? [
|
|
749
|
+
"// TODO(astroid): your AUTH seam—same resolveEditor as the generated worker.ts.",
|
|
750
|
+
'import { resolveEditor } from "./auth.js";',
|
|
751
|
+
]
|
|
752
|
+
: []),
|
|
628
753
|
// The portal's resolver lives in its OWN module, not the editor's auth
|
|
629
754
|
// seam—they're separate Better Auth instances and must not share a file.
|
|
630
755
|
...(portal ? ['import { resolvePortalUser } from "./portal-auth.js";'] : []),
|
|
631
756
|
"",
|
|
632
757
|
"// Rate-limit the public, unauthenticated POST surface, keyed by client IP",
|
|
633
758
|
"// (fixed-window KV counter that fails open). Derived from your config: the",
|
|
634
|
-
|
|
759
|
+
...(editor
|
|
760
|
+
? ["// editor magic-link always, plus the portal credential surfaces and checkout"]
|
|
761
|
+
: ["// versioned API under /api/v1, plus the portal credential surfaces and checkout"]),
|
|
635
762
|
"// when those are enabled. Add your own via `security.rateRules` in the config—",
|
|
636
763
|
"// they're matched first, so they can also override a default's budget.",
|
|
637
764
|
"// `env.RL` is read per request (a getter)—a KV binding is only valid in",
|
|
@@ -657,15 +784,27 @@ export function generateAstroidMiddleware(config) {
|
|
|
657
784
|
: []),
|
|
658
785
|
"",
|
|
659
786
|
"export const onRequest = createLouiseMiddleware({",
|
|
660
|
-
|
|
787
|
+
...(editor
|
|
788
|
+
? [" resolveEditor: (request) => resolveEditor(request),"]
|
|
789
|
+
: [
|
|
790
|
+
" // No editor (`editor: false`), so no request resolves to one and edit",
|
|
791
|
+
" // mode never turns on.",
|
|
792
|
+
" resolveEditor: () => null,",
|
|
793
|
+
]),
|
|
661
794
|
" rateLimit: { rules: RATE_RULES, kv: () => env.RL },",
|
|
662
795
|
" // The same deny-by-default gate for /api/louise routes that reach Astro—",
|
|
663
796
|
" // anything the worker's routes didn't answer. A second check behind the",
|
|
664
|
-
|
|
797
|
+
editor
|
|
798
|
+
? " // worker's gate, and free: the editor is resolved here on every request."
|
|
799
|
+
: " // worker's gate: with no editor, it refuses all but the public routes.",
|
|
665
800
|
" apiGate: true,",
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
801
|
+
...(editor
|
|
802
|
+
? [
|
|
803
|
+
" // A renamed page's old URL answers a 301 to the new one. It runs only after",
|
|
804
|
+
" // the page answered 404, so a page later created on the old path wins.",
|
|
805
|
+
" redirectFor: (path) => resolvePageRedirect(db(env.DB), pageRedirects, path),",
|
|
806
|
+
]
|
|
807
|
+
: []),
|
|
669
808
|
// `extend` runs once and may need to populate BOTH—a tenanted site with a
|
|
670
809
|
// portal resolves a tenant and a customer on the same request.
|
|
671
810
|
...(portal || tenancy
|
|
@@ -765,3 +904,78 @@ export function generateAstroidMiddleware(config) {
|
|
|
765
904
|
"",
|
|
766
905
|
].join("\n");
|
|
767
906
|
}
|
|
907
|
+
/** The `louise-toolkit/incidents` import both worker shapes emit. */
|
|
908
|
+
function incidentsImport(queues) {
|
|
909
|
+
const names = ["analyticsIncidents", "d1Incidents", ...(queues ? ["deadLetterConsumer"] : [])];
|
|
910
|
+
return `import { ${names.join(", ")} } from "louise-toolkit/incidents";`;
|
|
911
|
+
}
|
|
912
|
+
/**
|
|
913
|
+
* The declarations incident capture needs ahead of `composeWorker`, shared by
|
|
914
|
+
* both shapes (louise-toolkit ADR 0022). Its bindings are read through a local
|
|
915
|
+
* type rather than `CloudflareEnv`, because `src/env.d.ts` is scaffold-once: a
|
|
916
|
+
* site made before them still type-checks, and each sink skips what isn't bound.
|
|
917
|
+
*/
|
|
918
|
+
function emitIncidentPreamble(p, config, queues) {
|
|
919
|
+
p("// --- incidents --------------------------------------------------------------");
|
|
920
|
+
p("// Every failure this worker sees becomes an incident: counted into the site's");
|
|
921
|
+
p("// own D1 (the `incidents` table), and into Analytics Engine for counts over");
|
|
922
|
+
p("// time (louise-toolkit ADR 0022). These bindings are optional. A site whose");
|
|
923
|
+
p("// wrangler.jsonc predates them still type-checks, and a sink skips what isn't");
|
|
924
|
+
p("// bound.");
|
|
925
|
+
p("type IncidentBindings = {");
|
|
926
|
+
p(` ${ASTROID_INCIDENT_EVENTS_BINDING}?: AnalyticsEngineDataset;`);
|
|
927
|
+
p(` ${ASTROID_VERSION_METADATA_BINDING}?: WorkerVersionMetadata;`);
|
|
928
|
+
if (config.incidents?.sentry)
|
|
929
|
+
p(` ${ASTROID_SENTRY_DSN_BINDING}?: SecretSource;`);
|
|
930
|
+
p("};");
|
|
931
|
+
p("const incidentBindings = (env: CloudflareEnv) => env as CloudflareEnv & IncidentBindings;");
|
|
932
|
+
if (queues) {
|
|
933
|
+
const { dlq } = astroidQueueNames(config);
|
|
934
|
+
p("// The dead-letter queue's consumer: it keeps each message the queue gave up on");
|
|
935
|
+
p("// in the `dead_letters` table, counts it as an incident, and acks it, so a");
|
|
936
|
+
p("// failed webhook event is never lost unseen.");
|
|
937
|
+
p(`const DEAD_LETTER_QUEUE = ${JSON.stringify(dlq)};`);
|
|
938
|
+
p("const keepDeadLetters = deadLetterConsumer<CloudflareEnv, AstroidQueueMessage>(");
|
|
939
|
+
p(" (env) => env.DB,");
|
|
940
|
+
p(");");
|
|
941
|
+
}
|
|
942
|
+
p();
|
|
943
|
+
}
|
|
944
|
+
/** The `onIncident` option both shapes pass `composeWorker`. */
|
|
945
|
+
function emitIncidentOption(p, config) {
|
|
946
|
+
const critical = config.incidents?.critical ?? [];
|
|
947
|
+
p(" // Incident capture (louise-toolkit ADR 0022). A throw is reported, then");
|
|
948
|
+
p(" // re-thrown, so responses don't change; the sinks run after the response.");
|
|
949
|
+
p(" onIncident: {");
|
|
950
|
+
p(" sinks: [");
|
|
951
|
+
p(" // The record: one row per failure, with a count, in the site's own D1.");
|
|
952
|
+
p(" d1Incidents((env: CloudflareEnv) => env.DB),");
|
|
953
|
+
p(` analyticsIncidents((env: CloudflareEnv) => incidentBindings(env).${ASTROID_INCIDENT_EVENTS_BINDING}),`);
|
|
954
|
+
if (config.incidents?.sentry) {
|
|
955
|
+
p(" // A copy for the operator's issue system, with the stack. Dormant until");
|
|
956
|
+
p(` // the ${ASTROID_SENTRY_DSN_BINDING} secret holds a real DSN.`);
|
|
957
|
+
p(` sentryIncidents((env: CloudflareEnv) => incidentBindings(env).${ASTROID_SENTRY_DSN_BINDING}, { site: ${JSON.stringify(config.key)} }),`);
|
|
958
|
+
}
|
|
959
|
+
p(" ],");
|
|
960
|
+
if (critical.length > 0) {
|
|
961
|
+
p(" // What alerts, from `incidents.critical` in your config.");
|
|
962
|
+
p(` critical: ${JSON.stringify(critical)},`);
|
|
963
|
+
}
|
|
964
|
+
p(` release: (env) => incidentBindings(env).${ASTROID_VERSION_METADATA_BINDING}?.id,`);
|
|
965
|
+
p(" },");
|
|
966
|
+
}
|
|
967
|
+
/** The queue consumer both shapes emit: the dead-letter queue's batches to its
|
|
968
|
+
* consumer, and the rest through `processBatch`, which reports a message's
|
|
969
|
+
* last failed delivery as an incident. */
|
|
970
|
+
function emitQueueOption(p, config) {
|
|
971
|
+
p(" // Queue consumer. `processBatch` acks or retries each message");
|
|
972
|
+
p(" // INDEPENDENTLY, so one poisoned message can't block the rest of the");
|
|
973
|
+
p(" // batch; Cloudflare routes it to the DLQ once it exceeds max_retries,");
|
|
974
|
+
p(" // which `maxRetries` matches, so its last failure counts as an incident.");
|
|
975
|
+
p(" queue: (batch, env, ctx) =>");
|
|
976
|
+
p(" batch.queue === DEAD_LETTER_QUEUE");
|
|
977
|
+
p(" ? keepDeadLetters(batch, env, ctx)");
|
|
978
|
+
p(" : processBatch(batch, (message) => handleQueueMessage(env, message), {");
|
|
979
|
+
p(` maxRetries: ${config.queues?.maxRetries ?? 5},`);
|
|
980
|
+
p(" }),");
|
|
981
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "astroidjs",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.21.0",
|
|
4
4
|
"description": "Astroid — an opinionated meta-framework over Louise Toolkit and Astro for building editable, multi-editor sites on Cloudflare Workers.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"astro",
|
|
@@ -37,6 +37,11 @@
|
|
|
37
37
|
"import": "./dist/astro/index.js",
|
|
38
38
|
"default": "./dist/astro/index.js"
|
|
39
39
|
},
|
|
40
|
+
"./pages": {
|
|
41
|
+
"types": "./dist/pages/index.d.ts",
|
|
42
|
+
"import": "./dist/pages/index.js",
|
|
43
|
+
"default": "./dist/pages/index.js"
|
|
44
|
+
},
|
|
40
45
|
"./components/*.astro": "./src/components/*.astro",
|
|
41
46
|
"./components/Collection": {
|
|
42
47
|
"types": "./src/components/Collection.tsx",
|
|
@@ -64,14 +69,16 @@
|
|
|
64
69
|
"@typescript/native-preview": "7.0.0-dev.20260707.2",
|
|
65
70
|
"@vitest/coverage-v8": "4.1.11",
|
|
66
71
|
"astro": "^7.2.9",
|
|
67
|
-
"
|
|
72
|
+
"drizzle-orm": "^0.45.2",
|
|
73
|
+
"louise-toolkit": "^0.37.0",
|
|
68
74
|
"solid-js": "^1.9.15",
|
|
69
75
|
"typescript": "^6.0.3",
|
|
70
76
|
"vitest": "^4.1.11"
|
|
71
77
|
},
|
|
72
78
|
"peerDependencies": {
|
|
73
79
|
"astro": "^7.0.9",
|
|
74
|
-
"
|
|
80
|
+
"drizzle-orm": "^0.45.0",
|
|
81
|
+
"louise-toolkit": "^0.37.0",
|
|
75
82
|
"solid-js": "^1.9.0"
|
|
76
83
|
},
|
|
77
84
|
"peerDependenciesMeta": {
|