diff --git a/packages/api/drizzle/0017_settings_defaults.sql b/packages/api/drizzle/0017_settings_defaults.sql new file mode 100644 index 0000000..6bab493 --- /dev/null +++ b/packages/api/drizzle/0017_settings_defaults.sql @@ -0,0 +1,82 @@ +INSERT INTO "settings" ("key", "name", "type", "category", "description", "tags", "default_value", "value", "secure", "updated_at") VALUES +('issuer', 'Issuer', 'string', 'Core', 'Issuer URL used in OIDC discovery and tokens', ARRAY['core']::text[], '"http://localhost:9080"'::jsonb, '"http://localhost:9080"'::jsonb, false, now()), +('users.self_registration_enabled', 'Self Registration Enabled', 'boolean', 'Users', 'Allow new users to sign up without invitation', ARRAY['users']::text[], 'false'::jsonb, 'false'::jsonb, false, now()), +('users.require_email_verification', 'Require Email Verification', 'boolean', 'Users', 'Require users to verify their email before login completes', ARRAY['users', 'email']::text[], 'false'::jsonb, 'false'::jsonb, false, now()), +('public_origin', 'Public Origin', 'string', 'Core', 'Public base origin for redirects and links', ARRAY['core']::text[], '"http://localhost:9080"'::jsonb, '"http://localhost:9080"'::jsonb, false, now()), +('rp_id', 'RP ID', 'string', 'Core', 'WebAuthn relying party ID', ARRAY['core', 'webauthn']::text[], '"localhost"'::jsonb, '"localhost"'::jsonb, false, now()), +('zk_delivery.fragment_param', 'Fragment Param', 'string', 'ZK / Delivery', 'URL fragment parameter name for DRK JWE', ARRAY['zk']::text[], '"drk_jwe"'::jsonb, '"drk_jwe"'::jsonb, false, now()), +('zk_delivery.jwe_alg', 'JWE Alg', 'string', 'ZK / Delivery', 'Default JWE algorithm for DRK delivery', ARRAY['zk']::text[], '"ECDH-ES"'::jsonb, '"ECDH-ES"'::jsonb, false, now()), +('zk_delivery.jwe_enc', 'JWE Enc', 'string', 'ZK / Delivery', 'Default JWE encryption method for DRK delivery', ARRAY['zk']::text[], '"A256GCM"'::jsonb, '"A256GCM"'::jsonb, false, now()), +('zk_delivery.hash_alg', 'Hash Alg', 'string', 'ZK / Delivery', 'Hash algorithm used for fragment verification', ARRAY['zk']::text[], '"SHA-256"'::jsonb, '"SHA-256"'::jsonb, false, now()), +('opaque.kdf', 'OPAQUE KDF', 'string', 'Security / OPAQUE', 'OPAQUE KDF suite', ARRAY['opaque']::text[], '"ristretto255"'::jsonb, '"ristretto255"'::jsonb, false, now()), +('opaque.envelope_mode', 'Envelope Mode', 'string', 'Security / OPAQUE', 'OPAQUE envelope mode', ARRAY['opaque']::text[], '"base"'::jsonb, '"base"'::jsonb, false, now()), +('security_headers.enabled', 'Security Headers Enabled', 'boolean', 'Security / Headers', 'Enable security headers middleware', ARRAY['security']::text[], 'true'::jsonb, 'true'::jsonb, false, now()), +('security_headers.csp', 'CSP', 'string', 'Security / Headers', 'Content Security Policy applied to responses', ARRAY['security']::text[], '"default-src ''self''; script-src ''self''; style-src ''self''; img-src ''self'' data:; connect-src ''self'' https://darkauth.com https://release.darkauth.com; frame-ancestors ''self''; base-uri ''none''; form-action ''self''; object-src ''none''; require-trusted-types-for ''script''"'::jsonb, '"default-src ''self''; script-src ''self''; style-src ''self''; img-src ''self'' data:; connect-src ''self'' https://darkauth.com https://release.darkauth.com; frame-ancestors ''self''; base-uri ''none''; form-action ''self''; object-src ''none''; require-trusted-types-for ''script''"'::jsonb, false, now()), +('rate_limits.general.enabled', 'General Enabled', 'boolean', 'Security / Rate Limits / General', 'Enable general rate limiting', ARRAY['ratelimit']::text[], 'true'::jsonb, 'true'::jsonb, false, now()), +('rate_limits.general.window_minutes', 'General Window (min)', 'number', 'Security / Rate Limits / General', 'Window length in minutes', ARRAY['ratelimit']::text[], '1'::jsonb, '1'::jsonb, false, now()), +('rate_limits.general.max_requests', 'General Max Requests', 'number', 'Security / Rate Limits / General', 'Maximum requests per window', ARRAY['ratelimit']::text[], '1000'::jsonb, '1000'::jsonb, false, now()), +('rate_limits.auth.enabled', 'Auth Enabled', 'boolean', 'Security / Rate Limits / Auth', 'Enable rate limits for auth endpoints', ARRAY['ratelimit']::text[], 'true'::jsonb, 'true'::jsonb, false, now()), +('rate_limits.auth.window_minutes', 'Auth Window (min)', 'number', 'Security / Rate Limits / Auth', 'Window length in minutes', ARRAY['ratelimit']::text[], '1'::jsonb, '1'::jsonb, false, now()), +('rate_limits.auth.max_requests', 'Auth Max Requests', 'number', 'Security / Rate Limits / Auth', 'Maximum requests per window', ARRAY['ratelimit']::text[], '1000'::jsonb, '1000'::jsonb, false, now()), +('rate_limits.opaque.enabled', 'OPAQUE Enabled', 'boolean', 'Security / Rate Limits / OPAQUE', 'Enable rate limits for OPAQUE endpoints', ARRAY['ratelimit']::text[], 'true'::jsonb, 'true'::jsonb, false, now()), +('rate_limits.opaque.window_minutes', 'OPAQUE Window (min)', 'number', 'Security / Rate Limits / OPAQUE', NULL, ARRAY['ratelimit']::text[], '1'::jsonb, '1'::jsonb, false, now()), +('rate_limits.opaque.max_requests', 'OPAQUE Max Requests', 'number', 'Security / Rate Limits / OPAQUE', NULL, ARRAY['ratelimit']::text[], '1000'::jsonb, '1000'::jsonb, false, now()), +('rate_limits.token.enabled', 'Token Enabled', 'boolean', 'Security / Rate Limits / Token', NULL, ARRAY['ratelimit']::text[], 'true'::jsonb, 'true'::jsonb, false, now()), +('rate_limits.token.window_minutes', 'Token Window (min)', 'number', 'Security / Rate Limits / Token', NULL, ARRAY['ratelimit']::text[], '1'::jsonb, '1'::jsonb, false, now()), +('rate_limits.token.max_requests', 'Token Max Requests', 'number', 'Security / Rate Limits / Token', NULL, ARRAY['ratelimit']::text[], '1000'::jsonb, '1000'::jsonb, false, now()), +('rate_limits.otp.enabled', 'OTP Enabled', 'boolean', 'Security / Rate Limits / OTP', NULL, ARRAY['ratelimit']::text[], 'true'::jsonb, 'true'::jsonb, false, now()), +('rate_limits.otp.window_minutes', 'OTP Window (min)', 'number', 'Security / Rate Limits / OTP', NULL, ARRAY['ratelimit']::text[], '15'::jsonb, '15'::jsonb, false, now()), +('rate_limits.otp.max_requests', 'OTP Max Requests', 'number', 'Security / Rate Limits / OTP', NULL, ARRAY['ratelimit']::text[], '1000'::jsonb, '1000'::jsonb, false, now()), +('rate_limits.otp_setup.enabled', 'OTP Setup Enabled', 'boolean', 'Security / Rate Limits / OTP', NULL, ARRAY['ratelimit']::text[], 'true'::jsonb, 'true'::jsonb, false, now()), +('rate_limits.otp_setup.window_minutes', 'OTP Setup Window (min)', 'number', 'Security / Rate Limits / OTP', NULL, ARRAY['ratelimit']::text[], '60'::jsonb, '60'::jsonb, false, now()), +('rate_limits.otp_setup.max_requests', 'OTP Setup Max Requests', 'number', 'Security / Rate Limits / OTP', NULL, ARRAY['ratelimit']::text[], '1000'::jsonb, '1000'::jsonb, false, now()), +('rate_limits.otp_verify.enabled', 'OTP Verify Enabled', 'boolean', 'Security / Rate Limits / OTP', NULL, ARRAY['ratelimit']::text[], 'true'::jsonb, 'true'::jsonb, false, now()), +('rate_limits.otp_verify.window_minutes', 'OTP Verify Window (min)', 'number', 'Security / Rate Limits / OTP', NULL, ARRAY['ratelimit']::text[], '60'::jsonb, '60'::jsonb, false, now()), +('rate_limits.otp_verify.max_requests', 'OTP Verify Max Requests', 'number', 'Security / Rate Limits / OTP', NULL, ARRAY['ratelimit']::text[], '1000'::jsonb, '1000'::jsonb, false, now()), +('rate_limits.otp_disable.enabled', 'OTP Disable Enabled', 'boolean', 'Security / Rate Limits / OTP', NULL, ARRAY['ratelimit']::text[], 'true'::jsonb, 'true'::jsonb, false, now()), +('rate_limits.otp_disable.window_minutes', 'OTP Disable Window (min)', 'number', 'Security / Rate Limits / OTP', NULL, ARRAY['ratelimit']::text[], '60'::jsonb, '60'::jsonb, false, now()), +('rate_limits.otp_disable.max_requests', 'OTP Disable Max Requests', 'number', 'Security / Rate Limits / OTP', NULL, ARRAY['ratelimit']::text[], '1000'::jsonb, '1000'::jsonb, false, now()), +('rate_limits.otp_regenerate.enabled', 'OTP Regenerate Enabled', 'boolean', 'Security / Rate Limits / OTP', NULL, ARRAY['ratelimit']::text[], 'true'::jsonb, 'true'::jsonb, false, now()), +('rate_limits.otp_regenerate.window_minutes', 'OTP Regenerate Window (min)', 'number', 'Security / Rate Limits / OTP', NULL, ARRAY['ratelimit']::text[], '60'::jsonb, '60'::jsonb, false, now()), +('rate_limits.otp_regenerate.max_requests', 'OTP Regenerate Max Requests', 'number', 'Security / Rate Limits / OTP', NULL, ARRAY['ratelimit']::text[], '1000'::jsonb, '1000'::jsonb, false, now()), +('rate_limits.admin.enabled', 'Admin Enabled', 'boolean', 'Security / Rate Limits / Admin', NULL, ARRAY['ratelimit']::text[], 'true'::jsonb, 'true'::jsonb, false, now()), +('rate_limits.admin.window_minutes', 'Admin Window (min)', 'number', 'Security / Rate Limits / Admin', NULL, ARRAY['ratelimit']::text[], '1'::jsonb, '1'::jsonb, false, now()), +('rate_limits.admin.max_requests', 'Admin Max Requests', 'number', 'Security / Rate Limits / Admin', NULL, ARRAY['ratelimit']::text[], '1000'::jsonb, '1000'::jsonb, false, now()), +('rate_limits.install.enabled', 'Install Enabled', 'boolean', 'Security / Rate Limits / Install', NULL, ARRAY['ratelimit']::text[], 'true'::jsonb, 'true'::jsonb, false, now()), +('rate_limits.install.window_minutes', 'Install Window (min)', 'number', 'Security / Rate Limits / Install', NULL, ARRAY['ratelimit']::text[], '60'::jsonb, '60'::jsonb, false, now()), +('rate_limits.install.max_requests', 'Install Max Requests', 'number', 'Security / Rate Limits / Install', NULL, ARRAY['ratelimit']::text[], '1000'::jsonb, '1000'::jsonb, false, now()), +('user_keys.enc_public_visible_to_authenticated_users', 'Enc Public Visible To Authenticated Users', 'boolean', 'Security / User Keys', 'Expose users'' encryption public keys to authenticated users', ARRAY['keys']::text[], 'true'::jsonb, 'true'::jsonb, false, now()), +('admin_session.lifetime_seconds', 'Admin Session TTL (s)', 'number', 'Admin / Session', 'Admin session lifetime in seconds', ARRAY['session']::text[], '900'::jsonb, '900'::jsonb, false, now()), +('otp', 'OTP Settings', 'json', 'Security / OTP', 'TOTP configuration and policy', ARRAY['otp', 'security']::text[], '{"enabled":true,"issuer":"DarkAuth","algorithm":"SHA1","digits":6,"period":30,"window":1,"backup_codes_count":8,"max_failures":5,"lockout_duration_minutes":15,"require_for_admin":true,"require_for_users":false}'::jsonb, '{"enabled":true,"issuer":"DarkAuth","algorithm":"SHA1","digits":6,"period":30,"window":1,"backup_codes_count":8,"max_failures":5,"lockout_duration_minutes":15,"require_for_admin":true,"require_for_users":false}'::jsonb, false, now()), +('email.transport', 'Email Transport', 'string', 'Email / SMTP', 'Email transport provider', ARRAY['email', 'smtp']::text[], '"smtp"'::jsonb, '"smtp"'::jsonb, false, now()), +('email.from', 'From Address', 'string', 'Email / SMTP', 'From address for outgoing emails', ARRAY['email', 'smtp']::text[], '""'::jsonb, '""'::jsonb, false, now()), +('email.smtp.host', 'SMTP Host', 'string', 'Email / SMTP', 'SMTP host', ARRAY['email', 'smtp']::text[], '""'::jsonb, '""'::jsonb, false, now()), +('email.smtp.port', 'SMTP Port', 'number', 'Email / SMTP', 'SMTP port', ARRAY['email', 'smtp']::text[], '587'::jsonb, '587'::jsonb, false, now()), +('email.smtp.user', 'SMTP User', 'string', 'Email / SMTP', 'SMTP username', ARRAY['email', 'smtp']::text[], '""'::jsonb, '""'::jsonb, false, now()), +('email.smtp.password', 'SMTP Password', 'string', 'Email / SMTP', 'SMTP password', ARRAY['email', 'smtp']::text[], '""'::jsonb, '""'::jsonb, true, now()), +('email.smtp.enabled', 'SMTP Enabled', 'boolean', 'Email / SMTP', 'Enable SMTP for outgoing email', ARRAY['email', 'smtp']::text[], 'false'::jsonb, 'false'::jsonb, false, now()), +('email.verification.token_ttl_minutes', 'Verification Token TTL (minutes)', 'number', 'Email / Verification', 'Minutes until email verification links expire', ARRAY['email', 'verification']::text[], '1440'::jsonb, '1440'::jsonb, false, now()), +('branding.identity', 'Brand Identity', 'object', 'Branding/Identity', 'Product name and tagline used across user pages', ARRAY[]::text[], '{"title":"DarkAuth","tagline":"Secure Zero-Knowledge Authentication"}'::jsonb, '{"title":"DarkAuth","tagline":"Secure Zero-Knowledge Authentication"}'::jsonb, false, now()), +('branding.logo', 'Logo Image', 'object', 'Branding/Identity', 'Base64 logo image and MIME type', ARRAY[]::text[], '{"data":null,"mimeType":null}'::jsonb, '{"data":null,"mimeType":null}'::jsonb, false, now()), +('branding.logo_dark', 'Logo Image (Dark)', 'object', 'Branding/Identity', 'Base64 dark mode logo image and MIME type', ARRAY[]::text[], '{"data":null,"mimeType":null}'::jsonb, '{"data":null,"mimeType":null}'::jsonb, false, now()), +('branding.favicon', 'Favicon', 'object', 'Branding/Identity', 'Base64 favicon and MIME type', ARRAY[]::text[], '{"data":null,"mimeType":null}'::jsonb, '{"data":null,"mimeType":null}'::jsonb, false, now()), +('branding.favicon_dark', 'Favicon (Dark)', 'object', 'Branding/Identity', 'Base64 dark mode favicon and MIME type', ARRAY[]::text[], '{"data":null,"mimeType":null}'::jsonb, '{"data":null,"mimeType":null}'::jsonb, false, now()), +('branding.colors', 'Color Scheme', 'object', 'Branding/Appearance', 'Color palette for user login and consent screens', ARRAY[]::text[], '{"backgroundGradientStart":"#f3f4f6","backgroundGradientEnd":"#eff6ff","backgroundAngle":"135deg","primary":"#6600cc","primaryHover":"#2563eb","primaryLight":"#dbeafe","primaryDark":"#1d4ed8","secondary":"#6b7280","secondaryHover":"#4b5563","success":"#10b981","error":"#ef4444","warning":"#f59e0b","info":"#6600cc","text":"#111827","textSecondary":"#6b7280","textMuted":"#9ca3af","border":"#e5e7eb","cardBackground":"#ffffff","cardShadow":"rgba(0,0,0,0.1)","inputBackground":"#ffffff","inputBorder":"#d1d5db","inputFocus":"#6600cc"}'::jsonb, '{"backgroundGradientStart":"#f3f4f6","backgroundGradientEnd":"#eff6ff","backgroundAngle":"135deg","primary":"#6600cc","primaryHover":"#2563eb","primaryLight":"#dbeafe","primaryDark":"#1d4ed8","secondary":"#6b7280","secondaryHover":"#4b5563","success":"#10b981","error":"#ef4444","warning":"#f59e0b","info":"#6600cc","text":"#111827","textSecondary":"#6b7280","textMuted":"#9ca3af","border":"#e5e7eb","cardBackground":"#ffffff","cardShadow":"rgba(0,0,0,0.1)","inputBackground":"#ffffff","inputBorder":"#d1d5db","inputFocus":"#6600cc"}'::jsonb, false, now()), +('branding.colors_dark', 'Color Scheme (Dark)', 'object', 'Branding/Appearance', 'Dark mode color palette for user login and consent screens', ARRAY[]::text[], '{"backgroundGradientStart":"#1f2937","backgroundGradientEnd":"#111827","backgroundAngle":"135deg","primary":"#aec1e0","primaryHover":"#9eb3d6","primaryLight":"#374151","primaryDark":"#c5d3e8","secondary":"#9ca3af","secondaryHover":"#d1d5db","success":"#10b981","error":"#ef4444","warning":"#f59e0b","info":"#aec1e0","text":"#f9fafb","textSecondary":"#d1d5db","textMuted":"#9ca3af","border":"#374151","cardBackground":"#1f2937","cardShadow":"rgba(0,0,0,0.3)","inputBackground":"#111827","inputBorder":"#4b5563","inputFocus":"#aec1e0"}'::jsonb, '{"backgroundGradientStart":"#1f2937","backgroundGradientEnd":"#111827","backgroundAngle":"135deg","primary":"#aec1e0","primaryHover":"#9eb3d6","primaryLight":"#374151","primaryDark":"#c5d3e8","secondary":"#9ca3af","secondaryHover":"#d1d5db","success":"#10b981","error":"#ef4444","warning":"#f59e0b","info":"#aec1e0","text":"#f9fafb","textSecondary":"#d1d5db","textMuted":"#9ca3af","border":"#374151","cardBackground":"#1f2937","cardShadow":"rgba(0,0,0,0.3)","inputBackground":"#111827","inputBorder":"#4b5563","inputFocus":"#aec1e0"}'::jsonb, false, now()), +('branding.wording', 'UI Text', 'object', 'Branding/Text', 'Text labels used in user flows', ARRAY[]::text[], '{"welcomeBack":"Welcome back","createAccount":"Create your account","email":"Email","emailPlaceholder":"Enter your email","password":"Password","passwordPlaceholder":"Enter your password","confirmPassword":"Confirm Password","confirmPasswordPlaceholder":"Confirm your password","signin":"Continue","signingIn":"Signing in...","signup":"Sign up","signingUp":"Creating account...","signout":"Sign Out","changePassword":"Change Password","cancel":"Cancel","authorize":"Authorize","deny":"Deny","noAccount":"Don''t have an account?","hasAccount":"Already have an account?","forgotPassword":"Forgot your password?","signedInAs":"Signed in as","successAuth":"Successfully authenticated","errorGeneral":"An error occurred. Please try again.","errorNetwork":"Network error. Please check your connection.","errorInvalidCreds":"Invalid email or password.","authorizeTitle":"Authorize Application","authorizeDescription":"{app} would like to:","scopeProfile":"Access your profile information","scopeEmail":"Access your email address","scopeOpenid":"Authenticate you"}'::jsonb, '{"welcomeBack":"Welcome back","createAccount":"Create your account","email":"Email","emailPlaceholder":"Enter your email","password":"Password","passwordPlaceholder":"Enter your password","confirmPassword":"Confirm Password","confirmPasswordPlaceholder":"Confirm your password","signin":"Continue","signingIn":"Signing in...","signup":"Sign up","signingUp":"Creating account...","signout":"Sign Out","changePassword":"Change Password","cancel":"Cancel","authorize":"Authorize","deny":"Deny","noAccount":"Don''t have an account?","hasAccount":"Already have an account?","forgotPassword":"Forgot your password?","signedInAs":"Signed in as","successAuth":"Successfully authenticated","errorGeneral":"An error occurred. Please try again.","errorNetwork":"Network error. Please check your connection.","errorInvalidCreds":"Invalid email or password.","authorizeTitle":"Authorize Application","authorizeDescription":"{app} would like to:","scopeProfile":"Access your profile information","scopeEmail":"Access your email address","scopeOpenid":"Authenticate you"}'::jsonb, false, now()), +('branding.font', 'Typography', 'object', 'Branding/Appearance', 'Font family, base size, and weights', ARRAY[]::text[], '{"family":"system-ui, -apple-system, BlinkMacSystemFont, ''Segoe UI'', Roboto, sans-serif","size":"16px","weight":{"normal":"400","medium":"500","bold":"700"}}'::jsonb, '{"family":"system-ui, -apple-system, BlinkMacSystemFont, ''Segoe UI'', Roboto, sans-serif","size":"16px","weight":{"normal":"400","medium":"500","bold":"700"}}'::jsonb, false, now()), +('branding.custom_css', 'Custom CSS', 'string', 'Branding/Advanced', 'Additional CSS injected into login and consent pages', ARRAY[]::text[], '""'::jsonb, '""'::jsonb, false, now()), +('email.templates.signup_verification', 'signup_verification Template', 'object', 'Email / Templates', 'Template for signup_verification', ARRAY['email', 'templates']::text[], '{"subject":"Verify your email","text":"Hello {{name}},\n\nPlease verify your email by opening this link:\n{{verification_link}}\n\nIf you did not create this account, ignore this email.","html":"
Hello {{name}},
Please verify your email by opening this link:
If you did not create this account, ignore this email.
"}'::jsonb, '{"subject":"Verify your email","text":"Hello {{name}},\n\nPlease verify your email by opening this link:\n{{verification_link}}\n\nIf you did not create this account, ignore this email.","html":"Hello {{name}},
Please verify your email by opening this link:
If you did not create this account, ignore this email.
"}'::jsonb, false, now()), +('email.templates.verification_resend_confirmation', 'verification_resend_confirmation Template', 'object', 'Email / Templates', 'Template for verification_resend_confirmation', ARRAY['email', 'templates']::text[], '{"subject":"A new verification link has been sent","text":"Hello {{name}},\n\nA new verification link has been requested for this account.\n\nIf this was you, use the newest email in your inbox.","html":"Hello {{name}},
A new verification link has been requested for this account.
If this was you, use the newest email in your inbox.
"}'::jsonb, '{"subject":"A new verification link has been sent","text":"Hello {{name}},\n\nA new verification link has been requested for this account.\n\nIf this was you, use the newest email in your inbox.","html":"Hello {{name}},
A new verification link has been requested for this account.
If this was you, use the newest email in your inbox.
"}'::jsonb, false, now()), +('email.templates.email_change_verification', 'email_change_verification Template', 'object', 'Email / Templates', 'Template for email_change_verification', ARRAY['email', 'templates']::text[], '{"subject":"Verify your new email address","text":"Hello {{name}},\n\nPlease verify your new email address by opening this link:\n{{verification_link}}\n\nYour current email remains active until verification completes.","html":"Hello {{name}},
Please verify your new email address by opening this link:
Your current email remains active until verification completes.
"}'::jsonb, '{"subject":"Verify your new email address","text":"Hello {{name}},\n\nPlease verify your new email address by opening this link:\n{{verification_link}}\n\nYour current email remains active until verification completes.","html":"Hello {{name}},
Please verify your new email address by opening this link:
Your current email remains active until verification completes.
"}'::jsonb, false, now()), +('email.templates.password_recovery', 'password_recovery Template', 'object', 'Email / Templates', 'Template for password_recovery', ARRAY['email', 'templates']::text[], '{"subject":"Password recovery","text":"Hello {{name}},\n\nUse this link to recover access to your account:\n{{recovery_link}}","html":"Hello {{name}},
Use this link to recover access to your account:
"}'::jsonb, '{"subject":"Password recovery","text":"Hello {{name}},\n\nUse this link to recover access to your account:\n{{recovery_link}}","html":"Hello {{name}},
Use this link to recover access to your account:
"}'::jsonb, false, now()), +('email.templates.admin_test_email', 'admin_test_email Template', 'object', 'Email / Templates', 'Template for admin_test_email', ARRAY['email', 'templates']::text[], '{"subject":"DarkAuth SMTP test","text":"This is a test email from DarkAuth.\n\nSent at: {{sent_at}}","html":"This is a test email from DarkAuth.
Sent at: {{sent_at}}
"}'::jsonb, '{"subject":"DarkAuth SMTP test","text":"This is a test email from DarkAuth.\n\nSent at: {{sent_at}}","html":"This is a test email from DarkAuth.
Sent at: {{sent_at}}
"}'::jsonb, false, now()) +ON CONFLICT ("key") DO UPDATE SET +"name" = excluded."name", +"type" = excluded."type", +"category" = excluded."category", +"description" = excluded."description", +"tags" = excluded."tags", +"default_value" = excluded."default_value", +"secure" = excluded."secure", +"updated_at" = now(); \ No newline at end of file diff --git a/packages/api/drizzle/0018_default_clients_and_org_rbac.sql b/packages/api/drizzle/0018_default_clients_and_org_rbac.sql new file mode 100644 index 0000000..471ae70 --- /dev/null +++ b/packages/api/drizzle/0018_default_clients_and_org_rbac.sql @@ -0,0 +1,135 @@ +INSERT INTO "permissions" ("key", "description") +VALUES + ('darkauth.users:read', 'Allows searching and reading users from the user directory endpoints'), + ('darkauth.org:manage', 'Allows management of organization members, roles, and invites') +ON CONFLICT ("key") DO UPDATE +SET + "description" = EXCLUDED."description"; +--> statement-breakpoint +INSERT INTO "roles" ("key", "name", "description", "system", "created_at", "updated_at") +VALUES + ('org_admin', 'Organization Admin', 'Can manage organization members, roles, and invitations', true, now(), now()), + ('member', 'Member', 'Default organization member role', true, now(), now()) +ON CONFLICT ("key") DO UPDATE +SET + "name" = EXCLUDED."name", + "description" = EXCLUDED."description", + "system" = EXCLUDED."system", + "updated_at" = now(); +--> statement-breakpoint +INSERT INTO "role_permissions" ("role_id", "permission_key") +SELECT r."id", p."key" +FROM "roles" r +JOIN "permissions" p ON p."key" IN ('darkauth.org:manage', 'darkauth.users:read') +WHERE r."key" = 'org_admin' +ON CONFLICT DO NOTHING; +--> statement-breakpoint +INSERT INTO "organization_member_roles" ("organization_member_id", "role_id") +SELECT om."id", r."id" +FROM "organization_members" om +JOIN "roles" r ON r."key" = 'member' +LEFT JOIN "organization_member_roles" omr ON omr."organization_member_id" = om."id" +WHERE omr."organization_member_id" IS NULL +ON CONFLICT DO NOTHING; +--> statement-breakpoint +INSERT INTO "clients" ( + "client_id", + "name", + "type", + "token_endpoint_auth_method", + "client_secret_enc", + "require_pkce", + "zk_delivery", + "zk_required", + "allowed_jwe_algs", + "allowed_jwe_encs", + "redirect_uris", + "post_logout_redirect_uris", + "grant_types", + "response_types", + "scopes", + "allowed_zk_origins", + "created_at", + "updated_at" +) +VALUES + ( + 'user', + 'User Portal', + 'public', + 'none', + NULL, + true, + 'none', + false, + ARRAY[]::text[], + ARRAY[]::text[], + ARRAY['http://localhost:9080/callback']::text[], + ARRAY['http://localhost:9080']::text[], + ARRAY['authorization_code', 'refresh_token']::text[], + ARRAY['code']::text[], + ARRAY[ + '{"key":"openid","description":"Authenticate you"}', + '{"key":"profile","description":"Access your profile information"}', + '{"key":"email","description":"Access your email address"}' + ]::text[], + ARRAY['http://localhost:9080']::text[], + now(), + now() + ), + ( + 'demo-public-client', + 'Demo Public Client', + 'public', + 'none', + NULL, + true, + 'fragment-jwe', + true, + ARRAY['ECDH-ES']::text[], + ARRAY['A256GCM']::text[], + ARRAY[ + 'http://localhost:9092/', + 'http://localhost:9092/callback', + 'http://localhost:3000/', + 'http://localhost:3000/callback', + 'https://app.example.com/', + 'https://app.example.com/callback' + ]::text[], + ARRAY['http://localhost:9092/', 'http://localhost:3000', 'https://app.example.com']::text[], + ARRAY['authorization_code', 'refresh_token']::text[], + ARRAY['code']::text[], + ARRAY[ + '{"key":"openid","description":"Authenticate you"}', + '{"key":"profile","description":"Access your profile information"}', + '{"key":"email","description":"Access your email address"}' + ]::text[], + ARRAY['http://localhost:9092', 'http://localhost:3000', 'https://app.example.com']::text[], + now(), + now() + ), + ( + 'demo-confidential-client', + 'Demo Confidential Client', + 'confidential', + 'client_secret_basic', + NULL, + false, + 'none', + false, + ARRAY[]::text[], + ARRAY[]::text[], + ARRAY['http://localhost:4000/callback', 'https://support.example.com/callback']::text[], + ARRAY['http://localhost:4000', 'https://support.example.com']::text[], + ARRAY['authorization_code', 'refresh_token', 'client_credentials']::text[], + ARRAY['code']::text[], + ARRAY[ + '{"key":"openid","description":"Authenticate you"}', + '{"key":"profile","description":"Access your profile information"}', + '{"key":"darkauth.users:read","description":"Search and read users from the directory"}' + ]::text[], + ARRAY[]::text[], + now(), + now() + ) +ON CONFLICT ("client_id") DO NOTHING; diff --git a/packages/api/drizzle/meta/_journal.json b/packages/api/drizzle/meta/_journal.json index ce93c43..226d61b 100644 --- a/packages/api/drizzle/meta/_journal.json +++ b/packages/api/drizzle/meta/_journal.json @@ -106,6 +106,20 @@ "when": 1772445600000, "tag": "0016_email_verification", "breakpoints": true + }, + { + "idx": 15, + "version": "7", + "when": 1772447400000, + "tag": "0017_settings_defaults", + "breakpoints": true + }, + { + "idx": 16, + "version": "7", + "when": 1772449200000, + "tag": "0018_default_clients_and_org_rbac", + "breakpoints": true } ] } diff --git a/packages/api/scripts/install.ts b/packages/api/scripts/install.ts index d8f11ef..65a0104 100644 --- a/packages/api/scripts/install.ts +++ b/packages/api/scripts/install.ts @@ -1,12 +1,17 @@ #!/usr/bin/env node +/** + * Seeding policy: + * - Production/default bootstrap data (for example settings, RBAC, default clients) must be owned by database migrations. + * - Runtime install logic should only write install-specific values that are unique to this instance. + * - Code-based seeding is reserved for test/dev/sample/demo data. + */ +import { eq } from "drizzle-orm"; import { createContext } from "../src/context/createContext.ts"; -import { adminUsers, settings } from "../src/db/schema.ts"; -import { seedDefaultOrganizationRbac } from "../src/models/install.ts"; +import { adminUsers, clients, settings } from "../src/db/schema.ts"; import { generateEdDSAKeyPair, storeKeyPair } from "../src/services/jwks.ts"; import { createKekService, generateKdfParams } from "../src/services/kek.ts"; -import { isSystemInitialized, markSystemInitialized, seedDefaultSettings } from "../src/services/settings.ts"; -import { seedDefaultClients } from "../src/models/install.ts"; +import { isSystemInitialized, markSystemInitialized, setSetting } from "../src/services/settings.ts"; import type { Config, KdfParams } from "../src/types.ts"; import { generateRandomString } from "../src/utils/crypto.ts"; import fs from "node:fs"; @@ -135,13 +140,9 @@ async function install() { updatedAt: new Date(), }); - console.log("3. Seeding default settings..."); - await seedDefaultSettings( - context, - config.issuer, - config.publicOrigin, - config.rpId, - ); + await setSetting(context, "issuer", config.issuer); + await setSetting(context, "public_origin", config.publicOrigin); + await setSetting(context, "rp_id", config.rpId); await context.db.insert(settings).values({ key: "ui_user", @@ -289,13 +290,28 @@ async function install() { Buffer.from(demoConfidentialClientSecret), ); } - - await seedDefaultClients(context, demoConfidentialSecretEnc, config.publicOrigin); - - console.log("6. Seeding default group..."); - await seedDefaultOrganizationRbac(context); - - console.log("7. Creating default admin user..."); + // Seeding is migration-owned. Install only writes runtime-specific values. + if (demoConfidentialSecretEnc) { + await context.db + .update(clients) + .set({ + clientSecretEnc: demoConfidentialSecretEnc, + updatedAt: new Date(), + }) + .where(eq(clients.clientId, "demo-confidential-client")); + } + const normalizedOrigin = config.publicOrigin.replace(/\/+$/, ""); + await context.db + .update(clients) + .set({ + redirectUris: [`${normalizedOrigin}/callback`], + postLogoutRedirectUris: [normalizedOrigin], + allowedZkOrigins: [normalizedOrigin], + updatedAt: new Date(), + }) + .where(eq(clients.clientId, "user")); + + console.log("6. Creating default admin user..."); const adminEmail = "admin@example.com"; const adminName = "System Administrator"; @@ -306,7 +322,7 @@ async function install() { createdAt: new Date(), }); - console.log("8. Marking system as initialized..."); + console.log("7. Marking system as initialized..."); await markSystemInitialized(context); console.log( diff --git a/packages/api/src/controllers/install/postInstallComplete.ts b/packages/api/src/controllers/install/postInstallComplete.ts index 271486c..fbdc560 100644 --- a/packages/api/src/controllers/install/postInstallComplete.ts +++ b/packages/api/src/controllers/install/postInstallComplete.ts @@ -1,6 +1,8 @@ import type { IncomingMessage, ServerResponse } from "node:http"; +import { eq } from "drizzle-orm"; import { z } from "zod/v4"; +import { clients } from "../../db/schema.ts"; import { AlreadyInitializedError, ExpiredInstallTokenError, @@ -10,12 +12,7 @@ import { import { genericErrors } from "../../http/openapi-helpers.ts"; import { generateEdDSAKeyPair, storeKeyPair } from "../../services/jwks.ts"; import { ensureKekService, generateKdfParams } from "../../services/kek.ts"; -import { - isSystemInitialized, - markSystemInitialized, - seedDefaultSettings, - setSetting, -} from "../../services/settings.ts"; +import { isSystemInitialized, markSystemInitialized, setSetting } from "../../services/settings.ts"; import type { Context, ControllerSchema } from "../../types.ts"; import { withAudit } from "../../utils/auditWrapper.ts"; import { generateRandomString } from "../../utils/crypto.ts"; @@ -113,15 +110,11 @@ async function _postInstallComplete( throw new Error("No database available for installation"); } - context.logger.info("[install:post] Seeding default settings"); - const tempContextDb = { ...context, db } as Context; - await seedDefaultSettings( - tempContextDb, - context.config.issuer, - context.config.publicOrigin, - context.config.rpId - ); const installCtx = { ...context, db } as Context; + const tempContextDb = { ...context, db } as Context; + await setSetting(installCtx, "issuer", context.config.issuer); + await setSetting(installCtx, "public_origin", context.config.publicOrigin); + await setSetting(installCtx, "rp_id", context.config.rpId); await setSetting( installCtx, "users.self_registration_enabled", @@ -174,13 +167,24 @@ async function _postInstallComplete( const demoConfidentialSecretEnc = await kekService.encrypt( Buffer.from(demoConfidentialClientSecret) ); - await (await import("../../models/install.ts")).seedDefaultClients( - installCtx, - demoConfidentialSecretEnc, - context.config.publicOrigin - ); + await installCtx.db + .update(clients) + .set({ + clientSecretEnc: demoConfidentialSecretEnc, + updatedAt: new Date(), + }) + .where(eq(clients.clientId, "demo-confidential-client")); + const normalizedOrigin = context.config.publicOrigin.replace(/\/+$/, ""); + await installCtx.db + .update(clients) + .set({ + redirectUris: [`${normalizedOrigin}/callback`], + postLogoutRedirectUris: [normalizedOrigin], + allowedZkOrigins: [normalizedOrigin], + updatedAt: new Date(), + }) + .where(eq(clients.clientId, "user")); await (await import("../../models/install.ts")).ensureDefaultOrganizationAndSchema(installCtx); - await (await import("../../models/install.ts")).seedDefaultOrganizationRbac(installCtx); context.logger.debug( "[install:post] verifying admin user was created during OPAQUE registration" diff --git a/packages/api/src/models/install.test.ts b/packages/api/src/models/install.test.ts index 94bcab2..98d421f 100644 --- a/packages/api/src/models/install.test.ts +++ b/packages/api/src/models/install.test.ts @@ -1,33 +1,19 @@ import assert from "node:assert/strict"; import { test } from "node:test"; -import { parseClientScopeDefinitions } from "../utils/clientScopes.ts"; -import { buildDefaultClientSeeds } from "./install.ts"; +import { + parseClientScopeDefinitions, + serializeClientScopeDefinitions, +} from "../utils/clientScopes.ts"; -test("buildDefaultClientSeeds includes structured scope definitions", () => { - const demoSecret = Buffer.from("demo-secret"); - const seeds = buildDefaultClientSeeds(demoSecret, "https://auth.example.com"); - - assert.equal(seeds.length, 3); - assert.equal(seeds[0]?.clientId, "user"); - assert.equal(seeds[1]?.clientId, "demo-public-client"); - assert.equal(seeds[2]?.clientId, "demo-confidential-client"); - assert.equal(seeds[2]?.clientSecretEnc, demoSecret); - - const userScopes = parseClientScopeDefinitions(seeds[0]?.scopes ?? []); - const publicScopes = parseClientScopeDefinitions(seeds[1]?.scopes ?? []); - const confidentialScopes = parseClientScopeDefinitions(seeds[2]?.scopes ?? []); - - assert.deepEqual(userScopes, [ - { key: "openid", description: "Authenticate you" }, - { key: "profile", description: "Access your profile information" }, - { key: "email", description: "Access your email address" }, - ]); - assert.deepEqual(publicScopes, [ +test("client scope serialization preserves structured entries", () => { + const scopes = serializeClientScopeDefinitions([ { key: "openid", description: "Authenticate you" }, { key: "profile", description: "Access your profile information" }, - { key: "email", description: "Access your email address" }, + { key: "darkauth.users:read", description: "Search and read users from the directory" }, ]); - assert.deepEqual(confidentialScopes, [ + const parsedScopes = parseClientScopeDefinitions(scopes); + + assert.deepEqual(parsedScopes, [ { key: "openid", description: "Authenticate you" }, { key: "profile", description: "Access your profile information" }, { key: "darkauth.users:read", description: "Search and read users from the directory" }, diff --git a/packages/api/src/models/install.ts b/packages/api/src/models/install.ts index 888bb46..2e01d24 100644 --- a/packages/api/src/models/install.ts +++ b/packages/api/src/models/install.ts @@ -2,18 +2,14 @@ import { eq } from "drizzle-orm"; import { adminOpaqueRecords, adminUsers, - clients, organizationMemberRoles, organizationMembers, organizations, - permissions, - rolePermissions, roles, settings, } from "../db/schema.ts"; import { ConflictError, NotFoundError } from "../errors.ts"; import type { Context } from "../types.ts"; -import { serializeClientScopeDefinitions } from "../utils/clientScopes.ts"; export async function storeOpaqueAdmin( context: Context, @@ -63,181 +59,6 @@ export async function writeKdfSetting(context: Context, kdfParams: unknown) { .values({ key: "kek_kdf", value: kdfParams, secure: true, updatedAt: new Date() }); } -export function buildDefaultClientSeeds( - demoConfidentialSecretEnc: Buffer | null, - publicOrigin: string -) { - const normalizedOrigin = publicOrigin.replace(/\/+$/, ""); - return [ - { - clientId: "user", - name: "User Portal", - type: "public" as const, - tokenEndpointAuthMethod: "none" as const, - clientSecretEnc: null, - requirePkce: true, - zkDelivery: "none" as const, - zkRequired: false, - allowedJweAlgs: [], - allowedJweEncs: [], - redirectUris: [`${normalizedOrigin}/callback`], - postLogoutRedirectUris: [normalizedOrigin], - grantTypes: ["authorization_code", "refresh_token"], - responseTypes: ["code"], - scopes: serializeClientScopeDefinitions([ - { key: "openid", description: "Authenticate you" }, - { key: "profile", description: "Access your profile information" }, - { key: "email", description: "Access your email address" }, - ]), - allowedZkOrigins: [normalizedOrigin], - createdAt: new Date(), - updatedAt: new Date(), - }, - { - clientId: "demo-public-client", - name: "Demo Public Client", - type: "public" as const, - tokenEndpointAuthMethod: "none" as const, - clientSecretEnc: null, - requirePkce: true, - zkDelivery: "fragment-jwe" as const, - zkRequired: true, - allowedJweAlgs: ["ECDH-ES"], - allowedJweEncs: ["A256GCM"], - redirectUris: [ - "http://localhost:9092/", - "http://localhost:9092/callback", - "http://localhost:3000/", - "http://localhost:3000/callback", - "https://app.example.com/", - "https://app.example.com/callback", - ], - postLogoutRedirectUris: [ - "http://localhost:9092/", - "http://localhost:3000", - "https://app.example.com", - ], - grantTypes: ["authorization_code", "refresh_token"], - responseTypes: ["code"], - scopes: serializeClientScopeDefinitions([ - { key: "openid", description: "Authenticate you" }, - { key: "profile", description: "Access your profile information" }, - { key: "email", description: "Access your email address" }, - ]), - allowedZkOrigins: [ - "http://localhost:9092", - "http://localhost:3000", - "https://app.example.com", - ], - createdAt: new Date(), - updatedAt: new Date(), - }, - { - clientId: "demo-confidential-client", - name: "Demo Confidential Client", - type: "confidential" as const, - tokenEndpointAuthMethod: "client_secret_basic" as const, - clientSecretEnc: demoConfidentialSecretEnc, - requirePkce: false, - zkDelivery: "none" as const, - zkRequired: false, - allowedJweAlgs: [], - allowedJweEncs: [], - redirectUris: ["http://localhost:4000/callback", "https://support.example.com/callback"], - postLogoutRedirectUris: ["http://localhost:4000", "https://support.example.com"], - grantTypes: ["authorization_code", "refresh_token", "client_credentials"], - responseTypes: ["code"], - scopes: serializeClientScopeDefinitions([ - { key: "openid", description: "Authenticate you" }, - { key: "profile", description: "Access your profile information" }, - { - key: "darkauth.users:read", - description: "Search and read users from the directory", - }, - ]), - allowedZkOrigins: [], - createdAt: new Date(), - updatedAt: new Date(), - }, - ]; -} - -export async function seedDefaultClients( - context: Context, - demoConfidentialSecretEnc: Buffer | null, - publicOrigin: string -) { - await context.db - .insert(clients) - .values(buildDefaultClientSeeds(demoConfidentialSecretEnc, publicOrigin)); -} - -export async function seedDefaultOrganizationRbac(context: Context) { - const usersReadPermission = await context.db.query.permissions.findFirst({ - where: eq(permissions.key, "darkauth.users:read"), - }); - if (!usersReadPermission) { - await context.db.insert(permissions).values({ - key: "darkauth.users:read", - description: "Allows searching and reading users from the user directory endpoints", - }); - } - const orgManagePermission = await context.db.query.permissions.findFirst({ - where: eq(permissions.key, "darkauth.org:manage"), - }); - if (!orgManagePermission) { - await context.db.insert(permissions).values({ - key: "darkauth.org:manage", - description: "Allows management of organization members, roles, and invites", - }); - } - - const existingOrg = await context.db.query.organizations.findFirst({ - where: eq(organizations.slug, "default"), - }); - if (!existingOrg) { - await context.db.insert(organizations).values({ - slug: "default", - name: "Default", - forceOtp: false, - createdAt: new Date(), - updatedAt: new Date(), - }); - } - - const defaultOrg = await context.db.query.organizations.findFirst({ - where: eq(organizations.slug, "default"), - }); - if (!defaultOrg) return; - - await context.db - .insert(roles) - .values([ - { key: "member", name: "Member", system: true, createdAt: new Date(), updatedAt: new Date() }, - { - key: "org_admin", - name: "Organization Admin", - system: true, - createdAt: new Date(), - updatedAt: new Date(), - }, - ]) - .onConflictDoNothing(); - - const orgAdminRole = await context.db.query.roles.findFirst({ - where: eq(roles.key, "org_admin"), - }); - if (orgAdminRole) { - await context.db - .insert(rolePermissions) - .values([ - { roleId: orgAdminRole.id, permissionKey: "darkauth.org:manage" }, - { roleId: orgAdminRole.id, permissionKey: "darkauth.users:read" }, - ]) - .onConflictDoNothing(); - } -} - export async function ensureDefaultOrganizationAndSchema(context: Context) { try { await context.db.execute( @@ -319,10 +140,6 @@ export async function ensureDefaultOrganizationAndSchema(context: Context) { ); } catch {} - try { - await seedDefaultOrganizationRbac(context); - } catch {} - try { const defaultOrg = await context.db.query.organizations.findFirst({ where: eq(organizations.slug, "default"), diff --git a/packages/api/src/models/organizations.test.ts b/packages/api/src/models/organizations.test.ts index f75a301..b7213ce 100644 --- a/packages/api/src/models/organizations.test.ts +++ b/packages/api/src/models/organizations.test.ts @@ -55,7 +55,10 @@ test("createOrganizationInvite rejects unknown or non-assignable role ids", asyn .returning(); assert.ok(managerRole); - await db.insert(permissions).values({ key: "darkauth.org:manage", description: "Manage org" }); + await db + .insert(permissions) + .values({ key: "darkauth.org:manage", description: "Manage org" }) + .onConflictDoNothing(); await db .insert(rolePermissions) .values({ roleId: managerRole.id, permissionKey: "darkauth.org:manage" }); diff --git a/packages/api/src/services/settings.ts b/packages/api/src/services/settings.ts index 340f470..38da298 100644 --- a/packages/api/src/services/settings.ts +++ b/packages/api/src/services/settings.ts @@ -126,853 +126,6 @@ export async function pruneDeprecatedSettings(context: Context): Promise