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
2 changes: 1 addition & 1 deletion apps/community/app/(board)/member/[id]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -89,7 +89,7 @@ export default async function MemberProfilePage({ params }: { params: Promise<{
},
),
regions: {
plugins: pluginRegion('profile.panel', {
plugins: await pluginRegion('profile.panel', {
viewer: viewerRef(actor),
subjectId: id,
authorId: id,
Expand Down
2 changes: 1 addition & 1 deletion apps/community/app/(board)/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -143,7 +143,7 @@ export default async function BoardIndexPage() {
{latest}
</LiveRegion>
),
plugins: boardRegion('index.footer', actor),
plugins: await boardRegion('index.footer', actor),
...(announcements.length === 0
? {}
: {
Expand Down
4 changes: 2 additions & 2 deletions apps/community/app/(board)/thread/[slug]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -473,12 +473,12 @@ export default async function ThreadPage({
)}
</PostActions>
),
pluginBadges: pluginRegion('postbit.badges', {
pluginBadges: await pluginRegion('postbit.badges', {
viewer: viewerRef(actor),
subjectId: post.id,
authorId: post.author.userId,
}),
pluginFooter: pluginRegion('postbit.footer', {
pluginFooter: await pluginRegion('postbit.footer', {
viewer: viewerRef(actor),
subjectId: post.id,
authorId: post.author.userId,
Expand Down
2 changes: 1 addition & 1 deletion apps/community/src/components/shell/page-shell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -114,7 +114,7 @@ export async function PageShell({
</UserPanel>
</Header>

{boardRegion('header.notice', actor)}
{await boardRegion('header.notice', actor)}

{children}

Expand Down
120 changes: 119 additions & 1 deletion apps/community/src/server/plugin-admin-actions.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,13 +25,21 @@ vi.mock('../../community.config', () => ({
}))

const adminCalls: Array<{ action: string; detail: unknown }> = []
const requireAdminMock = vi.fn(async () => ({ userId: 1 }))
const requireFreshAdminMock = vi.fn(async () => ({ userId: 1 }))
vi.mock('./admin', () => ({
requireAdmin: async () => ({ userId: 1 }),
requireAdmin: () => requireAdminMock(),
requireFreshAdmin: () => requireFreshAdminMock(),
recordAdminAction: async (input: { action: string; detail?: unknown }) => {
adminCalls.push({ action: input.action, detail: input.detail })
},
}))

const overrides = { current: new Map<string, string>() }
vi.mock('./settings', () => ({
getSettingOverrides: async () => overrides.current,
}))

const synced = { count: 0 }
vi.mock('./plugin-host', () => ({
syncOperatorDisables: async () => {
Expand Down Expand Up @@ -85,12 +93,59 @@ function form(fields: Record<string, string>): FormData {

beforeEach(() => {
config.current.plugins = [{ key: 'alpha', plugin: ALPHA }]
overrides.current = new Map()
adminCalls.length = 0
invalidated.length = 0
revalidated.length = 0
written.length = 0
deleted.length = 0
synced.count = 0
vi.unstubAllEnvs()
requireAdminMock.mockClear()
requireAdminMock.mockResolvedValue({ userId: 1 })
requireFreshAdminMock.mockClear()
requireFreshAdminMock.mockResolvedValue({ userId: 1 })
})

const staleProof = () =>
Object.assign(new Error('confirm'), {
code: 'FORBIDDEN',
publicMessage: 'Confirm your password again before doing this.',
})

describe('the admin gate', () => {
it('asks for a fresh password before taking a plugin off the board', async () => {
await setPluginEnabledAction({}, form({ key: 'alpha', enabled: '0' }))

expect(requireFreshAdminMock).toHaveBeenCalledTimes(1)
expect(requireAdminMock).not.toHaveBeenCalled()
})

it('switches nothing off when the proof is stale', async () => {
requireFreshAdminMock.mockRejectedValue(staleProof())

const state = await setPluginEnabledAction({}, form({ key: 'alpha', enabled: '0' }))

expect(state.error).toBeDefined()
expect(written).toEqual([])
expect(adminCalls).toEqual([])
expect(synced.count).toBe(0)
})

it('puts a plugin back, and saves settings, on the panel session alone', async () => {
requireFreshAdminMock.mockRejectedValue(staleProof())

const back = await setPluginEnabledAction({}, form({ key: 'alpha', enabled: '1' }))
const settings = await savePluginSettingsAction(
{},
form({ key: 'alpha', 'setting.api_url': 'https://other.test', 'setting.batch': '1' }),
)

expect(back.notice).toBe('enabled')
expect(settings.notice).toBe('saved')
expect(requireAdminMock).toHaveBeenCalledTimes(2)
expect(requireFreshAdminMock).not.toHaveBeenCalled()
})
})

describe('the switch', () => {
Expand Down Expand Up @@ -211,6 +266,69 @@ describe('saving settings', () => {
})
})

it('leaves a setting the environment owns unwritten', async () => {
config.current.plugins = [
{
key: 'alpha',
plugin: {
...ALPHA,
settings: [
{ key: 'api_url', label: 'URL', env: 'ALPHA_API_URL', default: 'https://example.test' },
{ key: 'batch', label: 'Batch', default: 10 },
],
},
},
]
vi.stubEnv('ALPHA_API_URL', 'https://from-the-environment.test')

const state = await savePluginSettingsAction({}, form({ key: 'alpha', 'setting.batch': '25' }))

expect(state.notice).toBe('saved')
expect([...(written[0] ?? [])]).toEqual([['plugin.alpha.batch', '25']])
})

it('leaves an env-owned boolean unwritten rather than storing the inert box as "0"', async () => {
config.current.plugins = [
{
key: 'alpha',
plugin: {
...ALPHA,
settings: [
{ key: 'api_url', label: 'URL', default: 'https://example.test' },
{ key: 'verbose', label: 'Verbose', env: 'ALPHA_VERBOSE', default: false },
],
},
},
]
vi.stubEnv('ALPHA_VERBOSE', '1')

const state = await savePluginSettingsAction(
{},
form({ key: 'alpha', 'setting.api_url': 'https://other.test' }),
)

expect(state.notice).toBe('saved')
expect([...(written[0]?.keys() ?? [])]).toEqual(['plugin.alpha.api_url'])
expect(written[0]?.has('plugin.alpha.verbose')).toBe(false)
expect(adminCalls[0]?.detail).toEqual({ plugin: 'alpha', keys: ['plugin.alpha.api_url'] })
})

it('still writes a boolean whose variable is declared and unset', async () => {
config.current.plugins = [
{
key: 'alpha',
plugin: {
...ALPHA,
settings: [{ key: 'verbose', label: 'Verbose', env: 'ALPHA_VERBOSE', default: false }],
},
},
]

await savePluginSettingsAction({}, form({ key: 'alpha', 'setting.verbose': '1' }))

expect(written[0]?.get('plugin.alpha.verbose')).toBe('1')
})

it('refuses a plugin that declares no settings', async () => {
config.current.plugins = [{ key: 'alpha', plugin: { ...ALPHA, settings: [] } }]
const state = await savePluginSettingsAction({}, form({ key: 'alpha' }))
Expand Down
30 changes: 23 additions & 7 deletions apps/community/src/server/plugin-admin-actions.ts
Original file line number Diff line number Diff line change
@@ -1,21 +1,23 @@
'use server'

import { CacheTags, ValidationError, isAppError, logger } from '@meith/core'
import { CacheTags, ValidationError, isAppError, logger, readPluginEnv } from '@meith/core'
import { PostgresSettingsRepository, getDb } from '@meith/db'
import { drivers } from '@meith/drivers'
import { revalidatePath } from 'next/cache'
import {
parsePluginSetting,
pluginEnabledKey,
pluginSettingType,
resolvePluginSettingDetails,
serialisePluginSetting,
type PluginDefinition,
} from '@meith/plugin-kit'

import forumConfig from '../../community.config'
import { recordAdminAction, requireAdmin } from './admin'
import { recordAdminAction, requireAdmin, requireFreshAdmin } from './admin'
import type { FormState } from './auth-form-state'
import { syncOperatorDisables } from './plugin-host'
import { getSettingOverrides } from './settings'

function requireDefinition(key: string): PluginDefinition {
const entry = (forumConfig.plugins ?? []).find((candidate) => candidate.key === key)
Expand Down Expand Up @@ -43,10 +45,15 @@ export async function setPluginEnabledAction(
form: FormData,
): Promise<FormState> {
try {
await requireAdmin()

const key = String(form.get('key') ?? '')
const enabled = form.get('enabled') === '1'

if (enabled) {
await requireAdmin()
} else {
await requireFreshAdmin()
}

requireDefinition(key)

const repository = new PostgresSettingsRepository(getDb())
Expand Down Expand Up @@ -87,15 +94,24 @@ export async function savePluginSettingsAction(
throw new ValidationError(`"${key}" declares no settings.`)
}

const resolved = resolvePluginSettingDetails(
definition,
await getSettingOverrides(),
readPluginEnv,
)
const environmentOwned = new Set(
resolved
.filter((detail) => detail.source === 'environment')
.map((detail) => detail.setting.key),
)

const updates = new Map<string, string>()
for (const setting of declared) {
const field = `setting.${setting.key}`
const type = pluginSettingType(setting)
const raw = form.get(field)

// A field the environment owns arrives disabled and absent; writing a
// stored value under it would be a value nobody sees until the
// variable is unset, which is a surprise saved up for later.
if (environmentOwned.has(setting.key)) continue
if (raw === null && type !== 'boolean') continue

if (type === 'boolean') {
Expand Down
26 changes: 25 additions & 1 deletion apps/community/src/server/plugin-admin.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,13 +33,28 @@ const HEALTHY: PluginHealth = {
lastError: null,
}

const operatorDisabled = new Set<string>()
const host = {
health: (): readonly PluginHealth[] => [HEALTHY],
setOperatorDisabled: (keys: readonly string[]) => {
operatorDisabled.clear()
for (const key of keys) operatorDisabled.add(key)
},
health: (): readonly PluginHealth[] => [
{
...HEALTHY,
enabled: HEALTHY.enabled && !operatorDisabled.has(HEALTHY.key),
operatorDisabled: operatorDisabled.has(HEALTHY.key),
},
],
listeners: () => ({ 'post.created': ['alpha'], 'markdown.render.html': [] }),
}
vi.mock('./plugin-host', async (importOriginal) => ({
...(await importOriginal<typeof PluginHostModule>()),
pluginHost: host,
syncOperatorDisables: async () => {
const { operatorDisabledPlugins } = await import('@meith/plugin-kit')
host.setOperatorDisabled(operatorDisabledPlugins(overrides.current))
},
}))

const dataSource = { current: 'postgres' as 'postgres' | 'fixture' }
Expand Down Expand Up @@ -85,6 +100,7 @@ const ALPHA = {
beforeEach(() => {
config.current.plugins = [{ key: 'alpha', plugin: ALPHA }]
overrides.current = new Map()
operatorDisabled.clear()
dataSource.current = 'postgres'
applied.current = ['0001_first']
applied.throws = false
Expand All @@ -111,6 +127,14 @@ describe('the three states of "enabled"', () => {
expect(row).toMatchObject({ configuredEnabled: true, operatorEnabled: false, running: false })
})

it('reconciles the host before reading health, so a fresh process does not say "running"', async () => {
overrides.current = new Map([['plugin.alpha._enabled', '0']])

const [row] = (await pluginInventory()).plugins
expect(row?.health).toMatchObject({ enabled: false, operatorDisabled: true })
expect(row?.running).toBe(false)
})

it('is not running when the host has auto-disabled it', async () => {
const failing: PluginHealth = { ...HEALTHY, enabled: false, disabledReason: 'failed 5 times' }
vi.spyOn(host, 'health').mockReturnValueOnce([failing])
Expand Down
3 changes: 2 additions & 1 deletion apps/community/src/server/plugin-admin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ import {
} from '@meith/plugin-kit'

import forumConfig from '../../community.config'
import { configuredPlugins, pluginHost } from './plugin-host'
import { configuredPlugins, pluginHost, syncOperatorDisables } from './plugin-host'
import { getSettingOverrides } from './settings'

export type PluginSettingKind = PluginSettingType
Expand Down Expand Up @@ -110,6 +110,7 @@ async function appliedByPlugin(
export async function pluginInventory(): Promise<PluginInventory> {
const definitions = definitionsByKey()
const overrides = await getSettingOverrides()
await syncOperatorDisables()
const health = new Map(pluginHost.health().map((entry) => [entry.key, entry]))

const withMigrations = [...definitions]
Expand Down
Loading
Loading