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
1 change: 1 addition & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,7 @@ COPY config.example.json ./config.example.json
COPY --chmod=755 scripts/docker/run_daily.sh ./scripts/docker/run_daily.sh
COPY --chmod=755 scripts/docker/healthcheck.sh ./scripts/docker/healthcheck.sh
COPY --chmod=755 scripts/api/ ./scripts/api/
COPY --chmod=644 scripts/env.js ./scripts/env.js
COPY --chmod=644 scripts/package.json ./scripts/package.json
COPY --chmod=644 src/crontab.template /etc/cron.d/microsoft-rewards-cron.template
COPY --chmod=755 scripts/docker/entrypoint.sh /usr/local/bin/entrypoint.sh
Expand Down
40 changes: 40 additions & 0 deletions scripts/api/configEditor.js
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,18 @@ function resolveConfigPath(projectRoot) {
return candidates.find(p => fs.existsSync(p)) ?? path.join(projectRoot, 'config.json')
}

async function loadConfigSync(projectRoot, override) {
const modPath = override || path.join(projectRoot, 'dist', 'util', 'ConfigSync.js')
if (!override && !fs.existsSync(modPath)) return null
try {
return await import(pathToFileURL(path.resolve(modPath)).href)
} catch (error) {
throw new Error(
`Could not load ConfigSync module at ${modPath}: ${error instanceof Error ? error.message : String(error)}`
)
}
}

async function loadBotValidator(projectRoot, override) {
const modPath = override || path.join(projectRoot, 'dist', 'util', 'Validator.js')
if (!override && !fs.existsSync(modPath)) return null
Expand Down Expand Up @@ -212,6 +224,34 @@ export function deepMerge(base, patch) {
return out
}

// Compares the current config.json against config.example.json.
// Returns dotted key-paths present in the example but missing from the
// user's file - the same check entrypoint.sh runs on container start, now
// available on demand from the API/dashboard (e.g. GET /config/diff).
export async function diffConfig(projectRoot, { validatorModule } = {}) {
const mod = await loadConfigSync(projectRoot, validatorModule)
if (!mod) throw new Error('ConfigSync module not found - run `npm run build`.')
const { data: config } = readConfig(projectRoot)
const example = mod.readJson(mod.resolveExamplePath(projectRoot))
return { addedKeys: mod.diffKeyPaths(config, example) }
}

// Fills in any keys missing from config.json using config.example.json's
// values, without touching existing user values, and writes the result back
// (with a .bak backup via writeConfigAtomic). Intended for a gated endpoint
// like POST /config/sync, behind API_ALLOW_CONFIG_WRITE.
export async function syncMissingDefaults(projectRoot, { validatorModule } = {}) {
const mod = await loadConfigSync(projectRoot, validatorModule)
if (!mod) throw new Error('ConfigSync module not found - run `npm run build`.')
const { data: config } = readConfig(projectRoot)
const example = mod.readJson(mod.resolveExamplePath(projectRoot))
const { merged, addedKeys } = mod.mergeMissingDefaults(config, example)
if (addedKeys.length > 0) {
writeConfigAtomic(projectRoot, merged)
}
return { addedKeys, patched: addedKeys.length > 0 }
}

export function readConfig(projectRoot) {
const p = resolveConfigPath(projectRoot)
return { path: p, data: JSON.parse(fs.readFileSync(p, 'utf8')) }
Expand Down
46 changes: 45 additions & 1 deletion scripts/api/server.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import { fileURLToPath } from 'node:url'

import { ProcessManager } from './processManager.js'
import { buildExcludedAccountsEnv, buildSingleAccountEnv, loadAccounts, mergeAccountStats } from './accounts.js'
import { validateConfig, deepMerge, readConfig, writeConfigAtomic } from './configEditor.js'
import { validateConfig, deepMerge, readConfig, writeConfigAtomic, diffConfig, syncMissingDefaults } from './configEditor.js'
import { readSchedule, writeSchedule } from './scheduleStore.js'
import { deleteStoredSessions, listStoredSessions } from './sessionStore.js'
import { resolveRunCommand } from './runCommand.js'
Expand Down Expand Up @@ -293,6 +293,8 @@ const requestHandler = async (req, res) => {
'GET /diagnostics',
'GET /events',
'GET /config',
'GET /config/diff',
'POST /config/sync',
'GET /schedule',
'POST /start',
'POST /stop',
Expand Down Expand Up @@ -444,6 +446,48 @@ const requestHandler = async (req, res) => {
return sendJson(res, 200, { path: loaded.path, redacted: !reveal, config: data })
}

// conf diff - lists keys present in config.example.json but missing
// from the live config.json. Same check entrypoint.sh runs on
// container start, exposed here so a dashboard can surface it
// without waiting for the next restart. Always readable; no write
// gate needed since it doesn't touch the file.
if (method === 'GET' && pathname === '/config/diff') {
try {
const { addedKeys } = await diffConfig(projectRoot, {
validatorModule: envStr('API_VALIDATOR_MODULE')
})
return sendJson(res, 200, { addedKeys, upToDate: addedKeys.length === 0 })
} catch (err) {
return sendJson(res, 500, { error: err instanceof Error ? err.message : String(err) })
}
}

// conf sync - patches missing keys into config.json using
// config.example.json's defaults, without touching values the user
// already set. Same trust level as PUT/PATCH /config, so it rides
// the same flag.
if (method === 'POST' && pathname === '/config/sync') {
if (!ALLOW_CONFIG_WRITE) {
return sendJson(res, 403, {
error: 'Config writes are disabled. Set API_ALLOW_CONFIG_WRITE=true to enable.'
})
}
try {
const result = await syncMissingDefaults(projectRoot, {
validatorModule: envStr('API_VALIDATOR_MODULE')
})
if (result.patched) {
pm.note(
'info',
`config.json patched with ${result.addedKeys.length} missing key(s) via API /config/sync.`
)
}
return sendJson(res, 200, { ...result, appliesOnNextRun: true })
} catch (err) {
return sendJson(res, 500, { error: err instanceof Error ? err.message : String(err) })
}
}

// sched read
if (method === 'GET' && pathname === '/schedule') {
try {
Expand Down
Loading
Loading