astroidjs 0.13.0 → 0.14.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 +9 -3
- package/dist/analytics/index.js +2 -2
- package/dist/commerce/checkout-scaffold.js +18 -18
- package/dist/commerce/mirror.js +3 -3
- package/dist/config.d.ts +27 -0
- package/dist/map/scaffold.js +4 -4
- package/dist/portal/config.d.ts +1 -1
- package/dist/portal/config.js +9 -8
- package/dist/portal/scaffold.js +7 -7
- package/dist/portal/session.d.ts +9 -3
- package/dist/portal/session.js +5 -0
- package/dist/portfolio/scaffold.js +4 -4
- package/dist/project/actions.js +12 -6
- package/dist/project/generate.js +11 -11
- package/dist/project/index.d.ts +1 -0
- package/dist/project/index.js +1 -0
- package/dist/project/scaffold.js +58 -2
- package/dist/project/seed.d.ts +22 -0
- package/dist/project/seed.js +214 -0
- package/dist/pwa/generate.js +7 -7
- package/dist/queues/scaffold.js +9 -9
- package/dist/realtime/scaffold.js +4 -4
- package/dist/schema/collections.d.ts +36 -5
- package/dist/schema/collections.js +21 -20
- package/dist/schema/generate.js +4 -4
- package/dist/tenancy/index.js +5 -5
- package/dist/worker/generate.js +72 -38
- package/dist/worker/routes.js +10 -10
- package/dist/workflow/generate.js +7 -7
- package/package.json +3 -3
package/bin/astroid.mjs
CHANGED
|
@@ -264,9 +264,15 @@ async function cmdDoctor(cwd, flags) {
|
|
|
264
264
|
}
|
|
265
265
|
}
|
|
266
266
|
|
|
267
|
-
// 3. migrations directory
|
|
268
|
-
|
|
269
|
-
|
|
267
|
+
// 3. migrations directory: the D1 `migrations_dir` wrangler.jsonc declares,
|
|
268
|
+
// or `migrations/`, the generated default, when it names none. A site that
|
|
269
|
+
// keeps its migrations under another name (drizzle/) isn't missing them.
|
|
270
|
+
const wranglerText = existsSync(wranglerPath) ? readFileSync(wranglerPath, "utf8") : "";
|
|
271
|
+
const migrationsDir =
|
|
272
|
+
wranglerText.match(/"migrations_dir"\s*:\s*"([^"]+)"/)?.[1]?.replace(/\/+$/, "") ??
|
|
273
|
+
"migrations";
|
|
274
|
+
if (existsSync(join(cwd, migrationsDir))) ok(`${migrationsDir}/ directory present`);
|
|
275
|
+
else warn(`no ${migrationsDir}/ directory — create your D1 schema migrations there.`);
|
|
270
276
|
|
|
271
277
|
// 4. Local secret provisioning—which modules will run dormant under
|
|
272
278
|
// `astroid dev`, and what to set to wake them.
|
package/dist/analytics/index.js
CHANGED
|
@@ -51,14 +51,14 @@ export function generateAstroidVitalsBeacon(config, beacon) {
|
|
|
51
51
|
return {
|
|
52
52
|
path: "public/vitals.js",
|
|
53
53
|
contents: [
|
|
54
|
-
"// Real-visitor Core Web Vitals beacon
|
|
54
|
+
"// Real-visitor Core Web Vitals beacon—generated by astroidjs.",
|
|
55
55
|
"//",
|
|
56
56
|
"// A static file, not an inline script: Astro hashes processed scripts into",
|
|
57
57
|
"// script-src, and an is:inline script with generated content can't be",
|
|
58
58
|
"// hashed, so it would be CSP-blocked. From public/ it is same-origin and",
|
|
59
59
|
"// already covered by script-src 'self'.",
|
|
60
60
|
"//",
|
|
61
|
-
"// It reports once per page, on visibilitychange → hidden, via sendBeacon
|
|
61
|
+
"// It reports once per page, on visibilitychange → hidden, via sendBeacon—",
|
|
62
62
|
"// so it costs nothing on the critical path and never blocks unload.",
|
|
63
63
|
beacon,
|
|
64
64
|
"",
|
|
@@ -42,12 +42,12 @@ export function generateAstroidCheckoutRoute(config) {
|
|
|
42
42
|
return [
|
|
43
43
|
"// Server-authoritative checkout (POST /api/checkout).",
|
|
44
44
|
"//",
|
|
45
|
-
"// Scaffolded once and yours to extend
|
|
45
|
+
"// Scaffolded once and yours to extend—shipping, tax, an order row, a",
|
|
46
46
|
"// receipt email all belong here. What should NOT change is the ORDER of the",
|
|
47
47
|
"// steps below; each one is load-bearing:",
|
|
48
48
|
"//",
|
|
49
49
|
multi
|
|
50
|
-
? "// 0. Resolve WHICH MERCHANT this sale belongs to, from the host
|
|
50
|
+
? "// 0. Resolve WHICH MERCHANT this sale belongs to, from the host—never\n" +
|
|
51
51
|
"// from the request body. See `resolveLocationId` below."
|
|
52
52
|
: null,
|
|
53
53
|
multi
|
|
@@ -65,7 +65,7 @@ export function generateAstroidCheckoutRoute(config) {
|
|
|
65
65
|
"// a double-clicked Pay button charges once while two customers buying",
|
|
66
66
|
"// the same thing stay two charges.",
|
|
67
67
|
"// 4. Charge only when commerce is actually provisioned. With placeholder",
|
|
68
|
-
"// secrets this simulates instead
|
|
68
|
+
"// secrets this simulates instead—it must never call Square with a",
|
|
69
69
|
"// dummy credential.",
|
|
70
70
|
multi
|
|
71
71
|
? "//\n" +
|
|
@@ -95,13 +95,13 @@ export function generateAstroidCheckoutRoute(config) {
|
|
|
95
95
|
"// ── Which merchant is this? ────────────────────────────────────────────────",
|
|
96
96
|
"//",
|
|
97
97
|
'// `square.locations: "multi"` means the location is a property of the',
|
|
98
|
-
"// REQUEST, not of the environment
|
|
98
|
+
"// REQUEST, not of the environment—which is why Astroid does not require a",
|
|
99
99
|
"// SQUARE_LOCATION_ID for this project. Fill this in and keep two rules:",
|
|
100
100
|
"//",
|
|
101
101
|
"// * Derive it from the HOST (or an authenticated session), never from the",
|
|
102
102
|
"// request body. A body-supplied location lets a customer name the",
|
|
103
103
|
"// cheapest merchant's id and pay that price at the dearest merchant's",
|
|
104
|
-
"// shop
|
|
104
|
+
"// shop—the same exploit as a client-supplied price, one level back.",
|
|
105
105
|
"// * Return null for anything unrecognised. Falling back to a default",
|
|
106
106
|
"// rings one merchant's sale against another merchant's books, and looks",
|
|
107
107
|
"// completely successful while doing it.",
|
|
@@ -125,7 +125,7 @@ export function generateAstroidCheckoutRoute(config) {
|
|
|
125
125
|
" // mirror holds ONE price per item, so it structurally cannot answer",
|
|
126
126
|
' // "what does this cost at this location". `retrieveVariationPricesAt`',
|
|
127
127
|
" // resolves `location_overrides`, and omits any variation the merchant",
|
|
128
|
-
" // does not carry
|
|
128
|
+
" // does not carry—so an unstocked id fails closed as `unavailable`",
|
|
129
129
|
" // instead of silently selling at the base price.",
|
|
130
130
|
" const locationId = scope?.locationId;",
|
|
131
131
|
" if (!locationId) return new Map();",
|
|
@@ -150,7 +150,7 @@ export function generateAstroidCheckoutRoute(config) {
|
|
|
150
150
|
" const prices = new Map<string, number>();",
|
|
151
151
|
" for (const item of items) {",
|
|
152
152
|
" // The mirror stores MAJOR units (dollars); the charge is in minor units.",
|
|
153
|
-
" // `Math.round` is not decoration
|
|
153
|
+
" // `Math.round` is not decoration—19.99 * 100 is 1998.9999999999998, and",
|
|
154
154
|
" // a float cent here fails the exact-equality staleness check on every",
|
|
155
155
|
" // single checkout.",
|
|
156
156
|
" if (variantIds.includes(item.externalId)) {",
|
|
@@ -174,7 +174,7 @@ export function generateAstroidCheckoutRoute(config) {
|
|
|
174
174
|
" // or probe prices, even though the card token itself is single-use. Requires",
|
|
175
175
|
" // an Origin/Referer matching the host; a non-browser caller (a stripped",
|
|
176
176
|
" // header) is refused. If you deliberately serve checkout from another origin,",
|
|
177
|
-
" // this is the line to relax
|
|
177
|
+
" // this is the line to relax—it's yours.",
|
|
178
178
|
' if (!isSameOrigin(request)) return json({ error: "Forbidden" }, 403);',
|
|
179
179
|
"",
|
|
180
180
|
" const body = (await request.json().catch(() => null)) as {",
|
|
@@ -203,7 +203,7 @@ export function generateAstroidCheckoutRoute(config) {
|
|
|
203
203
|
' return json({ error: "This storefront is not open for orders." }, 409);',
|
|
204
204
|
" }",
|
|
205
205
|
"",
|
|
206
|
-
" // 4, EARLY
|
|
206
|
+
" // 4, EARLY—and the ordering is the point. Re-pricing per location is",
|
|
207
207
|
" // itself a live Square call, so the dormancy gate has to precede",
|
|
208
208
|
" // verification here rather than follow it; running it after would call",
|
|
209
209
|
" // Square with a placeholder token, which this route must never do.",
|
|
@@ -231,7 +231,7 @@ export function generateAstroidCheckoutRoute(config) {
|
|
|
231
231
|
" const check = await verifyCheckout(body.lines, serverPrices, {",
|
|
232
232
|
" scope: { locationId },",
|
|
233
233
|
" });",
|
|
234
|
-
" // Every stale line, with its live price
|
|
234
|
+
" // Every stale line, with its live price—feed `issues` to `repairCart`",
|
|
235
235
|
" // (louise-toolkit/commerce) to fix the whole cart in one step.",
|
|
236
236
|
" if (!check.ok) {",
|
|
237
237
|
" return json({ error: check.message, reason: check.reason, issues: check.issues }, 409);",
|
|
@@ -243,7 +243,7 @@ export function generateAstroidCheckoutRoute(config) {
|
|
|
243
243
|
: [
|
|
244
244
|
" // 1 + 2: re-price and refuse on mismatch.",
|
|
245
245
|
" const check = await verifyCheckout(body.lines, serverPrices);",
|
|
246
|
-
" // Every stale line, with its live price
|
|
246
|
+
" // Every stale line, with its live price—feed `issues` to `repairCart`",
|
|
247
247
|
" // (louise-toolkit/commerce) to fix the whole cart in one step.",
|
|
248
248
|
" if (!check.ok) {",
|
|
249
249
|
" return json({ error: check.message, reason: check.reason, issues: check.issues }, 409);",
|
|
@@ -253,7 +253,7 @@ export function generateAstroidCheckoutRoute(config) {
|
|
|
253
253
|
' const idempotencyKey = await checkoutIdempotencyKey(check, "order", cartId);',
|
|
254
254
|
"",
|
|
255
255
|
" // 4: dormant until provisioned. An unconfigured store still re-prices and",
|
|
256
|
-
" // still refuses a stale cart
|
|
256
|
+
" // still refuses a stale cart—it just doesn't move money.",
|
|
257
257
|
" // Cast as the toolkit's own `astroidModuleStatus` does: `readSecret`",
|
|
258
258
|
" // accepts a plain string OR a Secrets Store binding, which CloudflareEnv",
|
|
259
259
|
" // types more narrowly than the resolver's `SecretSource` map.",
|
|
@@ -278,7 +278,7 @@ export function generateAstroidCheckoutRoute(config) {
|
|
|
278
278
|
"",
|
|
279
279
|
" // Both are guaranteed real by the dormancy gate above; the narrowing here",
|
|
280
280
|
" // is for the type system, which can't know that. `environment` is a UNION",
|
|
281
|
-
' // ("sandbox" | "production"), not a free string
|
|
281
|
+
' // ("sandbox" | "production"), not a free string—an unrecognised value',
|
|
282
282
|
" // would otherwise silently select the sandbox host in production.",
|
|
283
283
|
' const environment = env.SQUARE_ENVIRONMENT === "production" ? "production" : "sandbox";',
|
|
284
284
|
" const payment = await createPayment(",
|
|
@@ -291,7 +291,7 @@ export function generateAstroidCheckoutRoute(config) {
|
|
|
291
291
|
" // The SERVER's number, never the client's.",
|
|
292
292
|
' amountMoney: { amount: check.subtotalCents, currency: "USD" },',
|
|
293
293
|
multi
|
|
294
|
-
? " // The location the cart was PRICED against
|
|
294
|
+
? " // The location the cart was PRICED against—necessarily the same one,\n" +
|
|
295
295
|
" // or the sale rings against a merchant who never quoted this total."
|
|
296
296
|
: null,
|
|
297
297
|
multi ? " locationId," : ' locationId: env.SQUARE_LOCATION_ID ?? "",',
|
|
@@ -336,7 +336,7 @@ export function generateAstroidSquareCard(config) {
|
|
|
336
336
|
"---",
|
|
337
337
|
"// Square Web Payments card input.",
|
|
338
338
|
"//",
|
|
339
|
-
"// The card field is an IFRAME served by Square's CDN
|
|
339
|
+
"// The card field is an IFRAME served by Square's CDN—the raw number never",
|
|
340
340
|
"// enters this page's DOM and never reaches the Worker, which is what keeps the",
|
|
341
341
|
"// site out of PCI scope. `tokenize()` returns a single-use token; POST it to",
|
|
342
342
|
"// /api/checkout, which re-prices server-side before charging.",
|
|
@@ -348,7 +348,7 @@ export function generateAstroidSquareCard(config) {
|
|
|
348
348
|
...(multi
|
|
349
349
|
? [
|
|
350
350
|
"// Multi-location: the merchant is a property of the PAGE, so the id comes",
|
|
351
|
-
"// in as a prop. Only the card iframe is bound to it
|
|
351
|
+
"// in as a prop. Only the card iframe is bound to it—the charge takes its",
|
|
352
352
|
"// location from the server, which resolves it independently in",
|
|
353
353
|
"// /api/checkout. This one cannot pick the merchant, and shouldn't: it is",
|
|
354
354
|
"// rendered from markup a customer can reach.",
|
|
@@ -359,7 +359,7 @@ export function generateAstroidSquareCard(config) {
|
|
|
359
359
|
"",
|
|
360
360
|
]
|
|
361
361
|
: []),
|
|
362
|
-
"// The PUBLIC application id
|
|
362
|
+
"// The PUBLIC application id—safe in the browser, unlike the access token.",
|
|
363
363
|
"// Absent (an unprovisioned store) → render nothing rather than a dead form.",
|
|
364
364
|
"const appId = env.SQUARE_APP_ID;",
|
|
365
365
|
multi ? null : "const locationId = env.SQUARE_LOCATION_ID;",
|
|
@@ -463,7 +463,7 @@ export function generateAstroidCheckoutEnv(config) {
|
|
|
463
463
|
// only, the environment belongs to any project that calls Square at all.
|
|
464
464
|
const lines = [];
|
|
465
465
|
if (usesCardCheckout(config)) {
|
|
466
|
-
lines.push(" /** Square's PUBLIC application id
|
|
466
|
+
lines.push(" /** Square's PUBLIC application id—shipped to the browser to mount the", " * Web Payments card field. Not a secret; see wrangler.jsonc `vars`. */", " SQUARE_APP_ID: string;");
|
|
467
467
|
}
|
|
468
468
|
if (usesSquare(config)) {
|
|
469
469
|
lines.push(' /** Square API environment: "sandbox" or "production". Selects the API', " * host for EVERY Square call, so it is required for invoicing too. */", " SQUARE_ENVIRONMENT: string;");
|
package/dist/commerce/mirror.js
CHANGED
|
@@ -54,7 +54,7 @@ export const BUILT_IN_OWNED = {
|
|
|
54
54
|
type: "text",
|
|
55
55
|
values: ["draft", "published"],
|
|
56
56
|
default: "draft",
|
|
57
|
-
note: "New remote items land as draft
|
|
57
|
+
note: "New remote items land as draft—nothing goes live until someone says so.",
|
|
58
58
|
},
|
|
59
59
|
sortOrder: { type: "real", default: 0 },
|
|
60
60
|
featured: { type: "boolean", default: false },
|
|
@@ -114,11 +114,11 @@ export function generateCatalogTable(config) {
|
|
|
114
114
|
p(`// The catalog ${mode}. The provider is the source of truth; these rows are`);
|
|
115
115
|
p(mode === "mirror"
|
|
116
116
|
? "// a local copy plus the owner's edits. Pulled columns are overwritten every"
|
|
117
|
-
: "// the owner's edits only
|
|
117
|
+
: "// the owner's edits only—catalog fields are read live from the provider.");
|
|
118
118
|
p("// sync; owned columns are preserved. See astroidCatalogUpsert.");
|
|
119
119
|
p(`export const ${camel(table)} = sqliteTable(${JSON.stringify(table)}, {`);
|
|
120
120
|
p(' id: integer("id").primaryKey({ autoIncrement: true }),');
|
|
121
|
-
p(" // The provider's id for this item
|
|
121
|
+
p(" // The provider's id for this item—the sync's idempotency key.");
|
|
122
122
|
p(' externalId: text("external_id").notNull().unique(),');
|
|
123
123
|
if (mode === "mirror") {
|
|
124
124
|
p(" // --- PULLED: overwritten on every sync, never hand-edit ---");
|
package/dist/config.d.ts
CHANGED
|
@@ -408,6 +408,19 @@ export interface SettingsConfig {
|
|
|
408
408
|
/** Extra media-library image keys beyond the base logo/favicon/OG defaults—*
|
|
409
409
|
settings values validated as media-library URLs on write. */
|
|
410
410
|
imageKeys?: string[];
|
|
411
|
+
/**
|
|
412
|
+
* Take per-key sanitizers and a GET transform from the scaffold-once
|
|
413
|
+
* `src/settings-hooks.ts`. The generated `settingsRoute` and the scaffolded
|
|
414
|
+
* settings Action both spread them in, so the two write paths clean a value
|
|
415
|
+
* the same way.
|
|
416
|
+
*
|
|
417
|
+
* The allowlist alone decides only which keys are written, not what's in
|
|
418
|
+
* them. Turn this on when a site's settings need clamping or normalizing
|
|
419
|
+
* before they're stored, such as a length limit, an email address, or a
|
|
420
|
+
* nested config object. The hooks live in a module rather than in this config
|
|
421
|
+
* because a sanitizer usually imports runtime code, which the CLI can't load.
|
|
422
|
+
*/
|
|
423
|
+
hooks?: boolean;
|
|
411
424
|
}
|
|
412
425
|
/** Media-library upload policy. */
|
|
413
426
|
export interface MediaConfig {
|
|
@@ -426,6 +439,18 @@ export interface MediaConfig {
|
|
|
426
439
|
*/
|
|
427
440
|
maxUploadBytes?: number;
|
|
428
441
|
}
|
|
442
|
+
export interface PagesConfig {
|
|
443
|
+
/**
|
|
444
|
+
* Take a transform and extra reserved slugs for the `pages` route from the
|
|
445
|
+
* scaffold-once `src/pages-hooks.ts`. The generated worker passes them to
|
|
446
|
+
* `astroidPagesWriteHooks`, so the site's transform runs before Astroid's own
|
|
447
|
+
* section sanitize and validate.
|
|
448
|
+
*
|
|
449
|
+
* Turn this on when a site cleans a page write itself: normalizing the slug,
|
|
450
|
+
* clamping a title, or filling a new page's defaults.
|
|
451
|
+
*/
|
|
452
|
+
hooks?: boolean;
|
|
453
|
+
}
|
|
429
454
|
export interface DeployConfig {
|
|
430
455
|
platform: "cloudflare";
|
|
431
456
|
/** Media base for R2 + `cf-image` resizing—matches Louise's media route
|
|
@@ -504,6 +529,8 @@ export interface AstroidConfig {
|
|
|
504
529
|
settings?: SettingsConfig;
|
|
505
530
|
/** Media-library upload policy (for example, a larger `maxUploadBytes`). */
|
|
506
531
|
media?: MediaConfig;
|
|
532
|
+
/** The editable `pages` collection's site-owned write hooks. */
|
|
533
|
+
pages?: PagesConfig;
|
|
507
534
|
/**
|
|
508
535
|
* Force the contact form + `inquiries` table on or off. Omit to detect from
|
|
509
536
|
* the config (a `contact` section, or a wholesale-inquiry module). Set `true`
|
package/dist/map/scaffold.js
CHANGED
|
@@ -30,7 +30,7 @@ export function generateMapTileRoute(config) {
|
|
|
30
30
|
return null;
|
|
31
31
|
return [
|
|
32
32
|
"// Serves the self-hosted Protomaps basemap (one PMTiles archive) from R2,",
|
|
33
|
-
"// same-origin, with HTTP range support
|
|
33
|
+
"// same-origin, with HTTP range support—the client reads only the byte",
|
|
34
34
|
"// ranges it needs per tile.",
|
|
35
35
|
"//",
|
|
36
36
|
"// Same-origin is the point: no external tile host and no API key means the",
|
|
@@ -38,7 +38,7 @@ export function generateMapTileRoute(config) {
|
|
|
38
38
|
"// irrelevant.",
|
|
39
39
|
"//",
|
|
40
40
|
"// Upload an archive to this key before the map draws anything (until then",
|
|
41
|
-
"// the canvas shows its background and pin
|
|
41
|
+
"// the canvas shows its background and pin—the module is dormant, not",
|
|
42
42
|
"// broken):",
|
|
43
43
|
`// wrangler r2 object put ${config.key}-media/${ASTROID_PMTILES_KEY} \\`,
|
|
44
44
|
"// --file=extract.pmtiles --content-type=application/octet-stream --remote",
|
|
@@ -82,7 +82,7 @@ export function generateMapEmbedComponent(config) {
|
|
|
82
82
|
return [
|
|
83
83
|
"---",
|
|
84
84
|
"// A MapLibre map over the self-hosted PMTiles basemap, centred on exact",
|
|
85
|
-
"// coordinates. Scaffolded once; yours to edit
|
|
85
|
+
"// coordinates. Scaffolded once; yours to edit—the pin, the gestures, and",
|
|
86
86
|
"// the placeholder are brand decisions.",
|
|
87
87
|
"//",
|
|
88
88
|
"// Coordinates, never a geocoded address string: geocoding at render time is",
|
|
@@ -195,7 +195,7 @@ export function generateMapEmbedComponent(config) {
|
|
|
195
195
|
" if (!entry.isIntersecting) continue;",
|
|
196
196
|
" obs.unobserve(entry.target);",
|
|
197
197
|
" // `init` is async and nothing awaits it, so without this catch a",
|
|
198
|
-
" // failed chunk fetch
|
|
198
|
+
" // failed chunk fetch—a page load racing a deploy is enough—is a",
|
|
199
199
|
" // silent unhandled rejection: the container just stays an empty",
|
|
200
200
|
" // tinted box with nothing in the console to explain it.",
|
|
201
201
|
" init(entry.target as HTMLElement).catch((err) => {",
|
package/dist/portal/config.d.ts
CHANGED
|
@@ -4,7 +4,7 @@ import type { PortalGuardConfig, PortalRoute } from "./guard.js";
|
|
|
4
4
|
* (`better-auth`) so the two sessions can coexist on one origin. */
|
|
5
5
|
export declare const ASTROID_PORTAL_COOKIE_PREFIX = "portal";
|
|
6
6
|
/** Table-name prefix for the portal's Better Auth tables—`portal_user`,
|
|
7
|
-
* `portal_session`, … The studio
|
|
7
|
+
* `portal_session`, … The studio's tables carry the `louise_` prefix. */
|
|
8
8
|
export declare const ASTROID_PORTAL_TABLE_PREFIX = "portal_";
|
|
9
9
|
/** Everything the generated portal wiring needs, defaults applied. */
|
|
10
10
|
export interface ResolvedPortal {
|
package/dist/portal/config.js
CHANGED
|
@@ -3,18 +3,19 @@
|
|
|
3
3
|
// Portal defaults derived from the project config—the single place that knows
|
|
4
4
|
// the portal's mount, cookie prefix, table prefix, and guard table.
|
|
5
5
|
//
|
|
6
|
-
// The
|
|
7
|
-
//
|
|
8
|
-
//
|
|
9
|
-
//
|
|
10
|
-
// mistake that matters—two instances sharing a cookie prefix, where
|
|
11
|
-
// into one silently signs you out of the other, intermittently, in
|
|
6
|
+
// The studio instance keeps `/api/auth` because the Louise editor client
|
|
7
|
+
// hardcodes it, so the portal is the one that moves: its own mount, cookie
|
|
8
|
+
// prefix, and table prefix. The defaults are safe distinct values, and
|
|
9
|
+
// `defineAstroid` rejects any override that collides with the editor. That guards
|
|
10
|
+
// the one mistake that matters—two instances sharing a cookie prefix, where
|
|
11
|
+
// signing into one silently signs you out of the other, intermittently, in
|
|
12
|
+
// production.
|
|
12
13
|
import { ASTROID_PORTAL_BASE_PATH } from "../security/rate-rules.js";
|
|
13
14
|
/** Cookie prefix for the portal instance. Distinct from the studio's default
|
|
14
15
|
* (`better-auth`) so the two sessions can coexist on one origin. */
|
|
15
16
|
export const ASTROID_PORTAL_COOKIE_PREFIX = "portal";
|
|
16
17
|
/** Table-name prefix for the portal's Better Auth tables—`portal_user`,
|
|
17
|
-
* `portal_session`, … The studio
|
|
18
|
+
* `portal_session`, … The studio's tables carry the `louise_` prefix. */
|
|
18
19
|
export const ASTROID_PORTAL_TABLE_PREFIX = "portal_";
|
|
19
20
|
/** Default guard table: the account area, for any signed-in portal user. */
|
|
20
21
|
const DEFAULT_ROUTES = [{ prefix: "/portal" }, { prefix: "/api/portal" }];
|
|
@@ -27,7 +28,7 @@ export function astroidPortal(config) {
|
|
|
27
28
|
return {
|
|
28
29
|
enabled: true,
|
|
29
30
|
// Isolation is configurable so a site with an existing second instance
|
|
30
|
-
// (
|
|
31
|
+
// (for example, a shop account at /api/shop-auth with its own cookie and the
|
|
31
32
|
// unprefixed `user` tables) keeps its live mount + cookies unchanged. The
|
|
32
33
|
// defaults stay the safe distinct-from-editor values; `defineAstroid`'s
|
|
33
34
|
// `assertAuthIsolation` rejects a resolved value that collides with the
|
package/dist/portal/scaffold.js
CHANGED
|
@@ -21,13 +21,13 @@ export function generateAstroidPortalAuth(config) {
|
|
|
21
21
|
if (!portal)
|
|
22
22
|
return null;
|
|
23
23
|
return [
|
|
24
|
-
"// The PORTAL auth instance
|
|
24
|
+
"// The PORTAL auth instance—customers/members, separate from the editor.",
|
|
25
25
|
"//",
|
|
26
26
|
"// Scaffolded once; yours to edit (the reset email, extra user columns, what",
|
|
27
27
|
"// role a new account gets). Three things should NOT change: the basePath,",
|
|
28
28
|
"// the cookiePrefix, and the tablePrefix. The studio instance keeps Better",
|
|
29
29
|
"// Auth's defaults because the Louise editor client hardcodes them, so this",
|
|
30
|
-
"// one moves
|
|
30
|
+
"// one moves—and if the two ever share a cookie prefix, signing into one",
|
|
31
31
|
"// silently signs you out of the other.",
|
|
32
32
|
'import { astroidMailTheme, magicLinkEmail, passwordResetEmail, resolveMailer, sendTransactional } from "astroidjs";',
|
|
33
33
|
'import { env } from "cloudflare:workers";',
|
|
@@ -41,7 +41,7 @@ export function generateAstroidPortalAuth(config) {
|
|
|
41
41
|
" return getLouiseAuth(env, new URL(request.url).origin, {",
|
|
42
42
|
" rpName: astroidConfig.theme.name,",
|
|
43
43
|
" mailFrom: { email: env.MAIL_FROM, name: astroidConfig.theme.name },",
|
|
44
|
-
" // The portal never sends magic links
|
|
44
|
+
" // The portal never sends magic links—it's email + password—but the",
|
|
45
45
|
" // toolkit's config asks for a renderer, so give it the real one.",
|
|
46
46
|
" renderMagicLinkEmail: ({ url, toEmail }) => magicLinkEmail(MAIL_THEME, { url, toEmail }),",
|
|
47
47
|
` basePath: ${JSON.stringify(portal.basePath)},`,
|
|
@@ -51,14 +51,14 @@ export function generateAstroidPortalAuth(config) {
|
|
|
51
51
|
" minPasswordLength: 8,",
|
|
52
52
|
portal.signUp
|
|
53
53
|
? " // Public sign-up is ON for this project."
|
|
54
|
-
: " // Accounts are provisioned by staff
|
|
54
|
+
: " // Accounts are provisioned by staff—no public sign-up.",
|
|
55
55
|
` disableSignUp: ${!portal.signUp},`,
|
|
56
56
|
" sendResetPassword: async ({ user, url }) => {",
|
|
57
57
|
" // Through `resolveMailer`, NOT a hand-built options object: it is the",
|
|
58
58
|
" // only thing that applies the DUMMY_REPLACE_ME sentinel check. Built by",
|
|
59
59
|
" // hand, a fresh deploy with a real EMAIL binding but a placeholder",
|
|
60
60
|
" // MAIL_FROM read as configured and called the Email API with an envelope",
|
|
61
|
-
' // sender of literally "DUMMY_REPLACE_ME"
|
|
61
|
+
' // sender of literally "DUMMY_REPLACE_ME"—rejected upstream, swallowed',
|
|
62
62
|
" // here, and reported to the user as a reset email that was sent.",
|
|
63
63
|
" const mailer = await resolveMailer(env);",
|
|
64
64
|
" await sendTransactional(mailer, [",
|
|
@@ -66,7 +66,7 @@ export function generateAstroidPortalAuth(config) {
|
|
|
66
66
|
" ]);",
|
|
67
67
|
" },",
|
|
68
68
|
" },",
|
|
69
|
-
" // The portal has its own users
|
|
69
|
+
" // The portal has its own users—never the editor allowlist.",
|
|
70
70
|
" resolveAdmins: () => [],",
|
|
71
71
|
" });",
|
|
72
72
|
"}",
|
|
@@ -133,7 +133,7 @@ export function generateAstroidPortalLocals(config) {
|
|
|
133
133
|
if (!astroidPortal(config))
|
|
134
134
|
return "";
|
|
135
135
|
return [
|
|
136
|
-
" /** The signed-in PORTAL user (customers/members)
|
|
136
|
+
" /** The signed-in PORTAL user (customers/members)—distinct from",
|
|
137
137
|
" * `editor`, which is the studio session. Null when signed out. */",
|
|
138
138
|
' portalUser: import("astroidjs").PortalUser | null;',
|
|
139
139
|
].join("\n");
|
package/dist/portal/session.d.ts
CHANGED
|
@@ -1,13 +1,19 @@
|
|
|
1
1
|
import type { PortalUser } from "./guard.js";
|
|
2
|
-
/** Resolves the portal user for a request, or null when signed out.
|
|
3
|
-
|
|
2
|
+
/** Resolves the portal user for a request, or null when signed out. A site's
|
|
3
|
+
* resolver can return its own richer user type; see {@link resolvePortalSession}. */
|
|
4
|
+
export type PortalSessionResolver<U extends PortalUser = PortalUser> = (request: Request) => Promise<U | null>;
|
|
4
5
|
/**
|
|
5
6
|
* Resolve the portal session at most once per request.
|
|
6
7
|
*
|
|
7
8
|
* Shares the *promise*, not the result, so two callers racing during the same
|
|
8
9
|
* request both await one lookup rather than starting a second.
|
|
10
|
+
*
|
|
11
|
+
* Generic over the site's user type, so the result keeps whatever the site's
|
|
12
|
+
* resolver returns (a customer ID, display initials) instead of narrowing to
|
|
13
|
+
* `PortalUser`. Every caller in a request passes the same resolver, so the
|
|
14
|
+
* shared promise always holds that type.
|
|
9
15
|
*/
|
|
10
|
-
export declare function resolvePortalSession(request: Request, resolve: PortalSessionResolver): Promise<
|
|
16
|
+
export declare function resolvePortalSession<U extends PortalUser = PortalUser>(request: Request, resolve: PortalSessionResolver<U>): Promise<U | null>;
|
|
11
17
|
/** JSON response helper—the shape every portal API route returns. */
|
|
12
18
|
export declare function json(body: unknown, status?: number, headers?: Record<string, string>): Response;
|
|
13
19
|
/** True when the request came from this same origin. */
|
package/dist/portal/session.js
CHANGED
|
@@ -18,6 +18,11 @@ const inFlight = new WeakMap();
|
|
|
18
18
|
*
|
|
19
19
|
* Shares the *promise*, not the result, so two callers racing during the same
|
|
20
20
|
* request both await one lookup rather than starting a second.
|
|
21
|
+
*
|
|
22
|
+
* Generic over the site's user type, so the result keeps whatever the site's
|
|
23
|
+
* resolver returns (a customer ID, display initials) instead of narrowing to
|
|
24
|
+
* `PortalUser`. Every caller in a request passes the same resolver, so the
|
|
25
|
+
* shared promise always holds that type.
|
|
21
26
|
*/
|
|
22
27
|
export function resolvePortalSession(request, resolve) {
|
|
23
28
|
const existing = inFlight.get(request);
|
|
@@ -25,7 +25,7 @@ export function generateAstroidGalleryPage(config) {
|
|
|
25
25
|
const mediaBase = config.deploy?.mediaBase ?? "/media";
|
|
26
26
|
return [
|
|
27
27
|
"---",
|
|
28
|
-
"// The work gallery
|
|
28
|
+
"// The work gallery—a justified grid over the media library.",
|
|
29
29
|
"//",
|
|
30
30
|
"// Scaffolded once; yours to edit. The layout primitive is general",
|
|
31
31
|
"// (astroidjs/components/JustifiedGallery.astro); what lives here is this",
|
|
@@ -33,8 +33,8 @@ export function generateAstroidGalleryPage(config) {
|
|
|
33
33
|
"//",
|
|
34
34
|
"// Rows carry `alt`/`caption` from the media registry, so an editor fixes alt",
|
|
35
35
|
"// text once in the library and every gallery showing that asset picks it up.",
|
|
36
|
-
"// Assets missing width/height still render
|
|
37
|
-
"// once the image decodes
|
|
36
|
+
"// Assets missing width/height still render—the client corrects the layout",
|
|
37
|
+
"// once the image decodes—but they cost a visible reflow, so it's worth",
|
|
38
38
|
"// backfilling dimensions on older uploads.",
|
|
39
39
|
'import JustifiedGallery from "astroidjs/components/JustifiedGallery.astro";',
|
|
40
40
|
'import type { GalleryItem } from "astroidjs/components/justify";',
|
|
@@ -59,7 +59,7 @@ export function generateAstroidGalleryPage(config) {
|
|
|
59
59
|
" ).all<MediaRow>();",
|
|
60
60
|
" rows = result.results ?? [];",
|
|
61
61
|
"} catch {",
|
|
62
|
-
" // No DB binding yet (pre-provision)
|
|
62
|
+
" // No DB binding yet (pre-provision)—render the empty state.",
|
|
63
63
|
"}",
|
|
64
64
|
"",
|
|
65
65
|
"const items: GalleryItem[] = rows.map((row) => ({",
|
package/dist/project/actions.js
CHANGED
|
@@ -29,12 +29,15 @@ export function generateAstroidActions(config) {
|
|
|
29
29
|
? ` columns: ${JSON.stringify(columnsOverride)},`
|
|
30
30
|
: " columns: ASTROID_SETTINGS_COLUMNS,",
|
|
31
31
|
...(customKeys.length ? [` customKeys: ${JSON.stringify(customKeys)},`] : []),
|
|
32
|
+
// The same sanitizers the generated settingsRoute spreads in. An Action
|
|
33
|
+
// writes no GET, so `read` has nothing to do here.
|
|
34
|
+
...(config.settings?.hooks ? [" sanitize: settingsHooks.sanitize,"] : []),
|
|
32
35
|
extraImageKeys.length
|
|
33
36
|
? ` imageKeys: [...ASTROID_SETTINGS_IMAGE_KEYS, ...${JSON.stringify(extraImageKeys)}],`
|
|
34
37
|
: " imageKeys: ASTROID_SETTINGS_IMAGE_KEYS,",
|
|
35
38
|
];
|
|
36
39
|
return [
|
|
37
|
-
"// The typed Astro Actions surface
|
|
40
|
+
"// The typed Astro Actions surface—ADR 0001 layer 2.",
|
|
38
41
|
"//",
|
|
39
42
|
"// Scaffolded once and yours to ADD to: put your own `defineAction`s in the",
|
|
40
43
|
"// `server` object below, alongside these.",
|
|
@@ -54,17 +57,20 @@ export function generateAstroidActions(config) {
|
|
|
54
57
|
" louiseSettingsAction,",
|
|
55
58
|
'} from "@louise-toolkit/astro";',
|
|
56
59
|
"import {",
|
|
57
|
-
|
|
60
|
+
// Only when the Action uses it: a site that overrides the columns gets them
|
|
61
|
+
// as a literal, and an unused import is a lint error in its own file.
|
|
62
|
+
...(columnsOverride ? [] : [" ASTROID_SETTINGS_COLUMNS,"]),
|
|
58
63
|
" ASTROID_SETTINGS_IMAGE_KEYS,",
|
|
59
64
|
" astroidPagesCollection,",
|
|
60
65
|
'} from "astroidjs";',
|
|
61
66
|
'import astroidConfig from "../../astroid.config.js";',
|
|
62
67
|
'import { pages, pagesVersions, siteSettings } from "../schema.js";',
|
|
68
|
+
...(config.settings?.hooks ? ['import { settingsHooks } from "../settings-hooks.js";'] : []),
|
|
63
69
|
"",
|
|
64
70
|
"const pagesCollection = astroidPagesCollection(astroidConfig);",
|
|
65
71
|
"",
|
|
66
72
|
"// Astro v6+ removed `Astro.locals.runtime.env`, so the bindings are resolved",
|
|
67
|
-
"// from `cloudflare:workers
|
|
73
|
+
"// from `cloudflare:workers`—the same env the raw routes read.",
|
|
68
74
|
"const getEnv = () => env as unknown as CloudflareEnv;",
|
|
69
75
|
"",
|
|
70
76
|
"// `getEditor` is left to its default (`locals.editor`), which the generated",
|
|
@@ -74,7 +80,7 @@ export function generateAstroidActions(config) {
|
|
|
74
80
|
"",
|
|
75
81
|
"export const server = {",
|
|
76
82
|
" louise: {",
|
|
77
|
-
" // Inline field save (title, SEO)
|
|
83
|
+
" // Inline field save (title, SEO)—the live, non-versioned path.",
|
|
78
84
|
" save: defineAction(",
|
|
79
85
|
" louiseSaveAction({",
|
|
80
86
|
" ...deps,",
|
|
@@ -87,7 +93,7 @@ export function generateAstroidActions(config) {
|
|
|
87
93
|
" }),",
|
|
88
94
|
" ),",
|
|
89
95
|
"",
|
|
90
|
-
" // The versioned body/sections save
|
|
96
|
+
" // The versioned body/sections save—stages a DRAFT, exactly as",
|
|
91
97
|
" // versionsRoute does, through the same `applySaveDraft`.",
|
|
92
98
|
" saveDraft: defineAction(",
|
|
93
99
|
" louiseSaveDraftAction({",
|
|
@@ -108,7 +114,7 @@ export function generateAstroidActions(config) {
|
|
|
108
114
|
" ...deps,",
|
|
109
115
|
" table: siteSettings,",
|
|
110
116
|
" // The SAME allowlist the generated worker enforces, imported rather",
|
|
111
|
-
" // than copied
|
|
117
|
+
" // than copied—a second literal here is a list that drifts from the",
|
|
112
118
|
" // one the routes check against, and nothing would fail when it did.",
|
|
113
119
|
...settingsExtra,
|
|
114
120
|
' mediaBase: astroidConfig.deploy?.mediaBase ?? "/media",',
|