@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 +8 -0
- package/README.md +146 -0
- package/actions/system.js +28 -0
- package/auth.js +235 -0
- package/client.svelte.js +19 -0
- package/index.module.js +59 -0
- package/middleware.js +59 -0
- package/models/User.js +107 -0
- package/package.json +36 -0
- package/route.js +25 -0
- package/schema.sql +70 -0
- package/ui/Avatar.svelte +115 -0
- package/ui/UserMenu.svelte +225 -0
- package/ui/preferences.js +120 -0
- package/views/LoginView.svelte +233 -0
package/CHANGELOG.md
ADDED
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
|
+
}
|
package/client.svelte.js
ADDED
|
@@ -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;
|
package/index.module.js
ADDED
|
@@ -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
|
+
}
|