Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 14 additions & 1 deletion .github/workflows/pr-tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,20 @@ jobs:
run: npm run type-check

- name: Lint E2E tests for banned skip patterns
run: bash scripts/lint-e2e-no-skip.sh
run: |
# Only lint E2E spec files changed in this PR/push — incremental enforcement.
if [ "${{ github.event_name }}" = "pull_request_target" ]; then
CHANGED=$(git diff --name-only origin/${{ github.base_ref }}...HEAD -- 'tests/e2e/*.spec.ts' 2>/dev/null || echo "")
else
CHANGED=$(git diff --name-only HEAD~1 -- 'tests/e2e/*.spec.ts' 2>/dev/null || echo "")
fi
if [ -z "$CHANGED" ]; then
echo "No E2E spec files changed — skipping lint."
exit 0
fi
echo "Linting changed E2E specs:"
echo "$CHANGED"
bash scripts/lint-e2e-no-skip.sh $CHANGED

- name: Run unit tests with coverage
run: npm run test:cov
Expand Down
14 changes: 12 additions & 2 deletions .vscode/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -11,12 +11,22 @@
"statusBar.background": "#42b883",
"statusBar.foreground": "#15202b",
"statusBarItem.hoverBackground": "#359268",
"statusBarItem.remoteBackground": "#42b883",
"statusBarItem.remoteBackground": "#f0e8f7",
"statusBarItem.remoteForeground": "#15202b",
"titleBar.activeBackground": "#42b883",
"titleBar.activeForeground": "#15202b",
"titleBar.inactiveBackground": "#42b88399",
"titleBar.inactiveForeground": "#15202b99"
"titleBar.inactiveForeground": "#15202b99",
"activityBar.activeBorder": "#15202b",
"activityBarTop.activeBackground": "#65c89b",
"activityBarTop.activeBorder": "#15202b",
"activityBarTop.background": "#65c89b",
"activityBarTop.foreground": "#15202b",
"activityBarTop.inactiveForeground": "#15202b99",
"commandCenter.foreground": "#15202b",
"statusBar.debuggingBackground": "#42b883",
"statusBar.debuggingForeground": "#15202b",
"statusBar.inactiveBackground": "#42b883"
},
"peacock.color": "#42b883",
"playwright.reuseBrowser": false,
Expand Down
2 changes: 2 additions & 0 deletions my-sonicjs-app/wrangler.toml
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,8 @@ name = "EMAIL"

# Environment variables
[vars]
# Opens POST /auth/seed-admin for E2E and local dev. Never set on real deployments.
ENABLE_SEED_ADMIN = "true"
ENVIRONMENT = "development"
CORS_ORIGINS = "http://127.0.0.1:8787,http://127.0.0.1:4321"
DEFAULT_FROM_EMAIL = "lane@sonicjs.com"
Expand Down
82 changes: 3 additions & 79 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

