@volter/world-runtime 3.0.166 → 3.0.167
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/known-external-services.json +0 -1
- package/dist/src/actions-runner.js +53 -14
- package/dist/src/attach.d.ts +9 -0
- package/dist/src/attach.js +9 -2
- package/dist/src/cli-commands.js +19 -3
- package/dist/src/console-apart.js +3 -2
- package/dist/src/credential-door.d.ts +8 -0
- package/dist/src/credential-door.js +54 -0
- package/dist/src/fixture-env.js +2 -2
- package/dist/src/forwardable-headers.d.ts +1 -0
- package/dist/src/forwardable-headers.js +19 -0
- package/dist/src/go-mysql-binding.d.ts +2 -0
- package/dist/src/go-mysql-binding.js +7 -1
- package/dist/src/guest-runner.js +8 -4
- package/dist/src/host-ready.d.ts +7 -3
- package/dist/src/host-ready.js +16 -3
- package/dist/src/index.d.ts +5 -1
- package/dist/src/index.js +4 -1
- package/dist/src/infra.js +10 -5
- package/dist/src/init.d.ts +5 -0
- package/dist/src/init.js +157 -23
- package/dist/src/managed-api-needs.d.ts +101 -0
- package/dist/src/managed-api-needs.js +312 -0
- package/dist/src/managed-api-source.d.ts +24 -0
- package/dist/src/managed-api-source.js +59 -0
- package/dist/src/mongodb-backing.js +33 -58
- package/dist/src/mongodb-command.d.ts +21 -0
- package/dist/src/mongodb-command.js +317 -0
- package/dist/src/pglite-host.mjs +2 -2
- package/dist/src/placeholder.d.ts +24 -1
- package/dist/src/placeholder.js +105 -13
- package/dist/src/process-signal.d.mts +2 -0
- package/dist/src/process-signal.mjs +44 -2
- package/dist/src/project-inspect.d.ts +1 -1
- package/dist/src/project-inspect.js +8 -1
- package/dist/src/redirect-proxy.js +4 -19
- package/dist/src/reflect.js +2 -2
- package/dist/src/runtime.d.ts +5 -1
- package/dist/src/runtime.js +309 -58
- package/dist/src/schema.d.ts +10 -0
- package/dist/src/schema.js +76 -3
- package/dist/src/serve.js +24 -4
- package/dist/src/served-world.d.ts +2 -1
- package/dist/src/served-world.js +6 -4
- package/dist/src/service-command.d.ts +7 -3
- package/dist/src/service-command.js +53 -22
- package/dist/src/service-environment.d.ts +4 -0
- package/dist/src/service-environment.js +21 -0
- package/dist/src/service-recorder.js +9 -13
- package/known-external-services.json +0 -1
- package/package.json +5 -5
- package/src/actions-runner.ts +38 -10
- package/src/attach.ts +15 -2
- package/src/cli-commands.ts +18 -3
- package/src/console-apart.ts +3 -2
- package/src/credential-door.ts +54 -0
- package/src/fixture-env.ts +2 -2
- package/src/forwardable-headers.ts +15 -0
- package/src/go-mysql-binding.ts +8 -1
- package/src/guest-runner.ts +8 -4
- package/src/host-ready.ts +12 -3
- package/src/index.ts +5 -1
- package/src/infra.ts +8 -3
- package/src/init.ts +145 -23
- package/src/managed-api-needs.ts +285 -0
- package/src/managed-api-source.ts +67 -0
- package/src/mongodb-backing.ts +32 -48
- package/src/mongodb-command.ts +238 -0
- package/src/pglite-host.mjs +2 -2
- package/src/placeholder.ts +90 -13
- package/src/process-signal.d.mts +2 -0
- package/src/process-signal.mjs +44 -2
- package/src/project-inspect.ts +9 -2
- package/src/redirect-proxy.ts +4 -15
- package/src/reflect.ts +2 -2
- package/src/runtime.ts +270 -44
- package/src/schema.ts +73 -3
- package/src/serve.ts +22 -4
- package/src/served-world.ts +7 -5
- package/src/service-command.ts +41 -19
- package/src/service-environment.ts +21 -0
- package/src/service-recorder.ts +9 -9
|
@@ -0,0 +1,312 @@
|
|
|
1
|
+
/** Needs9 metadata only. Plain CLI init without a live trusted source reader still refuses version9. No I/O, issuer or runtime attachment here. */
|
|
2
|
+
import { referenceOf } from '@volter/world-core/managed-original-inputs';
|
|
3
|
+
import { composeCredentialDoor, isScalarCredentialField } from "./credential-door.js";
|
|
4
|
+
import { goMysqlBindingFields } from "./go-mysql-binding.js";
|
|
5
|
+
import { nativeServerReference } from "./native-server-inputs.js";
|
|
6
|
+
import { assertWorldConfig, isEnvironmentName, worldConfigDocument } from "./schema.js";
|
|
7
|
+
const APPLICATION_REASONS = ['the declared application service', 'builds this Dockerfile', 'the service the stack builds', "runs the project's own image", "the stack's only service runs an image", 'the one service that publishes a port with an image of no family'];
|
|
8
|
+
function object(value, label) {
|
|
9
|
+
if (value === null || typeof value !== 'object' || Array.isArray(value)
|
|
10
|
+
|| ![Object.prototype, null].includes(Object.getPrototypeOf(value)))
|
|
11
|
+
throw new Error(`${label} must be a JSON object`);
|
|
12
|
+
for (const key of Reflect.ownKeys(value)) {
|
|
13
|
+
const property = Object.getOwnPropertyDescriptor(value, key);
|
|
14
|
+
if (typeof key !== 'string' || !property.enumerable || !Object.hasOwn(property, 'value'))
|
|
15
|
+
throw new Error(`${label} has a non-JSON property`);
|
|
16
|
+
}
|
|
17
|
+
return value;
|
|
18
|
+
}
|
|
19
|
+
function shape(value, required, optional, label) {
|
|
20
|
+
const row = object(value, label);
|
|
21
|
+
if (required.some(key => !Object.hasOwn(row, key)) || Object.keys(row).some(key => !required.includes(key) && !optional.includes(key)))
|
|
22
|
+
throw new Error(`${label} has missing or unknown fields`);
|
|
23
|
+
return row;
|
|
24
|
+
}
|
|
25
|
+
function list(value, label, read) {
|
|
26
|
+
if (!Array.isArray(value) || Object.getPrototypeOf(value) !== Array.prototype)
|
|
27
|
+
throw new Error(`${label} must be a JSON array`);
|
|
28
|
+
if (Reflect.ownKeys(value).some(key => key !== 'length' && (typeof key !== 'string' || !/^(0|[1-9][0-9]*)$/.test(key) || Number(key) >= value.length)))
|
|
29
|
+
throw new Error(`${label} has an unknown array field`);
|
|
30
|
+
const result = [];
|
|
31
|
+
for (let i = 0; i < value.length; i++) {
|
|
32
|
+
const property = Object.getOwnPropertyDescriptor(value, String(i));
|
|
33
|
+
if (!property || !property.enumerable || !Object.hasOwn(property, 'value'))
|
|
34
|
+
throw new Error(`${label}[${i}] is missing or not JSON data`);
|
|
35
|
+
result.push(read(property.value, `${label}[${i}]`));
|
|
36
|
+
}
|
|
37
|
+
return result;
|
|
38
|
+
}
|
|
39
|
+
function text(value, label, empty = false) {
|
|
40
|
+
if (typeof value !== 'string' || !empty && !value.length || typeof value === 'string' && value.includes('\0'))
|
|
41
|
+
throw new Error(`${label} must be ${empty ? 'a' : 'a non-empty'} string without NUL`);
|
|
42
|
+
return value;
|
|
43
|
+
}
|
|
44
|
+
function name(value, label) {
|
|
45
|
+
if (!isEnvironmentName(value))
|
|
46
|
+
throw new Error(`${label} must name an environment variable`);
|
|
47
|
+
return value;
|
|
48
|
+
}
|
|
49
|
+
function service(value, label) {
|
|
50
|
+
const out = text(value, label);
|
|
51
|
+
if (!/^[A-Za-z0-9][A-Za-z0-9_.-]*$/.test(out))
|
|
52
|
+
throw new Error(`${label} must name a service`);
|
|
53
|
+
return out;
|
|
54
|
+
}
|
|
55
|
+
function unique(values, label) {
|
|
56
|
+
if (new Set(values).size !== values.length)
|
|
57
|
+
throw new Error(`${label} has duplicate names`);
|
|
58
|
+
return values;
|
|
59
|
+
}
|
|
60
|
+
function names(value, label) { return unique(list(value, label, name), label); }
|
|
61
|
+
function strings(value, label) { return list(value, label, (item, at) => text(item, at, true)); }
|
|
62
|
+
function stringMap(value, label, read = (item, at) => text(item, at, true)) {
|
|
63
|
+
return Object.fromEntries(Object.entries(object(value, label)).map(([key, item]) => [name(key, label), read(item, `${label}.${key}`)]));
|
|
64
|
+
}
|
|
65
|
+
function port(value, label) {
|
|
66
|
+
if (typeof value !== 'number' || !Number.isInteger(value) || value < 1 || value > 65535)
|
|
67
|
+
throw new Error(`${label} must be a protocol port`);
|
|
68
|
+
return value;
|
|
69
|
+
}
|
|
70
|
+
/** Validate every JSON property before copying; neither getters nor toJSON functions execute. */
|
|
71
|
+
function json(value, label, ancestors = new Set()) {
|
|
72
|
+
if (value === null || typeof value === 'string' || typeof value === 'boolean')
|
|
73
|
+
return value;
|
|
74
|
+
if (typeof value === 'number' && Number.isFinite(value))
|
|
75
|
+
return value;
|
|
76
|
+
if (typeof value !== 'object' || value === null || ancestors.has(value))
|
|
77
|
+
throw new Error(`${label} must be acyclic JSON data`);
|
|
78
|
+
const next = new Set(ancestors);
|
|
79
|
+
next.add(value);
|
|
80
|
+
if (Array.isArray(value))
|
|
81
|
+
return list(value, label, (item, at) => json(item, at, next));
|
|
82
|
+
return Object.fromEntries(Object.entries(object(value, label)).map(([key, item]) => [key, json(item, `${label}.${key}`, next)]));
|
|
83
|
+
}
|
|
84
|
+
function equalJson(a, b) {
|
|
85
|
+
if (a === b)
|
|
86
|
+
return true;
|
|
87
|
+
if (a === null || b === null || typeof a !== 'object' || typeof b !== 'object' || Array.isArray(a) !== Array.isArray(b))
|
|
88
|
+
return false;
|
|
89
|
+
if (Array.isArray(a) && Array.isArray(b))
|
|
90
|
+
return a.length === b.length && a.every((item, i) => equalJson(item, b[i]));
|
|
91
|
+
const left = Object.keys(a).sort(), right = Object.keys(b).sort();
|
|
92
|
+
return left.length === right.length && left.every((key, i) => key === right[i] && equalJson(a[key], b[key]));
|
|
93
|
+
}
|
|
94
|
+
function door(value, label, required) {
|
|
95
|
+
const row = shape(value, required ? ['path', 'body', 'fill', 'required'] : ['path', 'body', 'fill'], [], label);
|
|
96
|
+
const fill = stringMap(row.fill, `${label}.fill`, (item, at) => text(item, at));
|
|
97
|
+
const body = object(json(row.body, `${label}.body`), `${label}.body`);
|
|
98
|
+
const selected = required ? names(row.required, `${label}.required`) : undefined;
|
|
99
|
+
for (const key of selected ?? [])
|
|
100
|
+
if (!Object.hasOwn(fill, key) || !isScalarCredentialField(fill[key]))
|
|
101
|
+
throw new Error(`${label}.required ${key} has no direct scalar field`);
|
|
102
|
+
return { path: text(row.path, `${label}.path`), body, fill, ...(selected === undefined ? {} : { required: selected }) };
|
|
103
|
+
}
|
|
104
|
+
/** Strict whole metadata shape, preserving common fields. References are metadata, not authenticated files. */
|
|
105
|
+
export function readManagedApiNeeds9(value) {
|
|
106
|
+
const row = shape(json(value, 'needs9'), ['kind', 'version', 'dockerfile', 'composeFile', 'application', 'needs', 'settings', 'unset', 'said', 'applicationApis'], ['target', 'bare', 'leftOut', 'notTold'], 'needs9');
|
|
107
|
+
if (row.kind !== 'browser-substrate.stack-needs' || row.version !== 9)
|
|
108
|
+
throw new Error('needs9 requires its explicit kind and version');
|
|
109
|
+
const notTold = Object.hasOwn(row, 'notTold') ? text(row.notTold, 'needs9.notTold', true) : undefined;
|
|
110
|
+
if (notTold)
|
|
111
|
+
throw new Error('needs9 cannot select an application whose service is not told');
|
|
112
|
+
const app = shape(row.application, ['service', 'told', 'notInTheImage', 'envFiles'], ['image', 'target'], 'needs9.application');
|
|
113
|
+
const statedReason = text(app.told, 'needs9.application.told');
|
|
114
|
+
const told = APPLICATION_REASONS.find(reason => reason === statedReason);
|
|
115
|
+
if (told === undefined)
|
|
116
|
+
throw new Error('needs9.application.told is unsupported');
|
|
117
|
+
const application = { service: service(app.service, 'needs9.application.service'), told,
|
|
118
|
+
notInTheImage: list(app.notInTheImage, 'needs9.application.notInTheImage', name), envFiles: strings(app.envFiles, 'needs9.application.envFiles'),
|
|
119
|
+
...(Object.hasOwn(app, 'image') ? { image: text(app.image, 'needs9.application.image') } : {}),
|
|
120
|
+
...(Object.hasOwn(app, 'target') ? { target: text(app.target, 'needs9.application.target') } : {}) };
|
|
121
|
+
const wired = new Set(), infrastructure = new Set();
|
|
122
|
+
const claim = (key) => { if (wired.has(key))
|
|
123
|
+
throw new Error(`needs9 infrastructure name ${key} has conflicting owners`); wired.add(key); };
|
|
124
|
+
const needs = list(row.needs, 'needs9.needs', (value, at) => {
|
|
125
|
+
const item = shape(value, ['service', 'supplier', 'names'], ['image', 'builds', 'family', 'originalInputs', 'nativeServerInputs', 'ports', 'portTargets', 'components'], at);
|
|
126
|
+
const id = service(item.service, `${at}.service`);
|
|
127
|
+
if (id === application.service || infrastructure.has(id))
|
|
128
|
+
throw new Error(`${at}.service is duplicate or the active application`);
|
|
129
|
+
infrastructure.add(id);
|
|
130
|
+
const supplier = item.supplier;
|
|
131
|
+
if (supplier !== 'a World' && supplier !== 'named by a catalog family' && supplier !== 'nobody' && supplier !== "the project's own build")
|
|
132
|
+
throw new Error(`${at}.supplier is unsupported`);
|
|
133
|
+
const bindings = list(item.names, `${at}.names`, (value, at) => {
|
|
134
|
+
const field = shape(value, ['name'], ['scheme', 'query', 'withPort', 'containerPort', 'dsnFormat', 'endpoint', 'dsnPrefix', 'dsnProtocol'], at);
|
|
135
|
+
const key = name(field.name, `${at}.name`);
|
|
136
|
+
claim(key);
|
|
137
|
+
if (Object.hasOwn(field, 'scheme') && !/^[a-z][a-z0-9+.-]*$/i.test(text(field.scheme, `${at}.scheme`)))
|
|
138
|
+
throw new Error(`${at}.scheme is unsupported`);
|
|
139
|
+
if (Object.hasOwn(field, 'withPort') && field.withPort !== true)
|
|
140
|
+
throw new Error(`${at}.withPort must be true when present`);
|
|
141
|
+
const typed = goMysqlBindingFields(field, key, 3306);
|
|
142
|
+
return { name: key, ...typed,
|
|
143
|
+
...(Object.hasOwn(field, 'scheme') ? { scheme: text(field.scheme, `${at}.scheme`) } : {}),
|
|
144
|
+
...(Object.hasOwn(field, 'query') ? { query: text(field.query, `${at}.query`, true) } : {}),
|
|
145
|
+
...(Object.hasOwn(field, 'withPort') ? { withPort: true } : {}),
|
|
146
|
+
...(Object.hasOwn(field, 'containerPort') ? { containerPort: port(field.containerPort, `${at}.containerPort`) } : {}) };
|
|
147
|
+
});
|
|
148
|
+
const ports = Object.hasOwn(item, 'ports') ? names(item.ports, `${at}.ports`) : undefined;
|
|
149
|
+
for (const key of ports ?? [])
|
|
150
|
+
claim(key);
|
|
151
|
+
const portTargets = Object.hasOwn(item, 'portTargets') ? Object.fromEntries(Object.entries(object(item.portTargets, `${at}.portTargets`)).map(([key, value]) => {
|
|
152
|
+
name(key, `${at}.portTargets`);
|
|
153
|
+
if (!ports?.includes(key))
|
|
154
|
+
throw new Error(`${at}.portTargets ${key} has no declared split port`);
|
|
155
|
+
return [key, port(value, `${at}.portTargets.${key}`)];
|
|
156
|
+
})) : undefined;
|
|
157
|
+
const components = Object.hasOwn(item, 'components') ? list(item.components, `${at}.components`, (value, at) => {
|
|
158
|
+
const part = shape(value, ['name', 'component'], [], at), key = name(part.name, `${at}.name`);
|
|
159
|
+
claim(key);
|
|
160
|
+
const component = part.component;
|
|
161
|
+
if (component !== 'database' && component !== 'user' && component !== 'password')
|
|
162
|
+
throw new Error(`${at}.component is unsupported`);
|
|
163
|
+
return { name: key, component };
|
|
164
|
+
}) : undefined;
|
|
165
|
+
return { service: id, supplier, names: bindings,
|
|
166
|
+
...(Object.hasOwn(item, 'image') ? { image: text(item.image, `${at}.image`) } : {}),
|
|
167
|
+
...(Object.hasOwn(item, 'builds') ? { builds: text(item.builds, `${at}.builds`) } : {}),
|
|
168
|
+
...(Object.hasOwn(item, 'family') ? { family: text(item.family, `${at}.family`) } : {}),
|
|
169
|
+
...(Object.hasOwn(item, 'originalInputs') ? { originalInputs: referenceOf(item.originalInputs) } : {}),
|
|
170
|
+
...(Object.hasOwn(item, 'nativeServerInputs') ? { nativeServerInputs: nativeServerReference(item.nativeServerInputs) } : {}),
|
|
171
|
+
...(ports === undefined ? {} : { ports }), ...(portTargets === undefined ? {} : { portTargets }), ...(components === undefined ? {} : { components }) };
|
|
172
|
+
});
|
|
173
|
+
const selectionKeys = new Set(), selectedNames = new Set();
|
|
174
|
+
const applicationApis = list(row.applicationApis, 'needs9.applicationApis', (value, at) => {
|
|
175
|
+
const item = shape(value, ['service', 'vendor', 'api', 'endpoints', 'credentials', 'cliRedirect', 'issues'], [], at);
|
|
176
|
+
if (item.service !== application.service || item.vendor !== 'aws' || item.api !== 's3')
|
|
177
|
+
throw new Error(`${at} must select this active application and the supported explicit vendor/API`);
|
|
178
|
+
const key = `${item.service}/aws/s3`;
|
|
179
|
+
if (selectionKeys.has(key))
|
|
180
|
+
throw new Error(`${at} duplicates an API selection`);
|
|
181
|
+
selectionKeys.add(key);
|
|
182
|
+
const endpoints = names(item.endpoints, `${at}.endpoints`), credentials = stringMap(item.credentials, `${at}.credentials`, name);
|
|
183
|
+
if (!endpoints.length && !Object.keys(credentials).length)
|
|
184
|
+
throw new Error(`${at} has no endpoint or credential consumer`);
|
|
185
|
+
for (const key of [...endpoints, ...Object.keys(credentials)]) {
|
|
186
|
+
if (selectedNames.has(key) || wired.has(key))
|
|
187
|
+
throw new Error(`${at} name ${key} has conflicting endpoint, credential or infrastructure ownership`);
|
|
188
|
+
selectedNames.add(key);
|
|
189
|
+
}
|
|
190
|
+
const redirect = stringMap(item.cliRedirect, `${at}.cliRedirect`);
|
|
191
|
+
if (Object.keys(redirect).length !== endpoints.length || Object.keys(redirect).some(key => !endpoints.includes(key) || redirect[key] !== '${url}'))
|
|
192
|
+
throw new Error(`${at}.cliRedirect must carry exactly the declared origin names`);
|
|
193
|
+
const issues = door(item.issues, `${at}.issues`, true);
|
|
194
|
+
const required = issues.required;
|
|
195
|
+
if (required.length !== Object.keys(credentials).length || required.some(key => !Object.hasOwn(credentials, key)))
|
|
196
|
+
throw new Error(`${at}.issues.required must retain every declared credential alias, including canonical names`);
|
|
197
|
+
return { service: application.service, vendor: 'aws', api: 's3', endpoints, credentials,
|
|
198
|
+
cliRedirect: Object.fromEntries(endpoints.map(key => [key, '${url}'])), issues: { ...issues, required } };
|
|
199
|
+
});
|
|
200
|
+
if (!applicationApis.length)
|
|
201
|
+
throw new Error('needs9 requires a consumed explicit API selection');
|
|
202
|
+
const leftOut = Object.hasOwn(row, 'leftOut') ? list(row.leftOut, 'needs9.leftOut', (value, at) => {
|
|
203
|
+
const item = shape(value, ['what', 'kind', 'why', 'wouldHaveBeen'], ['service', 'pointing'], at);
|
|
204
|
+
return { what: text(item.what, `${at}.what`), kind: text(item.kind, `${at}.kind`), why: text(item.why, `${at}.why`), wouldHaveBeen: text(item.wouldHaveBeen, `${at}.wouldHaveBeen`),
|
|
205
|
+
...(Object.hasOwn(item, 'service') ? { service: service(item.service, `${at}.service`) } : {}),
|
|
206
|
+
...(Object.hasOwn(item, 'pointing') ? { pointing: list(item.pointing, `${at}.pointing`, (value, at) => {
|
|
207
|
+
const part = shape(value, ['service', 'variable'], [], at);
|
|
208
|
+
return { service: service(part.service, `${at}.service`), variable: name(part.variable, `${at}.variable`) };
|
|
209
|
+
}) } : {}) };
|
|
210
|
+
}) : undefined;
|
|
211
|
+
const unset = Object.fromEntries(Object.entries(object(row.unset, 'needs9.unset')).map(([key, value]) => [name(key, 'needs9.unset'), list(value, `needs9.unset.${key}`, name)]));
|
|
212
|
+
return { kind: 'browser-substrate.stack-needs', version: 9, dockerfile: text(row.dockerfile, 'needs9.dockerfile', true), composeFile: text(row.composeFile, 'needs9.composeFile'), application, needs,
|
|
213
|
+
settings: stringMap(row.settings, 'needs9.settings'), unset, said: strings(row.said, 'needs9.said'), applicationApis,
|
|
214
|
+
...(Object.hasOwn(row, 'target') ? { target: text(row.target, 'needs9.target') } : {}),
|
|
215
|
+
...(Object.hasOwn(row, 'bare') ? { bare: list(row.bare, 'needs9.bare', name) } : {}), ...(notTold === undefined ? {} : { notTold }), ...(leftOut === undefined ? {} : { leftOut }) };
|
|
216
|
+
}
|
|
217
|
+
/** Data from a trusted caller's actual source reading; this validates shape, not custody. */
|
|
218
|
+
export function readManagedApiApplication(value) {
|
|
219
|
+
const row = shape(json(value, 'needs9 source application'), ['service', 'environment', 'inheritedNames'], [], 'needs9 source application');
|
|
220
|
+
return { service: service(row.service, 'needs9 source application service'),
|
|
221
|
+
environment: stringMap(row.environment, 'needs9 source application environment'),
|
|
222
|
+
inheritedNames: names(row.inheritedNames, 'needs9 source inherited names') };
|
|
223
|
+
}
|
|
224
|
+
/** Caller prerequisites: authenticated immutable source/full correspondence, supplied package/current descriptor,
|
|
225
|
+
* and deliberate owner selection. This metadata composition neither proves those prerequisites nor writes a World. */
|
|
226
|
+
export function composeManagedApiNeeds9(input) {
|
|
227
|
+
const needs = readManagedApiNeeds9(input.document);
|
|
228
|
+
const rawWorld = object(json(input.world, 'needs9 World'), 'needs9 World');
|
|
229
|
+
if (!Object.hasOwn(rawWorld, 'schemaVersion'))
|
|
230
|
+
throw new Error('needs9 composition requires an explicit format2 World');
|
|
231
|
+
const config = assertWorldConfig(rawWorld, 'needs9 World');
|
|
232
|
+
if (config.manifestVersion !== 2 || input.application.service !== needs.application.service)
|
|
233
|
+
throw new Error('needs9 composition requires this active application and format2 World');
|
|
234
|
+
const sourceEnvironment = stringMap(input.application.environment, 'needs9 source application environment');
|
|
235
|
+
const declared = new Set([...Object.keys(sourceEnvironment), ...names(input.application.inheritedNames, 'needs9 source inherited names')]);
|
|
236
|
+
const bindings = new Map();
|
|
237
|
+
for (const owner of list(input.owners, 'needs9 owners', (value, at) => shape(value, ['vendor', 'api', 'serviceId', 'currentDoor'], [], at))) {
|
|
238
|
+
if (owner.vendor !== 'aws' || owner.api !== 's3' || bindings.has('aws/s3'))
|
|
239
|
+
throw new Error('needs9 owner is unsupported or duplicate');
|
|
240
|
+
const id = service(owner.serviceId, 'needs9 selected twin owner'), actual = config.services.find(entry => entry.id === id);
|
|
241
|
+
if (!actual || actual.type !== 'twin')
|
|
242
|
+
throw new Error('needs9 actual selected owner is absent or not a twin');
|
|
243
|
+
bindings.set('aws/s3', { serviceId: id, current: door(owner.currentDoor, 'needs9 current supplied descriptor', false) });
|
|
244
|
+
}
|
|
245
|
+
const env = new Map(Object.entries(config.env ?? {})), changed = new Map(config.services.map(entry => [entry.id, entry]));
|
|
246
|
+
const consumed = new Set();
|
|
247
|
+
for (const row of needs.applicationApis) {
|
|
248
|
+
const key = `${row.vendor}/${row.api}`, binding = bindings.get(key);
|
|
249
|
+
if (!binding)
|
|
250
|
+
throw new Error(`needs9 selection ${key} has no deliberately selected actual owner`);
|
|
251
|
+
consumed.add(key);
|
|
252
|
+
const current = binding.current, actual = changed.get(binding.serviceId);
|
|
253
|
+
// Validate all saved canonical mappings and aliases before a current field can replace one.
|
|
254
|
+
const refreshed = composeCredentialDoor(actual.issues, current, actual.id);
|
|
255
|
+
if (row.issues.path !== current.path || !equalJson(json(row.issues.body, 'needs9 declared door body'), json(current.body, 'needs9 current door body')))
|
|
256
|
+
throw new Error('needs9 declared path/body differs from the current supplied descriptor');
|
|
257
|
+
const expected = new Map(Object.entries(current.fill));
|
|
258
|
+
for (const [alias, canonical] of Object.entries(row.credentials)) {
|
|
259
|
+
if (!declared.has(alias) || !Object.hasOwn(current.fill, canonical) || !isScalarCredentialField(current.fill[canonical]))
|
|
260
|
+
throw new Error(`needs9 alias ${alias} has no declared consumer or current canonical scalar field`);
|
|
261
|
+
const field = current.fill[canonical];
|
|
262
|
+
if (expected.has(alias) && expected.get(alias) !== field)
|
|
263
|
+
throw new Error(`needs9 alias ${alias} conflicts with a canonical binding`);
|
|
264
|
+
if (Object.hasOwn(actual.issues?.fill ?? {}, alias) && actual.issues?.fill[alias] !== field)
|
|
265
|
+
throw new Error(`needs9 alias ${alias} conflicts with its saved binding`);
|
|
266
|
+
expected.set(alias, field);
|
|
267
|
+
const request = `$issue:${actual.id}`, previous = env.get(alias);
|
|
268
|
+
if (previous !== undefined && previous !== request && previous !== '$mint')
|
|
269
|
+
throw new Error(`needs9 alias ${alias} conflicts with the global pending owner`);
|
|
270
|
+
env.set(alias, request);
|
|
271
|
+
}
|
|
272
|
+
if (!equalJson(json(row.issues.fill, 'needs9 declared fill'), json(Object.fromEntries(expected), 'needs9 expected fill')))
|
|
273
|
+
throw new Error('needs9 fill must carry exactly the canonical fields and declared aliases');
|
|
274
|
+
const redirects = new Map(Object.entries(actual.cliRedirect ?? {}));
|
|
275
|
+
for (const endpoint of row.endpoints) {
|
|
276
|
+
if (!declared.has(endpoint))
|
|
277
|
+
throw new Error(`needs9 endpoint ${endpoint} has no source-declared consumer`);
|
|
278
|
+
if (expected.has(endpoint))
|
|
279
|
+
throw new Error(`needs9 endpoint ${endpoint} conflicts with a current canonical credential binding`);
|
|
280
|
+
const value = Object.hasOwn(sourceEnvironment, endpoint) ? sourceEnvironment[endpoint] : undefined;
|
|
281
|
+
if (value !== undefined && value !== '') {
|
|
282
|
+
let origin;
|
|
283
|
+
try {
|
|
284
|
+
origin = new URL(value);
|
|
285
|
+
}
|
|
286
|
+
catch {
|
|
287
|
+
throw new Error(`needs9 endpoint ${endpoint} has no origin-only HTTP contract`);
|
|
288
|
+
}
|
|
289
|
+
if (!/^https?:\/\/[^/?#]+\/?$/i.test(value) || !['http:', 'https:'].includes(origin.protocol) || origin.username || origin.password || origin.pathname !== '/' || origin.search || origin.hash)
|
|
290
|
+
throw new Error(`needs9 endpoint ${endpoint} has no origin-only HTTP contract`);
|
|
291
|
+
}
|
|
292
|
+
for (const other of changed.values()) {
|
|
293
|
+
if (Object.hasOwn(other.env ?? {}, endpoint) || Object.hasOwn(other.issues?.fill ?? {}, endpoint) || other.injectEnv === endpoint
|
|
294
|
+
|| Object.hasOwn(other.injectEnvTemplates ?? {}, endpoint) || other.external?.discover?.some(item => item.as === endpoint)
|
|
295
|
+
|| Object.hasOwn(other.cliRedirect ?? {}, endpoint) && (other.id !== actual.id || other.cliRedirect?.[endpoint] !== '${url}'))
|
|
296
|
+
throw new Error(`needs9 endpoint ${endpoint} conflicts with service ${other.id}`);
|
|
297
|
+
}
|
|
298
|
+
redirects.set(endpoint, '${url}');
|
|
299
|
+
}
|
|
300
|
+
const saved = { path: current.path, body: current.body,
|
|
301
|
+
fill: Object.fromEntries([...Object.entries(refreshed.fill), ...expected]),
|
|
302
|
+
required: [...new Set([...(refreshed.required ?? []), ...row.issues.required])] };
|
|
303
|
+
const issues = composeCredentialDoor(saved, current, actual.id);
|
|
304
|
+
changed.set(actual.id, { ...actual, issues, ...(redirects.size ? { cliRedirect: Object.fromEntries(redirects) } : {}) });
|
|
305
|
+
}
|
|
306
|
+
if (consumed.size !== bindings.size)
|
|
307
|
+
throw new Error('needs9 has an unconsumed actual owner selection');
|
|
308
|
+
const proposed = { ...config, env: Object.fromEntries(env), services: config.services.map(entry => changed.get(entry.id)) };
|
|
309
|
+
const manifest = worldConfigDocument(proposed);
|
|
310
|
+
assertWorldConfig(manifest, 'needs9 proposed World');
|
|
311
|
+
return { needs, manifest };
|
|
312
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { type ManagedApiApplication, type ManagedApiNeeds9 } from './managed-api-needs.js';
|
|
2
|
+
/** Interface compatibility only. The caller separately authenticates the installed export/package closure. */
|
|
3
|
+
export declare const MANAGED_API_SOURCE_READER_CONTRACT = "volter.managed-api-source-reader.v1";
|
|
4
|
+
export type ManagedApiSourceReading = {
|
|
5
|
+
application: ManagedApiApplication;
|
|
6
|
+
identity: string;
|
|
7
|
+
};
|
|
8
|
+
/** Attached by reviewed host code, never from JSON. Each call must freshly authenticate the selected immutable
|
|
9
|
+
* application, full generated correspondence and supplied package facts against its retained setup identity.
|
|
10
|
+
* identity hashes that complete proof tuple for equality/cache binding only; it is not an authorization bit. */
|
|
11
|
+
export type ManagedApiSourceReader = (input: {
|
|
12
|
+
repo: string;
|
|
13
|
+
needsFile: string;
|
|
14
|
+
document: ManagedApiNeeds9;
|
|
15
|
+
}) => ManagedApiSourceReading;
|
|
16
|
+
export type ManagedApiSourceSession = {
|
|
17
|
+
readonly document: ManagedApiNeeds9;
|
|
18
|
+
readonly application: ManagedApiApplication;
|
|
19
|
+
readonly identity: string;
|
|
20
|
+
/** Re-read actual needs bytes and invoke the same live source door before output. */
|
|
21
|
+
assertCurrent(): void;
|
|
22
|
+
};
|
|
23
|
+
/** No World output. A plain needs file lacks the trusted function and keeps its explicit refusal. */
|
|
24
|
+
export declare function readManagedApiSourceSession(repo: string, needsFile: string, reader: ManagedApiSourceReader | undefined): ManagedApiSourceSession;
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/** Live host source-reader seam; metadata or an interface marker grants no source custody. */
|
|
2
|
+
import { readFileSync } from 'node:fs';
|
|
3
|
+
import { readManagedApiApplication, readManagedApiNeeds9 } from "./managed-api-needs.js";
|
|
4
|
+
/** Interface compatibility only. The caller separately authenticates the installed export/package closure. */
|
|
5
|
+
export const MANAGED_API_SOURCE_READER_CONTRACT = 'volter.managed-api-source-reader.v1';
|
|
6
|
+
/** Only validated data copies enter here; no caller object is frozen. */
|
|
7
|
+
function retainJson(value) {
|
|
8
|
+
const pending = [value];
|
|
9
|
+
while (pending.length) {
|
|
10
|
+
const item = pending.pop();
|
|
11
|
+
if (item === null || typeof item !== 'object' || Object.isFrozen(item))
|
|
12
|
+
continue;
|
|
13
|
+
pending.push(...Object.values(item));
|
|
14
|
+
Object.freeze(item);
|
|
15
|
+
}
|
|
16
|
+
return value;
|
|
17
|
+
}
|
|
18
|
+
function reading(value) {
|
|
19
|
+
if (value === null || typeof value !== 'object' || Array.isArray(value)
|
|
20
|
+
|| ![Object.prototype, null].includes(Object.getPrototypeOf(value))
|
|
21
|
+
|| Reflect.ownKeys(value).length !== 2)
|
|
22
|
+
throw new Error('needs9 source reader returned an invalid shape');
|
|
23
|
+
const data = (key) => {
|
|
24
|
+
const property = Object.getOwnPropertyDescriptor(value, key);
|
|
25
|
+
if (!property || !property.enumerable || !Object.hasOwn(property, 'value'))
|
|
26
|
+
throw new Error('needs9 source reader returned a missing or non-data field');
|
|
27
|
+
return property.value;
|
|
28
|
+
};
|
|
29
|
+
const identity = data('identity');
|
|
30
|
+
if (typeof identity !== 'string' || !/^sha256:[a-f0-9]{64}$/.test(identity))
|
|
31
|
+
throw new Error('needs9 source reader requires its full proof comparison digest');
|
|
32
|
+
return { application: readManagedApiApplication(data('application')), identity };
|
|
33
|
+
}
|
|
34
|
+
/** No World output. A plain needs file lacks the trusted function and keeps its explicit refusal. */
|
|
35
|
+
export function readManagedApiSourceSession(repo, needsFile, reader) {
|
|
36
|
+
if (typeof reader !== 'function')
|
|
37
|
+
throw new Error(`volter init: --needs ${needsFile} version9 requires its separately reviewed live source reader and writer; a needs file alone does not enable it`);
|
|
38
|
+
const bytes = readFileSync(needsFile), document = retainJson(readManagedApiNeeds9(JSON.parse(bytes.toString('utf8'))));
|
|
39
|
+
const read = () => {
|
|
40
|
+
const current = reading(reader({ repo, needsFile,
|
|
41
|
+
// The host reader receives its own data copy, so it cannot change this session's retained document.
|
|
42
|
+
document: readManagedApiNeeds9(JSON.parse(bytes.toString('utf8'))) }));
|
|
43
|
+
if (!readFileSync(needsFile).equals(bytes))
|
|
44
|
+
throw new Error('needs9 source input changed during its live reading');
|
|
45
|
+
return current;
|
|
46
|
+
};
|
|
47
|
+
const source = read();
|
|
48
|
+
if (source.application.service !== document.application.service)
|
|
49
|
+
throw new Error('needs9 source reader returned another active application');
|
|
50
|
+
const application = retainJson(source.application), identity = source.identity;
|
|
51
|
+
const applicationBytes = JSON.stringify(application);
|
|
52
|
+
return Object.freeze({ document, application, identity, assertCurrent() {
|
|
53
|
+
if (!readFileSync(needsFile).equals(bytes))
|
|
54
|
+
throw new Error('needs9 source input changed after planning');
|
|
55
|
+
const current = read();
|
|
56
|
+
if (current.identity !== identity || JSON.stringify(current.application) !== applicationBytes)
|
|
57
|
+
throw new Error('needs9 immutable application or complete correspondence changed after planning');
|
|
58
|
+
} });
|
|
59
|
+
}
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import { connect } from 'node:net';
|
|
2
|
+
import { mongodbCommand } from "./mongodb-command.js";
|
|
2
3
|
const PROTOCOL_QUESTION_MS = 1000;
|
|
3
|
-
/** A refused connection cannot answer MongoDB
|
|
4
|
-
*
|
|
5
|
-
* A TCP acceptance is only permission to ask the original protocol question below. */
|
|
4
|
+
/** A refused connection cannot answer MongoDB: that negative answer is TCP's, taken before a protocol question is
|
|
5
|
+
* framed and sent. A TCP acceptance is only permission to ask the original protocol question below. */
|
|
6
6
|
function portConnects(port) {
|
|
7
7
|
return new Promise((resolve) => {
|
|
8
8
|
const socket = connect({ host: '127.0.0.1', port, timeout: PROTOCOL_QUESTION_MS });
|
|
@@ -30,37 +30,25 @@ export function mongodbArgv(service, data) {
|
|
|
30
30
|
export async function mongodbServes(service, pid) {
|
|
31
31
|
if (!(await portConnects(service.hostPort)))
|
|
32
32
|
return false;
|
|
33
|
-
const { MongoClient } = await import('mongodb');
|
|
34
|
-
const client = new MongoClient(`mongodb://127.0.0.1:${service.hostPort}`, { directConnection: true, timeoutMS: PROTOCOL_QUESTION_MS, serverSelectionTimeoutMS: PROTOCOL_QUESTION_MS });
|
|
35
33
|
try {
|
|
36
|
-
await
|
|
37
|
-
const status = await client.db('admin').command({ serverStatus: 1 });
|
|
34
|
+
const status = await mongodbCommand(service.hostPort, { serverStatus: 1 }, PROTOCOL_QUESTION_MS);
|
|
38
35
|
return Number(status.pid) === pid;
|
|
39
36
|
}
|
|
40
37
|
catch {
|
|
41
38
|
return false;
|
|
42
39
|
}
|
|
43
|
-
finally {
|
|
44
|
-
await client.close().catch(() => undefined);
|
|
45
|
-
}
|
|
46
40
|
}
|
|
47
41
|
/** Whether anything speaking MongoDB's protocol answers on the port at all: a ping answered. */
|
|
48
42
|
async function mongodbAnswers(port) {
|
|
49
43
|
if (!(await portConnects(port)))
|
|
50
44
|
return false;
|
|
51
|
-
const { MongoClient } = await import('mongodb');
|
|
52
|
-
const client = new MongoClient(`mongodb://127.0.0.1:${port}`, { directConnection: true, timeoutMS: PROTOCOL_QUESTION_MS, serverSelectionTimeoutMS: PROTOCOL_QUESTION_MS });
|
|
53
45
|
try {
|
|
54
|
-
await
|
|
55
|
-
await client.db('admin').command({ ping: 1 });
|
|
46
|
+
await mongodbCommand(port, { ping: 1 }, PROTOCOL_QUESTION_MS);
|
|
56
47
|
return true;
|
|
57
48
|
}
|
|
58
49
|
catch {
|
|
59
50
|
return false;
|
|
60
51
|
}
|
|
61
|
-
finally {
|
|
62
|
-
await client.close().catch(() => undefined);
|
|
63
|
-
}
|
|
64
52
|
}
|
|
65
53
|
/** MongoDB's two questions for a twin that serves the kind (twin-backing.ts): the same ownership question the
|
|
66
54
|
* vendor's own server is asked above, so a pack that serves the kind answers serverStatus with its process's pid. */
|
|
@@ -73,11 +61,8 @@ export const MONGODB_PROTOCOL = {
|
|
|
73
61
|
return true;
|
|
74
62
|
// PID ownership alone cannot make a standalone listener ready for a replica-set URI. An older pack that
|
|
75
63
|
// ignores the new CLI option must fail this protocol question before the application is given its address.
|
|
76
|
-
const { MongoClient } = await import('mongodb');
|
|
77
|
-
const client = new MongoClient(`mongodb://127.0.0.1:${port}`, { directConnection: true,
|
|
78
|
-
timeoutMS: PROTOCOL_QUESTION_MS, serverSelectionTimeoutMS: PROTOCOL_QUESTION_MS });
|
|
79
64
|
try {
|
|
80
|
-
const hello = await
|
|
65
|
+
const hello = await mongodbCommand(port, { hello: 1 }, PROTOCOL_QUESTION_MS);
|
|
81
66
|
const self = `127.0.0.1:${port}`;
|
|
82
67
|
return hello.setName === service.replicaSet && hello.isWritablePrimary === true && hello.primary === self
|
|
83
68
|
&& Array.isArray(hello.hosts) && hello.hosts.length === 1 && hello.hosts[0] === self;
|
|
@@ -85,49 +70,39 @@ export const MONGODB_PROTOCOL = {
|
|
|
85
70
|
catch {
|
|
86
71
|
return false;
|
|
87
72
|
}
|
|
88
|
-
finally {
|
|
89
|
-
await client.close().catch(() => undefined);
|
|
90
|
-
}
|
|
91
73
|
},
|
|
92
74
|
};
|
|
93
75
|
/** Ping the server; initialize only an explicitly declared one-member set and await its primary. */
|
|
94
76
|
export async function mongodbReady(service, launch, deadline, initialize = true) {
|
|
95
|
-
|
|
96
|
-
//
|
|
97
|
-
|
|
98
|
-
if (remaining <= 0)
|
|
77
|
+
// Use the infrastructure lifecycle's remaining readiness budget rather than another fixed limit: each command
|
|
78
|
+
// has what is left of it when it is asked.
|
|
79
|
+
if (deadline - Date.now() <= 0)
|
|
99
80
|
throw new Error('World-managed MongoDB readiness deadline elapsed');
|
|
100
|
-
const
|
|
101
|
-
|
|
102
|
-
|
|
81
|
+
const admin = (command) => mongodbCommand(service.hostPort, command, deadline - Date.now(), launch.signal);
|
|
82
|
+
await admin({ ping: 1 });
|
|
83
|
+
if (!service.replicaSet)
|
|
84
|
+
return;
|
|
85
|
+
let config;
|
|
103
86
|
try {
|
|
104
|
-
await
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
if (error.code !== 94 || !initialize)
|
|
116
|
-
throw error;
|
|
117
|
-
config = { _id: service.replicaSet, members: [{ _id: 0, host: `127.0.0.1:${service.hostPort}` }] };
|
|
118
|
-
await admin.command({ replSetInitiate: config });
|
|
119
|
-
}
|
|
120
|
-
if (config._id !== service.replicaSet || config.members?.length !== 1 || config.members[0].host !== `127.0.0.1:${service.hostPort}`) {
|
|
121
|
-
throw new Error('World-managed MongoDB replica-set name or member address differs from the declared service; refusing reconfiguration');
|
|
122
|
-
}
|
|
123
|
-
while (!(await admin.command({ hello: 1 })).isWritablePrimary) {
|
|
124
|
-
launch.signal?.throwIfAborted();
|
|
125
|
-
if (Date.now() >= deadline)
|
|
126
|
-
throw new Error('World-managed MongoDB did not elect its single-node primary before the readiness deadline');
|
|
127
|
-
await new Promise((resolve) => setTimeout(resolve, 200));
|
|
128
|
-
}
|
|
87
|
+
config = (await admin({ replSetGetConfig: 1 })).config;
|
|
88
|
+
}
|
|
89
|
+
catch (error) {
|
|
90
|
+
// MongoDB's NotYetInitialized; any other refusal remains its own error.
|
|
91
|
+
if (error.code !== 94 || !initialize)
|
|
92
|
+
throw error;
|
|
93
|
+
config = { _id: service.replicaSet, members: [{ _id: 0, host: `127.0.0.1:${service.hostPort}` }] };
|
|
94
|
+
await admin({ replSetInitiate: config });
|
|
95
|
+
}
|
|
96
|
+
if (config?._id !== service.replicaSet || config.members?.length !== 1 || config.members[0].host !== `127.0.0.1:${service.hostPort}`) {
|
|
97
|
+
throw new Error('World-managed MongoDB replica-set name or member address differs from the declared service; refusing reconfiguration');
|
|
129
98
|
}
|
|
130
|
-
|
|
131
|
-
|
|
99
|
+
while (!(await admin({ hello: 1 })).isWritablePrimary) {
|
|
100
|
+
launch.signal?.throwIfAborted();
|
|
101
|
+
if (Date.now() >= deadline)
|
|
102
|
+
throw new Error('World-managed MongoDB did not elect its single-node primary before the readiness deadline');
|
|
103
|
+
await new Promise((resolve) => setTimeout(resolve, 200));
|
|
104
|
+
// A deadline that passed while it waited is the election's, not the next question's empty budget.
|
|
105
|
+
if (Date.now() >= deadline)
|
|
106
|
+
throw new Error('World-managed MongoDB did not elect its single-node primary before the readiness deadline');
|
|
132
107
|
}
|
|
133
108
|
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { type MongoDocument } from '@volter/world-core/mongodb';
|
|
2
|
+
export type { MongoDocument };
|
|
3
|
+
/** A command the server answered and refused (`ok` not 1), with its own code, as the driver's server error carries it. */
|
|
4
|
+
export declare class MongoCommandError extends Error {
|
|
5
|
+
readonly code: number | undefined;
|
|
6
|
+
readonly codeName: string | undefined;
|
|
7
|
+
constructor(code: number | undefined, codeName: string | undefined, message: string);
|
|
8
|
+
}
|
|
9
|
+
/** A document as BSON: the values the runtime's own commands hold (numbers, bigints, strings, booleans, null,
|
|
10
|
+
* documents and arrays). An undefined value is left out, as the driver leaves it; -0 is a double, as it is to BSON. */
|
|
11
|
+
export declare function encodeBson(document: MongoDocument): Buffer;
|
|
12
|
+
/** A BSON document read whole from `bytes` at `start`. Every BSON type is read, so an answer's fields the runtime does
|
|
13
|
+
* not use are still walked; a length out of bounds, a boolean other than 0 or 1, text that is not UTF-8, or a document
|
|
14
|
+
* that does not end where its length says is refused. An int64 is a number where it is a safe integer, as the driver
|
|
15
|
+
* promotes it, and a bigint where it is not. */
|
|
16
|
+
export declare function decodeBson(bytes: Buffer, start?: number, array?: boolean): MongoDocument | unknown[];
|
|
17
|
+
/** One admin command on one loopback connection, answered through the kernel's client: refused where the server says
|
|
18
|
+
* it is not ok, where its answer cannot be read (the client checks its request, opcode and length; a document
|
|
19
|
+
* sequence in it is joined as the field it names, as the client joins one), where the connection ends or refuses
|
|
20
|
+
* first, where `timeoutMs` passes, or where `signal` aborts. */
|
|
21
|
+
export declare function mongodbCommand(port: number, command: MongoDocument, timeoutMs: number, signal?: AbortSignal): Promise<MongoDocument>;
|