@adminforth/bulk-ai-flow 1.27.4 → 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/build.log CHANGED
@@ -14,5 +14,5 @@ custom/package.json
14
14
  custom/pnpm-lock.yaml
15
15
  custom/tsconfig.json
16
16
 
17
- sent 130,464 bytes received 191 bytes 261,310.00 bytes/sec
18
- total size is 129,741 speedup is 0.99
17
+ sent 130,414 bytes received 191 bytes 261,210.00 bytes/sec
18
+ total size is 129,691 speedup is 0.99
@@ -884,7 +884,7 @@ async function processOneRecord(recordId: string, sessionIds: string[]) {
884
884
  record.label = oldDataResult._label || record.label;
885
885
  initializeRecordData(record, oldDataResult);
886
886
  if (props.meta.isAttachFiles) {
887
- await fetchImages(record, oldDataResult);
887
+ await fetchImages(record, recordId);
888
888
  if (!checkIfDialogOpen()) {
889
889
  return;
890
890
  }
@@ -1162,16 +1162,14 @@ function normalizeEnumValue(key: string, value: any) {
1162
1162
  return match ? value : null;
1163
1163
  }
1164
1164
 
1165
- async function fetchImages(record: RecordState, oldRecord: Record<string, any>) {
1165
+ async function fetchImages(record: RecordState, recordId: string) {
1166
1166
  try {
1167
1167
  const res = await callAdminForthApi({
1168
1168
  path: `/plugin/${props.meta.pluginInstanceId}/get_images`,
1169
1169
  method: 'POST',
1170
- body: {
1171
- record: [oldRecord],
1172
- },
1170
+ body: { recordId },
1173
1171
  });
1174
- record.images = res.images?.[0] || [];
1172
+ record.images = res.images || [];
1175
1173
  touchRecords();
1176
1174
  } catch (error) {
1177
1175
  console.error('Failed to get images:', error);
@@ -884,7 +884,7 @@ async function processOneRecord(recordId: string, sessionIds: string[]) {
884
884
  record.label = oldDataResult._label || record.label;
885
885
  initializeRecordData(record, oldDataResult);
886
886
  if (props.meta.isAttachFiles) {
887
- await fetchImages(record, oldDataResult);
887
+ await fetchImages(record, recordId);
888
888
  if (!checkIfDialogOpen()) {
889
889
  return;
890
890
  }
@@ -1162,16 +1162,14 @@ function normalizeEnumValue(key: string, value: any) {
1162
1162
  return match ? value : null;
1163
1163
  }
1164
1164
 
1165
- async function fetchImages(record: RecordState, oldRecord: Record<string, any>) {
1165
+ async function fetchImages(record: RecordState, recordId: string) {
1166
1166
  try {
1167
1167
  const res = await callAdminForthApi({
1168
1168
  path: `/plugin/${props.meta.pluginInstanceId}/get_images`,
1169
1169
  method: 'POST',
1170
- body: {
1171
- record: [oldRecord],
1172
- },
1170
+ body: { recordId },
1173
1171
  });
1174
- record.images = res.images?.[0] || [];
1172
+ record.images = res.images || [];
1175
1173
  touchRecords();
1176
1174
  } catch (error) {
1177
1175
  console.error('Failed to get images:', error);
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;
@@ -34,5 +43,25 @@ export default class BulkAiFlowPlugin extends AdminForthPlugin {
34
43
  oldRecord: any;
35
44
  fields: Record<string, any>;
36
45
  }): Promise<string | null>;
46
+ /**
47
+ * Reads one record the way the core show route does: the resource-level `show` permission first,
48
+ * then the resource's own `show.beforeDatasourceRequest` hooks so row-level scoping narrows the
49
+ * filters before the query runs.
50
+ * The returned record is the raw row, so callers which send it to the browser must pass it
51
+ * through `scopeRecordForFrontend` first.
52
+ */
53
+ private getRecordForShow;
54
+ /**
55
+ * The action dialog only ever renders the fields this plugin generates, so only those leave the
56
+ * backend, next to the primary key and the record label. backendOnly columns are dropped the same
57
+ * way the core show route drops them.
58
+ */
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;
37
66
  setupEndpoints(server: IHttpServer): void;
38
67
  }
package/dist/index.js CHANGED
@@ -17,7 +17,7 @@ const getRecordsBodySchema = z.object({
17
17
  record: z.array(z.any()).nullish(),
18
18
  }).strict();
19
19
  const getImagesBodySchema = z.object({
20
- record: z.array(z.any()).nullish(),
20
+ recordId: z.union([z.string(), z.number()]),
21
21
  }).strict();
22
22
  const getOldDataBodySchema = z.object({
23
23
  recordId: z.union([z.string(), z.number()]).nullish(),
@@ -28,7 +28,7 @@ const updateFieldsBodySchema = z.object({
28
28
  saveImages: z.boolean().nullish(),
29
29
  }).strict();
30
30
  const getImageGenerationPromptsBodySchema = z.object({
31
- recordId: z.union([z.string(), z.number()]).nullish(),
31
+ recordId: z.union([z.string(), z.number()]),
32
32
  customPrompt: z.string().nullish(),
33
33
  }).strict();
34
34
  const createJobBodySchema = z.object({
@@ -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;
@@ -907,44 +905,143 @@ export default class BulkAiFlowPlugin extends AdminForthPlugin {
907
905
  return null;
908
906
  });
909
907
  }
908
+ /**
909
+ * Reads one record the way the core show route does: the resource-level `show` permission first,
910
+ * then the resource's own `show.beforeDatasourceRequest` hooks so row-level scoping narrows the
911
+ * filters before the query runs.
912
+ * The returned record is the raw row, so callers which send it to the browser must pass it
913
+ * through `scopeRecordForFrontend` first.
914
+ */
915
+ getRecordForShow(_a) {
916
+ return __awaiter(this, arguments, void 0, function* ({ adminUser, recordId, extra }) {
917
+ var _b, _c;
918
+ const meta = { requestBody: extra.body, pk: recordId };
919
+ const ctx = {
920
+ adminUser,
921
+ resource: this.resourceConfig,
922
+ meta,
923
+ source: ActionCheckSource.ShowRequest,
924
+ adminforth: this.adminforth,
925
+ };
926
+ const { allowedActions } = yield interpretResource(adminUser, this.resourceConfig, meta, ActionCheckSource.ShowRequest, this.adminforth);
927
+ const showAllowed = allowedActions[AllowedActionsEnum.show];
928
+ if (showAllowed !== true) {
929
+ return { error: typeof showAllowed === 'string' ? showAllowed : 'You are not allowed to view records in this resource' };
930
+ }
931
+ const primaryKeyColumn = this.resourceConfig.columns.find((col) => col.primaryKey);
932
+ const hookQuery = {
933
+ resourceId: this.resourceConfig.resourceId,
934
+ source: 'show',
935
+ filters: [Filters.EQ(primaryKeyColumn.name, recordId)],
936
+ limit: 1,
937
+ offset: 0,
938
+ sort: [],
939
+ };
940
+ for (const hook of ((_c = (_b = this.resourceConfig.hooks) === null || _b === void 0 ? void 0 : _b.show) === null || _c === void 0 ? void 0 : _c.beforeDatasourceRequest) || []) {
941
+ const filterTools = filtersTools.get(hookQuery);
942
+ hookQuery.filtersTools = filterTools;
943
+ const resp = yield hook({
944
+ resource: this.resourceConfig,
945
+ query: hookQuery,
946
+ adminUser,
947
+ //@ts-ignore
948
+ filtersTools: filterTools,
949
+ extra,
950
+ adminforth: this.adminforth,
951
+ });
952
+ if (!resp || (!resp.ok && !resp.error)) {
953
+ throw new Error(`Hook must return object with {ok: true} or { error: 'Error' } `);
954
+ }
955
+ if (resp.error) {
956
+ return { error: resp.error };
957
+ }
958
+ }
959
+ const record = yield this.adminforth.resource(this.resourceConfig.resourceId).get(hookQuery.filters);
960
+ if (!record) {
961
+ return { error: 'Record not found' };
962
+ }
963
+ return { record, ctx };
964
+ });
965
+ }
966
+ /**
967
+ * The action dialog only ever renders the fields this plugin generates, so only those leave the
968
+ * backend, next to the primary key and the record label. backendOnly columns are dropped the same
969
+ * way the core show route drops them.
970
+ */
971
+ scopeRecordForFrontend(record, ctx) {
972
+ return __awaiter(this, void 0, void 0, function* () {
973
+ const primaryKeyColumn = this.resourceConfig.columns.find((col) => col.primaryKey);
974
+ const managedFields = this.pluginManagedFields();
975
+ const scoped = {
976
+ [primaryKeyColumn.name]: record[primaryKeyColumn.name],
977
+ _label: this.resourceConfig.recordLabel(record),
978
+ };
979
+ for (const column of this.resourceConfig.columns.filter((col) => managedFields.has(col.name))) {
980
+ if (yield resolveBoolOrFn(column.backendOnly, ctx)) {
981
+ continue;
982
+ }
983
+ scoped[column.name] = record[column.name];
984
+ }
985
+ return scoped;
986
+ });
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
+ }
910
1005
  setupEndpoints(server) {
911
1006
  server.endpoint({
912
1007
  method: 'POST',
913
1008
  path: `/plugin/${this.pluginInstanceId}/get_old_data`,
914
1009
  request_schema: getOldDataBodySchema,
915
- handler: (_a) => __awaiter(this, [_a], void 0, function* ({ body, response }) {
1010
+ handler: (_a) => __awaiter(this, [_a], void 0, function* ({ body, adminUser, headers, query, cookies, requestUrl }) {
916
1011
  const data = body;
917
1012
  const recordId = data.recordId;
918
1013
  if (recordId === undefined || recordId === null) {
919
1014
  return { ok: false, error: "Missing recordId" };
920
1015
  }
921
- const primaryKeyColumn = this.resourceConfig.columns.find((col) => col.primaryKey);
922
- const record = yield this.adminforth.resource(this.resourceConfig.resourceId)
923
- .get([Filters.EQ(primaryKeyColumn.name, recordId)]);
924
- if (!record) {
925
- return { ok: false, error: "Record not found" };
1016
+ const { record, ctx, error } = yield this.getRecordForShow({
1017
+ adminUser,
1018
+ recordId,
1019
+ extra: { body, query, headers, cookies, requestUrl },
1020
+ });
1021
+ if (error) {
1022
+ return { ok: false, error };
926
1023
  }
927
- record._label = this.resourceConfig.recordLabel(record);
928
- return { ok: true, record };
1024
+ return { ok: true, record: yield this.scopeRecordForFrontend(record, ctx) };
929
1025
  })
930
1026
  });
931
1027
  server.endpoint({
932
1028
  method: 'POST',
933
1029
  path: `/plugin/${this.pluginInstanceId}/get_images`,
934
1030
  request_schema: getImagesBodySchema,
935
- handler: (_a) => __awaiter(this, [_a], void 0, function* ({ body, response }) {
1031
+ handler: (_a) => __awaiter(this, [_a], void 0, function* ({ body, adminUser, headers, query, cookies, requestUrl }) {
936
1032
  const data = body;
937
- let images = [];
938
- if (data.record) {
939
- for (const record of data.record) {
940
- if (this.options.attachFiles) {
941
- images.push(yield this.options.attachFiles({ record: record }));
942
- }
943
- }
1033
+ // attachFiles runs with server privileges and usually mints download URLs, so it is only
1034
+ // ever given a record loaded here after the show check, never one supplied by the client
1035
+ const { record, error } = yield this.getRecordForShow({
1036
+ adminUser,
1037
+ recordId: data.recordId,
1038
+ extra: { body, query, headers, cookies, requestUrl },
1039
+ });
1040
+ if (error) {
1041
+ return { ok: false, error };
944
1042
  }
945
- return {
946
- images,
947
- };
1043
+ const images = this.options.attachFiles ? yield this.options.attachFiles({ record }) : [];
1044
+ return { ok: true, images };
948
1045
  })
949
1046
  });
950
1047
  server.endpoint({
@@ -1074,13 +1171,18 @@ export default class BulkAiFlowPlugin extends AdminForthPlugin {
1074
1171
  method: 'POST',
1075
1172
  path: `/plugin/${this.pluginInstanceId}/get_image_generation_prompts`,
1076
1173
  request_schema: getImageGenerationPromptsBodySchema,
1077
- handler: (_a) => __awaiter(this, [_a], void 0, function* ({ body, headers, response }) {
1078
- var _b;
1174
+ handler: (_a) => __awaiter(this, [_a], void 0, function* ({ body, adminUser, headers, query, cookies, requestUrl }) {
1079
1175
  const data = body;
1080
- const Id = data.recordId || [];
1081
1176
  const customPrompt = data.customPrompt || null;
1082
- const record = yield this.adminforth.resource(this.resourceConfig.resourceId).get([Filters.EQ((_b = this.resourceConfig.columns.find(c => c.primaryKey)) === null || _b === void 0 ? void 0 : _b.name, Id)]);
1083
- const compiledGenerationOptions = yield this.compileGenerationFieldTemplates(record, JSON.stringify({ "prompt": customPrompt }));
1177
+ const { record, ctx, error } = yield this.getRecordForShow({
1178
+ adminUser,
1179
+ recordId: data.recordId,
1180
+ extra: { body, query, headers, cookies, requestUrl },
1181
+ });
1182
+ if (error) {
1183
+ return { ok: false, error };
1184
+ }
1185
+ const compiledGenerationOptions = yield this.compileGenerationFieldTemplates(record, ctx, JSON.stringify({ "prompt": customPrompt }));
1084
1186
  return compiledGenerationOptions;
1085
1187
  })
1086
1188
  });
@@ -1099,7 +1201,7 @@ export default class BulkAiFlowPlugin extends AdminForthPlugin {
1099
1201
  method: 'POST',
1100
1202
  path: `/plugin/${this.pluginInstanceId}/create-job`,
1101
1203
  request_schema: createJobBodySchema,
1102
- 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 }) {
1103
1205
  const data = body;
1104
1206
  const { actionType, recordId, customPrompt, filterFilledFields, sessionIds } = data;
1105
1207
  if (this.options.rateLimits) {
@@ -1115,41 +1217,47 @@ export default class BulkAiFlowPlugin extends AdminForthPlugin {
1115
1217
  return { ok: false, error: "Missing session ids" };
1116
1218
  }
1117
1219
  }
1118
- const jobId = randomUUID();
1119
- jobs.set(jobId, { status: "in_progress" });
1120
1220
  if (!actionType) {
1121
- jobs.set(jobId, { status: "failed", error: "Missing action type" });
1122
- //return { error: "Missing action type" };
1123
- }
1124
- else if (!recordId && typeof recordId !== 'number') {
1125
- jobs.set(jobId, { status: "failed", error: "Missing record id" });
1126
- //return { error: "Missing record id" };
1221
+ return { ok: false, error: "Missing action type" };
1222
+ }
1223
+ if (!recordId && typeof recordId !== 'number') {
1224
+ return { ok: false, error: "Missing record id" };
1225
+ }
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 };
1127
1236
  }
1128
- else {
1129
- switch (actionType) {
1130
- case 'generate_images':
1131
- this.initialImageGenerate(jobId, recordId, adminUser, headers, customPrompt, filterFilledFields);
1132
- break;
1133
- case 'analyze_no_images':
1134
- this.analyzeNoImages(jobId, recordId, adminUser, headers, customPrompt, filterFilledFields);
1135
- break;
1136
- case 'analyze':
1137
- this.analyze_image(jobId, recordId, adminUser, headers, customPrompt, filterFilledFields);
1138
- break;
1139
- case 'regenerate_images':
1140
- if (!data.prompt || !data.fieldName) {
1141
- jobs.set(jobId, { status: "failed", error: "Missing prompt or field name" });
1142
- break;
1143
- }
1144
- this.regenerateImage(jobId, recordId, data.fieldName, data.prompt, adminUser, headers);
1145
- break;
1146
- case 'regenerate_cell':
1147
- const fieldToRegenerate = data.fieldToRegenerate;
1148
- this.regenerateCell(jobId, fieldToRegenerate, recordId, data.action, data.prompt);
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" });
1149
1252
  break;
1150
- default:
1151
- jobs.set(jobId, { status: "failed", error: "Unknown action type" });
1152
- }
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" });
1153
1261
  }
1154
1262
  setTimeout(() => jobs.delete(jobId), 1800000);
1155
1263
  setTimeout(() => jobs.set(jobId, { status: "failed", error: "Job timed out" }), 180000);
@@ -1258,6 +1366,11 @@ export default class BulkAiFlowPlugin extends AdminForthPlugin {
1258
1366
  handler: (_a) => __awaiter(this, [_a], void 0, function* ({ body, adminUser, headers, query, cookies, requestUrl, response }) {
1259
1367
  var _b, _c;
1260
1368
  const resource = this.resourceConfig;
1369
+ const { allowedActions } = yield interpretResource(adminUser, resource, { requestBody: body }, ActionCheckSource.ListRequest, this.adminforth);
1370
+ const listAllowed = allowedActions[AllowedActionsEnum.list];
1371
+ if (listAllowed !== true) {
1372
+ return { ok: false, error: typeof listAllowed === 'string' ? listAllowed : 'You are not allowed to list records in this resource' };
1373
+ }
1261
1374
  for (const hook of ((_c = (_b = resource.hooks) === null || _b === void 0 ? void 0 : _b.list) === null || _c === void 0 ? void 0 : _c.beforeDatasourceRequest) || []) {
1262
1375
  const filterTools = filtersTools.get(body);
1263
1376
  body.filtersTools = filterTools;
package/index.ts CHANGED
@@ -12,7 +12,7 @@ const getRecordsBodySchema = z.object({
12
12
  }).strict();
13
13
 
14
14
  const getImagesBodySchema = z.object({
15
- record: z.array(z.any()).nullish(),
15
+ recordId: z.union([z.string(), z.number()]),
16
16
  }).strict();
17
17
 
18
18
  const getOldDataBodySchema = z.object({
@@ -26,7 +26,7 @@ const updateFieldsBodySchema = z.object({
26
26
  }).strict();
27
27
 
28
28
  const getImageGenerationPromptsBodySchema = z.object({
29
- recordId: z.union([z.string(), z.number()]).nullish(),
29
+ recordId: z.union([z.string(), z.number()]),
30
30
  customPrompt: z.string().nullish(),
31
31
  }).strict();
32
32
 
@@ -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;
@@ -949,25 +946,132 @@ export default class BulkAiFlowPlugin extends AdminForthPlugin {
949
946
  return null;
950
947
  }
951
948
 
949
+ /**
950
+ * Reads one record the way the core show route does: the resource-level `show` permission first,
951
+ * then the resource's own `show.beforeDatasourceRequest` hooks so row-level scoping narrows the
952
+ * filters before the query runs.
953
+ * The returned record is the raw row, so callers which send it to the browser must pass it
954
+ * through `scopeRecordForFrontend` first.
955
+ */
956
+ private async getRecordForShow({ adminUser, recordId, extra }: {
957
+ adminUser: AdminUser,
958
+ recordId: any,
959
+ extra: { body: any, query: any, headers: any, cookies: any, requestUrl: string },
960
+ }): Promise<{ record?: any, ctx?: AccessCheckContext, error?: string }> {
961
+ const meta = { requestBody: extra.body, pk: recordId };
962
+ const ctx: AccessCheckContext = {
963
+ adminUser,
964
+ resource: this.resourceConfig,
965
+ meta,
966
+ source: ActionCheckSource.ShowRequest,
967
+ adminforth: this.adminforth,
968
+ };
969
+
970
+ const { allowedActions } = await interpretResource(
971
+ adminUser,
972
+ this.resourceConfig,
973
+ meta,
974
+ ActionCheckSource.ShowRequest,
975
+ this.adminforth,
976
+ );
977
+ const showAllowed = allowedActions[AllowedActionsEnum.show] as boolean | string | undefined;
978
+ if (showAllowed !== true) {
979
+ return { error: typeof showAllowed === 'string' ? showAllowed : 'You are not allowed to view records in this resource' };
980
+ }
981
+
982
+ const primaryKeyColumn = this.resourceConfig.columns.find((col) => col.primaryKey);
983
+ const hookQuery: any = {
984
+ resourceId: this.resourceConfig.resourceId,
985
+ source: 'show',
986
+ filters: [Filters.EQ(primaryKeyColumn.name, recordId)],
987
+ limit: 1,
988
+ offset: 0,
989
+ sort: [],
990
+ };
991
+ for (const hook of this.resourceConfig.hooks?.show?.beforeDatasourceRequest || []) {
992
+ const filterTools = filtersTools.get(hookQuery);
993
+ hookQuery.filtersTools = filterTools;
994
+ const resp = await hook({
995
+ resource: this.resourceConfig,
996
+ query: hookQuery,
997
+ adminUser,
998
+ //@ts-ignore
999
+ filtersTools: filterTools,
1000
+ extra,
1001
+ adminforth: this.adminforth,
1002
+ });
1003
+ if (!resp || (!resp.ok && !resp.error)) {
1004
+ throw new Error(`Hook must return object with {ok: true} or { error: 'Error' } `);
1005
+ }
1006
+ if (resp.error) {
1007
+ return { error: resp.error };
1008
+ }
1009
+ }
1010
+
1011
+ const record = await this.adminforth.resource(this.resourceConfig.resourceId).get(hookQuery.filters);
1012
+ if (!record) {
1013
+ return { error: 'Record not found' };
1014
+ }
1015
+ return { record, ctx };
1016
+ }
1017
+
1018
+ /**
1019
+ * The action dialog only ever renders the fields this plugin generates, so only those leave the
1020
+ * backend, next to the primary key and the record label. backendOnly columns are dropped the same
1021
+ * way the core show route drops them.
1022
+ */
1023
+ private async scopeRecordForFrontend(record: any, ctx: AccessCheckContext): Promise<Record<string, any>> {
1024
+ const primaryKeyColumn = this.resourceConfig.columns.find((col) => col.primaryKey);
1025
+ const managedFields = this.pluginManagedFields();
1026
+ const scoped: Record<string, any> = {
1027
+ [primaryKeyColumn.name]: record[primaryKeyColumn.name],
1028
+ _label: this.resourceConfig.recordLabel(record),
1029
+ };
1030
+ for (const column of this.resourceConfig.columns.filter((col) => managedFields.has(col.name))) {
1031
+ if (await resolveBoolOrFn(column.backendOnly, ctx)) {
1032
+ continue;
1033
+ }
1034
+ scoped[column.name] = record[column.name];
1035
+ }
1036
+ return scoped;
1037
+ }
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
+
952
1055
  setupEndpoints(server: IHttpServer) {
953
1056
  server.endpoint({
954
1057
  method: 'POST',
955
1058
  path: `/plugin/${this.pluginInstanceId}/get_old_data`,
956
1059
  request_schema: getOldDataBodySchema,
957
- handler: async ({ body, response }) => {
1060
+ handler: async ({ body, adminUser, headers, query, cookies, requestUrl }) => {
958
1061
  const data = body as z.infer<typeof getOldDataBodySchema>;
959
1062
  const recordId = data.recordId;
960
1063
  if (recordId === undefined || recordId === null) {
961
1064
  return { ok: false, error: "Missing recordId" };
962
1065
  }
963
- const primaryKeyColumn = this.resourceConfig.columns.find((col) => col.primaryKey);
964
- const record = await this.adminforth.resource(this.resourceConfig.resourceId)
965
- .get([Filters.EQ(primaryKeyColumn.name, recordId)]);
966
- if (!record) {
967
- return { ok: false, error: "Record not found" };
1066
+ const { record, ctx, error } = await this.getRecordForShow({
1067
+ adminUser,
1068
+ recordId,
1069
+ extra: { body, query, headers, cookies, requestUrl },
1070
+ });
1071
+ if (error) {
1072
+ return { ok: false, error };
968
1073
  }
969
- record._label = this.resourceConfig.recordLabel(record);
970
- return { ok: true, record };
1074
+ return { ok: true, record: await this.scopeRecordForFrontend(record, ctx) };
971
1075
  }
972
1076
  });
973
1077
 
@@ -976,19 +1080,20 @@ export default class BulkAiFlowPlugin extends AdminForthPlugin {
976
1080
  method: 'POST',
977
1081
  path: `/plugin/${this.pluginInstanceId}/get_images`,
978
1082
  request_schema: getImagesBodySchema,
979
- handler: async ({ body, response }) => {
1083
+ handler: async ({ body, adminUser, headers, query, cookies, requestUrl }) => {
980
1084
  const data = body as z.infer<typeof getImagesBodySchema>;
981
- let images = [];
982
- if(data.record){
983
- for( const record of data.record ) {
984
- if (this.options.attachFiles) {
985
- images.push(await this.options.attachFiles({ record: record }));
986
- }
987
- }
1085
+ // attachFiles runs with server privileges and usually mints download URLs, so it is only
1086
+ // ever given a record loaded here after the show check, never one supplied by the client
1087
+ const { record, error } = await this.getRecordForShow({
1088
+ adminUser,
1089
+ recordId: data.recordId,
1090
+ extra: { body, query, headers, cookies, requestUrl },
1091
+ });
1092
+ if (error) {
1093
+ return { ok: false, error };
988
1094
  }
989
- return {
990
- images,
991
- };
1095
+ const images = this.options.attachFiles ? await this.options.attachFiles({ record }) : [];
1096
+ return { ok: true, images };
992
1097
  }
993
1098
  });
994
1099
 
@@ -1130,12 +1235,18 @@ export default class BulkAiFlowPlugin extends AdminForthPlugin {
1130
1235
  method: 'POST',
1131
1236
  path: `/plugin/${this.pluginInstanceId}/get_image_generation_prompts`,
1132
1237
  request_schema: getImageGenerationPromptsBodySchema,
1133
- handler: async ({ body, headers, response }) => {
1238
+ handler: async ({ body, adminUser, headers, query, cookies, requestUrl }) => {
1134
1239
  const data = body as z.infer<typeof getImageGenerationPromptsBodySchema>;
1135
- const Id = data.recordId || [];
1136
1240
  const customPrompt = data.customPrompt || null;
1137
- const record = await this.adminforth.resource(this.resourceConfig.resourceId).get([Filters.EQ(this.resourceConfig.columns.find(c => c.primaryKey)?.name, Id)]);
1138
- const compiledGenerationOptions = await this.compileGenerationFieldTemplates(record, JSON.stringify({"prompt": customPrompt}));
1241
+ const { record, ctx, error } = await this.getRecordForShow({
1242
+ adminUser,
1243
+ recordId: data.recordId,
1244
+ extra: { body, query, headers, cookies, requestUrl },
1245
+ });
1246
+ if (error) {
1247
+ return { ok: false, error };
1248
+ }
1249
+ const compiledGenerationOptions = await this.compileGenerationFieldTemplates(record, ctx, JSON.stringify({"prompt": customPrompt}));
1139
1250
  return compiledGenerationOptions;
1140
1251
  }
1141
1252
  });
@@ -1158,7 +1269,7 @@ export default class BulkAiFlowPlugin extends AdminForthPlugin {
1158
1269
  method: 'POST',
1159
1270
  path: `/plugin/${this.pluginInstanceId}/create-job`,
1160
1271
  request_schema: createJobBodySchema,
1161
- handler: async ({ body, adminUser, headers, response }) => {
1272
+ handler: async ({ body, adminUser, headers, query, cookies, requestUrl, response }) => {
1162
1273
  const data = body as z.infer<typeof createJobBodySchema>;
1163
1274
  const { actionType, recordId, customPrompt, filterFilledFields, sessionIds } = data;
1164
1275
  if (this.options.rateLimits) {
@@ -1173,40 +1284,47 @@ export default class BulkAiFlowPlugin extends AdminForthPlugin {
1173
1284
  return { ok: false, error: "Missing session ids" };
1174
1285
  }
1175
1286
  }
1176
- const jobId = randomUUID();
1177
- jobs.set(jobId, { status: "in_progress" });
1178
1287
  if (!actionType) {
1179
- jobs.set(jobId, { status: "failed", error: "Missing action type" });
1180
- //return { error: "Missing action type" };
1288
+ return { ok: false, error: "Missing action type" };
1181
1289
  }
1182
- else if (!recordId && typeof recordId !== 'number') {
1183
- jobs.set(jobId, { status: "failed", error: "Missing record id" });
1184
- //return { error: "Missing record id" };
1185
- } else {
1186
- switch(actionType) {
1187
- case 'generate_images':
1188
- this.initialImageGenerate(jobId, recordId, adminUser, headers, customPrompt, filterFilledFields);
1189
- break;
1190
- case 'analyze_no_images':
1191
- this.analyzeNoImages(jobId, recordId, adminUser, headers, customPrompt, filterFilledFields);
1192
- break;
1193
- case 'analyze':
1194
- this.analyze_image(jobId, recordId, adminUser, headers, customPrompt, filterFilledFields);
1195
- break;
1196
- case 'regenerate_images':
1197
- if (!data.prompt || !data.fieldName) {
1198
- jobs.set(jobId, { status: "failed", error: "Missing prompt or field name" });
1199
- break;
1200
- }
1201
- this.regenerateImage(jobId, recordId, data.fieldName, data.prompt, adminUser, headers);
1202
- break;
1203
- case 'regenerate_cell':
1204
- const fieldToRegenerate = data.fieldToRegenerate;
1205
- this.regenerateCell(jobId, fieldToRegenerate, recordId, data.action, data.prompt);
1206
- break;
1207
- default:
1208
- jobs.set(jobId, { status: "failed", error: "Unknown action type" });
1209
- }
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" });
1210
1328
  }
1211
1329
  setTimeout(() => jobs.delete(jobId), 1_800_000);
1212
1330
  setTimeout(() => jobs.set(jobId, { status: "failed", error: "Job timed out" }), 180_000);
@@ -1324,6 +1442,18 @@ export default class BulkAiFlowPlugin extends AdminForthPlugin {
1324
1442
  handler: async ({ body, adminUser, headers, query, cookies, requestUrl, response }) => {
1325
1443
  const resource = this.resourceConfig;
1326
1444
 
1445
+ const { allowedActions } = await interpretResource(
1446
+ adminUser,
1447
+ resource,
1448
+ { requestBody: body },
1449
+ ActionCheckSource.ListRequest,
1450
+ this.adminforth,
1451
+ );
1452
+ const listAllowed = allowedActions[AllowedActionsEnum.list] as boolean | string | undefined;
1453
+ if (listAllowed !== true) {
1454
+ return { ok: false, error: typeof listAllowed === 'string' ? listAllowed : 'You are not allowed to list records in this resource' };
1455
+ }
1456
+
1327
1457
  for (const hook of resource.hooks?.list?.beforeDatasourceRequest || []) {
1328
1458
  const filterTools = filtersTools.get(body);
1329
1459
  body.filtersTools = filterTools;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@adminforth/bulk-ai-flow",
3
- "version": "1.27.4",
3
+ "version": "1.27.6",
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },