Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
3 changes: 2 additions & 1 deletion apps/docs/docs/auth/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,8 @@ npx holo install auth --workos
npx holo install auth --clerk
```

When `auth` is installed, `session` is installed with it automatically because session-backed auth depends on it.
When `auth` is installed, `session` and `security` are installed with it automatically because session-backed auth
depends on cookies, CSRF/rate-limit defaults, and CORS support for separate frontend/API deployments.

## Authentication Quickstart

Expand Down
28 changes: 26 additions & 2 deletions apps/docs/docs/security.md
Original file line number Diff line number Diff line change
@@ -1,19 +1,20 @@
# Security

`@holo-js/security` is the optional package for CSRF protection and rate limiting.
`@holo-js/security` is the optional package for CSRF protection, CORS, and rate limiting.

Install it only when the app needs browser form protection or named request throttles:

```bash
npx holo install security
```

That writes `config/security.ts`, adds `@holo-js/security`, and lets core boot the package lazily only when
That writes `config/security.ts` and `config/cors.ts`, adds `@holo-js/security`, and lets core boot the package lazily only when
it is installed.

## What the package owns

- CSRF token helpers for server-rendered forms and browser clients
- CORS headers for separate frontend/API deployments
- request protection for plain routes with `protect(...)`
- named rate limiters with `limit.perMinute(...)` and `limit.perHour(...)`
- low-level `rateLimit(...)` and `clearRateLimit(...)` helpers
Expand Down Expand Up @@ -57,6 +58,26 @@ export default defineSecurityConfig({
})
```

`config/cors.ts` controls cross-origin API access:

Comment thread
coderabbitai[bot] marked this conversation as resolved.
```ts
import { defineCorsConfig, env } from '@holo-js/config'

export default defineCorsConfig({
paths: ['/api/*', '/broadcasting/auth'],
origins: [
env('FRONTEND_URL', 'http://localhost:3000'),
],
methods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'],
headers: ['Content-Type', 'Authorization', 'X-CSRF-TOKEN', 'X-Requested-With'],
credentials: true,
maxAge: 7200,
statefulDomains: [
env('FRONTEND_DOMAIN', 'localhost:3000'),
],
})
```

When `rateLimit.driver` is `redis`, `rateLimit.redis.connection` points to a named connection in
`config/redis.ts`.