23 changes: 23 additions & 0 deletions packages/core/src/auth/credential-account.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
/**
* Better Auth signs in against `auth_account.password` (provider 'credential'),
* not `auth_user.password_hash`. Every path that sets a password must write both.
*
* Returns statements for the caller's `db.batch([...])`.
*/
export function syncCredentialPassword(
db: D1Database,
userId: string,
passwordHash: string,
): D1PreparedStatement[] {
const nowSec = Math.floor(Date.now() / 1000)
return [
db.prepare(
`UPDATE auth_account SET password = ?, updated_at = ? WHERE user_id = ? AND provider_id = 'credential'`,
).bind(passwordHash, nowSec, userId),
db.prepare(
`INSERT INTO auth_account (id, user_id, account_id, provider_id, password, created_at, updated_at)
SELECT ?, ?, ?, 'credential', ?, ?, ?
WHERE NOT EXISTS (SELECT 1 FROM auth_account WHERE user_id = ? AND provider_id = 'credential')`,
).bind(`cred-${userId}`, userId, userId, passwordHash, nowSec, nowSec, userId),
]
}
2 changes: 1 addition & 1 deletion packages/core/src/db/migrations-bundle.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
/**
* AUTO-GENERATED FILE - DO NOT EDIT
* Generated by: scripts/generate-migrations.ts
* Generated at: 2026-09-08T20:48:31.602Z
* Generated at: 2026-10-16T13:28:40.622Z
*
* This file contains all migration SQL bundled for use in Cloudflare Workers
* where filesystem access is not available at runtime.
Expand Down
39 changes: 33 additions & 6 deletions packages/core/src/middleware/bootstrap.ts
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,30 @@ export function isBetterAuthSessionPath(path: string): boolean {
);
}

// Per-isolate memo so verifySecurityConfig logs warnings once but throws on
// every production request with a bad JWT_SECRET.
let lastSecurityBindings: string | null = null
let lastSecurityError: Error | null = null
let securityWarned = false

function verifySecurityConfigMemoized(env: Bindings): void {
const sig = `${env.JWT_SECRET ?? ''}|${env.CORS_ORIGINS ?? ''}|${env.ENVIRONMENT ?? ''}`
if (sig !== lastSecurityBindings) {
lastSecurityBindings = sig
lastSecurityError = null
securityWarned = false
}
if (lastSecurityError) throw lastSecurityError
if (securityWarned) return
try {
verifySecurityConfig(env)
securityWarned = true
} catch (e) {
lastSecurityError = e instanceof Error ? e : new Error(String(e))
throw lastSecurityError
}
}

/**
* Bootstrap middleware that ensures system initialization
* Runs once per worker instance
Expand All @@ -120,6 +144,10 @@ export function bootstrapMiddleware(config: SonicJSConfig = {}, allPlugins?: Arr
(c as any).set("hookSystem", getHookSystem());
}

// Security check runs on EVERY request — before any short-circuit.
// Production with a bad/missing JWT_SECRET must fail closed.
verifySecurityConfigMemoized(c.env as Bindings);

// Skip if already bootstrapped in this worker instance
if (bootstrapComplete) {
return next();
Expand Down Expand Up @@ -380,19 +408,18 @@ export function bootstrapMiddleware(config: SonicJSConfig = {}, allPlugins?: Arr
// Don't prevent the app from starting, but log the error
}

// 4. Verify security configuration (outside try/catch so critical
// errors in production propagate and prevent insecure deployments)
verifySecurityConfig(c.env as Bindings);

return next();
};
}

/**
* Reset bootstrap flag (useful for testing)
*/
export function resetBootstrap() {
bootstrapComplete = false;
export function resetBootstrap(complete?: boolean) {
bootstrapComplete = complete ?? false;
lastSecurityBindings = null
lastSecurityError = null
securityWarned = false
}

/**
Expand Down
59 changes: 35 additions & 24 deletions packages/core/src/routes/admin-users.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import type { Bindings, Variables } from '../app'
import { getUserProfileConfig, getRegistrationFields, renderCustomProfileSection, getCustomData, saveCustomData, extractCustomFieldsFromForm, sanitizeCustomData, validateCustomData, readProfileData, writeProfileData } from '../plugins/core-plugins/user-profiles'
import { TenantService, VALID_MEMBER_ROLES } from '../plugins/core-plugins/multi-tenant-plugin/services/tenant-service'
import { RbacService } from '../services/rbac'
import { syncCredentialPassword } from '../auth/credential-account'
import { ensureTwoFactorRequiredColumn } from '../services/migrations'

const userRoutes = new Hono<{ Bindings: Bindings; Variables: Variables }>()
Expand Down Expand Up @@ -423,12 +424,14 @@ userRoutes.post('/profile/password', async (c) => {
Date.now()
).run()

// Update user password
const updateStmt = db.prepare(`
UPDATE auth_user SET password_hash = ?, updated_at = ?
WHERE id = ?
`)
await updateStmt.bind(newPasswordHash, Date.now(), user!.userId).run()
// Update user password (and the credential row Better Auth signs in with)
await db.batch([
db.prepare(`
UPDATE auth_user SET password_hash = ?, updated_at = ?
WHERE id = ?
`).bind(newPasswordHash, Date.now(), user!.userId),
...syncCredentialPassword(db, user!.userId, newPasswordHash),
])

// Log the activity
await logActivity(
Expand Down Expand Up @@ -749,20 +752,26 @@ userRoutes.post('/users/new', async (c) => {
// Hash password
const passwordHash = await AuthManager.hashPassword(password)

// Create user
// Create user (with auth_account credential row for Better Auth sign-in)
const userId = crypto.randomUUID()
const createStmt = db.prepare(`
INSERT INTO auth_user (
id, email, first_name, last_name, phone, bio,
password_hash, role, is_active, email_verified, created_at, updated_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`)

await createStmt.bind(
userId, email, firstName, lastName, phone, bio,
passwordHash, role, isActive ? 1 : 0, emailVerified ? 1 : 0,
Date.now(), Date.now()
).run()
const nowMs = Date.now()
const nowSec = Math.floor(nowMs / 1000)

await db.batch([
db.prepare(`
INSERT INTO auth_user (
id, email, first_name, last_name, phone, bio,
password_hash, role, is_active, email_verified, created_at, updated_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`).bind(
userId, email, firstName, lastName, phone, bio,
passwordHash, role, isActive ? 1 : 0, emailVerified ? 1 : 0,
nowMs, nowMs
),
db.prepare(`INSERT OR IGNORE INTO auth_account (id, user_id, account_id, provider_id, password, created_at, updated_at)
VALUES (?, ?, ?, 'credential', ?, ?, ?)`)
.bind(`cred-${userId}`, userId, userId, passwordHash, nowSec, nowSec),
])

// Save registration profile fields if profile plugin is configured
if (sanitizedRegistrationCustom !== null && Object.keys(sanitizedRegistrationCustom).length > 0) {
Expand Down Expand Up @@ -1098,13 +1107,15 @@ userRoutes.put('/users/:id', async (c) => {
Date.now(), userId
).run()

// Update password if provided
// Update password if provided (and the credential row Better Auth signs in with)
if (newPassword) {
const passwordHash = await AuthManager.hashPassword(newPassword)
const updatePasswordStmt = db.prepare(`
UPDATE auth_user SET password_hash = ?, updated_at = ? WHERE id = ?
`)
await updatePasswordStmt.bind(passwordHash, Date.now(), userId).run()
await db.batch([
db.prepare(`
UPDATE auth_user SET password_hash = ?, updated_at = ? WHERE id = ?
`).bind(passwordHash, Date.now(), userId),
...syncCredentialPassword(db, userId, passwordHash),
])
}

// Check if any profile field has data
Expand Down
Loading
Loading