@platformatic/foundation 3.67.0 → 4.0.0-new-config.0

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.
@@ -0,0 +1,185 @@
1
+ import Ajv from 'ajv'
2
+ import { createRequire } from 'node:module'
3
+ import { join, resolve } from 'node:path'
4
+ import { pathToFileURL } from 'node:url'
5
+ import { CapabilitySchemaNotFoundError, InvalidApplicationConfigurationError } from './errors.js'
6
+
7
+ /*
8
+ The AJV custom keywords are one of the deliberately-kept pieces, carried over as code by explicit
9
+ decision rather than by surviving a refactor. They are re-implemented here rather than imported
10
+ from the v3 configuration module, which leaves foundation with migrate's legacy reader.
11
+
12
+ The root they resolve against is the application's, not the runtime's: a capability's config is
13
+ written where the application lives, so a relative path in it means a path from there.
14
+ */
15
+ export function createCapabilityValidator (schema, { root, fixPaths = true, useDefaults = true } = {}) {
16
+ // Coercion is disabled in v4. Its only justification was placeholder strings, and on the genuine
17
+ // unions that survive the audit — boolean | number, boolean | object — AJV coercion is a
18
+ // documented hazard in this very codebase.
19
+ const ajv = new Ajv({ useDefaults, coerceTypes: false, allErrors: true, strict: false })
20
+
21
+ ajv.addKeyword({
22
+ keyword: 'resolvePath',
23
+ type: 'string',
24
+ schemaType: 'boolean',
25
+ validate (_schema, path, parentSchema, data) {
26
+ if (typeof path !== 'string' || path.trim() === '') {
27
+ return Boolean(parentSchema.allowEmptyPaths)
28
+ }
29
+
30
+ if (fixPaths) {
31
+ data.parentData[data.parentDataProperty] = resolve(root, path)
32
+ }
33
+
34
+ return true
35
+ }
36
+ })
37
+
38
+ ajv.addKeyword({ keyword: 'allowEmptyPaths', type: 'string', schemaType: 'boolean' })
39
+
40
+ ajv.addKeyword({
41
+ keyword: 'resolveModule',
42
+ type: 'string',
43
+ schemaType: 'boolean',
44
+ validate (_schema, path, _parentSchema, data) {
45
+ if (typeof path !== 'string' || path.trim() === '') {
46
+ return false
47
+ }
48
+
49
+ if (!fixPaths) {
50
+ return true
51
+ }
52
+
53
+ try {
54
+ data.parentData[data.parentDataProperty] = createRequire(join(root, 'noop.js')).resolve(path)
55
+ return true
56
+ } catch {
57
+ return false
58
+ }
59
+ }
60
+ })
61
+
62
+ ajv.addKeyword({
63
+ keyword: 'typeof',
64
+ validate: function validate (schema, value, _parentSchema, data) {
65
+ // eslint-disable-next-line valid-typeof
66
+ if (typeof value === schema) {
67
+ return true
68
+ }
69
+
70
+ validate.errors = [{ message: `"${data.parentDataProperty}" should be a ${schema}.`, params: data.parentData }]
71
+ return false
72
+ }
73
+ })
74
+
75
+ return ajv.compile(schema)
76
+ }
77
+
78
+ /*
79
+ The schema is imported through the capability's light subpath, resolved application-scoped first
80
+ with the runtime-bundled fallback — the canonical capability resolution order, so the schema copy
81
+ that validates is the same copy whose implementation the worker will load.
82
+
83
+ The subpath is part of the v4 capability contract, and it is light only in import cost: it
84
+ executes in the main process with full privileges, like any capability code. Falling back to the
85
+ package's main entry is a transitional step: until every capability ships the subpath, boot would
86
+ otherwise not be able to validate at all, and a validator that skips what it cannot import is not
87
+ a validator. Removing the fallback is part of the capability work.
88
+ */
89
+ export async function importCapabilitySchema (module, applicationRoot, { runtimeScope } = {}) {
90
+ const scopes = [
91
+ { scope: 'application', require: createRequire(join(applicationRoot, 'noop.js')) },
92
+ // The bundled fallback resolves from the caller's position, not from this module's: foundation
93
+ // is the lowest package in the graph and depends on no capability, so resolving from here would
94
+ // make "runtime-bundled" name a place no capability has ever been installed.
95
+ { scope: 'runtime', require: createRequire(runtimeScope ?? import.meta.filename) }
96
+ ]
97
+
98
+ for (const { scope, require } of scopes) {
99
+ for (const [via, specifier] of [
100
+ ['subpath', `${module}/schema`],
101
+ ['entry', module]
102
+ ]) {
103
+ let resolved
104
+
105
+ try {
106
+ resolved = require.resolve(specifier)
107
+ } catch {
108
+ continue
109
+ }
110
+
111
+ /*
112
+ A package without an exports map resolves <name>/schema through the filesystem, and the
113
+ extension search finds schema.json -- the generated JSON Schema that sits at the root of
114
+ most capabilities -- before anything else. That file is data, not the subpath: importing it
115
+ as a module fails for needing an import attribute, and even with one it carries none of the
116
+ metadata the contract asks the subpath to export. Skipping it lets the entry fallback
117
+ answer, which is where an exports-less capability keeps its schema.
118
+ */
119
+ if (!/\.(js|mjs|cjs|ts|mts|cts)$/.test(resolved)) {
120
+ continue
121
+ }
122
+
123
+ const loaded = await import(pathToFileURL(resolved).href)
124
+
125
+ if (loaded?.schema) {
126
+ return {
127
+ scope,
128
+ via,
129
+ path: resolved,
130
+ schema: loaded.schema,
131
+ // The package-level metadata main-side preparation needs besides the schema. An absent
132
+ // servesWithoutPort means 'worker', which is what the serving predicate reads.
133
+ metadata: {
134
+ skipTelemetryHooks: loaded.skipTelemetryHooks ?? false,
135
+ modulesToLoad: loaded.modulesToLoad ?? [],
136
+ servesWithoutPort: loaded.servesWithoutPort ?? 'worker'
137
+ }
138
+ }
139
+ }
140
+ }
141
+ }
142
+
143
+ throw new CapabilitySchemaNotFoundError(module, applicationRoot)
144
+ }
145
+
146
+ /*
147
+ AJV puts the offending property name in params rather than in the message, so the commonest
148
+ mistake of all — a typo in an option name — reads as "must NOT have additional properties" and
149
+ leaves the author to find which one. Naming it is the difference between an error you can act on
150
+ and one you have to bisect.
151
+ */
152
+ function describeFailure (error) {
153
+ if (error.params?.additionalProperty) {
154
+ return `must NOT have the additional property '${error.params.additionalProperty}'`
155
+ }
156
+
157
+ if (error.params?.allowedValues) {
158
+ return `${error.message} (${error.params.allowedValues.join(', ')})`
159
+ }
160
+
161
+ return error.message
162
+ }
163
+
164
+ export function validateCapabilityConfiguration (config, schema, { id, module, root, fixPaths = true } = {}) {
165
+ const validator = createCapabilityValidator(schema, { root, fixPaths })
166
+
167
+ if (validator(config)) {
168
+ return config
169
+ }
170
+
171
+ const failures = validator.errors.map(error => ({
172
+ path: error.instancePath === '' ? '/' : error.instancePath,
173
+ message: describeFailure(error),
174
+ params: error.params
175
+ }))
176
+
177
+ const error = new InvalidApplicationConfigurationError(
178
+ id,
179
+ module,
180
+ failures.map(failure => `\n - ${failure.path}: ${failure.message}`).join('')
181
+ )
182
+
183
+ Object.defineProperty(error, 'validationErrors', { value: failures })
184
+ throw error
185
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@platformatic/foundation",
3
- "version": "3.67.0",
3
+ "version": "4.0.0-new-config.0",
4
4
  "description": "Platformatic Foundation",
5
5
  "main": "index.js",
6
6
  "types": "index.d.ts",