Expand Down Expand Up @@ -96,6 +117,9 @@ Holo-JS falls back to standalone `host`, which may also be a Unix socket path.
- `csrf.header` is the header accepted for XHR and `fetch` requests.
- `csrf.cookie` stores the signed token cookie that `useForm(..., { csrf: true })` reads on the client.
- `csrf.except` skips CSRF verification for matching paths such as webhooks.
- `cors.origins` lists frontend origins allowed to call the API.
- `cors.credentials` must be true when the frontend uses cookie-backed auth with `fetch(..., { credentials: 'include' })`.
- `cors.statefulDomains` lists browser hosts that should be treated as first-party cookie clients.
- `rateLimit.driver` must be `memory`, `file`, or `redis`.
- `rateLimit.redis.connection` must reference a named shared Redis connection when `rateLimit.driver` is `redis`.
- `rateLimit.limiters` is the named limiter registry used by `validate(...)`, `protect(...)`, and
Expand Down
7 changes: 6 additions & 1 deletion packages/cli/src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -574,6 +574,8 @@ export function createInternalCommands(
const changed = result.updatedPackageJson
|| result.createdAuthConfig
|| result.createdSessionConfig
|| result.createdSecurityConfig
|| result.createdCorsConfig
|| result.createdUserModel
|| result.createdMigrationFiles.length > 0
|| result.updatedEnv
Expand All @@ -583,6 +585,8 @@ export function createInternalCommands(
if (result.updatedPackageJson) writeLine(context.stdout, ' - updated package.json')
if (result.createdAuthConfig) writeLine(context.stdout, ' - created config/auth.ts')
if (result.createdSessionConfig) writeLine(context.stdout, ' - created config/session.ts')
if (result.createdSecurityConfig) writeLine(context.stdout, ' - created config/security.ts')
if (result.createdCorsConfig) writeLine(context.stdout, ' - created config/cors.ts')
if (result.createdUserModel) writeLine(context.stdout, ' - created server/models/User.ts')
if (result.updatedEnv) writeLine(context.stdout, ' - updated .env')
if (result.updatedEnvExample) writeLine(context.stdout, ' - updated .env.example')
Expand Down Expand Up @@ -669,11 +673,12 @@ export function createInternalCommands(
if (target === 'security') {
const { installSecurityIntoProject } = await loadProjectScaffoldModule()
const result = await installSecurityIntoProject(context.projectRoot)
const changed = result.updatedPackageJson || result.createdSecurityConfig
const changed = result.updatedPackageJson || result.createdSecurityConfig || result.createdCorsConfig

writeLine(context.stdout, changed ? 'Installed security support.' : 'Security support is already installed.')
if (result.updatedPackageJson) writeLine(context.stdout, ' - updated package.json')
if (result.createdSecurityConfig) writeLine(context.stdout, ' - created config/security.ts')
if (result.createdCorsConfig) writeLine(context.stdout, ' - created config/cors.ts')
return
}

Expand Down
2 changes: 2 additions & 0 deletions packages/cli/src/project.ts
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,7 @@ import {
renderMediaConfig,
renderMailConfig,
renderCacheConfig,
renderCorsConfig,
renderSecurityConfig,
renderQueueConfig,
renderCacheEnvFiles,
Expand Down Expand Up @@ -232,6 +233,7 @@ export const projectInternals = {
renderScaffoldTsconfig,
renderVSCodeSettings,
renderCacheConfig,
renderCorsConfig,
renderQueueConfig,
renderCacheEnvFiles,
renderEnvFileContents,
Expand Down
73 changes: 69 additions & 4 deletions packages/cli/src/project/config.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,14 @@
import { mkdir, writeFile } from 'node:fs/promises'
import { dirname, extname, join, resolve } from 'node:path'
import { pathToFileURL } from 'node:url'
import {
loadConfigDirectory,
holoAppDefaults,
holoDatabaseDefaults,
configureEnvRuntime,
loadEnvironment,
normalizeAppConfig,
normalizeDatabaseConfig,
} from '@holo-js/config'
import {
DEFAULT_HOLO_PROJECT_PATHS,
Expand All @@ -24,6 +29,60 @@ import {
resolveFirstExistingPath,
} from './runtime'

function isObject(value: unknown): value is Record<string, unknown> {
return !!value && typeof value === 'object' && !Array.isArray(value)
}

function resolveConfigExport<TConfig extends object>(moduleValue: unknown): TConfig {
if (isObject(moduleValue) && isObject(moduleValue.default)) {
return moduleValue.default as TConfig
}

if (isObject(moduleValue) && isObject(moduleValue.config)) {
return moduleValue.config as TConfig
}

if (isObject(moduleValue) && ('default' in moduleValue || 'config' in moduleValue)) {
return {} as TConfig
}

if (isObject(moduleValue)) {
return moduleValue as TConfig
}

return {} as TConfig
}

let projectConfigImportNonce = 0

async function importProjectConfigFile<TConfig extends object>(
filePath: string,
environmentValues: Readonly<Record<string, string>>,
): Promise<TConfig> {
const previousEnvEntries = new Map<string, string | undefined>()
projectConfigImportNonce += 1

try {
configureEnvRuntime(environmentValues)
for (const [key, value] of Object.entries(environmentValues)) {
previousEnvEntries.set(key, process.env[key])
process.env[key] = value
}

return resolveConfigExport<TConfig>(await import(`${pathToFileURL(filePath).href}?t=${projectConfigImportNonce}`))
} finally {
configureEnvRuntime(undefined)
for (const [key, value] of previousEnvEntries) {
if (typeof value === 'string') {
process.env[key] = value
continue
}

Reflect.deleteProperty(process.env, key)
}
}
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

export async function loadProjectConfig(
projectRoot: string,
options: { required?: boolean } = {},
Expand All @@ -39,12 +98,18 @@ export async function loadProjectConfig(
}
}

const loaded = await loadConfigDirectory(projectRoot, {
const databaseConfigPath = await resolveFirstExistingPath(projectRoot, DATABASE_CONFIG_FILE_NAMES)
const environment = await loadEnvironment({
cwd: projectRoot,
processEnv: process.env,
})
const app = normalizeAppConfig(await importProjectConfigFile(appConfigPath, environment.values))
const database = normalizeDatabaseConfig(databaseConfigPath
? await importProjectConfigFile(databaseConfigPath, environment.values)
: undefined)
const baseConfig = normalizeHoloProjectConfig({
paths: loaded.app.paths,
database: loaded.database,
paths: app.paths,
database,
})
const registry = await loadGeneratedProjectRegistry(projectRoot)

Expand All @@ -56,7 +121,7 @@ export async function loadProjectConfig(
models: registry.models.map(entry => entry.sourcePath),
migrations: registry.migrations.map(entry => entry.sourcePath),
seeders: registry.seeders.map(entry => entry.sourcePath),
database: loaded.database,
database,
})
: baseConfig,
}
Expand Down
2 changes: 1 addition & 1 deletion packages/cli/src/project/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -681,7 +681,7 @@ function renderGeneratedConfigTypes(
projectRoot: string,
entries: readonly { configName: string, filePath: string }[],
): string {
const customEntries = entries.filter(entry => !['app', 'database', 'redis', 'cache', 'storage', 'queue', 'broadcast', 'notifications', 'mail', 'media', 'session', 'security', 'auth'].includes(entry.configName))
const customEntries = entries.filter(entry => !['app', 'database', 'redis', 'cache', 'cors', 'storage', 'queue', 'broadcast', 'notifications', 'mail', 'media', 'session', 'security', 'auth'].includes(entry.configName))

if (customEntries.length === 0) {
return [
Expand Down
26 changes: 26 additions & 0 deletions packages/cli/src/project/scaffold.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
AUTH_CONFIG_FILE_NAMES,
BROADCAST_CONFIG_FILE_NAMES,
CACHE_CONFIG_FILE_NAMES,
CORS_CONFIG_FILE_NAMES,
MAIL_CONFIG_FILE_NAMES,
NOTIFICATIONS_CONFIG_FILE_NAMES,
QUEUE_CONFIG_FILE_NAMES,
Expand Down Expand Up @@ -41,6 +42,7 @@ import {
authFeaturesRequireConfigUpdate,
canSafelyRewriteAuthConfig,
detectAuthInstallFeaturesFromConfig,
ensureCorsConfigFile,
ensureRateLimitStorageIgnore,
ensureRedisConfigFile,
injectBroadcastAuthEndpoint,
Expand All @@ -49,6 +51,7 @@ import {
renderBroadcastConfig,
renderBroadcastEnvFiles,
renderCacheConfig,
renderCorsConfig,
renderMailConfig,
renderMediaConfig,
renderNotificationsConfig,
Expand Down Expand Up @@ -242,6 +245,8 @@ export async function installAuthIntoProject(
const defaultDatabaseConnection = project.config.database?.defaultConnection ?? 'default'
const authConfigPath = await resolveFirstExistingPath(projectRoot, AUTH_CONFIG_FILE_NAMES)
const sessionConfigPath = await resolveFirstExistingPath(projectRoot, SESSION_CONFIG_FILE_NAMES)
const securityConfigPath = await resolveFirstExistingPath(projectRoot, SECURITY_CONFIG_FILE_NAMES)
const corsConfigPath = await resolveFirstExistingPath(projectRoot, CORS_CONFIG_FILE_NAMES)
const userModelPath = await resolveExistingModelPath(modelsRoot, 'User')
const existingMigrationFiles = await resolveExistingAuthMigrationFiles(migrationsRoot)
const hasAllAuthMigrations = AUTH_MIGRATION_SLUGS.every(slug => existingMigrationFiles.has(slug))
Expand Down Expand Up @@ -282,13 +287,22 @@ export async function installAuthIntoProject(
await writeTextFile(envExamplePath, nextEnvExample.contents)
}

let createdSecurityConfig = false
if (!securityConfigPath) {
await writeTextFile(resolve(projectRoot, 'config/security.ts'), renderSecurityConfig())
createdSecurityConfig = true
}
const createdCorsConfig = await ensureCorsConfigFile(projectRoot)

await syncBroadcastAuthSupportAfterAuthInstall(projectRoot)
await syncHostedAuthRouteFiles(projectRoot, nextAuthFeatures)

return {
updatedPackageJson: await upsertAuthPackageDependencies(projectRoot, nextAuthFeatures),
createdAuthConfig: authConfigChanged,
createdSessionConfig: false,
createdSecurityConfig,
createdCorsConfig,
createdUserModel: false,
createdMigrationFiles: [],
updatedEnv: nextEnv.changed,
Expand Down Expand Up @@ -324,6 +338,12 @@ export async function installAuthIntoProject(
if (!sessionConfigPath) {
await writeTextFile(sessionConfigTargetPath, renderSessionConfig(defaultDatabaseConnection))
}
let createdSecurityConfig = false
if (!securityConfigPath) {
await writeTextFile(resolve(projectRoot, 'config/security.ts'), renderSecurityConfig())
createdSecurityConfig = true
}
const createdCorsConfig = corsConfigPath ? false : await ensureCorsConfigFile(projectRoot)
await writeTextFile(
userModelTargetPath,
renderAuthUserModel(resolveAuthUserModelSchemaImportPath(
Expand Down Expand Up @@ -359,6 +379,8 @@ export async function installAuthIntoProject(
updatedPackageJson: await upsertAuthPackageDependencies(projectRoot, features),
createdAuthConfig: true,
createdSessionConfig: !sessionConfigPath,
createdSecurityConfig,
createdCorsConfig,
createdUserModel: true,
createdMigrationFiles,
updatedEnv: nextEnv.changed,
Expand Down Expand Up @@ -578,6 +600,7 @@ export async function installSecurityIntoProject(
): Promise<SecurityInstallResult> {
await loadProjectConfig(projectRoot, { required: true })
const securityConfigPath = await resolveFirstExistingPath(projectRoot, SECURITY_CONFIG_FILE_NAMES)
const corsConfigPath = await resolveFirstExistingPath(projectRoot, CORS_CONFIG_FILE_NAMES)

await mkdir(resolve(projectRoot, 'config'), { recursive: true })
await ensureRateLimitStorageIgnore(projectRoot)
Expand All @@ -586,10 +609,12 @@ export async function installSecurityIntoProject(
if (!securityConfigPath) {
await writeTextFile(resolve(projectRoot, 'config/security.ts'), renderSecurityConfig())
}
const createdCorsConfig = corsConfigPath ? false : await ensureCorsConfigFile(projectRoot)

return {
updatedPackageJson: await upsertSecurityPackageDependency(projectRoot),
createdSecurityConfig: !securityConfigPath,
createdCorsConfig,
}
}

Expand Down Expand Up @@ -760,6 +785,7 @@ export {
renderAuthUserModel,
renderCacheConfig,
renderCacheEnvFiles,
renderCorsConfig,
renderEnvFileContents,
renderFrameworkFiles,
renderFrameworkRunner,
Expand Down
33 changes: 33 additions & 0 deletions packages/cli/src/project/scaffold/config-renderers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { holoStorageDefaults } from '@holo-js/config'
import {
AUTH_CONFIG_FILE_NAMES,
BROADCAST_CONFIG_FILE_NAMES,
CORS_CONFIG_FILE_NAMES,
REDIS_CONFIG_FILE_NAMES,
SUPPORTED_AUTH_SOCIAL_PROVIDERS,
type SupportedCacheInstallerDriver,
Expand Down Expand Up @@ -296,6 +297,38 @@ export function renderSecurityConfig(): string {
].join('\n')
}

export function renderCorsConfig(): string {
return [
'import { defineCorsConfig, env } from \'@holo-js/config\'',
'',
'export default defineCorsConfig({',
' paths: [\'/api/*\', \'/broadcasting/auth\'],',
' origins: [',
' env(\'FRONTEND_URL\', \'http://localhost:3000\'),',
' ],',
' methods: [\'GET\', \'POST\', \'PUT\', \'PATCH\', \'DELETE\', \'OPTIONS\'],',
' headers: [\'Content-Type\', \'Authorization\', \'X-CSRF-TOKEN\', \'X-Requested-With\'],',
' credentials: true,',
' maxAge: 7200,',
' statefulDomains: [',
' env(\'FRONTEND_DOMAIN\', \'localhost:3000\'),',
' ],',
'})',
'',
].join('\n')
}

export async function ensureCorsConfigFile(projectRoot: string): Promise<boolean> {
const corsConfigPath = await resolveFirstExistingPath(projectRoot, CORS_CONFIG_FILE_NAMES) ?? resolve(projectRoot, 'config/cors.ts')
const corsConfigExists = await pathExists(corsConfigPath)

if (!corsConfigExists) {
await writeTextFile(corsConfigPath, renderCorsConfig())
}

return !corsConfigExists
}

export async function ensureRateLimitStorageIgnore(projectRoot: string): Promise<void> {
const rateLimitRoot = resolve(projectRoot, 'storage/framework/rate-limits')
const ignorePath = resolve(rateLimitRoot, '.gitignore')
Expand Down
Loading