@adminforth/bulk-ai-flow 1.27.5 → 1.27.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.d.ts CHANGED
@@ -10,6 +10,15 @@ export default class BulkAiFlowPlugin extends AdminForthPlugin {
10
10
  rateLimiters: Record<string, RateLimiter>;
11
11
  sessionIds: Set<string>;
12
12
  constructor(options: PluginOptions);
13
+ /**
14
+ * Compiles Handlebars templates from `source`.
15
+ *
16
+ * `record` is the raw row and is always what `provideAdditionalContextForRecord` sees, but the
17
+ * templates themselves are rendered against `record` only when they come from `PluginOptions`:
18
+ * those are written by the app author and may reference any column. Templates which arrived in
19
+ * the request body are rendered against the record as the caller is allowed to read it, so a
20
+ * hand-written `{{column}}` cannot pull a backendOnly column out of the row.
21
+ */
13
22
  private compileTemplates;
14
23
  private compileOutputFieldsTemplates;
15
24
  private compileOutputFieldsTemplatesNoImage;
@@ -48,5 +57,11 @@ export default class BulkAiFlowPlugin extends AdminForthPlugin {
48
57
  * way the core show route drops them.
49
58
  */
50
59
  private scopeRecordForFrontend;
60
+ /**
61
+ * The row as the caller is allowed to read it: columns which are not declared on the resource and
62
+ * columns whose `backendOnly` resolves true for this request are dropped, exactly the way the
63
+ * core show route drops them before a row reaches the browser.
64
+ */
65
+ private stripBackendOnlyColumns;
51
66
  setupEndpoints(server: IHttpServer): void;
52
67
  }
package/dist/index.js CHANGED
@@ -106,40 +106,49 @@ export default class BulkAiFlowPlugin extends AdminForthPlugin {
106
106
  // for rate limiting
107
107
  this.sessionIds = new Set();
108
108
  }
109
- // Compile Handlebars templates in outputFields using record fields as context
110
- compileTemplates(source, record, valueSelector) {
111
- return __awaiter(this, void 0, void 0, function* () {
109
+ /**
110
+ * Compiles Handlebars templates from `source`.
111
+ *
112
+ * `record` is the raw row and is always what `provideAdditionalContextForRecord` sees, but the
113
+ * templates themselves are rendered against `record` only when they come from `PluginOptions`:
114
+ * those are written by the app author and may reference any column. Templates which arrived in
115
+ * the request body are rendered against the record as the caller is allowed to read it, so a
116
+ * hand-written `{{column}}` cannot pull a backendOnly column out of the row.
117
+ */
118
+ compileTemplates(source_1, _a, valueSelector_1) {
119
+ return __awaiter(this, arguments, void 0, function* (source, { record, ctx, clientSupplied }, valueSelector) {
120
+ let templateContext = clientSupplied ? yield this.stripBackendOnlyColumns(record, ctx) : record;
112
121
  if (this.options.provideAdditionalContextForRecord) {
113
- const additionalFields = yield this.options.provideAdditionalContextForRecord({ record, adminUser: null, resource: this.resourceConfig });
114
- record = Object.assign(Object.assign({}, record), additionalFields);
122
+ const additionalFields = yield this.options.provideAdditionalContextForRecord({ record, adminUser: ctx.adminUser, resource: this.resourceConfig });
123
+ templateContext = Object.assign(Object.assign({}, templateContext), additionalFields);
115
124
  }
116
125
  const compiled = {};
117
126
  for (const [key, value] of Object.entries(source)) {
118
127
  const templateStr = valueSelector(value);
119
128
  try {
120
129
  const tpl = Handlebars.compile(templateStr);
121
- compiled[key] = tpl(record);
130
+ compiled[key] = tpl(templateContext);
122
131
  }
123
- catch (_a) {
132
+ catch (_b) {
124
133
  compiled[key] = templateStr;
125
134
  }
126
135
  }
127
136
  return compiled;
128
137
  });
129
138
  }
130
- compileOutputFieldsTemplates(record, customPrompt) {
131
- return __awaiter(this, void 0, void 0, function* () {
132
- return yield this.compileTemplates(customPrompt ? JSON.parse(customPrompt) : this.options.fillFieldsFromImages, record, v => String(v));
139
+ compileOutputFieldsTemplates(record_1, ctx_1, customPrompt_1) {
140
+ return __awaiter(this, arguments, void 0, function* (record, ctx, customPrompt, clientSupplied = !!customPrompt) {
141
+ return yield this.compileTemplates(customPrompt ? JSON.parse(customPrompt) : this.options.fillFieldsFromImages, { record, ctx, clientSupplied }, v => String(v));
133
142
  });
134
143
  }
135
- compileOutputFieldsTemplatesNoImage(record, customPrompt) {
136
- return __awaiter(this, void 0, void 0, function* () {
137
- return yield this.compileTemplates(customPrompt ? JSON.parse(customPrompt) : this.options.fillPlainFields, record, v => String(v));
144
+ compileOutputFieldsTemplatesNoImage(record_1, ctx_1, customPrompt_1) {
145
+ return __awaiter(this, arguments, void 0, function* (record, ctx, customPrompt, clientSupplied = !!customPrompt) {
146
+ return yield this.compileTemplates(customPrompt ? JSON.parse(customPrompt) : this.options.fillPlainFields, { record, ctx, clientSupplied }, v => String(v));
138
147
  });
139
148
  }
140
- compileGenerationFieldTemplates(record, customPrompt) {
141
- return __awaiter(this, void 0, void 0, function* () {
142
- return yield this.compileTemplates(customPrompt ? JSON.parse(customPrompt) : this.options.generateImages, record, v => String(customPrompt ? v : v.prompt));
149
+ compileGenerationFieldTemplates(record_1, ctx_1, customPrompt_1) {
150
+ return __awaiter(this, arguments, void 0, function* (record, ctx, customPrompt, clientSupplied = !!customPrompt) {
151
+ return yield this.compileTemplates(customPrompt ? JSON.parse(customPrompt) : this.options.generateImages, { record, ctx, clientSupplied }, v => String(customPrompt ? v : v.prompt));
143
152
  });
144
153
  }
145
154
  removeFromPromptFilledFields(compiledOutputFields, record) {
@@ -188,19 +197,15 @@ export default class BulkAiFlowPlugin extends AdminForthPlugin {
188
197
  Do NOT wrap in \`\`\` or \`\`\`json. Do not add any extra text. Do not return prompt in response`;
189
198
  return prompt;
190
199
  }
191
- analyze_image(jobId_1, recordId_1, adminUser_1, headers_1, customPrompt_1) {
192
- return __awaiter(this, arguments, void 0, function* (jobId, recordId, adminUser, headers, customPrompt, filterFilledFields = true) {
200
+ analyze_image(jobId_1, record_1, ctx_1, headers_1, customPrompt_1) {
201
+ return __awaiter(this, arguments, void 0, function* (jobId, record, ctx, headers, customPrompt, filterFilledFields = true) {
193
202
  var _a, _b, _c, _d, _e, _f, _g, _h, _j, _k, _l, _m, _o, _p, _q, _r, _s, _t, _u;
194
203
  // TODO: need to do correct check of rate limit
195
204
  // if (this.options.rateLimits && this.options.rateLimits.fillFieldsFromImages && await this.checkRateLimit("fillFieldsFromImages" ,this.options.rateLimits.fillFieldsFromImages, headers)) {
196
205
  // jobs.set(jobId, { status: 'failed', error: "Rate limit exceeded" });
197
206
  // return { error: "Rate limit exceeded" };
198
207
  // }
199
- const selectedId = recordId;
200
208
  let isError = false;
201
- // Fetch the record using the provided ID
202
- const primaryKeyColumn = this.resourceConfig.columns.find((col) => col.primaryKey);
203
- const record = yield this.adminforth.resource(this.resourceConfig.resourceId).get([Filters.EQ(primaryKeyColumn.name, selectedId)]);
204
209
  //recieve image URLs to analyze
205
210
  const attachmentFiles = yield this.options.attachFiles({ record: record });
206
211
  if (STUB_MODE) {
@@ -228,7 +233,7 @@ export default class BulkAiFlowPlugin extends AdminForthPlugin {
228
233
  return { ok: false, error: 'One of the image URLs is not valid' };
229
234
  }
230
235
  //create prompt for OpenAI
231
- const compiledOutputFields = yield this.compileOutputFieldsTemplates(record, customPrompt);
236
+ const compiledOutputFields = yield this.compileOutputFieldsTemplates(record, ctx, customPrompt);
232
237
  const filteredCompiledOutputFields = filterFilledFields ? this.removeFromPromptFilledFields(compiledOutputFields, record) : compiledOutputFields;
233
238
  if (Object.keys(filteredCompiledOutputFields).length === 0) {
234
239
  jobs.set(jobId, { status: 'completed', result: {} });
@@ -276,14 +281,13 @@ export default class BulkAiFlowPlugin extends AdminForthPlugin {
276
281
  }
277
282
  });
278
283
  }
279
- analyzeNoImages(jobId_1, recordId_1, adminUser_1, headers_1, customPrompt_1) {
280
- return __awaiter(this, arguments, void 0, function* (jobId, recordId, adminUser, headers, customPrompt, filterFilledFields = true) {
284
+ analyzeNoImages(jobId_1, record_1, ctx_1, headers_1, customPrompt_1) {
285
+ return __awaiter(this, arguments, void 0, function* (jobId, record, ctx, headers, customPrompt, filterFilledFields = true) {
281
286
  // TODO: need to do correct check of rate limit
282
287
  // if (this.options.rateLimits && this.options.rateLimits.fillPlainFields && await this.checkRateLimit("fillPlainFields" ,this.options.rateLimits.fillPlainFields, headers)) {
283
288
  // jobs.set(jobId, { status: 'failed', error: "Rate limit exceeded" });
284
289
  // return { error: "Rate limit exceeded" };
285
290
  // }
286
- const selectedId = recordId;
287
291
  let isError = false;
288
292
  if (STUB_MODE) {
289
293
  const fakeError = Math.random() < 0.005; // 0.05% chance of error
@@ -298,9 +302,7 @@ export default class BulkAiFlowPlugin extends AdminForthPlugin {
298
302
  }
299
303
  }
300
304
  else {
301
- const primaryKeyColumn = this.resourceConfig.columns.find((col) => col.primaryKey);
302
- const record = yield this.adminforth.resource(this.resourceConfig.resourceId).get([Filters.EQ(primaryKeyColumn.name, selectedId)]);
303
- const compiledOutputFields = yield this.compileOutputFieldsTemplatesNoImage(record, customPrompt);
305
+ const compiledOutputFields = yield this.compileOutputFieldsTemplatesNoImage(record, ctx, customPrompt);
304
306
  const filteredCompiledOutputFields = filterFilledFields ? this.removeFromPromptFilledFields(compiledOutputFields, record) : compiledOutputFields;
305
307
  if (Object.keys(filteredCompiledOutputFields).length === 0) {
306
308
  jobs.set(jobId, { status: 'completed', result: {} });
@@ -342,18 +344,16 @@ export default class BulkAiFlowPlugin extends AdminForthPlugin {
342
344
  }
343
345
  });
344
346
  }
345
- initialImageGenerate(jobId_1, recordId_1, adminUser_1, headers_1, customPrompt_1) {
346
- return __awaiter(this, arguments, void 0, function* (jobId, recordId, adminUser, headers, customPrompt, filterFilledFields = true) {
347
- var _a, _b;
347
+ initialImageGenerate(jobId_1, record_1, ctx_1, headers_1, customPrompt_1) {
348
+ return __awaiter(this, arguments, void 0, function* (jobId, record, ctx, headers, customPrompt, filterFilledFields = true) {
349
+ var _a;
348
350
  // TODO: need to do correct check of rate limit
349
351
  // if (this.options.rateLimits && this.options.rateLimits.generateImages && await this.checkRateLimit("generateImages" ,this.options.rateLimits.generateImages, headers)) {
350
352
  // jobs.set(jobId, { status: 'failed', error: "Rate limit exceeded" });
351
353
  // return { error: "Rate limit exceeded" };
352
354
  // }
353
- const selectedId = recordId;
354
355
  let isError = false;
355
356
  const start = +new Date();
356
- const record = yield this.adminforth.resource(this.resourceConfig.resourceId).get([Filters.EQ((_a = this.resourceConfig.columns.find(c => c.primaryKey)) === null || _a === void 0 ? void 0 : _a.name, selectedId)]);
357
357
  let attachmentFiles;
358
358
  if (!this.options.attachFiles) {
359
359
  attachmentFiles = [];
@@ -370,7 +370,7 @@ export default class BulkAiFlowPlugin extends AdminForthPlugin {
370
370
  return { ok: false, error: 'One of the image URLs is not valid' };
371
371
  }
372
372
  }
373
- const fieldTasks = Object.keys(((_b = this.options) === null || _b === void 0 ? void 0 : _b.generateImages) || {}).map((key) => __awaiter(this, void 0, void 0, function* () {
373
+ const fieldTasks = Object.keys(((_a = this.options) === null || _a === void 0 ? void 0 : _a.generateImages) || {}).map((key) => __awaiter(this, void 0, void 0, function* () {
374
374
  if (record[key] && filterFilledFields) {
375
375
  const plugin = this.adminforth.activatedPlugins.find(p => p.resourceConfig.resourceId === this.resourceConfig.resourceId &&
376
376
  p.pluginOptions.pathColumnName === key);
@@ -384,7 +384,7 @@ export default class BulkAiFlowPlugin extends AdminForthPlugin {
384
384
  }
385
385
  };
386
386
  }
387
- const prompt = (yield this.compileGenerationFieldTemplates(record, customPrompt))[key];
387
+ const prompt = (yield this.compileGenerationFieldTemplates(record, ctx, customPrompt))[key];
388
388
  let images;
389
389
  if (this.options.attachFiles && attachmentFiles.length === 0) {
390
390
  isError = true;
@@ -456,10 +456,8 @@ export default class BulkAiFlowPlugin extends AdminForthPlugin {
456
456
  }
457
457
  });
458
458
  }
459
- regenerateImage(jobId, recordId, fieldName, prompt, adminUser, headers) {
459
+ regenerateImage(jobId, record, fieldName, prompt, headers) {
460
460
  return __awaiter(this, void 0, void 0, function* () {
461
- var _a;
462
- const Id = recordId;
463
461
  let isError = false;
464
462
  // TODO: need to do correct check of rate limit
465
463
  // if (await this.checkRateLimit(fieldName, this.options.generateImages[fieldName].rateLimit, headers)) {
@@ -467,7 +465,6 @@ export default class BulkAiFlowPlugin extends AdminForthPlugin {
467
465
  // return { error: "Rate limit exceeded" };
468
466
  // }
469
467
  const start = +new Date();
470
- const record = yield this.adminforth.resource(this.resourceConfig.resourceId).get([Filters.EQ((_a = this.resourceConfig.columns.find(c => c.primaryKey)) === null || _a === void 0 ? void 0 : _a.name, Id)]);
471
468
  let attachmentFiles;
472
469
  if (!this.options.attachFiles) {
473
470
  attachmentFiles = [];
@@ -525,13 +522,16 @@ export default class BulkAiFlowPlugin extends AdminForthPlugin {
525
522
  }
526
523
  });
527
524
  }
528
- regenerateCell(jobId, fieldToRegenerate, recordId, actionType, prompt) {
525
+ regenerateCell(jobId, fieldToRegenerate, record, ctx, actionType, prompt) {
529
526
  return __awaiter(this, void 0, void 0, function* () {
530
527
  var _a, _b, _c, _d, _e, _f, _g, _h, _j;
531
- if (!fieldToRegenerate || !recordId || !actionType) {
528
+ if (!fieldToRegenerate || !actionType) {
532
529
  jobs.set(jobId, { status: 'failed', error: 'Missing parameters' });
533
530
  //return { ok: false, error: "Missing parameters" };
534
531
  }
532
+ // a prompt which arrived in the request body is client-authored even though it is wrapped into
533
+ // `promptToPass` below, while the PluginOptions fallback is written by the app author
534
+ const clientSupplied = !!prompt;
535
535
  if (!prompt) {
536
536
  if (actionType === 'analyze') {
537
537
  prompt = this.options.fillFieldsFromImages ? this.options.fillFieldsFromImages[fieldToRegenerate] : null;
@@ -540,8 +540,6 @@ export default class BulkAiFlowPlugin extends AdminForthPlugin {
540
540
  prompt = this.options.fillPlainFields ? this.options.fillPlainFields[fieldToRegenerate] : null;
541
541
  }
542
542
  }
543
- const primaryKeyColumn = this.resourceConfig.columns.find((col) => col.primaryKey);
544
- const record = yield this.adminforth.resource(this.resourceConfig.resourceId).get([Filters.EQ(primaryKeyColumn.name, recordId)]);
545
543
  let promptToPass = JSON.stringify({ [fieldToRegenerate]: prompt });
546
544
  if (STUB_MODE) {
547
545
  yield new Promise((resolve) => setTimeout(resolve, Math.floor(Math.random() * 20000) + 1000));
@@ -550,7 +548,7 @@ export default class BulkAiFlowPlugin extends AdminForthPlugin {
550
548
  }
551
549
  else {
552
550
  if (actionType === 'analyze') {
553
- const compiledPropmt = yield this.compileOutputFieldsTemplates(record, promptToPass);
551
+ const compiledPropmt = yield this.compileOutputFieldsTemplates(record, ctx, promptToPass, clientSupplied);
554
552
  const finalPrompt = this.getPromptForImageAnalysis(compiledPropmt);
555
553
  const attachmentFiles = yield this.options.attachFiles({ record: record });
556
554
  if (attachmentFiles.length === 0) {
@@ -587,7 +585,7 @@ export default class BulkAiFlowPlugin extends AdminForthPlugin {
587
585
  jobs.set(jobId, { status: 'completed', result: resData });
588
586
  }
589
587
  else if (actionType === 'analyze_no_images') {
590
- const compiledPropmt = yield this.compileOutputFieldsTemplatesNoImage(record, promptToPass);
588
+ const compiledPropmt = yield this.compileOutputFieldsTemplatesNoImage(record, ctx, promptToPass, clientSupplied);
591
589
  const finalPrompt = this.getPromptForPlainFields(compiledPropmt);
592
590
  const numberOfTokens = this.options.fillPlainFieldsMaxTokens ? this.options.fillPlainFieldsMaxTokens : 1000;
593
591
  let resp;
@@ -987,6 +985,23 @@ export default class BulkAiFlowPlugin extends AdminForthPlugin {
987
985
  return scoped;
988
986
  });
989
987
  }
988
+ /**
989
+ * The row as the caller is allowed to read it: columns which are not declared on the resource and
990
+ * columns whose `backendOnly` resolves true for this request are dropped, exactly the way the
991
+ * core show route drops them before a row reaches the browser.
992
+ */
993
+ stripBackendOnlyColumns(record, ctx) {
994
+ return __awaiter(this, void 0, void 0, function* () {
995
+ const visible = {};
996
+ for (const column of this.resourceConfig.columns) {
997
+ if (yield resolveBoolOrFn(column.backendOnly, ctx)) {
998
+ continue;
999
+ }
1000
+ visible[column.name] = record[column.name];
1001
+ }
1002
+ return visible;
1003
+ });
1004
+ }
990
1005
  setupEndpoints(server) {
991
1006
  server.endpoint({
992
1007
  method: 'POST',
@@ -1159,7 +1174,7 @@ export default class BulkAiFlowPlugin extends AdminForthPlugin {
1159
1174
  handler: (_a) => __awaiter(this, [_a], void 0, function* ({ body, adminUser, headers, query, cookies, requestUrl }) {
1160
1175
  const data = body;
1161
1176
  const customPrompt = data.customPrompt || null;
1162
- const { record, error } = yield this.getRecordForShow({
1177
+ const { record, ctx, error } = yield this.getRecordForShow({
1163
1178
  adminUser,
1164
1179
  recordId: data.recordId,
1165
1180
  extra: { body, query, headers, cookies, requestUrl },
@@ -1167,7 +1182,7 @@ export default class BulkAiFlowPlugin extends AdminForthPlugin {
1167
1182
  if (error) {
1168
1183
  return { ok: false, error };
1169
1184
  }
1170
- const compiledGenerationOptions = yield this.compileGenerationFieldTemplates(record, JSON.stringify({ "prompt": customPrompt }));
1185
+ const compiledGenerationOptions = yield this.compileGenerationFieldTemplates(record, ctx, JSON.stringify({ "prompt": customPrompt }));
1171
1186
  return compiledGenerationOptions;
1172
1187
  })
1173
1188
  });
@@ -1186,7 +1201,7 @@ export default class BulkAiFlowPlugin extends AdminForthPlugin {
1186
1201
  method: 'POST',
1187
1202
  path: `/plugin/${this.pluginInstanceId}/create-job`,
1188
1203
  request_schema: createJobBodySchema,
1189
- handler: (_a) => __awaiter(this, [_a], void 0, function* ({ body, adminUser, headers, response }) {
1204
+ handler: (_a) => __awaiter(this, [_a], void 0, function* ({ body, adminUser, headers, query, cookies, requestUrl, response }) {
1190
1205
  const data = body;
1191
1206
  const { actionType, recordId, customPrompt, filterFilledFields, sessionIds } = data;
1192
1207
  if (this.options.rateLimits) {
@@ -1202,41 +1217,47 @@ export default class BulkAiFlowPlugin extends AdminForthPlugin {
1202
1217
  return { ok: false, error: "Missing session ids" };
1203
1218
  }
1204
1219
  }
1205
- const jobId = randomUUID();
1206
- jobs.set(jobId, { status: "in_progress" });
1207
1220
  if (!actionType) {
1208
- jobs.set(jobId, { status: "failed", error: "Missing action type" });
1209
- //return { error: "Missing action type" };
1221
+ return { ok: false, error: "Missing action type" };
1210
1222
  }
1211
- else if (!recordId && typeof recordId !== 'number') {
1212
- jobs.set(jobId, { status: "failed", error: "Missing record id" });
1213
- //return { error: "Missing record id" };
1223
+ if (!recordId && typeof recordId !== 'number') {
1224
+ return { ok: false, error: "Missing record id" };
1214
1225
  }
1215
- else {
1216
- switch (actionType) {
1217
- case 'generate_images':
1218
- this.initialImageGenerate(jobId, recordId, adminUser, headers, customPrompt, filterFilledFields);
1219
- break;
1220
- case 'analyze_no_images':
1221
- this.analyzeNoImages(jobId, recordId, adminUser, headers, customPrompt, filterFilledFields);
1222
- break;
1223
- case 'analyze':
1224
- this.analyze_image(jobId, recordId, adminUser, headers, customPrompt, filterFilledFields);
1225
- break;
1226
- case 'regenerate_images':
1227
- if (!data.prompt || !data.fieldName) {
1228
- jobs.set(jobId, { status: "failed", error: "Missing prompt or field name" });
1229
- break;
1230
- }
1231
- this.regenerateImage(jobId, recordId, data.fieldName, data.prompt, adminUser, headers);
1232
- break;
1233
- case 'regenerate_cell':
1234
- const fieldToRegenerate = data.fieldToRegenerate;
1235
- this.regenerateCell(jobId, fieldToRegenerate, recordId, data.action, data.prompt);
1226
+ // every worker below compiles this record into an AI prompt whose answer is handed back
1227
+ // through get-job-status, so the record is read here, behind the same show check and
1228
+ // row-level scoping the other read endpoints use, and the row itself is what gets dispatched
1229
+ const { record, ctx, error } = yield this.getRecordForShow({
1230
+ adminUser,
1231
+ recordId,
1232
+ extra: { body, query, headers, cookies, requestUrl },
1233
+ });
1234
+ if (error) {
1235
+ return { ok: false, error };
1236
+ }
1237
+ const jobId = randomUUID();
1238
+ jobs.set(jobId, { status: "in_progress" });
1239
+ switch (actionType) {
1240
+ case 'generate_images':
1241
+ this.initialImageGenerate(jobId, record, ctx, headers, customPrompt, filterFilledFields);
1242
+ break;
1243
+ case 'analyze_no_images':
1244
+ this.analyzeNoImages(jobId, record, ctx, headers, customPrompt, filterFilledFields);
1245
+ break;
1246
+ case 'analyze':
1247
+ this.analyze_image(jobId, record, ctx, headers, customPrompt, filterFilledFields);
1248
+ break;
1249
+ case 'regenerate_images':
1250
+ if (!data.prompt || !data.fieldName) {
1251
+ jobs.set(jobId, { status: "failed", error: "Missing prompt or field name" });
1236
1252
  break;
1237
- default:
1238
- jobs.set(jobId, { status: "failed", error: "Unknown action type" });
1239
- }
1253
+ }
1254
+ this.regenerateImage(jobId, record, data.fieldName, data.prompt, headers);
1255
+ break;
1256
+ case 'regenerate_cell':
1257
+ this.regenerateCell(jobId, data.fieldToRegenerate, record, ctx, data.action, data.prompt);
1258
+ break;
1259
+ default:
1260
+ jobs.set(jobId, { status: "failed", error: "Unknown action type" });
1240
1261
  }
1241
1262
  setTimeout(() => jobs.delete(jobId), 1800000);
1242
1263
  setTimeout(() => jobs.set(jobId, { status: "failed", error: "Job timed out" }), 180000);
package/index.ts CHANGED
@@ -123,22 +123,31 @@ export default class BulkAiFlowPlugin extends AdminForthPlugin {
123
123
  this.sessionIds = new Set();
124
124
  }
125
125
 
126
- // Compile Handlebars templates in outputFields using record fields as context
126
+ /**
127
+ * Compiles Handlebars templates from `source`.
128
+ *
129
+ * `record` is the raw row and is always what `provideAdditionalContextForRecord` sees, but the
130
+ * templates themselves are rendered against `record` only when they come from `PluginOptions`:
131
+ * those are written by the app author and may reference any column. Templates which arrived in
132
+ * the request body are rendered against the record as the caller is allowed to read it, so a
133
+ * hand-written `{{column}}` cannot pull a backendOnly column out of the row.
134
+ */
127
135
  private async compileTemplates<T extends Record<string, any>>(
128
136
  source: T,
129
- record: any,
137
+ { record, ctx, clientSupplied }: { record: any, ctx: AccessCheckContext, clientSupplied: boolean },
130
138
  valueSelector: (value: T[keyof T]) => string
131
139
  ): Promise<Record<string, string>> {
140
+ let templateContext = clientSupplied ? await this.stripBackendOnlyColumns(record, ctx) : record;
132
141
  if (this.options.provideAdditionalContextForRecord) {
133
- const additionalFields = await this.options.provideAdditionalContextForRecord({ record, adminUser: null, resource: this.resourceConfig });
134
- record = { ...record, ...additionalFields };
142
+ const additionalFields = await this.options.provideAdditionalContextForRecord({ record, adminUser: ctx.adminUser, resource: this.resourceConfig });
143
+ templateContext = { ...templateContext, ...additionalFields };
135
144
  }
136
145
  const compiled: Record<string, string> = {};
137
146
  for (const [key, value] of Object.entries(source)) {
138
147
  const templateStr = valueSelector(value);
139
148
  try {
140
149
  const tpl = Handlebars.compile(templateStr);
141
- compiled[key] = tpl(record);
150
+ compiled[key] = tpl(templateContext);
142
151
  } catch {
143
152
  compiled[key] = templateStr;
144
153
  }
@@ -146,16 +155,16 @@ export default class BulkAiFlowPlugin extends AdminForthPlugin {
146
155
  return compiled;
147
156
  }
148
157
 
149
- private async compileOutputFieldsTemplates(record: any, customPrompt? : string) {
150
- return await this.compileTemplates(customPrompt ? JSON.parse(customPrompt) :this.options.fillFieldsFromImages, record, v => String(v));
158
+ private async compileOutputFieldsTemplates(record: any, ctx: AccessCheckContext, customPrompt? : string, clientSupplied: boolean = !!customPrompt) {
159
+ return await this.compileTemplates(customPrompt ? JSON.parse(customPrompt) :this.options.fillFieldsFromImages, { record, ctx, clientSupplied }, v => String(v));
151
160
  }
152
161
 
153
- private async compileOutputFieldsTemplatesNoImage(record: any, customPrompt? : string) {
154
- return await this.compileTemplates(customPrompt ? JSON.parse(customPrompt) : this.options.fillPlainFields, record, v => String(v));
162
+ private async compileOutputFieldsTemplatesNoImage(record: any, ctx: AccessCheckContext, customPrompt? : string, clientSupplied: boolean = !!customPrompt) {
163
+ return await this.compileTemplates(customPrompt ? JSON.parse(customPrompt) : this.options.fillPlainFields, { record, ctx, clientSupplied }, v => String(v));
155
164
  }
156
165
 
157
- private async compileGenerationFieldTemplates(record: any, customPrompt? : string) {
158
- return await this.compileTemplates(customPrompt ? JSON.parse(customPrompt) : this.options.generateImages, record, v => String(customPrompt ? v : v.prompt));
166
+ private async compileGenerationFieldTemplates(record: any, ctx: AccessCheckContext, customPrompt? : string, clientSupplied: boolean = !!customPrompt) {
167
+ return await this.compileTemplates(customPrompt ? JSON.parse(customPrompt) : this.options.generateImages, { record, ctx, clientSupplied }, v => String(customPrompt ? v : v.prompt));
159
168
  }
160
169
 
161
170
  private removeFromPromptFilledFields(compiledOutputFields: Record<string, string>, record: Record<string, any>): Record<string, string> {
@@ -206,17 +215,13 @@ export default class BulkAiFlowPlugin extends AdminForthPlugin {
206
215
  return prompt;
207
216
  }
208
217
 
209
- private async analyze_image(jobId: string, recordId: string | number, adminUser: any, headers: Record<string, string | string[] | undefined>, customPrompt? : string, filterFilledFields: boolean = true) {
218
+ private async analyze_image(jobId: string, record: any, ctx: AccessCheckContext, headers: Record<string, string | string[] | undefined>, customPrompt? : string, filterFilledFields: boolean = true) {
210
219
  // TODO: need to do correct check of rate limit
211
220
  // if (this.options.rateLimits && this.options.rateLimits.fillFieldsFromImages && await this.checkRateLimit("fillFieldsFromImages" ,this.options.rateLimits.fillFieldsFromImages, headers)) {
212
221
  // jobs.set(jobId, { status: 'failed', error: "Rate limit exceeded" });
213
222
  // return { error: "Rate limit exceeded" };
214
223
  // }
215
- const selectedId = recordId;
216
224
  let isError = false;
217
- // Fetch the record using the provided ID
218
- const primaryKeyColumn = this.resourceConfig.columns.find((col) => col.primaryKey);
219
- const record = await this.adminforth.resource(this.resourceConfig.resourceId).get([Filters.EQ(primaryKeyColumn.name, selectedId)] );
220
225
 
221
226
  //recieve image URLs to analyze
222
227
  const attachmentFiles = await this.options.attachFiles({ record: record });
@@ -241,7 +246,7 @@ export default class BulkAiFlowPlugin extends AdminForthPlugin {
241
246
  return { ok: false, error: 'One of the image URLs is not valid' };
242
247
  }
243
248
  //create prompt for OpenAI
244
- const compiledOutputFields = await this.compileOutputFieldsTemplates(record, customPrompt);
249
+ const compiledOutputFields = await this.compileOutputFieldsTemplates(record, ctx, customPrompt);
245
250
  const filteredCompiledOutputFields = filterFilledFields ? this.removeFromPromptFilledFields(compiledOutputFields, record) : compiledOutputFields;
246
251
 
247
252
  if (Object.keys(filteredCompiledOutputFields).length === 0) {
@@ -291,13 +296,12 @@ export default class BulkAiFlowPlugin extends AdminForthPlugin {
291
296
 
292
297
  }
293
298
 
294
- private async analyzeNoImages(jobId: string, recordId: string | number, adminUser: any, headers: Record<string, string | string[] | undefined>, customPrompt? : string, filterFilledFields: boolean = true) {
299
+ private async analyzeNoImages(jobId: string, record: any, ctx: AccessCheckContext, headers: Record<string, string | string[] | undefined>, customPrompt? : string, filterFilledFields: boolean = true) {
295
300
  // TODO: need to do correct check of rate limit
296
301
  // if (this.options.rateLimits && this.options.rateLimits.fillPlainFields && await this.checkRateLimit("fillPlainFields" ,this.options.rateLimits.fillPlainFields, headers)) {
297
302
  // jobs.set(jobId, { status: 'failed', error: "Rate limit exceeded" });
298
303
  // return { error: "Rate limit exceeded" };
299
304
  // }
300
- const selectedId = recordId;
301
305
  let isError = false;
302
306
  if (STUB_MODE) {
303
307
  const fakeError = Math.random() < 0.005; // 0.05% chance of error
@@ -310,10 +314,7 @@ export default class BulkAiFlowPlugin extends AdminForthPlugin {
310
314
  return { ok: true };
311
315
  }
312
316
  } else {
313
- const primaryKeyColumn = this.resourceConfig.columns.find((col) => col.primaryKey);
314
- const record = await this.adminforth.resource(this.resourceConfig.resourceId).get( [Filters.EQ(primaryKeyColumn.name, selectedId)] );
315
-
316
- const compiledOutputFields = await this.compileOutputFieldsTemplatesNoImage(record, customPrompt);
317
+ const compiledOutputFields = await this.compileOutputFieldsTemplatesNoImage(record, ctx, customPrompt);
317
318
 
318
319
  const filteredCompiledOutputFields = filterFilledFields ? this.removeFromPromptFilledFields(compiledOutputFields, record) : compiledOutputFields;
319
320
 
@@ -355,16 +356,14 @@ export default class BulkAiFlowPlugin extends AdminForthPlugin {
355
356
  }
356
357
  }
357
358
 
358
- private async initialImageGenerate(jobId: string, recordId: string | number, adminUser: any, headers: Record<string, string | string[] | undefined>, customPrompt? : string, filterFilledFields: boolean = true) {
359
+ private async initialImageGenerate(jobId: string, record: any, ctx: AccessCheckContext, headers: Record<string, string | string[] | undefined>, customPrompt? : string, filterFilledFields: boolean = true) {
359
360
  // TODO: need to do correct check of rate limit
360
361
  // if (this.options.rateLimits && this.options.rateLimits.generateImages && await this.checkRateLimit("generateImages" ,this.options.rateLimits.generateImages, headers)) {
361
362
  // jobs.set(jobId, { status: 'failed', error: "Rate limit exceeded" });
362
363
  // return { error: "Rate limit exceeded" };
363
364
  // }
364
- const selectedId = recordId;
365
365
  let isError = false;
366
366
  const start = +new Date();
367
- const record = await this.adminforth.resource(this.resourceConfig.resourceId).get([Filters.EQ(this.resourceConfig.columns.find(c => c.primaryKey)?.name, selectedId)]);
368
367
  let attachmentFiles
369
368
  if(!this.options.attachFiles){
370
369
  attachmentFiles = [];
@@ -395,7 +394,7 @@ export default class BulkAiFlowPlugin extends AdminForthPlugin {
395
394
  }
396
395
  };
397
396
  }
398
- const prompt = (await this.compileGenerationFieldTemplates(record, customPrompt))[key];
397
+ const prompt = (await this.compileGenerationFieldTemplates(record, ctx, customPrompt))[key];
399
398
  let images;
400
399
  if (this.options.attachFiles && attachmentFiles.length === 0) {
401
400
  isError = true;
@@ -467,8 +466,7 @@ export default class BulkAiFlowPlugin extends AdminForthPlugin {
467
466
  }
468
467
  }
469
468
 
470
- private async regenerateImage(jobId: string, recordId: string | number, fieldName: string, prompt: string, adminUser: any, headers: Record<string, string | string[] | undefined>) {
471
- const Id = recordId;
469
+ private async regenerateImage(jobId: string, record: any, fieldName: string, prompt: string, headers: Record<string, string | string[] | undefined>) {
472
470
  let isError = false;
473
471
  // TODO: need to do correct check of rate limit
474
472
  // if (await this.checkRateLimit(fieldName, this.options.generateImages[fieldName].rateLimit, headers)) {
@@ -476,7 +474,6 @@ export default class BulkAiFlowPlugin extends AdminForthPlugin {
476
474
  // return { error: "Rate limit exceeded" };
477
475
  // }
478
476
  const start = +new Date();
479
- const record = await this.adminforth.resource(this.resourceConfig.resourceId).get([Filters.EQ(this.resourceConfig.columns.find(c => c.primaryKey)?.name, Id)]);
480
477
  let attachmentFiles
481
478
  if(!this.options.attachFiles){
482
479
  attachmentFiles = [];
@@ -536,11 +533,14 @@ export default class BulkAiFlowPlugin extends AdminForthPlugin {
536
533
  }
537
534
  }
538
535
 
539
- private async regenerateCell(jobId, fieldToRegenerate, recordId, actionType, prompt) {
540
- if (!fieldToRegenerate || !recordId || !actionType ) {
536
+ private async regenerateCell(jobId, fieldToRegenerate, record: any, ctx: AccessCheckContext, actionType, prompt) {
537
+ if (!fieldToRegenerate || !actionType ) {
541
538
  jobs.set(jobId, { status: 'failed', error: 'Missing parameters' });
542
539
  //return { ok: false, error: "Missing parameters" };
543
540
  }
541
+ // a prompt which arrived in the request body is client-authored even though it is wrapped into
542
+ // `promptToPass` below, while the PluginOptions fallback is written by the app author
543
+ const clientSupplied = !!prompt;
544
544
  if ( !prompt ) {
545
545
  if (actionType === 'analyze') {
546
546
  prompt = this.options.fillFieldsFromImages ? (this.options.fillFieldsFromImages as any)[fieldToRegenerate] : null;
@@ -548,9 +548,6 @@ export default class BulkAiFlowPlugin extends AdminForthPlugin {
548
548
  prompt = this.options.fillPlainFields ? (this.options.fillPlainFields as any)[fieldToRegenerate] : null;
549
549
  }
550
550
  }
551
- const primaryKeyColumn = this.resourceConfig.columns.find((col) => col.primaryKey);
552
- const record = await this.adminforth.resource(this.resourceConfig.resourceId).get( [Filters.EQ(primaryKeyColumn.name, recordId)] );
553
-
554
551
  let promptToPass = JSON.stringify({[fieldToRegenerate]: prompt});
555
552
  if (STUB_MODE) {
556
553
  await new Promise((resolve) => setTimeout(resolve, Math.floor(Math.random() * 20000) + 1000));
@@ -558,7 +555,7 @@ export default class BulkAiFlowPlugin extends AdminForthPlugin {
558
555
  jobs.set(jobId, { status: 'completed', result: {[fieldToRegenerate]: "stub value"} });
559
556
  } else {
560
557
  if ( actionType === 'analyze') {
561
- const compiledPropmt = await this.compileOutputFieldsTemplates(record, promptToPass);
558
+ const compiledPropmt = await this.compileOutputFieldsTemplates(record, ctx, promptToPass, clientSupplied);
562
559
  const finalPrompt = this.getPromptForImageAnalysis(compiledPropmt);
563
560
  const attachmentFiles = await this.options.attachFiles({ record: record });
564
561
  if (attachmentFiles.length === 0) {
@@ -595,7 +592,7 @@ export default class BulkAiFlowPlugin extends AdminForthPlugin {
595
592
  jobs.set(jobId, { status: 'completed', result: resData });
596
593
 
597
594
  } else if ( actionType === 'analyze_no_images') {
598
- const compiledPropmt = await this.compileOutputFieldsTemplatesNoImage(record, promptToPass);
595
+ const compiledPropmt = await this.compileOutputFieldsTemplatesNoImage(record, ctx, promptToPass, clientSupplied);
599
596
  const finalPrompt = this.getPromptForPlainFields(compiledPropmt);
600
597
  const numberOfTokens = this.options.fillPlainFieldsMaxTokens ? this.options.fillPlainFieldsMaxTokens : 1000;
601
598
  let resp;
@@ -1039,6 +1036,22 @@ export default class BulkAiFlowPlugin extends AdminForthPlugin {
1039
1036
  return scoped;
1040
1037
  }
1041
1038
 
1039
+ /**
1040
+ * The row as the caller is allowed to read it: columns which are not declared on the resource and
1041
+ * columns whose `backendOnly` resolves true for this request are dropped, exactly the way the
1042
+ * core show route drops them before a row reaches the browser.
1043
+ */
1044
+ private async stripBackendOnlyColumns(record: any, ctx: AccessCheckContext): Promise<Record<string, any>> {
1045
+ const visible: Record<string, any> = {};
1046
+ for (const column of this.resourceConfig.columns) {
1047
+ if (await resolveBoolOrFn(column.backendOnly, ctx)) {
1048
+ continue;
1049
+ }
1050
+ visible[column.name] = record[column.name];
1051
+ }
1052
+ return visible;
1053
+ }
1054
+
1042
1055
  setupEndpoints(server: IHttpServer) {
1043
1056
  server.endpoint({
1044
1057
  method: 'POST',
@@ -1225,7 +1238,7 @@ export default class BulkAiFlowPlugin extends AdminForthPlugin {
1225
1238
  handler: async ({ body, adminUser, headers, query, cookies, requestUrl }) => {
1226
1239
  const data = body as z.infer<typeof getImageGenerationPromptsBodySchema>;
1227
1240
  const customPrompt = data.customPrompt || null;
1228
- const { record, error } = await this.getRecordForShow({
1241
+ const { record, ctx, error } = await this.getRecordForShow({
1229
1242
  adminUser,
1230
1243
  recordId: data.recordId,
1231
1244
  extra: { body, query, headers, cookies, requestUrl },
@@ -1233,7 +1246,7 @@ export default class BulkAiFlowPlugin extends AdminForthPlugin {
1233
1246
  if (error) {
1234
1247
  return { ok: false, error };
1235
1248
  }
1236
- const compiledGenerationOptions = await this.compileGenerationFieldTemplates(record, JSON.stringify({"prompt": customPrompt}));
1249
+ const compiledGenerationOptions = await this.compileGenerationFieldTemplates(record, ctx, JSON.stringify({"prompt": customPrompt}));
1237
1250
  return compiledGenerationOptions;
1238
1251
  }
1239
1252
  });
@@ -1256,7 +1269,7 @@ export default class BulkAiFlowPlugin extends AdminForthPlugin {
1256
1269
  method: 'POST',
1257
1270
  path: `/plugin/${this.pluginInstanceId}/create-job`,
1258
1271
  request_schema: createJobBodySchema,
1259
- handler: async ({ body, adminUser, headers, response }) => {
1272
+ handler: async ({ body, adminUser, headers, query, cookies, requestUrl, response }) => {
1260
1273
  const data = body as z.infer<typeof createJobBodySchema>;
1261
1274
  const { actionType, recordId, customPrompt, filterFilledFields, sessionIds } = data;
1262
1275
  if (this.options.rateLimits) {
@@ -1271,40 +1284,47 @@ export default class BulkAiFlowPlugin extends AdminForthPlugin {
1271
1284
  return { ok: false, error: "Missing session ids" };
1272
1285
  }
1273
1286
  }
1274
- const jobId = randomUUID();
1275
- jobs.set(jobId, { status: "in_progress" });
1276
1287
  if (!actionType) {
1277
- jobs.set(jobId, { status: "failed", error: "Missing action type" });
1278
- //return { error: "Missing action type" };
1288
+ return { ok: false, error: "Missing action type" };
1279
1289
  }
1280
- else if (!recordId && typeof recordId !== 'number') {
1281
- jobs.set(jobId, { status: "failed", error: "Missing record id" });
1282
- //return { error: "Missing record id" };
1283
- } else {
1284
- switch(actionType) {
1285
- case 'generate_images':
1286
- this.initialImageGenerate(jobId, recordId, adminUser, headers, customPrompt, filterFilledFields);
1287
- break;
1288
- case 'analyze_no_images':
1289
- this.analyzeNoImages(jobId, recordId, adminUser, headers, customPrompt, filterFilledFields);
1290
- break;
1291
- case 'analyze':
1292
- this.analyze_image(jobId, recordId, adminUser, headers, customPrompt, filterFilledFields);
1293
- break;
1294
- case 'regenerate_images':
1295
- if (!data.prompt || !data.fieldName) {
1296
- jobs.set(jobId, { status: "failed", error: "Missing prompt or field name" });
1297
- break;
1298
- }
1299
- this.regenerateImage(jobId, recordId, data.fieldName, data.prompt, adminUser, headers);
1300
- break;
1301
- case 'regenerate_cell':
1302
- const fieldToRegenerate = data.fieldToRegenerate;
1303
- this.regenerateCell(jobId, fieldToRegenerate, recordId, data.action, data.prompt);
1304
- break;
1305
- default:
1306
- jobs.set(jobId, { status: "failed", error: "Unknown action type" });
1307
- }
1290
+ if (!recordId && typeof recordId !== 'number') {
1291
+ return { ok: false, error: "Missing record id" };
1292
+ }
1293
+ // every worker below compiles this record into an AI prompt whose answer is handed back
1294
+ // through get-job-status, so the record is read here, behind the same show check and
1295
+ // row-level scoping the other read endpoints use, and the row itself is what gets dispatched
1296
+ const { record, ctx, error } = await this.getRecordForShow({
1297
+ adminUser,
1298
+ recordId,
1299
+ extra: { body, query, headers, cookies, requestUrl },
1300
+ });
1301
+ if (error) {
1302
+ return { ok: false, error };
1303
+ }
1304
+ const jobId = randomUUID();
1305
+ jobs.set(jobId, { status: "in_progress" });
1306
+ switch(actionType) {
1307
+ case 'generate_images':
1308
+ this.initialImageGenerate(jobId, record, ctx, headers, customPrompt, filterFilledFields);
1309
+ break;
1310
+ case 'analyze_no_images':
1311
+ this.analyzeNoImages(jobId, record, ctx, headers, customPrompt, filterFilledFields);
1312
+ break;
1313
+ case 'analyze':
1314
+ this.analyze_image(jobId, record, ctx, headers, customPrompt, filterFilledFields);
1315
+ break;
1316
+ case 'regenerate_images':
1317
+ if (!data.prompt || !data.fieldName) {
1318
+ jobs.set(jobId, { status: "failed", error: "Missing prompt or field name" });
1319
+ break;
1320
+ }
1321
+ this.regenerateImage(jobId, record, data.fieldName, data.prompt, headers);
1322
+ break;
1323
+ case 'regenerate_cell':
1324
+ this.regenerateCell(jobId, data.fieldToRegenerate, record, ctx, data.action, data.prompt);
1325
+ break;
1326
+ default:
1327
+ jobs.set(jobId, { status: "failed", error: "Unknown action type" });
1308
1328
  }
1309
1329
  setTimeout(() => jobs.delete(jobId), 1_800_000);
1310
1330
  setTimeout(() => jobs.set(jobId, { status: "failed", error: "Job timed out" }), 180_000);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@adminforth/bulk-ai-flow",
3
- "version": "1.27.5",
3
+ "version": "1.27.6",
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },