@stnd/account 0.1.1

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/CHANGELOG.md ADDED
@@ -0,0 +1,8 @@
1
+ # @stnd/account
2
+
3
+ ## 0.1.1
4
+
5
+ ### Patch Changes
6
+
7
+ - Updated dependencies
8
+ - @stnd/styles@0.5.2
package/README.md ADDED
@@ -0,0 +1,146 @@
1
+ ---
2
+ title: "@stnd/account"
3
+ aliases: []
4
+ created: 2026-07-05 07:37
5
+ modified: 2026-07-05 19:22
6
+ last_audited: 2026-07-14
7
+ audit_interval_days: 90
8
+ next_audit: 2026-10-12
9
+ audit_priority: 3
10
+ maturity: tree
11
+ mode: read
12
+ publish: false
13
+ status: active
14
+ tags:
15
+ - package
16
+ - stnd
17
+ theme: kernel
18
+ type: package
19
+ visibility: private
20
+ ---
21
+
22
+ # @[stnd](../README)/account
23
+
24
+ A comprehensive, self-contained authentication module for the Standard Garden framework, leveraging [BetterAuth](https://better-auth.com).
25
+
26
+ ## ELI5
27
+
28
+ Login, sessions, *“sign in with Google”*, passkeys (Face ID/Touch ID) — all of it, already wired together, so an app doesn’t reinvent auth from scratch. It’s a Standard module: add it to `moduleLoad`, point it at a D1 database, and the `/api/auth/*` routes + the sign-in UI just exist.
29
+
30
+ **Install:** `moduleLoad: ["@stnd/account"]` in `astro.config.mjs`, then follow *“Getting Started”* below (D1 schema + env vars — there’s real setup, this isn’t zero-config).
31
+
32
+ **Use it (client-side sign-in):**
33
+
34
+ ```javascript
35
+ import { signIn } from "@stnd/account/client";
36
+ await signIn.social({ provider: "google" });
37
+ ```
38
+
39
+ ## Features
40
+
41
+ - **Google OAuth:** Pre-configured social login integration.
42
+ - **Passkeys:** Built-in support for Touch ID / Face ID / WebAuthn.
43
+ - **Magic Links:** Ready to be wired up with email service providers.
44
+ - **Astro Integration:** Automatic API route generation (`/api/auth/[…all]`) and middleware protection.
45
+ - **D1 Compatibility:** Seamlessly integrates with Cloudflare D1 and `cloudflare:workers` environment variables.
46
+
47
+ ## Getting Started
48
+
49
+ ### 1. Database Setup
50
+
51
+ This module requires specific tables in your Cloudflare D1 database. A `schema.sql` file is provided in this directory.
52
+
53
+ When you’re ready to test locally or deploy, apply the schema using Wrangler:
54
+
55
+ ```bash
56
+ wrangler d1 execute <YOUR_DB_NAME> --local --file=packages/account/schema.sql
57
+ ```
58
+
59
+ *(Remove `--local` when applying to your production database).*
60
+
61
+ ### 2. Environment Variables
62
+
63
+ Ensure your application’s `.dev.vars` (for local development) and Cloudflare environment secrets contain the following:
64
+
65
+ ```env
66
+ BETTER_AUTH_SECRET="your-generated-secret"
67
+ GOOGLE_CLIENT_ID="your-google-client-id"
68
+ GOOGLE_CLIENT_SECRET="your-google-client-secret"
69
+ ```
70
+
71
+ > **Note:** Generate a secure secret using `npx @better-auth/cli secret` or `openssl rand -base64 32`.
72
+
73
+ ### 3. Application Integration
74
+
75
+ In your app’s `astro.config.js` (e.g., `apps/ade/astro.config.js`), ensure the module is loaded:
76
+
77
+ ```javascript
78
+ import standard from "@stnd/core";
79
+
80
+ export default defineConfig({
81
+ integrations: [
82
+ standard({
83
+ moduleLoad: [
84
+ "@stnd/account",
85
+ // ... other modules
86
+ ],
87
+ account: {
88
+ // Optional Configuration overrides (defaults fallback to env vars)
89
+ // db: env.DB, // D1 database binding
90
+ // rpName: "My Custom App", // Passkey Relying Party Name
91
+ // rpID: "my-app.com", // Passkey Relying Party ID
92
+ // baseURL: "https://auth.my-app.com",
93
+ // trustedOrigins: ["https://my-app.com"]
94
+ },
95
+ }),
96
+ ],
97
+ });
98
+ ```
99
+
100
+ ### 4. Google OAuth Setup
101
+
102
+ To enable Google Login, you must configure a project in the [Google Cloud Console](https://console.cloud.google.com/):
103
+
104
+ 1. **Create Project**: Start a new project (e.g., `Utopie-ADE`).
105
+ 2. **OAuth Consent Screen**:
106
+ - Set User Type to **External**.
107
+ - Add your email as a **Test User** (required while the app is in *“Testing”* status).
108
+ 3. **Credentials**:
109
+ - Create **OAuth client ID** of type **Web application**.
110
+ - **Authorized JavaScript origins**: `http://localhost:8083` (and your production domain).
111
+ - **Authorized redirect URIs**: `http://localhost:8083/api/auth/callback/google` (and `https://your-domain.com/api/auth/callback/google`).
112
+ 4. **Copy IDs**: Paste the Client ID and Secret into your `.env` or `.dev.vars`.
113
+ 5. **Passkey (WebAuthn) Setup**
114
+ Passkeys require a secure context (HTTPS or Localhost).
115
+
116
+ - **Development**: Use `http://localhost:8083` or your local port. Note that some browsers require the `rpID` to match the exact hostname. The module defaults to `localhost`.
117
+ - **Production**: Ensure `env.RP_ID` (or `account.rpID` in `astro.config.js`) is set to your domain (e.g., `ade.stnd.build`). You should also configure `env.RP_NAME` (or `account.rpName`) for the prompt display.
118
+
119
+ ### 6. Client Usage
120
+
121
+ In your Svelte frontend components, you can import the pre-configured better-auth clients:
122
+
123
+ ```javascript
124
+ import { signIn, signUp, useSession, signOut } from "@stnd/account/client";
125
+
126
+ // Example: sign in with Google
127
+ const handleSignIn = async () => {
128
+ await signIn.social({ provider: "google" });
129
+ };
130
+ ```
131
+
132
+ ## Maintenance & Troubleshooting
133
+
134
+ ### Database Adapter Errors
135
+ If you see `Failed to initialize database adapter` or `db.insertInto is not a function`, ensure that the `kysely` and `kysely-d1` dependencies are correctly installed in the module workspace and that the `D1Dialect` is being passed the valid D1 binding from the request context.
136
+
137
+ ### Origin Mismatch (403 Forbidden)
138
+ Ensure `baseURL` in `auth.js` matches the port your app is actually running on (e.g., `8083` for ADE). BetterAuth’s CSRF protection is strict about port numbers.
139
+
140
+ ## Notes / Observations
141
+
142
+ *(jot down anything noticed here — quirks, gotchas, ideas)*
143
+
144
+ ## Todo
145
+
146
+ - [ ] Nothing tracked yet. [priority:: 3] [token_scale:: 3] [created:: 2026-07-14] [area:: framework]
@@ -0,0 +1,28 @@
1
+ /**
2
+ * System actions bundle for Account Module embedded in Launcher.
3
+ */
4
+ import { signOut } from "@stnd/account/client";
5
+
6
+ const systemActions = [
7
+ {
8
+ trigger: "::logout",
9
+ meta: {
10
+ title: "Logout",
11
+ desc: "End your session and log out",
12
+ icon: "logout",
13
+ requireAuth: true,
14
+ },
15
+ action: async () => {
16
+ try {
17
+ await signOut();
18
+ window.toast("Logged out successfully", "success");
19
+ window.location.href = "/"; // Force refresh to clear state
20
+ } catch (err) {
21
+ console.error("Logout failed:", err);
22
+ window.toast("Failed to logout", "error");
23
+ }
24
+ },
25
+ },
26
+ ];
27
+
28
+ export default systemActions;
package/auth.js ADDED
@@ -0,0 +1,235 @@
1
+ import { betterAuth } from "better-auth";
2
+ import { passkey } from "@better-auth/passkey";
3
+ import { magicLink, emailOTP } from "better-auth/plugins";
4
+ import { Kysely } from "kysely";
5
+ import { D1Dialect } from "kysely-d1";
6
+ import { createKyselyAdapter } from "@better-auth/kysely-adapter";
7
+ import config from "virtual:stnd/config";
8
+ import Log from "@stnd/log";
9
+
10
+ const log = Log({ scope: "Account" });
11
+
12
+ /**
13
+ * Resolves a configuration value based on the current environment.
14
+ * Accepts either a direct value (string/array) or an object with
15
+ * 'dev', 'preview', and 'production' keys.
16
+ */
17
+ function resolveConfigValue(value, env) {
18
+ if (!value) return undefined;
19
+ if (typeof value === "string" || Array.isArray(value)) return value;
20
+
21
+ const isDev = import.meta.env.DEV;
22
+ const isPreview = !!env?.CF_PAGES && env?.CF_PAGES_BRANCH !== "main";
23
+
24
+ if (isDev) {
25
+ return value.dev || value.preview || value.production;
26
+ } else if (isPreview) {
27
+ return value.preview || value.production || value.dev;
28
+ } else {
29
+ return value.production || value.preview || value.dev;
30
+ }
31
+ }
32
+
33
+ /**
34
+ * Creates the BetterAuth instance relying on standard config overrides
35
+ * if available (e.g., config.account.db). Let's lazily initialize this to
36
+ * avoid top-level await that can crash some environments.
37
+ */
38
+
39
+ export function initAuth(env) {
40
+ // In Cloudflare Workers, we must be careful with singletons that capture
41
+ // request-bound resources (like D1 bindings).
42
+ // We recreate the instance if the env changes or simply return a fresh one
43
+ // to avoid "Cannot perform I/O on behalf of a different request".
44
+
45
+ // Expected standard config structure:
46
+ // config.account = {
47
+ // db: env.ADE_DB,
48
+ // googleClientId: env.GOOGLE_CLIENT_ID,
49
+ // googleClientSecret: env.GOOGLE_CLIENT_SECRET,
50
+ // baseURL: "https://your.site",
51
+ // secret: env.JWT_SECRET,
52
+ // }
53
+
54
+ const accountConfig = config.account || {};
55
+
56
+ // For D1 bindings (Cloudflare Workers)
57
+ // BetterAuth expects standard adapter syntax. If a raw D1 binding is provided,
58
+ // Svelte/Astro usually relies on an adapter package like better-sqlite3 or d1
59
+ let database = accountConfig.db || env?.DB || env?.ADE_DB || env?.Garden;
60
+
61
+ log.debug(
62
+ `[initAuth] Determining database. Have ADE_DB? ${!!env?.ADE_DB}. Have DB? ${!!env?.DB}. Have Garden? ${!!env?.Garden}`,
63
+ );
64
+ log.debug(`[initAuth] database Type: ${typeof database}`);
65
+ if (database) {
66
+ log.debug(`[initAuth] database keys: ${Object.keys(database).join(", ")}`);
67
+ } else {
68
+ log.warn(
69
+ "No database connection provided to Account module in config.account.db",
70
+ );
71
+ }
72
+
73
+ try {
74
+ log.debug("[initAuth] Calling betterAuth(...)");
75
+
76
+ const resolvedBaseURL = resolveConfigValue(accountConfig.baseURL, env);
77
+ const resolvedTrustedOrigins = resolveConfigValue(
78
+ accountConfig.trustedOrigins,
79
+ env,
80
+ ) || [
81
+ "https://www.lartdenseigner.com",
82
+ "https://www.standard.garden",
83
+ "https://www.stnd.build",
84
+ "http://localhost:8083",
85
+ "http://localhost:8084",
86
+ "http://localhost:8089",
87
+ // ADE Desktop — Vite dev server and Tauri WebView
88
+ "http://localhost:1420",
89
+ "tauri://localhost",
90
+ ];
91
+
92
+ const auth = betterAuth({
93
+ baseURL:
94
+ resolvedBaseURL ||
95
+ (env.BASE_URL ? env.BASE_URL : "http://localhost:8089"),
96
+ secret:
97
+ accountConfig.secret ||
98
+ env.BETTER_AUTH_SECRET ||
99
+ env.JWT_SECRET ||
100
+ "dev-secret-key",
101
+ advanced: {
102
+ trustedOrigins: resolvedTrustedOrigins,
103
+ checkOrigin: false,
104
+ },
105
+ database: {
106
+ db: new Kysely({
107
+ dialect: new D1Dialect({
108
+ database: database,
109
+ }),
110
+ }),
111
+ type: "sqlite",
112
+ },
113
+ emailAndPassword: {
114
+ enabled: true,
115
+ },
116
+ user: {
117
+ additionalFields: {
118
+ access_rights: {
119
+ type: "string",
120
+ required: false,
121
+ defaultValue: "{}",
122
+ },
123
+ settings: {
124
+ type: "string",
125
+ required: false,
126
+ defaultValue: "{}",
127
+ },
128
+ },
129
+ },
130
+ socialProviders: {
131
+ ...(accountConfig.googleClientId || env?.GOOGLE_CLIENT_ID ? {
132
+ google: {
133
+ clientId: accountConfig.googleClientId || env?.GOOGLE_CLIENT_ID || "",
134
+ clientSecret: accountConfig.googleClientSecret || env?.GOOGLE_CLIENT_SECRET || "",
135
+ },
136
+ } : {}),
137
+ // Apple Sign In — requires Apple Developer Program ($99/year)
138
+ // Uncomment and set APPLE_CLIENT_ID + APPLE_CLIENT_SECRET when ready
139
+ // ...(accountConfig.appleClientId || env?.APPLE_CLIENT_ID ? {
140
+ // apple: {
141
+ // clientId: accountConfig.appleClientId || env?.APPLE_CLIENT_ID || "",
142
+ // clientSecret: accountConfig.appleClientSecret || env?.APPLE_CLIENT_SECRET || "",
143
+ // },
144
+ // } : {}),
145
+ },
146
+ plugins: [
147
+ passkey({
148
+ rpID: accountConfig.rpID || env.RP_ID || "localhost",
149
+ rpName: accountConfig.rpName || env.RP_NAME || "Standard Account",
150
+ }),
151
+ magicLink({
152
+ sendMagicLink: async ({ email, token, url }) => {
153
+ log.info(`Mock sending Magic Link to ${email}: ${url}`);
154
+
155
+ if (!env.RESEND_API_KEY) {
156
+ if (import.meta.env.DEV) return;
157
+ log.warn("RESEND_API_KEY is not set. Magic Link not sent.");
158
+ return;
159
+ }
160
+
161
+ try {
162
+ const res = await fetch("https://api.resend.com/emails", {
163
+ method: "POST",
164
+ headers: {
165
+ Authorization: `Bearer ${env.RESEND_API_KEY}`,
166
+ "Content-Type": "application/json",
167
+ },
168
+ body: JSON.stringify({
169
+ from: accountConfig.emailFrom || "auth@standard.garden",
170
+ to: email,
171
+ subject: `Your login link to ${accountConfig.rpName || "Standard App"}`,
172
+ html: `
173
+ <div style="font-family: system-ui, sans-serif; max-width: 600px; margin: 0 auto; color: #111;">
174
+ <h1>Welcome!</h1>
175
+ <p>Click the link below to sign in:</p>
176
+ <a href="${url}" style="display:inline-block; padding:12px 24px; background:#111; color:#fff; text-decoration:none; border-radius:4px; font-weight:bold;">Sign In</a>
177
+ <p style="margin-top:24px; color:#666; font-size:14px;">This link expires in 15 minutes.</p>
178
+ </div>
179
+ `,
180
+ }),
181
+ });
182
+ if (!res.ok) throw new Error(await res.text());
183
+ } catch (e) {
184
+ log.error("Failed to send Magic Link email:", e);
185
+ }
186
+ },
187
+ }),
188
+ emailOTP({
189
+ async sendVerificationOTP({ email, otp, type }) {
190
+ log.info(`Mock sending OTP ${otp} to ${email}`);
191
+
192
+ if (!env.RESEND_API_KEY) {
193
+ if (import.meta.env.DEV) return;
194
+ log.warn("RESEND_API_KEY is not set. OTP not sent.");
195
+ return;
196
+ }
197
+
198
+ try {
199
+ const res = await fetch("https://api.resend.com/emails", {
200
+ method: "POST",
201
+ headers: {
202
+ Authorization: `Bearer ${env.RESEND_API_KEY}`,
203
+ "Content-Type": "application/json",
204
+ },
205
+ body: JSON.stringify({
206
+ from: accountConfig.emailFrom || "auth@standard.garden",
207
+ to: email,
208
+ subject: `Your login code: ${otp}`,
209
+ html: `
210
+ <div style="font-family: system-ui, sans-serif; max-width: 600px; margin: 0 auto; color: #111;">
211
+ <h1>Your Login Code</h1>
212
+ <p>Use the following code to sign in to ${accountConfig.rpName || "Standard App"}:</p>
213
+ <div style="font-size: 32px; font-weight: bold; letter-spacing: 4px; padding: 20px; background: #f4f4f5; text-align: center; border-radius: 8px; margin: 24px 0;">
214
+ ${otp}
215
+ </div>
216
+ <p style="color:#666; font-size:14px;">This code expires in 10 minutes.</p>
217
+ </div>
218
+ `,
219
+ }),
220
+ });
221
+ if (!res.ok) throw new Error(await res.text());
222
+ } catch (e) {
223
+ log.error("Failed to send OTP email:", e);
224
+ }
225
+ }
226
+ }),
227
+ ],
228
+ });
229
+ log.debug("[initAuth] betterAuth successfully initialized");
230
+ return auth;
231
+ } catch (e) {
232
+ log.error("[initAuth] FATAL: betterAuth failed to initialize", e);
233
+ throw e;
234
+ }
235
+ }
@@ -0,0 +1,19 @@
1
+ import { createAuthClient } from "better-auth/svelte";
2
+ import { passkeyClient } from "@better-auth/passkey/client";
3
+ import { magicLinkClient, emailOTPClient } from "better-auth/client/plugins";
4
+
5
+ /**
6
+ * Creates a configured BetterAuth client tailored for the Standard framework.
7
+ *
8
+ * Includes the Passkeys plugin per requirements.
9
+ */
10
+ export const accountClient = createAuthClient({
11
+ plugins: [
12
+ passkeyClient(),
13
+ magicLinkClient(),
14
+ emailOTPClient()
15
+ ]
16
+ });
17
+
18
+ // Re-export common client capabilities
19
+ export const { signIn, signUp, useSession, signOut, emailOtp } = accountClient;
@@ -0,0 +1,59 @@
1
+ /**
2
+ * Account Module Manifest
3
+ *
4
+ * Exposes BetterAuth authentication via the Standard Module System.
5
+ * Handles routes, middleware, and exports the User model.
6
+ */
7
+ export default {
8
+ id: "stnd-account",
9
+ name: "Stnd :: Account",
10
+ description:
11
+ "User authentication, Google OAuth, and Passkeys support using BetterAuth.",
12
+
13
+ routes: [
14
+ {
15
+ path: "/api/auth/[...all]",
16
+ entrypoint: "./route.js",
17
+ },
18
+ ],
19
+ middleware: [
20
+ {
21
+ entrypoint: "./middleware.js",
22
+ order: -100, // Run early
23
+ },
24
+ ],
25
+ optimizeDeps: [
26
+ "better-auth",
27
+ "better-auth/db",
28
+ "better-auth/plugins",
29
+ "@better-auth/passkey",
30
+ "kysely",
31
+ "kysely-d1",
32
+ "@better-auth/kysely-adapter",
33
+ ],
34
+ store: {
35
+ user: "./models/User.js#userStore",
36
+ },
37
+ aliases: {
38
+ // Allows `import { auth } from "@stnd/account/auth"`
39
+ "@stnd/account/auth": "./auth.js",
40
+ "@stnd/account/client": "./client.svelte.js",
41
+ "@stnd/account/models": "./models",
42
+ "@stnd/account/ui": "./ui",
43
+ "@stnd/account/views": "./views",
44
+ },
45
+ hooks: {
46
+ // No "::login" launcher:view hook here: apps that want a login view
47
+ // must register their own (see apps/stnd.gd/modules/launcher). Two
48
+ // modules both registering "::login" with override:true made the
49
+ // winner depend on module discovery order — a real "last one wins"
50
+ // race. LoginView.svelte is still exported via the "@stnd/account/views"
51
+ // alias for apps that want to register it themselves.
52
+ "launcher:action": [
53
+ {
54
+ action: "./actions/system.js",
55
+ meta: { label: "Account System Actions" },
56
+ },
57
+ ],
58
+ },
59
+ };
package/middleware.js ADDED
@@ -0,0 +1,59 @@
1
+ import { defineMiddleware } from "astro:middleware";
2
+ import { initAuth } from "./auth.js";
3
+ import Log from "@stnd/log";
4
+ import { env as cfEnv } from "cloudflare:workers";
5
+
6
+ const log = Log({ scope: "AccountMiddleware" });
7
+
8
+ /**
9
+ * Standard Account Middleware
10
+ *
11
+ * Intercepts requests, validates the session via BetterAuth, and injects
12
+ * `context.locals.user` and `context.locals.session` for downstream endpoints.
13
+ */
14
+ export const onRequest = defineMiddleware(async (context, next) => {
15
+ let env = null;
16
+
17
+ if (cfEnv && (cfEnv.ADE_DB || cfEnv.DB || cfEnv.Garden)) {
18
+ env = cfEnv;
19
+ }
20
+
21
+ if (!env && context.locals?.runtime?.env) {
22
+ env = context.locals.runtime.env;
23
+ }
24
+ if (!env && context.locals?.cfContext?.env) {
25
+ env = context.locals.cfContext.env;
26
+ }
27
+ if (!env && context.locals?.env) {
28
+ env = context.locals.env;
29
+ }
30
+ if (!env) {
31
+ env = {};
32
+ }
33
+
34
+ try {
35
+ const auth = initAuth(env);
36
+
37
+ if (!auth) {
38
+ throw new Error("initAuth returned null or undefined");
39
+ }
40
+
41
+ const isAuthed = await auth.api.getSession({
42
+ headers: context.request.headers,
43
+ });
44
+
45
+ if (isAuthed) {
46
+ context.locals.user = isAuthed.user;
47
+ context.locals.session = isAuthed.session;
48
+ } else {
49
+ context.locals.user = null;
50
+ context.locals.session = null;
51
+ }
52
+ } catch (err) {
53
+ log.error("Error during session validation", err);
54
+ context.locals.user = null;
55
+ context.locals.session = null;
56
+ }
57
+
58
+ return next();
59
+ });
package/models/User.js ADDED
@@ -0,0 +1,107 @@
1
+ import { atom } from "nanostores";
2
+ import Log from "@stnd/log";
3
+
4
+ const log = Log({ scope: "User" });
5
+
6
+ // ============================================================================
7
+ // AUTH STORE (Canonical — used by all Standard apps)
8
+ // ============================================================================
9
+
10
+ export const userStore = atom(null);
11
+ export const sessionStore = atom(null);
12
+
13
+ /**
14
+ * Set the authenticated user.
15
+ * Dispatches "user-login" + legacy "visitor-login" events.
16
+ */
17
+ export function setUser(user, session = null) {
18
+ if (user && user.isAuthenticated === undefined) {
19
+ user.isAuthenticated = true;
20
+ }
21
+ log.info("Set user:", user?.email || user?.username || user?.id);
22
+ userStore.set(user);
23
+ sessionStore.set(session);
24
+
25
+ if (typeof window !== "undefined" && user) {
26
+ window.dispatchEvent(new CustomEvent("user-login", { detail: user }));
27
+ window.dispatchEvent(new CustomEvent("visitor-login"));
28
+ }
29
+ }
30
+
31
+ /**
32
+ * Clear the authenticated user (logout).
33
+ * Dispatches "user-logout" + legacy "visitor-logout" events.
34
+ */
35
+ export function clearUser() {
36
+ log.info("Clear user");
37
+ userStore.set(null);
38
+ sessionStore.set(null);
39
+
40
+ if (typeof window !== "undefined") {
41
+ window.dispatchEvent(new CustomEvent("user-logout"));
42
+ window.dispatchEvent(new CustomEvent("visitor-logout"));
43
+ }
44
+ }
45
+
46
+ /**
47
+ * Check if the current store holds an authenticated user.
48
+ */
49
+ export function isAuthenticated() {
50
+ const user = userStore.get();
51
+ if (!user) return false;
52
+ return user.isAuthenticated === true;
53
+ }
54
+
55
+ // Expose on window for backward compat and debugging
56
+ if (typeof window !== "undefined") {
57
+ window.setUser = setUser;
58
+ window.clearUser = clearUser;
59
+ // Legacy names (stnd.gd)
60
+ window.setVisitor = (v) => setUser(v);
61
+ window.clearVisitor = () => clearUser();
62
+ }
63
+
64
+ // ============================================================================
65
+ // USER CLASS (Generic shared logic)
66
+ // ============================================================================
67
+
68
+ export default class User {
69
+ constructor(raw) {
70
+ this.raw = raw;
71
+ }
72
+
73
+ /**
74
+ * Check if user has access to a specific resource (e.g. formation slug).
75
+ *
76
+ * Uses the generic `access_rights` JSON field stored on the User model.
77
+ */
78
+ hasAccessTo(slug) {
79
+ if (this.raw?.role === "admin") return true;
80
+
81
+ const rights = this.raw?.access_rights;
82
+ if (typeof rights === "string") {
83
+ try {
84
+ const parsed = JSON.parse(rights);
85
+ return !!parsed[slug];
86
+ } catch (e) { return false; }
87
+ } else if (rights && typeof rights === "object") {
88
+ return !!rights[slug];
89
+ }
90
+
91
+ return false;
92
+ }
93
+
94
+ // ==========================================================================
95
+ // STATIC FACTORIES
96
+ // ==========================================================================
97
+
98
+ /**
99
+ * Resolve User from Astro locals (placed by middleware)
100
+ */
101
+ static fromLocals(locals) {
102
+ if (locals?.user) {
103
+ return new User(locals.user);
104
+ }
105
+ return null;
106
+ }
107
+ }