@frontera-sdk/functions 1.51.0 → 1.51.1
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 +2 -2
- package/src/index.ts +4 -0
- package/src/inputs.ts +65 -18
- package/src/manifest.ts +6 -5
- package/src/runtime-context.ts +3 -2
- package/src/types.ts +13 -7
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@frontera-sdk/functions",
|
|
3
|
-
"version": "1.51.
|
|
3
|
+
"version": "1.51.1",
|
|
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.
|
|
45
|
+
"@frontera-sdk/blueprint": "1.51.1",
|
|
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:
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
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
|
|
200
|
-
// type so a typo like `accept` on a string
|
|
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
|
|
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
|
|
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
|
|
225
|
-
// default is meaningless — refused rather than type-checked.
|
|
226
|
-
if (t
|
|
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(
|
|
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
|
|
250
|
-
// "add a default" remedy would send the author
|
|
251
|
-
// next validation error — name the two remedies
|
|
252
|
-
|
|
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(
|
package/src/runtime-context.ts
CHANGED
|
@@ -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
|
|
1073
|
-
//
|
|
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`
|
|
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`
|
|
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
|
|
492
|
-
* authoritative mime/size)
|
|
493
|
-
*
|
|
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. */
|