0ae59f3550
Introduces RECAP_MODE=multi alongside single-mode self-host: - Tenant auth + accounts (magic-link via System SMTP), per-tenant credit pool, anonymous trial minting with per-IP/-64 caps - Self-serve Pro/Max purchase: inline Lightning (BTCPay) + card (Zaprite), prepaid 30-day periods, expiry-reminder emails - Core-decoupling: relay owns cloud tier/expiry keyed by Recaps user-id - SQLite (better-sqlite3) schema for multi-mode; filesystem unchanged for single - StartOS actions/versions through 0.2.155
109 lines
3.1 KiB
TypeScript
109 lines
3.1 KiB
TypeScript
import { sdk } from '../sdk'
|
|
import { configFile } from '../file-models/config.json'
|
|
import { randomBytes, scryptSync } from 'crypto'
|
|
|
|
const { InputSpec, Value } = sdk
|
|
|
|
const SCRYPT_KEYLEN = 64
|
|
|
|
const inputSpec = InputSpec.of({
|
|
recap_admin_username: Value.text({
|
|
name: 'Admin Username',
|
|
description:
|
|
'Username required at the login screen. Defaults to "admin".',
|
|
required: true,
|
|
default: 'admin',
|
|
minLength: 1,
|
|
maxLength: 64,
|
|
}),
|
|
recap_admin_password: Value.text({
|
|
name: 'Admin Password',
|
|
description:
|
|
'Password required at the login screen. Must be at least 8 characters. Leave blank to disable the login gate.',
|
|
required: false,
|
|
default: null,
|
|
masked: true,
|
|
minLength: 0,
|
|
maxLength: 256,
|
|
}),
|
|
recap_admin_password_confirm: Value.text({
|
|
name: 'Confirm Password',
|
|
description: 'Re-enter the password to confirm.',
|
|
required: false,
|
|
default: null,
|
|
masked: true,
|
|
minLength: 0,
|
|
maxLength: 256,
|
|
}),
|
|
})
|
|
|
|
export const setAdminPassword = sdk.Action.withInput(
|
|
'set-admin-password',
|
|
|
|
async ({ effects }) => ({
|
|
name: 'Set Admin Password',
|
|
description:
|
|
'Set a username and password that gate the Recaps web UI. Anyone visiting the site (LAN or clearnet) must log in before reaching the activation screen. Leave the password blank to disable the gate.',
|
|
warning: null,
|
|
allowedStatuses: 'any',
|
|
group: 'Setup',
|
|
visibility: 'enabled',
|
|
}),
|
|
|
|
inputSpec,
|
|
|
|
async ({ effects }) => {
|
|
const config = await configFile.read().once()
|
|
return {
|
|
recap_admin_username: config?.recap_admin_username || 'admin',
|
|
recap_admin_password: undefined,
|
|
recap_admin_password_confirm: undefined,
|
|
}
|
|
},
|
|
|
|
async ({ effects, input }) => {
|
|
const username = (input.recap_admin_username || '').trim()
|
|
const password = input.recap_admin_password || ''
|
|
const confirm = input.recap_admin_password_confirm || ''
|
|
|
|
if (!username) {
|
|
throw new Error('Username is required.')
|
|
}
|
|
|
|
if (password === '' && confirm === '') {
|
|
// Disable the gate: clear hash + salt, keep username for next time.
|
|
await configFile.merge(effects, {
|
|
recap_admin_username: username,
|
|
recap_admin_password_hash: '',
|
|
recap_admin_password_salt: '',
|
|
})
|
|
return null
|
|
}
|
|
|
|
if (password !== confirm) {
|
|
throw new Error('Password and confirmation do not match.')
|
|
}
|
|
if (password.length < 8) {
|
|
throw new Error('Password must be at least 8 characters.')
|
|
}
|
|
|
|
const salt = randomBytes(16).toString('hex')
|
|
const hash = scryptSync(password, salt, SCRYPT_KEYLEN).toString('hex')
|
|
|
|
const existing = await configFile.read().once()
|
|
const sessionSecret =
|
|
existing?.recap_admin_session_secret && existing.recap_admin_session_secret.length > 0
|
|
? existing.recap_admin_session_secret
|
|
: randomBytes(32).toString('hex')
|
|
|
|
await configFile.merge(effects, {
|
|
recap_admin_username: username,
|
|
recap_admin_password_hash: hash,
|
|
recap_admin_password_salt: salt,
|
|
recap_admin_session_secret: sessionSecret,
|
|
})
|
|
|
|
return null
|
|
},
|
|
)
|