astroidjs 0.1.2 → 0.3.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/auth/index.d.ts +27 -0
- package/dist/auth/index.js +59 -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 +69 -0
- package/dist/commerce/mirror.js +214 -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 +239 -12
- package/dist/config.js +49 -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 +15 -0
- package/dist/index.js +15 -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 +56 -0
- package/dist/portal/guard.d.ts +54 -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 +121 -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 +166 -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 +41 -7
- package/dist/schema/collections.js +110 -12
- package/dist/schema/framework.js +5 -0
- package/dist/schema/generate.js +17 -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 +110 -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 +353 -42
- 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 -4
- 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,154 @@
|
|
|
1
|
+
// Copyright (c) 2026 BowenLabs. Astroid is MIT licensed.
|
|
2
|
+
//
|
|
3
|
+
// A MapLibre style, built from brand colours.
|
|
4
|
+
//
|
|
5
|
+
// Deliberately dependency-free: a style is JSON, so this returns a plain object
|
|
6
|
+
// rather than importing `maplibre-gl` (a megabyte) or `protomaps-themes-base`
|
|
7
|
+
// for its types. astroidjs stays installable by a project that will never draw
|
|
8
|
+
// a map, and a project that wants Protomaps' full maintained theme can swap
|
|
9
|
+
// this out — the only contract is "an object MapLibre accepts".
|
|
10
|
+
//
|
|
11
|
+
// The layer set is the quiet-basemap subset: land, water, green space, a road
|
|
12
|
+
// ramp with casings, buildings, and admin boundaries. Labels are opt-in and
|
|
13
|
+
// need `glyphs`, because self-hosted SDF glyph ranges are a provisioning step
|
|
14
|
+
// (and the reason the reference site could keep `font-src 'self'`).
|
|
15
|
+
//
|
|
16
|
+
// Source layer names follow the Protomaps v3 schema, which is what a `pmtiles`
|
|
17
|
+
// archive built with `pmtiles extract` / planetiler contains.
|
|
18
|
+
const DEFAULTS = {
|
|
19
|
+
land: "#faf7ef",
|
|
20
|
+
water: "#9fdad8",
|
|
21
|
+
green: "#e9efdc",
|
|
22
|
+
road: "#fffdf7",
|
|
23
|
+
arterial: "#f3eee0",
|
|
24
|
+
casing: "#ece5d2",
|
|
25
|
+
building: "#f0e9d9",
|
|
26
|
+
boundary: "#cfc6b0",
|
|
27
|
+
label: "#2a2a2a",
|
|
28
|
+
labelHalo: "#fffdf7",
|
|
29
|
+
};
|
|
30
|
+
/**
|
|
31
|
+
* Build a brand-recoloured basemap style over a self-hosted PMTiles archive.
|
|
32
|
+
*
|
|
33
|
+
* ```ts
|
|
34
|
+
* const style = astroidMapStyle({
|
|
35
|
+
* pmtilesUrl: `${location.origin}/map/basemap.pmtiles`,
|
|
36
|
+
* colors: { water: theme.colors.brand },
|
|
37
|
+
* });
|
|
38
|
+
* ```
|
|
39
|
+
*/
|
|
40
|
+
export function astroidMapStyle(options) {
|
|
41
|
+
const c = { ...DEFAULTS, ...options.colors };
|
|
42
|
+
const { pmtilesUrl, glyphs, fontstack = "Noto Sans Regular", attribution = '<a href="https://openstreetmap.org">OpenStreetMap</a> · <a href="https://protomaps.com">Protomaps</a>', } = options;
|
|
43
|
+
const layers = [
|
|
44
|
+
{ id: "background", type: "background", paint: { "background-color": c.land } },
|
|
45
|
+
{
|
|
46
|
+
id: "earth",
|
|
47
|
+
type: "fill",
|
|
48
|
+
source: "protomaps",
|
|
49
|
+
"source-layer": "earth",
|
|
50
|
+
paint: { "fill-color": c.land },
|
|
51
|
+
},
|
|
52
|
+
{
|
|
53
|
+
id: "landuse",
|
|
54
|
+
type: "fill",
|
|
55
|
+
source: "protomaps",
|
|
56
|
+
"source-layer": "landuse",
|
|
57
|
+
// Only the green classes; leaving the rest unpainted keeps the map quiet
|
|
58
|
+
// rather than blotchy.
|
|
59
|
+
filter: ["in", "pmap:kind", "park", "forest", "wood", "grass", "scrub", "farmland"],
|
|
60
|
+
paint: { "fill-color": c.green },
|
|
61
|
+
},
|
|
62
|
+
{
|
|
63
|
+
id: "water",
|
|
64
|
+
type: "fill",
|
|
65
|
+
source: "protomaps",
|
|
66
|
+
"source-layer": "water",
|
|
67
|
+
paint: { "fill-color": c.water },
|
|
68
|
+
},
|
|
69
|
+
{
|
|
70
|
+
id: "buildings",
|
|
71
|
+
type: "fill",
|
|
72
|
+
source: "protomaps",
|
|
73
|
+
"source-layer": "buildings",
|
|
74
|
+
// Buildings only resolve once you're zoomed in; drawing them earlier is
|
|
75
|
+
// noise at city scale.
|
|
76
|
+
minzoom: 14,
|
|
77
|
+
paint: { "fill-color": c.building },
|
|
78
|
+
},
|
|
79
|
+
// Casings sit UNDER the road fills, so a road reads as a stroked ribbon
|
|
80
|
+
// rather than a flat band. Order matters more than colour here.
|
|
81
|
+
{
|
|
82
|
+
id: "roads-casing",
|
|
83
|
+
type: "line",
|
|
84
|
+
source: "protomaps",
|
|
85
|
+
"source-layer": "roads",
|
|
86
|
+
paint: {
|
|
87
|
+
"line-color": c.casing,
|
|
88
|
+
"line-width": ["interpolate", ["exponential", 1.6], ["zoom"], 10, 1.5, 16, 10],
|
|
89
|
+
},
|
|
90
|
+
},
|
|
91
|
+
{
|
|
92
|
+
id: "roads",
|
|
93
|
+
type: "line",
|
|
94
|
+
source: "protomaps",
|
|
95
|
+
"source-layer": "roads",
|
|
96
|
+
paint: {
|
|
97
|
+
"line-color": [
|
|
98
|
+
"match",
|
|
99
|
+
["get", "pmap:kind"],
|
|
100
|
+
"highway",
|
|
101
|
+
c.arterial,
|
|
102
|
+
"major_road",
|
|
103
|
+
c.arterial,
|
|
104
|
+
c.road,
|
|
105
|
+
],
|
|
106
|
+
"line-width": ["interpolate", ["exponential", 1.6], ["zoom"], 10, 0.8, 16, 7],
|
|
107
|
+
},
|
|
108
|
+
},
|
|
109
|
+
{
|
|
110
|
+
id: "boundaries",
|
|
111
|
+
type: "line",
|
|
112
|
+
source: "protomaps",
|
|
113
|
+
"source-layer": "boundaries",
|
|
114
|
+
paint: { "line-color": c.boundary, "line-width": 0.8, "line-dasharray": [3, 2] },
|
|
115
|
+
},
|
|
116
|
+
];
|
|
117
|
+
if (glyphs) {
|
|
118
|
+
layers.push({
|
|
119
|
+
id: "roads-labels",
|
|
120
|
+
type: "symbol",
|
|
121
|
+
source: "protomaps",
|
|
122
|
+
"source-layer": "roads",
|
|
123
|
+
minzoom: 14,
|
|
124
|
+
layout: {
|
|
125
|
+
"text-field": ["get", "name"],
|
|
126
|
+
"text-font": [fontstack],
|
|
127
|
+
"text-size": 11,
|
|
128
|
+
"symbol-placement": "line",
|
|
129
|
+
},
|
|
130
|
+
paint: { "text-color": c.label, "text-halo-color": c.labelHalo, "text-halo-width": 1.5 },
|
|
131
|
+
}, {
|
|
132
|
+
id: "places-labels",
|
|
133
|
+
type: "symbol",
|
|
134
|
+
source: "protomaps",
|
|
135
|
+
"source-layer": "places",
|
|
136
|
+
layout: { "text-field": ["get", "name"], "text-font": [fontstack], "text-size": 13 },
|
|
137
|
+
paint: { "text-color": c.label, "text-halo-color": c.labelHalo, "text-halo-width": 1.5 },
|
|
138
|
+
});
|
|
139
|
+
}
|
|
140
|
+
return {
|
|
141
|
+
version: 8,
|
|
142
|
+
...(glyphs ? { glyphs } : {}),
|
|
143
|
+
sources: {
|
|
144
|
+
protomaps: {
|
|
145
|
+
type: "vector",
|
|
146
|
+
// The `pmtiles://` prefix is what routes reads through the protocol
|
|
147
|
+
// handler the page registers, instead of MapLibre fetching tile URLs.
|
|
148
|
+
url: `pmtiles://${pmtilesUrl}`,
|
|
149
|
+
attribution,
|
|
150
|
+
},
|
|
151
|
+
},
|
|
152
|
+
layers,
|
|
153
|
+
};
|
|
154
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import type { AstroidConfig } from "../config.js";
|
|
2
|
+
import type { PortalGuardConfig, PortalRoute } from "./guard.js";
|
|
3
|
+
/** Cookie prefix for the portal instance. Distinct from the studio's default
|
|
4
|
+
* (`better-auth`) so the two sessions can coexist on one origin. */
|
|
5
|
+
export declare const ASTROID_PORTAL_COOKIE_PREFIX = "portal";
|
|
6
|
+
/** Table-name prefix for the portal's Better Auth tables — `portal_user`,
|
|
7
|
+
* `portal_session`, … The studio owns the unprefixed names. */
|
|
8
|
+
export declare const ASTROID_PORTAL_TABLE_PREFIX = "portal_";
|
|
9
|
+
/** Everything the generated portal wiring needs, defaults applied. */
|
|
10
|
+
export interface ResolvedPortal {
|
|
11
|
+
enabled: boolean;
|
|
12
|
+
basePath: string;
|
|
13
|
+
cookiePrefix: string;
|
|
14
|
+
tablePrefix: string;
|
|
15
|
+
roles: string[];
|
|
16
|
+
/** First role in `roles` — what a newly created account gets. */
|
|
17
|
+
defaultRole: string;
|
|
18
|
+
routes: PortalRoute[];
|
|
19
|
+
home: Record<string, string>;
|
|
20
|
+
signUp: boolean;
|
|
21
|
+
gated: boolean;
|
|
22
|
+
}
|
|
23
|
+
/** Resolve the portal block, or null when the project has no portal. */
|
|
24
|
+
export declare function astroidPortal(config: AstroidConfig): ResolvedPortal | null;
|
|
25
|
+
/** The guard config for a project, ready to hand to `portalGuard`. */
|
|
26
|
+
export declare function astroidPortalGuardConfig(config: AstroidConfig): PortalGuardConfig | null;
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
// Copyright (c) 2026 BowenLabs. Astroid is MIT licensed.
|
|
2
|
+
//
|
|
3
|
+
// Portal defaults derived from the project config — the single place that knows
|
|
4
|
+
// the portal's mount, cookie prefix, table prefix, and guard table.
|
|
5
|
+
//
|
|
6
|
+
// The isolation constants are fixed rather than configurable, and that's the
|
|
7
|
+
// point: the studio instance MUST keep Better Auth's defaults (`/api/auth`, the
|
|
8
|
+
// unprefixed tables) because the Louise editor client hardcodes them, so the
|
|
9
|
+
// portal is the one that moves. Leaving that to a project invites the one
|
|
10
|
+
// mistake that matters — two instances sharing a cookie prefix, where signing
|
|
11
|
+
// into one silently signs you out of the other, intermittently, in production.
|
|
12
|
+
import { ASTROID_PORTAL_BASE_PATH } from "../security/rate-rules.js";
|
|
13
|
+
/** Cookie prefix for the portal instance. Distinct from the studio's default
|
|
14
|
+
* (`better-auth`) so the two sessions can coexist on one origin. */
|
|
15
|
+
export const ASTROID_PORTAL_COOKIE_PREFIX = "portal";
|
|
16
|
+
/** Table-name prefix for the portal's Better Auth tables — `portal_user`,
|
|
17
|
+
* `portal_session`, … The studio owns the unprefixed names. */
|
|
18
|
+
export const ASTROID_PORTAL_TABLE_PREFIX = "portal_";
|
|
19
|
+
/** Default guard table: the account area, for any signed-in portal user. */
|
|
20
|
+
const DEFAULT_ROUTES = [{ prefix: "/portal" }, { prefix: "/api/portal" }];
|
|
21
|
+
/** Resolve the portal block, or null when the project has no portal. */
|
|
22
|
+
export function astroidPortal(config) {
|
|
23
|
+
const portal = config.portal;
|
|
24
|
+
if (!portal?.enabled)
|
|
25
|
+
return null;
|
|
26
|
+
const roles = portal.roles?.length ? portal.roles : ["customer"];
|
|
27
|
+
return {
|
|
28
|
+
enabled: true,
|
|
29
|
+
// Isolation is configurable so a site with an existing second instance
|
|
30
|
+
// (coracle's shop account at /api/shop-auth, cookie `coracle_shop`, the
|
|
31
|
+
// unprefixed `user` tables) keeps its live mount + cookies unchanged. The
|
|
32
|
+
// defaults stay the safe distinct-from-editor values; `defineAstroid`'s
|
|
33
|
+
// `assertAuthIsolation` rejects a resolved value that collides with the
|
|
34
|
+
// editor. `?? ""` is respected for tablePrefix (empty = unprefixed tables).
|
|
35
|
+
basePath: portal.basePath ?? ASTROID_PORTAL_BASE_PATH,
|
|
36
|
+
cookiePrefix: portal.cookiePrefix ?? ASTROID_PORTAL_COOKIE_PREFIX,
|
|
37
|
+
tablePrefix: portal.tablePrefix ?? ASTROID_PORTAL_TABLE_PREFIX,
|
|
38
|
+
roles,
|
|
39
|
+
defaultRole: roles[0],
|
|
40
|
+
routes: portal.routes?.length ? portal.routes : DEFAULT_ROUTES,
|
|
41
|
+
home: portal.home ?? {},
|
|
42
|
+
signUp: portal.signUp ?? false,
|
|
43
|
+
gated: portal.gated ?? false,
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
/** The guard config for a project, ready to hand to `portalGuard`. */
|
|
47
|
+
export function astroidPortalGuardConfig(config) {
|
|
48
|
+
const portal = astroidPortal(config);
|
|
49
|
+
if (!portal)
|
|
50
|
+
return null;
|
|
51
|
+
return {
|
|
52
|
+
routes: portal.routes,
|
|
53
|
+
loginPath: "/login",
|
|
54
|
+
home: (role) => portal.home[role] ?? "/portal",
|
|
55
|
+
};
|
|
56
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/** A signed-in portal user. The guard only reads `role`; `id` + `email` are the
|
|
2
|
+
* universal identity fields every instance resolves, typed so consumers
|
|
3
|
+
* (`locals.portalUser`) read them without a cast. Anything else a project's
|
|
4
|
+
* resolver returns (name, phone, a linked commerce id) is reachable via the
|
|
5
|
+
* index signature. */
|
|
6
|
+
export interface PortalUser {
|
|
7
|
+
id: string;
|
|
8
|
+
email: string;
|
|
9
|
+
role: string;
|
|
10
|
+
[key: string]: unknown;
|
|
11
|
+
}
|
|
12
|
+
/** One rule: everything under `prefix` requires one of `roles`. */
|
|
13
|
+
export interface PortalRoute {
|
|
14
|
+
/** Path prefix, e.g. `/portal` — matches the prefix itself and everything
|
|
15
|
+
* beneath it, but NOT `/portalling`. */
|
|
16
|
+
prefix: string;
|
|
17
|
+
/** Roles allowed through. Empty means "any signed-in user". */
|
|
18
|
+
roles?: string[];
|
|
19
|
+
}
|
|
20
|
+
export interface PortalGuardConfig {
|
|
21
|
+
/** The rule table, in order. The first matching prefix decides. */
|
|
22
|
+
routes: PortalRoute[];
|
|
23
|
+
/** Where to send a signed-out visitor. Default `/login`. */
|
|
24
|
+
loginPath?: string;
|
|
25
|
+
/** Landing page for a signed-in user, by role — used to bounce someone who
|
|
26
|
+
* reached an area they don't belong in. Default `/portal` for everyone. */
|
|
27
|
+
home?: (role: string) => string;
|
|
28
|
+
}
|
|
29
|
+
/** What the guard decided. `null` means "carry on". */
|
|
30
|
+
export type GuardDecision = null | {
|
|
31
|
+
kind: "redirect";
|
|
32
|
+
location: string;
|
|
33
|
+
} | {
|
|
34
|
+
kind: "json";
|
|
35
|
+
status: 401 | 403;
|
|
36
|
+
body: {
|
|
37
|
+
ok: false;
|
|
38
|
+
error: string;
|
|
39
|
+
};
|
|
40
|
+
};
|
|
41
|
+
/** Prefix match on a path SEGMENT boundary — `/portal` covers `/portal` and
|
|
42
|
+
* `/portal/orders`, but never `/portalling`. */
|
|
43
|
+
export declare function matchesPrefix(path: string, prefix: string): boolean;
|
|
44
|
+
/**
|
|
45
|
+
* Decide whether a request may proceed.
|
|
46
|
+
*
|
|
47
|
+
* Pure — it returns a decision rather than a `Response`, so it's testable
|
|
48
|
+
* without an Astro context and the middleware stays responsible for turning a
|
|
49
|
+
* decision into a redirect or a body.
|
|
50
|
+
*/
|
|
51
|
+
export declare function portalGuard(path: string, user: PortalUser | null, config: PortalGuardConfig): GuardDecision;
|
|
52
|
+
/** Turn a decision into a `Response`. `redirect` is left to the caller, since
|
|
53
|
+
* Astro's context builds those with its own base-path handling. */
|
|
54
|
+
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>;
|