astroidjs 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 +240 -5
- package/bin/astroid.mjs +185 -9
- package/dist/analytics/index.d.ts +37 -0
- package/dist/analytics/index.js +108 -0
- package/dist/astro/csp.d.ts +64 -0
- package/dist/astro/csp.js +173 -0
- package/dist/astro/index.d.ts +1 -0
- package/dist/astro/index.js +7 -0
- package/dist/commerce/adapters.d.ts +60 -0
- package/dist/commerce/adapters.js +90 -0
- package/dist/commerce/checkout-scaffold.d.ts +42 -0
- package/dist/commerce/checkout-scaffold.js +306 -0
- package/dist/commerce/checkout.d.ts +72 -0
- package/dist/commerce/checkout.js +124 -0
- package/dist/commerce/index.d.ts +8 -0
- package/dist/commerce/index.js +9 -0
- package/dist/commerce/loader.d.ts +71 -0
- package/dist/commerce/loader.js +90 -0
- package/dist/commerce/mirror.d.ts +67 -0
- package/dist/commerce/mirror.js +203 -0
- package/dist/commerce/roles.d.ts +38 -0
- package/dist/commerce/roles.js +93 -0
- package/dist/commerce/secrets.d.ts +74 -0
- package/dist/commerce/secrets.js +129 -0
- package/dist/commerce/sync.d.ts +86 -0
- package/dist/commerce/sync.js +154 -0
- package/dist/components/sections.d.ts +577 -0
- package/dist/components/sections.js +425 -0
- package/dist/config.d.ts +174 -12
- package/dist/config.js +43 -1
- package/dist/email/index.d.ts +4 -0
- package/dist/email/index.js +5 -0
- package/dist/email/inquiry.d.ts +33 -0
- package/dist/email/inquiry.js +63 -0
- package/dist/email/send.d.ts +120 -0
- package/dist/email/send.js +196 -0
- package/dist/email/templates.d.ts +24 -0
- package/dist/email/templates.js +184 -0
- package/dist/email/theme.d.ts +24 -0
- package/dist/email/theme.js +150 -0
- package/dist/errors.d.ts +14 -0
- package/dist/errors.js +17 -0
- package/dist/index.d.ts +14 -0
- package/dist/index.js +14 -0
- package/dist/map/index.d.ts +3 -0
- package/dist/map/index.js +4 -0
- package/dist/map/pmtiles.d.ts +92 -0
- package/dist/map/pmtiles.js +130 -0
- package/dist/map/scaffold.d.ts +29 -0
- package/dist/map/scaffold.js +212 -0
- package/dist/map/style.d.ts +58 -0
- package/dist/map/style.js +154 -0
- package/dist/portal/config.d.ts +26 -0
- package/dist/portal/config.js +50 -0
- package/dist/portal/guard.d.ts +48 -0
- package/dist/portal/guard.js +64 -0
- package/dist/portal/index.d.ts +5 -0
- package/dist/portal/index.js +6 -0
- package/dist/portal/nav.d.ts +26 -0
- package/dist/portal/nav.js +35 -0
- package/dist/portal/scaffold.d.ts +28 -0
- package/dist/portal/scaffold.js +140 -0
- package/dist/portal/session.d.ts +36 -0
- package/dist/portal/session.js +86 -0
- package/dist/portfolio/index.d.ts +1 -0
- package/dist/portfolio/index.js +4 -0
- package/dist/portfolio/scaffold.d.ts +9 -0
- package/dist/portfolio/scaffold.js +93 -0
- package/dist/project/actions.d.ts +3 -0
- package/dist/project/actions.js +106 -0
- package/dist/project/generate.d.ts +15 -0
- package/dist/project/generate.js +144 -2
- package/dist/project/index.d.ts +2 -0
- package/dist/project/index.js +2 -0
- package/dist/project/scaffold.d.ts +29 -0
- package/dist/project/scaffold.js +140 -0
- package/dist/pwa/generate.d.ts +49 -0
- package/dist/pwa/generate.js +218 -0
- package/dist/pwa/index.d.ts +1 -0
- package/dist/pwa/index.js +2 -0
- package/dist/queues/consumer.d.ts +29 -0
- package/dist/queues/consumer.js +37 -0
- package/dist/queues/index.d.ts +4 -0
- package/dist/queues/index.js +5 -0
- package/dist/queues/messages.d.ts +60 -0
- package/dist/queues/messages.js +71 -0
- package/dist/queues/scaffold.d.ts +44 -0
- package/dist/queues/scaffold.js +204 -0
- package/dist/queues/webhook.d.ts +60 -0
- package/dist/queues/webhook.js +81 -0
- package/dist/realtime/index.d.ts +1 -0
- package/dist/realtime/index.js +4 -0
- package/dist/realtime/scaffold.d.ts +30 -0
- package/dist/realtime/scaffold.js +159 -0
- package/dist/schema/collections.d.ts +42 -8
- package/dist/schema/collections.js +102 -8
- package/dist/schema/generate.js +10 -1
- package/dist/secrets.d.ts +54 -0
- package/dist/secrets.js +80 -0
- package/dist/security/index.d.ts +1 -0
- package/dist/security/index.js +2 -0
- package/dist/security/rate-rules.d.ts +21 -0
- package/dist/security/rate-rules.js +107 -0
- package/dist/seo/index.d.ts +3 -0
- package/dist/seo/index.js +4 -0
- package/dist/seo/resolve.d.ts +68 -0
- package/dist/seo/resolve.js +73 -0
- package/dist/seo/routes.d.ts +44 -0
- package/dist/seo/routes.js +104 -0
- package/dist/seo/structured-data.d.ts +51 -0
- package/dist/seo/structured-data.js +105 -0
- package/dist/status.d.ts +51 -0
- package/dist/status.js +113 -0
- package/dist/worker/generate.d.ts +18 -10
- package/dist/worker/generate.js +325 -37
- package/dist/worker/routes.d.ts +1 -1
- package/dist/worker/routes.js +42 -0
- package/dist/workflow/advance.d.ts +102 -0
- package/dist/workflow/advance.js +145 -0
- package/dist/workflow/config.d.ts +60 -0
- package/dist/workflow/config.js +73 -0
- package/dist/workflow/generate.d.ts +22 -0
- package/dist/workflow/generate.js +138 -0
- package/dist/workflow/index.d.ts +3 -0
- package/dist/workflow/index.js +4 -0
- package/package.json +21 -5
- package/src/components/Editable.astro +33 -9
- package/src/components/JustifiedGallery.astro +254 -0
- package/src/components/MediaSlot.astro +178 -0
- package/src/components/PortalShell.astro +80 -0
- package/src/components/RegisterSW.astro +45 -0
- package/src/components/Section.astro +101 -35
- package/src/components/Sections.astro +64 -0
- package/src/components/Seo.astro +57 -0
- package/src/components/StageBar.astro +137 -0
- package/src/components/StructuredData.astro +33 -0
- package/src/components/justify.ts +170 -0
- package/src/components/media-meta.ts +174 -0
- package/src/components/sections/AboutIntro.astro +46 -0
- package/src/components/sections/Banner.astro +31 -0
- package/src/components/sections/Contact.astro +22 -9
- package/src/components/sections/Cta.astro +33 -10
- package/src/components/sections/Faq.astro +50 -0
- package/src/components/sections/FeatureGrid.astro +40 -11
- package/src/components/sections/Gallery.astro +46 -0
- package/src/components/sections/Hero.astro +40 -12
- package/src/components/sections/LocationHours.astro +59 -0
- package/src/components/sections/Media.astro +44 -0
- package/src/components/sections/PricingTiers.astro +79 -0
- package/src/components/sections/ProductGrid.astro +73 -0
- package/src/components/sections/SplitImage.astro +61 -0
- package/src/components/sections/Steps.astro +58 -0
- package/src/components/sections/Testimonial.astro +51 -0
- package/src/components/sections.ts +452 -67
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/** A signed-in portal user, as far as the guard is concerned. */
|
|
2
|
+
export interface PortalUser {
|
|
3
|
+
role: string;
|
|
4
|
+
[key: string]: unknown;
|
|
5
|
+
}
|
|
6
|
+
/** One rule: everything under `prefix` requires one of `roles`. */
|
|
7
|
+
export interface PortalRoute {
|
|
8
|
+
/** Path prefix, e.g. `/portal` — matches the prefix itself and everything
|
|
9
|
+
* beneath it, but NOT `/portalling`. */
|
|
10
|
+
prefix: string;
|
|
11
|
+
/** Roles allowed through. Empty means "any signed-in user". */
|
|
12
|
+
roles?: string[];
|
|
13
|
+
}
|
|
14
|
+
export interface PortalGuardConfig {
|
|
15
|
+
/** The rule table, in order. The first matching prefix decides. */
|
|
16
|
+
routes: PortalRoute[];
|
|
17
|
+
/** Where to send a signed-out visitor. Default `/login`. */
|
|
18
|
+
loginPath?: string;
|
|
19
|
+
/** Landing page for a signed-in user, by role — used to bounce someone who
|
|
20
|
+
* reached an area they don't belong in. Default `/portal` for everyone. */
|
|
21
|
+
home?: (role: string) => string;
|
|
22
|
+
}
|
|
23
|
+
/** What the guard decided. `null` means "carry on". */
|
|
24
|
+
export type GuardDecision = null | {
|
|
25
|
+
kind: "redirect";
|
|
26
|
+
location: string;
|
|
27
|
+
} | {
|
|
28
|
+
kind: "json";
|
|
29
|
+
status: 401 | 403;
|
|
30
|
+
body: {
|
|
31
|
+
ok: false;
|
|
32
|
+
error: string;
|
|
33
|
+
};
|
|
34
|
+
};
|
|
35
|
+
/** Prefix match on a path SEGMENT boundary — `/portal` covers `/portal` and
|
|
36
|
+
* `/portal/orders`, but never `/portalling`. */
|
|
37
|
+
export declare function matchesPrefix(path: string, prefix: string): boolean;
|
|
38
|
+
/**
|
|
39
|
+
* Decide whether a request may proceed.
|
|
40
|
+
*
|
|
41
|
+
* Pure — it returns a decision rather than a `Response`, so it's testable
|
|
42
|
+
* without an Astro context and the middleware stays responsible for turning a
|
|
43
|
+
* decision into a redirect or a body.
|
|
44
|
+
*/
|
|
45
|
+
export declare function portalGuard(path: string, user: PortalUser | null, config: PortalGuardConfig): GuardDecision;
|
|
46
|
+
/** Turn a decision into a `Response`. `redirect` is left to the caller, since
|
|
47
|
+
* Astro's context builds those with its own base-path handling. */
|
|
48
|
+
export declare function guardResponse(decision: Exclude<GuardDecision, null>): Response | null;
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
// Copyright (c) 2026 BowenLabs. Astroid is MIT licensed.
|
|
2
|
+
//
|
|
3
|
+
// Role-gated routing for the portal.
|
|
4
|
+
//
|
|
5
|
+
// coracle and ghostfire independently built the same thing: a declarative table
|
|
6
|
+
// of `prefix → roles`, walked once per request. Declarative rather than a guard
|
|
7
|
+
// call inside each page, because a guard you have to remember to write is a
|
|
8
|
+
// guard someone eventually forgets — and the page that forgets it is the one
|
|
9
|
+
// that leaks.
|
|
10
|
+
//
|
|
11
|
+
// Three answers, and which one you give matters:
|
|
12
|
+
//
|
|
13
|
+
// not signed in, HTML → redirect to the login page, carrying `next`
|
|
14
|
+
// not signed in, API → 401 JSON (a redirect to an HTML login page is
|
|
15
|
+
// useless to fetch(); it looks like success)
|
|
16
|
+
// signed in, wrong role → 403 for API, and for HTML a redirect to the area
|
|
17
|
+
// this user DOES have — not back to login, which
|
|
18
|
+
// reads as "your password failed" when it didn't
|
|
19
|
+
/** Prefix match on a path SEGMENT boundary — `/portal` covers `/portal` and
|
|
20
|
+
* `/portal/orders`, but never `/portalling`. */
|
|
21
|
+
export function matchesPrefix(path, prefix) {
|
|
22
|
+
return path === prefix || path.startsWith(`${prefix}/`);
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* Decide whether a request may proceed.
|
|
26
|
+
*
|
|
27
|
+
* Pure — it returns a decision rather than a `Response`, so it's testable
|
|
28
|
+
* without an Astro context and the middleware stays responsible for turning a
|
|
29
|
+
* decision into a redirect or a body.
|
|
30
|
+
*/
|
|
31
|
+
export function portalGuard(path, user, config) {
|
|
32
|
+
const rule = config.routes.find((r) => matchesPrefix(path, r.prefix));
|
|
33
|
+
if (!rule)
|
|
34
|
+
return null;
|
|
35
|
+
// An API route answers in JSON whatever happens: redirecting `fetch()` to an
|
|
36
|
+
// HTML login page returns 200 and a page of markup, which client code reads
|
|
37
|
+
// as success and then fails on somewhere far less obvious.
|
|
38
|
+
const isApi = path.startsWith("/api/");
|
|
39
|
+
if (!user) {
|
|
40
|
+
if (isApi)
|
|
41
|
+
return { kind: "json", status: 401, body: { ok: false, error: "Unauthorized" } };
|
|
42
|
+
const login = config.loginPath ?? "/login";
|
|
43
|
+
return { kind: "redirect", location: `${login}?next=${encodeURIComponent(path)}` };
|
|
44
|
+
}
|
|
45
|
+
const allowed = !rule.roles?.length || rule.roles.includes(user.role);
|
|
46
|
+
if (!allowed) {
|
|
47
|
+
if (isApi)
|
|
48
|
+
return { kind: "json", status: 403, body: { ok: false, error: "Forbidden" } };
|
|
49
|
+
// Signed in, wrong door. Sending them back to /login would say "your
|
|
50
|
+
// credentials failed" about credentials that worked fine.
|
|
51
|
+
return { kind: "redirect", location: config.home?.(user.role) ?? "/portal" };
|
|
52
|
+
}
|
|
53
|
+
return null;
|
|
54
|
+
}
|
|
55
|
+
/** Turn a decision into a `Response`. `redirect` is left to the caller, since
|
|
56
|
+
* Astro's context builds those with its own base-path handling. */
|
|
57
|
+
export function guardResponse(decision) {
|
|
58
|
+
if (decision.kind === "redirect")
|
|
59
|
+
return null;
|
|
60
|
+
return new Response(JSON.stringify(decision.body), {
|
|
61
|
+
status: decision.status,
|
|
62
|
+
headers: { "content-type": "application/json" },
|
|
63
|
+
});
|
|
64
|
+
}
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
export { ASTROID_PORTAL_COOKIE_PREFIX, ASTROID_PORTAL_TABLE_PREFIX, astroidPortal, astroidPortalGuardConfig, type ResolvedPortal, } from "./config.js";
|
|
2
|
+
export { type GuardDecision, guardResponse, matchesPrefix, portalGuard, type PortalGuardConfig, type PortalRoute, type PortalUser, } from "./guard.js";
|
|
3
|
+
export { definePortalNav, type PortalNav, type PortalNavItem } from "./nav.js";
|
|
4
|
+
export { generateAstroidPortalAuth, generateAstroidPortalAuthRoute, generateAstroidPortalLocals, } from "./scaffold.js";
|
|
5
|
+
export { type CustomerGuardResult, isSameOrigin, json, type PortalSessionResolver, requireCustomer, resolvePortalSession, } from "./session.js";
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
// Copyright (c) 2026 BowenLabs. Astroid is MIT licensed.
|
|
2
|
+
export { ASTROID_PORTAL_COOKIE_PREFIX, ASTROID_PORTAL_TABLE_PREFIX, astroidPortal, astroidPortalGuardConfig, } from "./config.js";
|
|
3
|
+
export { guardResponse, matchesPrefix, portalGuard, } from "./guard.js";
|
|
4
|
+
export { definePortalNav } from "./nav.js";
|
|
5
|
+
export { generateAstroidPortalAuth, generateAstroidPortalAuthRoute, generateAstroidPortalLocals, } from "./scaffold.js";
|
|
6
|
+
export { isSameOrigin, json, requireCustomer, resolvePortalSession, } from "./session.js";
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
export interface PortalNavItem {
|
|
2
|
+
label: string;
|
|
3
|
+
href: string;
|
|
4
|
+
/** Roles that may see this item. Omit for "everyone signed in". */
|
|
5
|
+
roles?: string[];
|
|
6
|
+
/** Optional icon name, passed through to the shell's slot. */
|
|
7
|
+
icon?: string;
|
|
8
|
+
}
|
|
9
|
+
export interface PortalNav {
|
|
10
|
+
items: PortalNavItem[];
|
|
11
|
+
/** The items this role may see. */
|
|
12
|
+
forRole(role: string | null | undefined): PortalNavItem[];
|
|
13
|
+
/** The item matching a path, by the same prefix rule the guard uses. */
|
|
14
|
+
activeFor(path: string): PortalNavItem | null;
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* Declare the portal navigation.
|
|
18
|
+
*
|
|
19
|
+
* ```ts
|
|
20
|
+
* export const nav = definePortalNav([
|
|
21
|
+
* { label: "Orders", href: "/portal/orders" },
|
|
22
|
+
* { label: "Team", href: "/admin/team", roles: ["manager"] },
|
|
23
|
+
* ]);
|
|
24
|
+
* ```
|
|
25
|
+
*/
|
|
26
|
+
export declare function definePortalNav(items: PortalNavItem[]): PortalNav;
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
// Copyright (c) 2026 BowenLabs. Astroid is MIT licensed.
|
|
2
|
+
//
|
|
3
|
+
// The portal's navigation, as data.
|
|
4
|
+
//
|
|
5
|
+
// Two things fall out of declaring it rather than writing markup per page.
|
|
6
|
+
// Items can be filtered by the viewer's role in one place — so an item a user
|
|
7
|
+
// can't reach is never rendered, instead of rendered-then-403'd, which reads as
|
|
8
|
+
// a broken link. And "which item is active" is computed the same way the guard
|
|
9
|
+
// matches prefixes, so the highlight can't disagree with the routing.
|
|
10
|
+
import { matchesPrefix } from "./guard.js";
|
|
11
|
+
/**
|
|
12
|
+
* Declare the portal navigation.
|
|
13
|
+
*
|
|
14
|
+
* ```ts
|
|
15
|
+
* export const nav = definePortalNav([
|
|
16
|
+
* { label: "Orders", href: "/portal/orders" },
|
|
17
|
+
* { label: "Team", href: "/admin/team", roles: ["manager"] },
|
|
18
|
+
* ]);
|
|
19
|
+
* ```
|
|
20
|
+
*/
|
|
21
|
+
export function definePortalNav(items) {
|
|
22
|
+
return {
|
|
23
|
+
items,
|
|
24
|
+
forRole(role) {
|
|
25
|
+
return items.filter((item) => !item.roles?.length || (role ? item.roles.includes(role) : false));
|
|
26
|
+
},
|
|
27
|
+
activeFor(path) {
|
|
28
|
+
// Longest href first, so `/portal/orders` wins over `/portal` on a page
|
|
29
|
+
// both would match — otherwise the parent item is always the active one.
|
|
30
|
+
return ([...items]
|
|
31
|
+
.sort((a, b) => b.href.length - a.href.length)
|
|
32
|
+
.find((item) => matchesPrefix(path, item.href)) ?? null);
|
|
33
|
+
},
|
|
34
|
+
};
|
|
35
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import type { AstroidConfig } from "../config.js";
|
|
2
|
+
/**
|
|
3
|
+
* `src/portal-auth.ts` — the portal Better Auth instance and its session
|
|
4
|
+
* resolver.
|
|
5
|
+
*
|
|
6
|
+
* Returns null when the project has no portal.
|
|
7
|
+
*/
|
|
8
|
+
export declare function generateAstroidPortalAuth(config: AstroidConfig): string | null;
|
|
9
|
+
/**
|
|
10
|
+
* `src/pages/api/portal-auth/[...all].ts` — the portal's Better Auth catch-all,
|
|
11
|
+
* mounted at its own basePath so it never collides with the studio's
|
|
12
|
+
* `/api/auth`.
|
|
13
|
+
*
|
|
14
|
+
* Lives here rather than as a literal in `create-astroid` for the same reason
|
|
15
|
+
* the archetype sections moved (#277): the scaffolder is plain JS, so a drifted
|
|
16
|
+
* import path there is invisible until a user's build fails. It is also the half
|
|
17
|
+
* `generateAstroidPortalAuth` is useless without — `src/portal-auth.ts` exports
|
|
18
|
+
* `handlePortalAuth`, and nothing calls it unless this route exists.
|
|
19
|
+
*
|
|
20
|
+
* Returns null when the project has no portal.
|
|
21
|
+
*/
|
|
22
|
+
export declare function generateAstroidPortalAuthRoute(config: AstroidConfig): string | null;
|
|
23
|
+
/**
|
|
24
|
+
* The `App.Locals` member the portal adds, as a block `create-astroid`
|
|
25
|
+
* substitutes into `src/env.d.ts`. Empty without a portal — a project that
|
|
26
|
+
* types `portalUser` it never sets is inviting a null-check nobody needs.
|
|
27
|
+
*/
|
|
28
|
+
export declare function generateAstroidPortalLocals(config: AstroidConfig): string;
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
// Copyright (c) 2026 BowenLabs. Astroid is MIT licensed.
|
|
2
|
+
//
|
|
3
|
+
// The portal's SCAFFOLD-ONCE pieces: the second Better Auth instance, and the
|
|
4
|
+
// `App.Locals` / `CloudflareEnv` additions that come with it.
|
|
5
|
+
//
|
|
6
|
+
// The auth instance is scaffolded rather than generated because a site edits it
|
|
7
|
+
// — the reset email, the role a new account gets, extra user columns. What
|
|
8
|
+
// Astroid fixes are the three things that must not drift: the mount, the cookie
|
|
9
|
+
// prefix, and the table prefix. Get any of those wrong and the two instances
|
|
10
|
+
// fight over one origin's cookies, which fails intermittently and looks like a
|
|
11
|
+
// session bug rather than a configuration one.
|
|
12
|
+
import { astroidPortal } from "./config.js";
|
|
13
|
+
/**
|
|
14
|
+
* `src/portal-auth.ts` — the portal Better Auth instance and its session
|
|
15
|
+
* resolver.
|
|
16
|
+
*
|
|
17
|
+
* Returns null when the project has no portal.
|
|
18
|
+
*/
|
|
19
|
+
export function generateAstroidPortalAuth(config) {
|
|
20
|
+
const portal = astroidPortal(config);
|
|
21
|
+
if (!portal)
|
|
22
|
+
return null;
|
|
23
|
+
return [
|
|
24
|
+
"// The PORTAL auth instance — customers/members, separate from the editor.",
|
|
25
|
+
"//",
|
|
26
|
+
"// Scaffolded once; yours to edit (the reset email, extra user columns, what",
|
|
27
|
+
"// role a new account gets). Three things should NOT change: the basePath,",
|
|
28
|
+
"// the cookiePrefix, and the tablePrefix. The studio instance keeps Better",
|
|
29
|
+
"// Auth's defaults because the Louise editor client hardcodes them, so this",
|
|
30
|
+
"// one moves — and if the two ever share a cookie prefix, signing into one",
|
|
31
|
+
"// silently signs you out of the other.",
|
|
32
|
+
'import { astroidMailTheme, magicLinkEmail, passwordResetEmail, resolveMailer, sendTransactional } from "astroidjs";',
|
|
33
|
+
'import { env } from "cloudflare:workers";',
|
|
34
|
+
'import { getLouiseAuth } from "louise-toolkit/auth";',
|
|
35
|
+
'import astroidConfig from "../astroid.config.js";',
|
|
36
|
+
"",
|
|
37
|
+
"const MAIL_THEME = astroidMailTheme(astroidConfig);",
|
|
38
|
+
"",
|
|
39
|
+
"/** The request-scoped portal auth instance. */",
|
|
40
|
+
"function getPortalAuth(request: Request) {",
|
|
41
|
+
" return getLouiseAuth(env, new URL(request.url).origin, {",
|
|
42
|
+
" rpName: astroidConfig.theme.name,",
|
|
43
|
+
" mailFrom: { email: env.MAIL_FROM, name: astroidConfig.theme.name },",
|
|
44
|
+
" // The portal never sends magic links — it's email + password — but the",
|
|
45
|
+
" // toolkit's config asks for a renderer, so give it the real one.",
|
|
46
|
+
" renderMagicLinkEmail: ({ url, toEmail }) => magicLinkEmail(MAIL_THEME, { url, toEmail }),",
|
|
47
|
+
` basePath: ${JSON.stringify(portal.basePath)},`,
|
|
48
|
+
` cookiePrefix: ${JSON.stringify(portal.cookiePrefix)},`,
|
|
49
|
+
` tablePrefix: ${JSON.stringify(portal.tablePrefix)},`,
|
|
50
|
+
" customers: {",
|
|
51
|
+
" minPasswordLength: 8,",
|
|
52
|
+
portal.signUp
|
|
53
|
+
? " // Public sign-up is ON for this project."
|
|
54
|
+
: " // Accounts are provisioned by staff — no public sign-up.",
|
|
55
|
+
` disableSignUp: ${!portal.signUp},`,
|
|
56
|
+
" sendResetPassword: async ({ user, url }) => {",
|
|
57
|
+
" // Through `resolveMailer`, NOT a hand-built options object: it is the",
|
|
58
|
+
" // only thing that applies the DUMMY_REPLACE_ME sentinel check. Built by",
|
|
59
|
+
" // hand, a fresh deploy with a real EMAIL binding but a placeholder",
|
|
60
|
+
" // MAIL_FROM read as configured and called the Email API with an envelope",
|
|
61
|
+
" // sender of literally \"DUMMY_REPLACE_ME\" — rejected upstream, swallowed",
|
|
62
|
+
" // here, and reported to the user as a reset email that was sent.",
|
|
63
|
+
" const mailer = await resolveMailer(env);",
|
|
64
|
+
" await sendTransactional(mailer, [",
|
|
65
|
+
" { to: user.email, content: passwordResetEmail(MAIL_THEME, { url, toEmail: user.email }) },",
|
|
66
|
+
" ]);",
|
|
67
|
+
" },",
|
|
68
|
+
" },",
|
|
69
|
+
" // The portal has its own users — never the editor allowlist.",
|
|
70
|
+
" resolveAdmins: () => [],",
|
|
71
|
+
" });",
|
|
72
|
+
"}",
|
|
73
|
+
"",
|
|
74
|
+
"/** Better Auth catch-all for the portal, mounted at its own basePath. */",
|
|
75
|
+
"export async function handlePortalAuth(request: Request): Promise<Response> {",
|
|
76
|
+
" const auth = await getPortalAuth(request);",
|
|
77
|
+
" return auth.handler(request);",
|
|
78
|
+
"}",
|
|
79
|
+
"",
|
|
80
|
+
"/**",
|
|
81
|
+
" * Resolve the signed-in portal user, or null. The generated middleware passes",
|
|
82
|
+
" * this to `resolvePortalSession`, which shares the lookup for the request.",
|
|
83
|
+
" */",
|
|
84
|
+
"export async function resolvePortalUser(request: Request) {",
|
|
85
|
+
" try {",
|
|
86
|
+
" const auth = await getPortalAuth(request);",
|
|
87
|
+
" const session = await auth.api.getSession({ headers: request.headers });",
|
|
88
|
+
" const user = session?.user;",
|
|
89
|
+
" if (!user) return null;",
|
|
90
|
+
` return { id: user.id, email: user.email ?? "", role: user.role ?? ${JSON.stringify(portal.defaultRole)} };`,
|
|
91
|
+
" } catch {",
|
|
92
|
+
" // No bindings (plain `astro preview`) → treat as signed out.",
|
|
93
|
+
" return null;",
|
|
94
|
+
" }",
|
|
95
|
+
"}",
|
|
96
|
+
"",
|
|
97
|
+
].join("\n");
|
|
98
|
+
}
|
|
99
|
+
/**
|
|
100
|
+
* `src/pages/api/portal-auth/[...all].ts` — the portal's Better Auth catch-all,
|
|
101
|
+
* mounted at its own basePath so it never collides with the studio's
|
|
102
|
+
* `/api/auth`.
|
|
103
|
+
*
|
|
104
|
+
* Lives here rather than as a literal in `create-astroid` for the same reason
|
|
105
|
+
* the archetype sections moved (#277): the scaffolder is plain JS, so a drifted
|
|
106
|
+
* import path there is invisible until a user's build fails. It is also the half
|
|
107
|
+
* `generateAstroidPortalAuth` is useless without — `src/portal-auth.ts` exports
|
|
108
|
+
* `handlePortalAuth`, and nothing calls it unless this route exists.
|
|
109
|
+
*
|
|
110
|
+
* Returns null when the project has no portal.
|
|
111
|
+
*/
|
|
112
|
+
export function generateAstroidPortalAuthRoute(config) {
|
|
113
|
+
if (!astroidPortal(config))
|
|
114
|
+
return null;
|
|
115
|
+
return [
|
|
116
|
+
"// The portal Better Auth catch-all, mounted at its own basePath so it",
|
|
117
|
+
"// never collides with the studio's /api/auth.",
|
|
118
|
+
'import type { APIRoute } from "astro";',
|
|
119
|
+
'import { handlePortalAuth } from "../../../portal-auth.js";',
|
|
120
|
+
"",
|
|
121
|
+
"export const prerender = false;",
|
|
122
|
+
"",
|
|
123
|
+
"export const ALL: APIRoute = ({ request }) => handlePortalAuth(request);",
|
|
124
|
+
"",
|
|
125
|
+
].join("\n");
|
|
126
|
+
}
|
|
127
|
+
/**
|
|
128
|
+
* The `App.Locals` member the portal adds, as a block `create-astroid`
|
|
129
|
+
* substitutes into `src/env.d.ts`. Empty without a portal — a project that
|
|
130
|
+
* types `portalUser` it never sets is inviting a null-check nobody needs.
|
|
131
|
+
*/
|
|
132
|
+
export function generateAstroidPortalLocals(config) {
|
|
133
|
+
if (!astroidPortal(config))
|
|
134
|
+
return "";
|
|
135
|
+
return [
|
|
136
|
+
" /** The signed-in PORTAL user (customers/members) — distinct from",
|
|
137
|
+
" * `editor`, which is the studio session. Null when signed out. */",
|
|
138
|
+
' portalUser: import("astroidjs").PortalUser | null;',
|
|
139
|
+
].join("\n");
|
|
140
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import type { PortalUser } from "./guard.js";
|
|
2
|
+
/** Resolves the portal user for a request, or null when signed out. */
|
|
3
|
+
export type PortalSessionResolver = (request: Request) => Promise<PortalUser | null>;
|
|
4
|
+
/**
|
|
5
|
+
* Resolve the portal session at most once per request.
|
|
6
|
+
*
|
|
7
|
+
* Shares the *promise*, not the result, so two callers racing during the same
|
|
8
|
+
* request both await one lookup rather than starting a second.
|
|
9
|
+
*/
|
|
10
|
+
export declare function resolvePortalSession(request: Request, resolve: PortalSessionResolver): Promise<PortalUser | null>;
|
|
11
|
+
/** JSON response helper — the shape every portal API route returns. */
|
|
12
|
+
export declare function json(body: unknown, status?: number, headers?: Record<string, string>): Response;
|
|
13
|
+
/** True when the request came from this same origin. */
|
|
14
|
+
export declare function isSameOrigin(request: Request): boolean;
|
|
15
|
+
export type CustomerGuardResult = {
|
|
16
|
+
ok: true;
|
|
17
|
+
user: PortalUser;
|
|
18
|
+
} | {
|
|
19
|
+
ok: false;
|
|
20
|
+
response: Response;
|
|
21
|
+
};
|
|
22
|
+
/**
|
|
23
|
+
* Guard a portal API handler: a signed-in user, and — on mutations — a
|
|
24
|
+
* same-origin request.
|
|
25
|
+
*
|
|
26
|
+
* ```ts
|
|
27
|
+
* const guard = await requireCustomer(request, (req) => portalUser(req));
|
|
28
|
+
* if (!guard.ok) return guard.response;
|
|
29
|
+
* // guard.user is signed in and this is a same-origin call
|
|
30
|
+
* ```
|
|
31
|
+
*
|
|
32
|
+
* `roles` narrows further, for a route only some portal users may reach.
|
|
33
|
+
*/
|
|
34
|
+
export declare function requireCustomer(request: Request, resolve: PortalSessionResolver, options?: {
|
|
35
|
+
roles?: string[];
|
|
36
|
+
}): Promise<CustomerGuardResult>;
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
// Copyright (c) 2026 BowenLabs. Astroid is MIT licensed.
|
|
2
|
+
//
|
|
3
|
+
// Resolving the portal session once per request, and guarding mutations.
|
|
4
|
+
//
|
|
5
|
+
// The middleware resolves it (to gate routes) and so does whatever handler runs
|
|
6
|
+
// next (to know who's asking). Both hitting the session store is a wasted D1
|
|
7
|
+
// round-trip on every authenticated request, so the in-flight promise is shared
|
|
8
|
+
// per request via a `WeakMap` — keyed on the `Request`, which means entries
|
|
9
|
+
// disappear with the request rather than needing eviction.
|
|
10
|
+
//
|
|
11
|
+
// `requireCustomer` then adds the check a session alone doesn't give you:
|
|
12
|
+
// same-origin on mutations. A cookie is attached by the browser to any request
|
|
13
|
+
// to this origin, including one a third-party page triggered — so a session
|
|
14
|
+
// proves identity, and the origin check proves intent.
|
|
15
|
+
const inFlight = new WeakMap();
|
|
16
|
+
/**
|
|
17
|
+
* Resolve the portal session at most once per request.
|
|
18
|
+
*
|
|
19
|
+
* Shares the *promise*, not the result, so two callers racing during the same
|
|
20
|
+
* request both await one lookup rather than starting a second.
|
|
21
|
+
*/
|
|
22
|
+
export function resolvePortalSession(request, resolve) {
|
|
23
|
+
const existing = inFlight.get(request);
|
|
24
|
+
if (existing)
|
|
25
|
+
return existing;
|
|
26
|
+
// A rejected lookup degrades to signed-out rather than propagating: missing
|
|
27
|
+
// bindings under plain `astro preview` shouldn't 500 a public page.
|
|
28
|
+
const promise = resolve(request).catch(() => null);
|
|
29
|
+
inFlight.set(request, promise);
|
|
30
|
+
return promise;
|
|
31
|
+
}
|
|
32
|
+
/** JSON response helper — the shape every portal API route returns. */
|
|
33
|
+
export function json(body, status = 200, headers = {}) {
|
|
34
|
+
return new Response(JSON.stringify(body), {
|
|
35
|
+
status,
|
|
36
|
+
headers: { "content-type": "application/json", ...headers },
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
/** Methods that change state, and therefore need the origin check. */
|
|
40
|
+
const MUTATING = new Set(["POST", "PUT", "PATCH", "DELETE"]);
|
|
41
|
+
/** True when the request came from this same origin. */
|
|
42
|
+
export function isSameOrigin(request) {
|
|
43
|
+
const target = new URL(request.url).origin;
|
|
44
|
+
const origin = request.headers.get("origin");
|
|
45
|
+
if (origin)
|
|
46
|
+
return origin === target;
|
|
47
|
+
// No Origin header: browsers always send one on cross-origin mutations, so
|
|
48
|
+
// its absence means a same-origin or non-browser caller. Fall back to Referer
|
|
49
|
+
// when present, and allow otherwise — being stricter would break legitimate
|
|
50
|
+
// server-to-server callers without stopping a real CSRF, which always carries
|
|
51
|
+
// an Origin.
|
|
52
|
+
const referer = request.headers.get("referer");
|
|
53
|
+
if (referer) {
|
|
54
|
+
try {
|
|
55
|
+
return new URL(referer).origin === target;
|
|
56
|
+
}
|
|
57
|
+
catch {
|
|
58
|
+
return false;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
return true;
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* Guard a portal API handler: a signed-in user, and — on mutations — a
|
|
65
|
+
* same-origin request.
|
|
66
|
+
*
|
|
67
|
+
* ```ts
|
|
68
|
+
* const guard = await requireCustomer(request, (req) => portalUser(req));
|
|
69
|
+
* if (!guard.ok) return guard.response;
|
|
70
|
+
* // guard.user is signed in and this is a same-origin call
|
|
71
|
+
* ```
|
|
72
|
+
*
|
|
73
|
+
* `roles` narrows further, for a route only some portal users may reach.
|
|
74
|
+
*/
|
|
75
|
+
export async function requireCustomer(request, resolve, options = {}) {
|
|
76
|
+
if (MUTATING.has(request.method) && !isSameOrigin(request)) {
|
|
77
|
+
return { ok: false, response: json({ ok: false, error: "Forbidden" }, 403) };
|
|
78
|
+
}
|
|
79
|
+
const user = await resolvePortalSession(request, resolve);
|
|
80
|
+
if (!user)
|
|
81
|
+
return { ok: false, response: json({ ok: false, error: "Unauthorized" }, 401) };
|
|
82
|
+
if (options.roles?.length && !options.roles.includes(user.role)) {
|
|
83
|
+
return { ok: false, response: json({ ok: false, error: "Forbidden" }, 403) };
|
|
84
|
+
}
|
|
85
|
+
return { ok: true, user };
|
|
86
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { generateAstroidGalleryPage } from "./scaffold.js";
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { AstroidConfig } from "../config.js";
|
|
2
|
+
/**
|
|
3
|
+
* `src/pages/work.astro` — the portfolio gallery. Null for any other archetype.
|
|
4
|
+
*
|
|
5
|
+
* Intrinsic `width`/`height` are carried through deliberately: they feed the
|
|
6
|
+
* pre-decode layout, so a library with dimensions recorded lays out correctly on
|
|
7
|
+
* first paint instead of reflowing once the images decode.
|
|
8
|
+
*/
|
|
9
|
+
export declare function generateAstroidGalleryPage(config: AstroidConfig): string | null;
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
// Copyright (c) 2026 BowenLabs. Astroid is MIT licensed.
|
|
2
|
+
//
|
|
3
|
+
// The `portfolio` archetype's scaffold-once page: a justified gallery over the
|
|
4
|
+
// media library.
|
|
5
|
+
//
|
|
6
|
+
// Scaffold-once, not regenerated, for the usual reason — this is the first file
|
|
7
|
+
// a portfolio site edits (which assets appear, in what order, whether tiles link
|
|
8
|
+
// to a detail page), so `astroid generate` must never rewrite it.
|
|
9
|
+
//
|
|
10
|
+
// It exists because the primitives alone don't finish the job. `<MediaSlot>` and
|
|
11
|
+
// `<JustifiedGallery>` are archetype-agnostic, but the wiring between them and
|
|
12
|
+
// the media registry — the public URL shape, filtering to images, carrying
|
|
13
|
+
// alt/caption and intrinsic dimensions through so the first paint isn't a guess
|
|
14
|
+
// — is identical every time, and is exactly what the consuming sites hand-wrote.
|
|
15
|
+
/**
|
|
16
|
+
* `src/pages/work.astro` — the portfolio gallery. Null for any other archetype.
|
|
17
|
+
*
|
|
18
|
+
* Intrinsic `width`/`height` are carried through deliberately: they feed the
|
|
19
|
+
* pre-decode layout, so a library with dimensions recorded lays out correctly on
|
|
20
|
+
* first paint instead of reflowing once the images decode.
|
|
21
|
+
*/
|
|
22
|
+
export function generateAstroidGalleryPage(config) {
|
|
23
|
+
if (config.archetype !== "portfolio")
|
|
24
|
+
return null;
|
|
25
|
+
const mediaBase = config.deploy?.mediaBase ?? "/media";
|
|
26
|
+
return [
|
|
27
|
+
"---",
|
|
28
|
+
"// The work gallery — a justified grid over the media library.",
|
|
29
|
+
"//",
|
|
30
|
+
"// Scaffolded once; yours to edit. The layout primitive is general",
|
|
31
|
+
"// (astroidjs/components/JustifiedGallery.astro); what lives here is this",
|
|
32
|
+
"// project's own answer to *which* assets appear and in what order.",
|
|
33
|
+
"//",
|
|
34
|
+
"// Rows carry `alt`/`caption` from the media registry, so an editor fixes alt",
|
|
35
|
+
"// text once in the library and every gallery showing that asset picks it up.",
|
|
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
|
+
"// backfilling dimensions on older uploads.",
|
|
39
|
+
'import JustifiedGallery from "astroidjs/components/JustifiedGallery.astro";',
|
|
40
|
+
'import type { GalleryItem } from "astroidjs/components/justify";',
|
|
41
|
+
'import { env } from "cloudflare:workers";',
|
|
42
|
+
'import Site from "../layouts/Site.astro";',
|
|
43
|
+
"",
|
|
44
|
+
"export const prerender = false;",
|
|
45
|
+
"",
|
|
46
|
+
"interface MediaRow {",
|
|
47
|
+
" key: string;",
|
|
48
|
+
" alt: string | null;",
|
|
49
|
+
" caption: string | null;",
|
|
50
|
+
" width: number | null;",
|
|
51
|
+
" height: number | null;",
|
|
52
|
+
"}",
|
|
53
|
+
"",
|
|
54
|
+
"let rows: MediaRow[] = [];",
|
|
55
|
+
"try {",
|
|
56
|
+
" const result = await env.DB.prepare(",
|
|
57
|
+
' "SELECT key, alt, caption, width, height FROM media" +',
|
|
58
|
+
" \" WHERE content_type LIKE 'image/%' ORDER BY uploaded_at DESC LIMIT 120\",",
|
|
59
|
+
" ).all<MediaRow>();",
|
|
60
|
+
" rows = result.results ?? [];",
|
|
61
|
+
"} catch {",
|
|
62
|
+
" // No DB binding yet (pre-provision) — render the empty state.",
|
|
63
|
+
"}",
|
|
64
|
+
"",
|
|
65
|
+
"const items: GalleryItem[] = rows.map((row) => ({",
|
|
66
|
+
` src: \`${mediaBase}/\${row.key}\`,`,
|
|
67
|
+
' // An asset with no alt gets "" rather than its filename: an empty alt makes',
|
|
68
|
+
" // a screen reader skip a decorative tile, while a filename is read aloud",
|
|
69
|
+
" // character by character and tells the listener nothing.",
|
|
70
|
+
' alt: row.alt ?? "",',
|
|
71
|
+
" ...(row.caption ? { caption: row.caption } : {}),",
|
|
72
|
+
" ...(row.width ? { width: row.width } : {}),",
|
|
73
|
+
" ...(row.height ? { height: row.height } : {}),",
|
|
74
|
+
"}));",
|
|
75
|
+
"---",
|
|
76
|
+
"",
|
|
77
|
+
'<Site title="Work">',
|
|
78
|
+
' <main class="mx-auto max-w-6xl px-6 py-16">',
|
|
79
|
+
' <h1 class="text-4xl font-bold">Work</h1>',
|
|
80
|
+
" {",
|
|
81
|
+
" items.length > 0 ? (",
|
|
82
|
+
' <JustifiedGallery items={items} class="mt-8" />',
|
|
83
|
+
" ) : (",
|
|
84
|
+
' <p class="mt-6 opacity-70">',
|
|
85
|
+
" Upload images in the media library and they'll appear here.",
|
|
86
|
+
" </p>",
|
|
87
|
+
" )",
|
|
88
|
+
" }",
|
|
89
|
+
" </main>",
|
|
90
|
+
"</Site>",
|
|
91
|
+
"",
|
|
92
|
+
].join("\n");
|
|
93
|
+
}
|