From efb507961a2b1d85a3bb6ae46dec59c6767cc36a Mon Sep 17 00:00:00 2001 From: Roman Malenko Date: Wed, 9 Sep 2026 17:04:49 +0300 Subject: [PATCH 1/3] feat: export the column visibility and writability helpers plugins keep re-implementing --- adminforth/modules/restApi.ts | 116 ++------------------ adminforth/modules/utils.ts | 101 +++++++++++++++++- tests/jest_tests/column_access.test.ts | 142 +++++++++++++++++++++++++ 3 files changed, 251 insertions(+), 108 deletions(-) create mode 100644 tests/jest_tests/column_access.test.ts diff --git a/adminforth/modules/restApi.ts b/adminforth/modules/restApi.ts index 6f4857325..af9d12625 100644 --- a/adminforth/modules/restApi.ts +++ b/adminforth/modules/restApi.ts @@ -22,7 +22,8 @@ import {cascadeChildrenDelete} from './utils.js' import { afLogger } from "./logger.js"; -import { ADMINFORTH_VERSION, listify, md5hash, getLoginPromptHTML, hookResponseError, parseLooseJson, RateLimiter } from './utils.js'; +import { ADMINFORTH_VERSION, listify, md5hash, getLoginPromptHTML, hookResponseError, parseLooseJson, RateLimiter, + isBackendOnly, isShown, stripBackendOnly, recordWriteError } from './utils.js'; import AdminForthAuth from "../auth.js"; import { ActionCheckSource, AdminForthActionFront, AdminForthConfigMenuItem, AdminForthDataTypes, AdminForthFilterOperators, AdminForthResourceColumnInputCommon, AdminForthResourceFrontend, AdminForthResourcePages, @@ -35,51 +36,6 @@ import { filtersTools } from "../modules/filtersTools.js"; import { normalizeColumnValue } from './columnValueNormalizer.js'; -async function resolveBoolOrFn( - val: BackendOnlyInput | undefined, - ctx: { - adminUser: AdminUser; - resource: AdminForthResource; - meta: any; - source: ActionCheckSource; - adminforth: IAdminForth; - } -): Promise { - if (typeof val === 'function') { - return !!(await (val)(ctx)); - } - return !!val; -} - -async function isBackendOnly( - col: AdminForthResource['columns'][number], - ctx: { - adminUser: AdminUser; - resource: AdminForthResource; - meta: any; - source: ActionCheckSource; - adminforth: IAdminForth; - } -): Promise { - return await resolveBoolOrFn(col.backendOnly, ctx); -} - -async function isShown( - col: AdminForthResource['columns'][number], - page: 'list' | 'show' | 'edit' | 'create' | 'filter', - ctx: Parameters[1] -): Promise { - const s = (col.showIn as any) || {}; - if (s[page] !== undefined) return await resolveBoolOrFn(s[page], ctx); - if (s.all !== undefined) return await resolveBoolOrFn(s.all, ctx); - return true; -} - -async function isFilledOnCreate( col: AdminForthResource['columns'][number] ): Promise { - const fillOnCreate = !!col.fillOnCreate; - return fillOnCreate; -} - function stripResourceColumnFrontendMeta(column: Record) { const { default: _default, _baseTypeDebug, ...sanitizedColumn } = column; return sanitizedColumn; @@ -1061,13 +1017,7 @@ export default class AdminForthRestAPI implements IAdminForthRestAPI { source: ActionCheckSource.ShowRequest, adminforth: this.adminforth, }; - for (const key of Object.keys(adminUser.dbUser)) { - const col = userResource.columns.find((c) => c.name === key); - const bo = col ? await isBackendOnly(col, ctx) : true; - if (!col || bo) { - delete adminUser.dbUser[key]; - } - } + await stripBackendOnly(adminUser.dbUser, ctx); return { loggedIn: true, @@ -1632,13 +1582,7 @@ export default class AdminForthRestAPI implements IAdminForthRestAPI { }; for (const item of data.data) { - for (const key of Object.keys(item)) { - const col = resource.columns.find((c) => c.name === key); - const bo = col ? await isBackendOnly(col, ctx) : true; - if (!col || bo) { - delete item[key]; - } - } + await stripBackendOnly(item, ctx); if (!selectedColumnNameSet || shouldAddListHelpers) { item._label = resource.recordLabel(item); } @@ -2198,26 +2142,9 @@ export default class AdminForthRestAPI implements IAdminForthRestAPI { } } - for (const column of resource.columns) { - const fieldName = column.name; - if (fieldName in record) { - const shown = await isShown(column, 'create', ctxCreate); // - const bo = await isBackendOnly(column, ctxCreate); - const filledOnCreate = await isFilledOnCreate(column); - if (bo) { - return { - error: `Field "${fieldName}" cannot be modified as it is restricted from creation (backendOnly is true).`, - ok: false, - }; - } - - if (!shown && !filledOnCreate && !column.allowModifyWhenNotShowInCreate) { - return { - error: `Field "${fieldName}" cannot be modified as it is restricted from creation (showIn.create is false). If you need to set this hidden field during creation, either configure column.fillOnCreate or set column.allowModifyWhenNotShowInCreate = true.`, - ok: false, - }; - } - } + const createFieldError = await recordWriteError(record, 'create', ctxCreate); + if (createFieldError) { + return { error: createFieldError, ok: false }; } // for polymorphic foreign resources, we need to find out the value for polymorphicOn column @@ -2342,32 +2269,9 @@ export default class AdminForthRestAPI implements IAdminForthRestAPI { adminforth: this.adminforth, }; - for (const column of resource.columns) { - const fieldName = column.name; - if (fieldName in record) { - const shown = await isShown(column, 'edit', ctxEdit); - const bo = await isBackendOnly(column, ctxEdit); - if (bo) { - return { - error: `Field "${fieldName}" cannot be modified as it is restricted from editing (backendOnly is true).`, - ok: false, - }; - } - - if (column.editReadonly) { - return { - error: `Field "${fieldName}" cannot be modified as it is restricted from editing (editReadonly is true).`, - ok: false, - }; - } - - if (!shown && !column.allowModifyWhenNotShowInEdit) { - return { - error: `Field "${fieldName}" cannot be modified as it is restricted from editing (showIn.edit is false). If you need to allow updating this hidden field during editing, set column.allowModifyWhenNotShowInEdit = true.`, - ok: false, - }; - } - } + const editFieldError = await recordWriteError(record, 'edit', ctxEdit); + if (editFieldError) { + return { error: editFieldError, ok: false }; } // for polymorphic foreign resources, we need to find out the value for polymorphicOn column for (const column of resource.columns) { diff --git a/adminforth/modules/utils.ts b/adminforth/modules/utils.ts index 038fdbbb6..cc405da5c 100644 --- a/adminforth/modules/utils.ts +++ b/adminforth/modules/utils.ts @@ -5,7 +5,8 @@ import Fuse from 'fuse.js'; import crypto from 'crypto'; import { AdminForthConfig, AdminForthResource, AdminForthResourceColumnInputCommon,Filters, IAdminForth, Predicate } from '../index.js'; import { RateLimiterMemory, RateLimiterAbstract } from "rate-limiter-flexible"; -import { PERIOD_UNITS, type PeriodString, type PeriodUnit } from '../types/Back.js'; +import { PERIOD_UNITS, type PeriodString, type PeriodUnit, type BackendOnlyInput } from '../types/Back.js'; +import type { AdminUser, ActionCheckSource } from '../types/Common.js'; // @ts-ignore-next-line @@ -667,4 +668,100 @@ export function checkIfLinkInAllowedHosts(url: string, allowedHosts: string[]) { if (!allowed) { throw new Error(`Attachment host "${hostname}" is not in attachImagesAllowedHosts`); } -} \ No newline at end of file +} + + +export type ColumnAccessContext = { + adminUser: AdminUser; + resource: AdminForthResource; + meta?: any; + source: ActionCheckSource; + adminforth: IAdminForth; +}; + +type Column = AdminForthResource['columns'][number]; + +export async function resolveBoolOrFn(val: BackendOnlyInput | undefined, ctx: ColumnAccessContext): Promise { + if (typeof val === 'function') { + return !!(await (val as any)(ctx)); + } + return !!val; +} + +export async function isBackendOnly(col: Column, ctx: ColumnAccessContext): Promise { + return await resolveBoolOrFn(col.backendOnly, ctx); +} + +export async function isShown( + col: Column, + page: 'list' | 'show' | 'edit' | 'create' | 'filter', + ctx: ColumnAccessContext, +): Promise { + const s = (col.showIn as any) || {}; + if (s[page] !== undefined) { + return await resolveBoolOrFn(s[page], ctx); + } + if (s.all !== undefined) { + return await resolveBoolOrFn(s.all, ctx); + } + return true; +} + +/** + * Removes every key that is not a declared column of the resource, or is backendOnly for this user. + * Apply it to any record before it leaves the server. Mutates and returns the record. + */ +export async function stripBackendOnly>(record: T, ctx: ColumnAccessContext): Promise { + for (const key of Object.keys(record)) { + const col = ctx.resource.columns.find((c) => c.name === key); + if (!col || await isBackendOnly(col, ctx)) { + delete record[key]; + } + } + return record; +} + +/** + * Returns null when the column may be written on this page, otherwise the message + * the REST create/update routes return. + */ +export async function columnWriteError( + col: Column, + page: 'create' | 'edit', + ctx: ColumnAccessContext, +): Promise { + const action = page === 'edit' ? 'editing' : 'creation'; + if (await isBackendOnly(col, ctx)) { + return `Field "${col.name}" cannot be modified as it is restricted from ${action} (backendOnly is true).`; + } + if (page === 'edit' && col.editReadonly) { + return `Field "${col.name}" cannot be modified as it is restricted from editing (editReadonly is true).`; + } + if (await isShown(col, page, ctx)) { + return null; + } + if (page === 'edit') { + return col.allowModifyWhenNotShowInEdit ? null + : `Field "${col.name}" cannot be modified as it is restricted from editing (showIn.edit is false). If you need to allow updating this hidden field during editing, set column.allowModifyWhenNotShowInEdit = true.`; + } + return (col.fillOnCreate || col.allowModifyWhenNotShowInCreate) ? null + : `Field "${col.name}" cannot be modified as it is restricted from creation (showIn.create is false). If you need to set this hidden field during creation, either configure column.fillOnCreate or set column.allowModifyWhenNotShowInCreate = true.`; +} + +/** Returns null when every declared column the record touches may be written on this page, otherwise the first error. */ +export async function recordWriteError( + record: Record, + page: 'create' | 'edit', + ctx: ColumnAccessContext, +): Promise { + for (const col of ctx.resource.columns) { + if (!(col.name in record)) { + continue; + } + const error = await columnWriteError(col, page, ctx); + if (error) { + return error; + } + } + return null; +} diff --git a/tests/jest_tests/column_access.test.ts b/tests/jest_tests/column_access.test.ts new file mode 100644 index 000000000..b5247f06a --- /dev/null +++ b/tests/jest_tests/column_access.test.ts @@ -0,0 +1,142 @@ +import { jest } from '@jest/globals'; +import { ActionCheckSource } from '../../adminforth/types/Common.js'; +import { + resolveBoolOrFn, + isBackendOnly, + isShown, + stripBackendOnly, + columnWriteError, + recordWriteError, +} from '../../adminforth/modules/utils.js'; + +const EDITOR = { dbUser: { role: 'editor' } } as any; +const VIEWER = { dbUser: { role: 'viewer' } } as any; + +function ctxFor(columns: any[], adminUser: any = EDITOR, source = ActionCheckSource.EditRequest) { + const resource: any = { resourceId: 'things', columns }; + return { adminUser, resource, meta: { pk: 1 }, source, adminforth: {} as any }; +} + +const col = (name: string, extra: Record = {}) => ({ name, ...extra }); + +describe('resolveBoolOrFn', () => { + it.each([ + ['true', true, true], + ['false', false, false], + ['undefined', undefined, false], + ])('passes a %s through as a boolean', async (_label, value, expected) => { + expect(await resolveBoolOrFn(value as any, ctxFor([]))).toBe(expected); + }); + + it('calls a function value and coerces whatever it returns', async () => { + expect(await resolveBoolOrFn((async () => 'yes') as any, ctxFor([]))).toBe(true); + expect(await resolveBoolOrFn((async () => '') as any, ctxFor([]))).toBe(false); + }); + + it('hands the function the request context', async () => { + const rule = jest.fn(async () => true); + const ctx = ctxFor([]); + + await resolveBoolOrFn(rule as any, ctx); + + expect(rule).toHaveBeenCalledWith(ctx); + }); +}); + +describe('isBackendOnly', () => { + it('resolves a per-user rule differently for different users', async () => { + const column = col('salary', { backendOnly: async ({ adminUser }: any) => adminUser.dbUser.role !== 'editor' }); + + expect(await isBackendOnly(column as any, ctxFor([column], EDITOR))).toBe(false); + expect(await isBackendOnly(column as any, ctxFor([column], VIEWER))).toBe(true); + }); +}); + +describe('isShown', () => { + it('prefers the page key, falls back to all, and defaults to shown', async () => { + expect(await isShown(col('a', { showIn: { edit: false, all: true } }) as any, 'edit', ctxFor([]))).toBe(false); + expect(await isShown(col('b', { showIn: { all: false } }) as any, 'edit', ctxFor([]))).toBe(false); + expect(await isShown(col('c') as any, 'edit', ctxFor([]))).toBe(true); + }); +}); + +describe('stripBackendOnly', () => { + it('drops undeclared keys and backendOnly columns, keeping the rest', async () => { + const columns = [col('id'), col('title'), col('secret', { backendOnly: true })]; + const record = { id: 1, title: 'Mug', secret: 'x', password_hash: 'y' }; + + const visible = await stripBackendOnly(record, ctxFor(columns)); + + expect(visible).toEqual({ id: 1, title: 'Mug' }); + // both call sites in core discard the return value and rely on the record being mutated + expect(visible).toBe(record); + }); + + it('resolves a per-user rule, so one user keeps what another loses', async () => { + const columns = [col('id'), col('note', { backendOnly: async ({ adminUser }: any) => adminUser.dbUser.role !== 'editor' })]; + + expect(await stripBackendOnly({ id: 1, note: 'hi' }, ctxFor(columns, EDITOR))).toEqual({ id: 1, note: 'hi' }); + expect(await stripBackendOnly({ id: 1, note: 'hi' }, ctxFor(columns, VIEWER))).toEqual({ id: 1 }); + }); +}); + +describe('columnWriteError', () => { + it('refuses a backendOnly column with the message the REST routes return', async () => { + const column = col('secret', { backendOnly: true }); + + expect(await columnWriteError(column as any, 'edit', ctxFor([column]))) + .toBe('Field "secret" cannot be modified as it is restricted from editing (backendOnly is true).'); + expect(await columnWriteError(column as any, 'create', ctxFor([column]))) + .toBe('Field "secret" cannot be modified as it is restricted from creation (backendOnly is true).'); + }); + + it('refuses an editReadonly column on edit but not on create', async () => { + const column = col('created_at', { editReadonly: true }); + + expect(await columnWriteError(column as any, 'edit', ctxFor([column]))) + .toBe('Field "created_at" cannot be modified as it is restricted from editing (editReadonly is true).'); + expect(await columnWriteError(column as any, 'create', ctxFor([column]))).toBeNull(); + }); + + it('refuses a column hidden from the page', async () => { + const edit = col('hidden', { showIn: { edit: false } }); + const create = col('hidden', { showIn: { create: false } }); + + expect(await columnWriteError(edit as any, 'edit', ctxFor([edit]))).toMatch(/showIn\.edit is false/); + expect(await columnWriteError(create as any, 'create', ctxFor([create]))).toMatch(/showIn\.create is false/); + }); + + it.each([ + ['allowModifyWhenNotShowInEdit', 'edit', { showIn: { edit: false }, allowModifyWhenNotShowInEdit: true }], + ['allowModifyWhenNotShowInCreate', 'create', { showIn: { create: false }, allowModifyWhenNotShowInCreate: true }], + ['fillOnCreate', 'create', { showIn: { create: false }, fillOnCreate: () => 1 }], + ])('allows a hidden column when %s says so', async (_label, page, extra) => { + const column = col('hidden', extra as Record); + + expect(await columnWriteError(column as any, page as 'create' | 'edit', ctxFor([column]))).toBeNull(); + }); + + it('refuses a column a per-user rule hides from this user only', async () => { + const column = col('salary', { showIn: { edit: async ({ adminUser }: any) => adminUser.dbUser.role === 'editor' } }); + + expect(await columnWriteError(column as any, 'edit', ctxFor([column], EDITOR))).toBeNull(); + expect(await columnWriteError(column as any, 'edit', ctxFor([column], VIEWER))).toMatch(/showIn\.edit is false/); + }); +}); + +describe('recordWriteError', () => { + const columns = [col('id'), col('title'), col('secret', { backendOnly: true })]; + + it('reports the first unwritable field in the record', async () => { + expect(await recordWriteError({ title: 'ok', secret: 'x' }, 'edit', ctxFor(columns))) + .toMatch(/^Field "secret"/); + }); + + it('passes a record that only touches writable columns', async () => { + expect(await recordWriteError({ title: 'ok' }, 'edit', ctxFor(columns))).toBeNull(); + }); + + it('ignores a restricted column the record does not touch', async () => { + expect(await recordWriteError({ id: 1 }, 'edit', ctxFor(columns))).toBeNull(); + }); +}); From dda2d39f0ea82212f84450093af685d6d8d346e6 Mon Sep 17 00:00:00 2001 From: Roman Malenko Date: Wed, 9 Sep 2026 17:04:59 +0300 Subject: [PATCH 2/3] feat: add an opt-in enforceColumnAccess check to createResourceRecord and updateResourceRecord --- adminforth/index.ts | 26 ++++++++++++++++++++++--- adminforth/types/Back.ts | 19 ++++++++++++++++-- tests/jest_tests/column_access.test.ts | 27 ++++++++++++++++++++++++++ 3 files changed, 67 insertions(+), 5 deletions(-) diff --git a/adminforth/index.ts b/adminforth/index.ts index cad96d1aa..bfe000e4d 100644 --- a/adminforth/index.ts +++ b/adminforth/index.ts @@ -3,7 +3,7 @@ import CodeInjector from './modules/codeInjector.js'; import ExpressServer from './servers/express.js'; import OpenApiRegistry from './servers/openapi.js'; // import FastifyServer from './servers/fastify.js'; -import { ADMINFORTH_VERSION, listify, suggestIfTypo, RateLimiter, RAMLock, getClientIp, isProbablyUUIDColumn, convertPeriodToSeconds, hookResponseError, md5hash, applyRegexValidation, formatHugePluginError } from './modules/utils.js'; +import { ADMINFORTH_VERSION, listify, suggestIfTypo, RateLimiter, RAMLock, getClientIp, isProbablyUUIDColumn, convertPeriodToSeconds, hookResponseError, md5hash, applyRegexValidation, formatHugePluginError, recordWriteError } from './modules/utils.js'; import { type AdminForthConfig, type IAdminForth, @@ -730,7 +730,16 @@ class AdminForth implements IAdminForth { async createResourceRecord( params: CreateResourceRecordParams, ): Promise { - const { resource, record, adminUser, extra, response } = params; + const { resource, record, adminUser, extra, response, enforceColumnAccess } = params; + + if (enforceColumnAccess) { + const accessError = await recordWriteError(record, 'create', { + adminUser, resource, meta: { requestBody: record }, source: ActionCheckSource.CreateRequest, adminforth: this, + }); + if (accessError) { + return { error: accessError }; + } + } normalizeRecordValues(resource, record); @@ -822,8 +831,19 @@ class AdminForth implements IAdminForth { async updateResourceRecord( params: UpdateResourceRecordParams, ): Promise { - const { resource, recordId, record, oldRecord, adminUser, response, extra, updates } = params; + const { resource, recordId, record, oldRecord, adminUser, response, extra, updates, enforceColumnAccess } = params; const dataToUse = updates || record; + + // before the editReadonly strip below, which would erase the field and hide the refusal + if (enforceColumnAccess) { + const accessError = await recordWriteError(dataToUse, 'edit', { + adminUser, resource, meta: { newRecord: dataToUse, oldRecord, pk: recordId }, source: ActionCheckSource.EditRequest, adminforth: this, + }); + if (accessError) { + return { error: accessError }; + } + } + normalizeRecordValues(resource, dataToUse); const err = this.validateRecordValues(resource, dataToUse, 'edit'); if (err) { diff --git a/adminforth/types/Back.ts b/adminforth/types/Back.ts index d31b04ab5..cfb3e224a 100644 --- a/adminforth/types/Back.ts +++ b/adminforth/types/Back.ts @@ -849,12 +849,19 @@ export type CreateResourceRecordParams = { * Extra HTTP information. Prefer using extra.response over the top-level response field. */ extra?: HttpExtra; + + /** + * Apply the same column checks the REST routes apply: backendOnly, showIn, editReadonly and the + * allowModifyWhenNotShowIn* escapes. Off by default, because the programmatic API is a system-level + * API and plugins legitimately write columns that no user may write. + */ + enforceColumnAccess?: boolean; }; /** * Parameters for {@link IAdminForth.updateResourceRecord}. */ -export type UpdateResourceRecordParams = +export type UpdateResourceRecordParams = ( | { /** * Resource configuration used to update a record. @@ -944,7 +951,15 @@ export type UpdateResourceRecordParams = * Partial record data with only changed fields. Mutually exclusive with record. */ updates: any; - }; + } + ) & { + /** + * Apply the same column checks the REST routes apply: backendOnly, showIn, editReadonly and the + * allowModifyWhenNotShowIn* escapes. Off by default, because the programmatic API is a system-level + * API and plugins legitimately write columns that no user may write. + */ + enforceColumnAccess?: boolean; +}; /** * Parameters for {@link IAdminForth.deleteResourceRecord}. diff --git a/tests/jest_tests/column_access.test.ts b/tests/jest_tests/column_access.test.ts index b5247f06a..3425e49a7 100644 --- a/tests/jest_tests/column_access.test.ts +++ b/tests/jest_tests/column_access.test.ts @@ -1,4 +1,5 @@ import { jest } from '@jest/globals'; +import AdminForth from '../../adminforth/index.js'; import { ActionCheckSource } from '../../adminforth/types/Common.js'; import { resolveBoolOrFn, @@ -140,3 +141,29 @@ describe('recordWriteError', () => { expect(await recordWriteError({ id: 1 }, 'edit', ctxFor(columns))).toBeNull(); }); }); + +describe('enforceColumnAccess on the programmatic write API', () => { + // the guard must run before anything else touches the record, so a stubbed + // validateRecordValues tells us whether the call got past it + const stub = { validateRecordValues: () => 'reached validation' } as any; + const resource: any = { resourceId: 'things', columns: [col('id', { primaryKey: true }), col('secret', { backendOnly: true })], hooks: {} }; + const params = (extra: Record) => ({ resource, record: { secret: 'x' }, adminUser: EDITOR, ...extra }); + + it('refuses a backendOnly column when asked to enforce', async () => { + const result = await AdminForth.prototype.createResourceRecord.call(stub, params({ enforceColumnAccess: true })); + + expect(result.error).toBe('Field "secret" cannot be modified as it is restricted from creation (backendOnly is true).'); + }); + + it('stays out of the way by default', async () => { + const result = await AdminForth.prototype.createResourceRecord.call(stub, params({})); + + expect(result.error).toBe('reached validation'); + }); + + it('refuses on update before anything else touches the record', async () => { + const result = await AdminForth.prototype.updateResourceRecord.call(stub, params({ enforceColumnAccess: true, recordId: 1, updates: { secret: 'x' }, record: undefined })); + + expect(result.error).toBe('Field "secret" cannot be modified as it is restricted from editing (backendOnly is true).'); + }); +}); From 289fc2288171aff80f931f77c039ac49acd73c26 Mon Sep 17 00:00:00 2001 From: Roman Malenko Date: Wed, 9 Sep 2026 17:05:10 +0300 Subject: [PATCH 3/3] fix: stop the required-on-create check misreading a function-valued showIn.create --- adminforth/modules/restApi.ts | 14 -------------- adminforth/types/Back.ts | 8 ++++++++ 2 files changed, 8 insertions(+), 14 deletions(-) diff --git a/adminforth/modules/restApi.ts b/adminforth/modules/restApi.ts index af9d12625..5342bb5f4 100644 --- a/adminforth/modules/restApi.ts +++ b/adminforth/modules/restApi.ts @@ -2100,20 +2100,6 @@ export default class AdminForthRestAPI implements IAdminForthRestAPI { const { record, requiredColumnsToSkip } = body; - // todo if showIn.create is function, code below will be buggy (will not detect required fact) - for (const column of resource.columns) { - if ( - (column.required as {create?: boolean, edit?: boolean})?.create && - record[column.name] === undefined && - column.showIn.create - ) { - const shouldWeSkipColumn = requiredColumnsToSkip.find(reqColumnToSkip => reqColumnToSkip.name === column.name); - if (!shouldWeSkipColumn) { - return { error: `Column '${column.name}' is required`, ok: false }; - } - } - } - const primaryKeyColumn = resource.columns.find((col) => col.primaryKey); if (record[primaryKeyColumn.name] !== undefined) { const existingRecord = await this.adminforth.resource(resource.resourceId).get([Filters.EQ(primaryKeyColumn.name, record[primaryKeyColumn.name])]); diff --git a/adminforth/types/Back.ts b/adminforth/types/Back.ts index cfb3e224a..af0250c81 100644 --- a/adminforth/types/Back.ts +++ b/adminforth/types/Back.ts @@ -2141,6 +2141,14 @@ export class Sorts { } } +/** + * Low-level system API. It takes no adminUser, so it applies no allowedActions, no backendOnly, + * no showIn, no editReadonly and no hooks: get() and list() return every non-virtual column, + * including backendOnly ones, and update()/create() write whatever they are given. + * + * Before returning a record to a browser call stripBackendOnly(record, ctx); before writing + * user-supplied fields call recordWriteError(record, 'edit' | 'create', ctx). + */ export interface IOperationalResource { get: (filter: IAdminForthSingleFilter | IAdminForthAndOrFilter | Array) => Promise;