@frontera-sdk/functions 1.51.0 → 1.51.2

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@frontera-sdk/functions",
3
- "version": "1.51.0",
3
+ "version": "1.51.2",
4
4
  "description": "Author Frontera functions: manifest, triggers and the typed handler contract.",
5
5
  "keywords": [
6
6
  "frontera",
@@ -42,7 +42,7 @@
42
42
  "typescript": "^5.9.3"
43
43
  },
44
44
  "dependencies": {
45
- "@frontera-sdk/blueprint": "1.51.0",
45
+ "@frontera-sdk/blueprint": "1.51.2",
46
46
  "cron-parser": "^5.0.6"
47
47
  }
48
48
  }
package/src/index.ts CHANGED
@@ -12,8 +12,12 @@ export {
12
12
  export { createTestContext } from './testing'
13
13
  export type { TestCall, TestContext, TestContextOptions } from './testing'
14
14
  export {
15
+ DEFAULT_MAX_FILES,
16
+ MAX_FILES_CAP,
15
17
  MAX_INPUT_BYTES,
16
18
  checkInputFieldSpec,
19
+ isFileInputType,
20
+ maxFilesOf,
17
21
  redactedInputKeys,
18
22
  sanitizeInputsSchema,
19
23
  validateInputValue,
package/src/inputs.ts CHANGED
@@ -27,20 +27,39 @@ export type InputValidation =
27
27
  * naming a non-empty string `fileId`). It deliberately does NOT authorize the id
28
28
  * or check mime/size — those need the DB and the caller's workspace scope, so the
29
29
  * SERVER (`run-service` via `resolveFile`) authorizes and resolves the reference
30
- * after this passes.
30
+ * after this passes. `files` is a list of the same reference; its count is
31
+ * checked in `validateInputValue`, which knows the field's `maxFiles`.
31
32
  */
33
+ const isFileRef = (v: unknown): boolean =>
34
+ typeof v === 'object' &&
35
+ v !== null &&
36
+ !Array.isArray(v) &&
37
+ typeof (v as { fileId?: unknown }).fileId === 'string' &&
38
+ (v as { fileId: string }).fileId.length > 0
39
+
32
40
  export const TYPE_CHECK: Record<InputFieldSpec['type'], (v: unknown) => boolean> = {
33
41
  string: (v) => typeof v === 'string',
34
42
  number: (v) => typeof v === 'number' && Number.isFinite(v),
35
43
  boolean: (v) => typeof v === 'boolean',
36
44
  object: (v) => typeof v === 'object' && v !== null && !Array.isArray(v),
37
45
  array: Array.isArray,
38
- file: (v) =>
39
- typeof v === 'object' &&
40
- v !== null &&
41
- !Array.isArray(v) &&
42
- typeof (v as { fileId?: unknown }).fileId === 'string' &&
43
- (v as { fileId: string }).fileId.length > 0,
46
+ file: isFileRef,
47
+ files: (v) => Array.isArray(v) && v.every(isFileRef),
48
+ }
49
+
50
+ /** The most files a `files` input holds when its spec names no `maxFiles`. */
51
+ export const DEFAULT_MAX_FILES = 20
52
+ /** The highest `maxFiles` a `files` input may declare. */
53
+ export const MAX_FILES_CAP = 50
54
+
55
+ /** Whether an input of this type holds file references: one, or a list. */
56
+ export function isFileInputType(type: unknown): type is 'file' | 'files' {
57
+ return type === 'file' || type === 'files'
58
+ }
59
+
60
+ /** The most files a `files` input holds. */
61
+ export function maxFilesOf(spec: Pick<InputFieldSpec, 'maxFiles'>): number {
62
+ return spec.maxFiles ?? DEFAULT_MAX_FILES
44
63
  }
45
64
 
46
65
  /** Lowercase kebab, matching `validateManifest`'s automation-`name` grammar. */
@@ -48,7 +67,7 @@ const INPUT_NAME_KEBAB_RE = /^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/
48
67
  /** Lowercase snake — the one allowance kebab doesn't cover. */
49
68
  const INPUT_NAME_SNAKE_RE = /^[a-z][a-z0-9_]*$/
50
69
 
51
- const INPUT_TYPES = new Set<InputFieldSpec['type']>(['string', 'number', 'boolean', 'object', 'array', 'file'])
70
+ const INPUT_TYPES = new Set<InputFieldSpec['type']>(['string', 'number', 'boolean', 'object', 'array', 'file', 'files'])
52
71
 
53
72
  /** Keys `checkInputFieldSpec` understands on ONE input spec (`inputs.<name>`).
54
73
  * Anything else there is a warning, same philosophy as `manifest.ts`'s
@@ -63,6 +82,7 @@ const INPUT_SPEC_KEYS = new Set([
63
82
  'redact',
64
83
  'accept',
65
84
  'maxBytes',
85
+ 'maxFiles',
66
86
  ])
67
87
 
68
88
  /**
@@ -129,6 +149,7 @@ export function checkInputFieldSpec(key: string, raw: unknown): InputFieldCheck
129
149
  redact?: unknown
130
150
  accept?: unknown
131
151
  maxBytes?: unknown
152
+ maxFiles?: unknown
132
153
  }
133
154
 
134
155
  if (spec.description !== undefined && typeof spec.description !== 'string') {
@@ -136,7 +157,7 @@ export function checkInputFieldSpec(key: string, raw: unknown): InputFieldCheck
136
157
  }
137
158
 
138
159
  if (typeof spec.type !== 'string' || !INPUT_TYPES.has(spec.type as InputFieldSpec['type'])) {
139
- errors.push(`input "${key}": type must be one of string, number, boolean, object, array, file`)
160
+ errors.push(`input "${key}": type must be one of string, number, boolean, object, array, file, files`)
140
161
  return { errors, warnings }
141
162
  }
142
163
  const t = spec.type as InputFieldSpec['type']
@@ -196,12 +217,12 @@ export function checkInputFieldSpec(key: string, raw: unknown): InputFieldCheck
196
217
  }
197
218
  }
198
219
 
199
- // `accept`/`maxBytes` are the file-only constraints — refused on any other
200
- // type so a typo like `accept` on a string field is loud, not silently
201
- // ignored. They constrain the UPLOAD, not the value on the run row (which is
220
+ // `accept`/`maxBytes` are the file-only constraints, for one file or a list
221
+ // of them — refused on any other type so a typo like `accept` on a string
222
+ // field is loud, not silently ignored. They constrain the UPLOAD, not the value on the run row (which is
202
223
  // only a reference), so the server enforces them; here we check well-formedness.
203
224
  if (spec.accept !== undefined) {
204
- if (t !== 'file') {
225
+ if (!isFileInputType(t)) {
205
226
  errors.push(`input "${key}": accept is only valid for file inputs`)
206
227
  } else if (
207
228
  !Array.isArray(spec.accept)
@@ -212,18 +233,30 @@ export function checkInputFieldSpec(key: string, raw: unknown): InputFieldCheck
212
233
  }
213
234
  }
214
235
  if (spec.maxBytes !== undefined) {
215
- if (t !== 'file') {
236
+ if (!isFileInputType(t)) {
216
237
  errors.push(`input "${key}": maxBytes is only valid for file inputs`)
217
238
  } else if (typeof spec.maxBytes !== 'number' || !Number.isFinite(spec.maxBytes) || spec.maxBytes <= 0) {
218
239
  errors.push(`input "${key}": maxBytes must be a positive number`)
219
240
  }
220
241
  }
242
+ if (spec.maxFiles !== undefined) {
243
+ if (t !== 'files') {
244
+ errors.push(`input "${key}": maxFiles is only valid for files inputs`)
245
+ } else if (
246
+ typeof spec.maxFiles !== 'number'
247
+ || !Number.isInteger(spec.maxFiles)
248
+ || spec.maxFiles < 1
249
+ || spec.maxFiles > MAX_FILES_CAP
250
+ ) {
251
+ errors.push(`input "${key}": maxFiles must be a whole number from 1 to ${MAX_FILES_CAP}`)
252
+ }
253
+ }
221
254
 
222
255
  let defaultOk = true
223
256
  if (spec.default !== undefined) {
224
- // A `file` carries a reference to an uploaded item, so a fixed literal
225
- // default is meaningless — refused rather than type-checked.
226
- if (t === 'file') {
257
+ // A `file` or `files` input carries references to uploaded items, so a
258
+ // fixed literal default is meaningless — refused rather than type-checked.
259
+ if (isFileInputType(t)) {
227
260
  errors.push(`input "${key}": a file input cannot have a default`)
228
261
  defaultOk = false
229
262
  } else if (!TYPE_CHECK[t](spec.default)) {
@@ -290,9 +323,23 @@ export function validateInputValue(
290
323
  continue
291
324
  }
292
325
  if (!check(v)) {
293
- errors.push(`"${key}" must be of type ${spec.type}`)
326
+ errors.push(
327
+ spec.type === 'files' ? `"${key}" must be a list of files` : `"${key}" must be of type ${spec.type}`,
328
+ )
294
329
  continue
295
330
  }
331
+ if (spec.type === 'files') {
332
+ const count = (v as unknown[]).length
333
+ // An empty list says "no files", which a required input does not accept.
334
+ if (count === 0 && spec.required === true) {
335
+ errors.push(`"${key}" is required — add at least one file`)
336
+ continue
337
+ }
338
+ if (count > maxFilesOf(spec)) {
339
+ errors.push(`"${key}" holds ${count} files — the most it takes is ${maxFilesOf(spec)}`)
340
+ continue
341
+ }
342
+ }
296
343
  if (spec.enum && !spec.enum.includes(v as string | number)) {
297
344
  errors.push(`"${key}" must be one of ${spec.enum.join(', ')}`)
298
345
  continue
package/src/manifest.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import { CronExpressionParser } from 'cron-parser'
2
- import { MAX_INPUT_BYTES, checkInputFieldSpec } from './inputs'
2
+ import { MAX_INPUT_BYTES, checkInputFieldSpec, isFileInputType } from './inputs'
3
3
 
4
4
  const SEGMENT = '[a-z][a-z0-9]*(?:-[a-z0-9]+)*'
5
5
  const NAME_RE = new RegExp(`^${SEGMENT}$`)
@@ -246,10 +246,11 @@ export function validateManifest(input: unknown): ValidationResult {
246
246
  for (const [key, raw] of Object.entries(inputs as Record<string, unknown>)) {
247
247
  const spec = raw as { type?: unknown; required?: unknown; default?: unknown }
248
248
  if (spec?.required === true && spec.default === undefined) {
249
- // A `file` input can never carry a default (refused above), so the
250
- // "add a default" remedy would send the author straight into the
251
- // next validation error — name the two remedies that actually work.
252
- const remedy = spec.type === 'file'
249
+ // A `file` or `files` input can never carry a default (refused
250
+ // above), so the "add a default" remedy would send the author
251
+ // straight into the next validation error — name the two remedies
252
+ // that actually work.
253
+ const remedy = isFileInputType(spec.type)
253
254
  ? 'A file input cannot have a default — make the input optional or the trigger manual.'
254
255
  : 'Add a default or make the trigger manual.'
255
256
  errors.push(
@@ -1069,8 +1069,9 @@ export function buildContext(deps: Deps): AutomationContext {
1069
1069
  file: (ref: { fileId: string }) =>
1070
1070
  step('file', `file:${ref.fileId.slice(0, 8)}`, async () => {
1071
1071
  // No grant: this only reads a file the run was GIVEN — the route
1072
- // refuses any fileId that is not among this run's own inputs. In a dry
1073
- // dev run it returns null, like every other ctx call.
1072
+ // refuses any fileId that is not among this run's own inputs (a `file`
1073
+ // input, or one item of a `files` input). In a dry dev run it returns
1074
+ // null, like every other ctx call.
1074
1075
  const res = await scoped('/ctx/file-resolve', {
1075
1076
  method: 'POST',
1076
1077
  body: JSON.stringify({ fileId: ref.fileId }),
package/src/types.ts CHANGED
@@ -78,21 +78,26 @@ export type Grant =
78
78
  * philosophy as the grant grammar: small enough that a wrong shape is
79
79
  * refusable with a sentence, wide enough for real parameters. */
80
80
  export interface InputFieldSpec {
81
- type: 'string' | 'number' | 'boolean' | 'object' | 'array' | 'file'
81
+ type: 'string' | 'number' | 'boolean' | 'object' | 'array' | 'file' | 'files'
82
82
  /** Refused at run start when absent. Mutually exclusive with `default`. */
83
83
  required?: boolean
84
84
  /** Applied at run start when the field is absent. Cron runs rely on these.
85
- * Not permitted on a `file` field — a file has no meaningful literal default. */
85
+ * Not permitted on a `file` or `files` field — a file has no meaningful literal default. */
86
86
  default?: unknown
87
87
  description?: string
88
88
  /** Allowed values — string and number types only. */
89
89
  enum?: readonly (string | number)[]
90
- /** `file` type only. Allowed MIME patterns, e.g. `['image/*', 'application/pdf']`.
90
+ /** `file` and `files` types only. Allowed MIME patterns, e.g. `['image/*', 'application/pdf']`.
91
+ * On a `files` field it applies to each file.
91
92
  * A declaration aid: the value on the run row is only a reference, so this is
92
93
  * enforced server-side at upload and at run start, never against the value here. */
93
94
  accept?: readonly string[]
94
- /** `file` type only. Maximum upload size in bytes, enforced server-side. */
95
+ /** `file` and `files` types only. Maximum upload size in bytes, enforced server-side.
96
+ * On a `files` field it applies to each file. */
95
97
  maxBytes?: number
98
+ /** `files` type only. The most files the field holds: a whole number from 1
99
+ * to 50, 20 when absent. */
100
+ maxFiles?: number
96
101
  /**
97
102
  * Mask this field's VALUE wherever a person reads the run.
98
103
  *
@@ -488,9 +493,10 @@ export interface AutomationContext {
488
493
  log(message: string, data?: Record<string, unknown>): Promise<void>
489
494
  agent(slug: string): AgentHandle
490
495
  /**
491
- * Resolve one of THIS run's `file` inputs to a readable form (signed URL +
492
- * authoritative mime/size). No grant — it only reads files the run was given;
493
- * any other fileId is refused. `null` in a dry dev run.
496
+ * Resolve one of THIS run's files to a readable form (signed URL +
497
+ * authoritative mime/size): the value of a `file` input, or one item of a
498
+ * `files` input. No grant — it only reads files the run was given; any other
499
+ * fileId is refused. `null` in a dry dev run.
494
500
  */
495
501
  file(ref: FileRef): Promise<ResolvedFileHandle | null>
496
502
  /** One Plugin install, by the name `frontera plugin list` shows. Needs `plugin:<install>:<capability>` per call. */