@lanes-sh/link 0.4.0 → 0.4.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 +1 -1
- package/src/cli/commands/mcp/list.ts +123 -29
- package/src/cli/main.ts +5 -1
- package/src/providers/bunq/hints.ts +3 -1
- package/src/providers/bunq/redact.ts +13 -2
- package/src/providers/bunq/specs/bunq.v1.json +20 -1
- package/src/providers/bunq/specs/vendor.ts +59 -1
- package/src/providers/shared/vendor-operations.ts +81 -0
package/package.json
CHANGED
|
@@ -1,8 +1,46 @@
|
|
|
1
|
-
import { heading, print, style } from '../../output.ts';
|
|
1
|
+
import { emit, heading, print, style } from '../../output.ts';
|
|
2
2
|
import { assetState, plannedAssets, readAsset } from './assets.ts';
|
|
3
3
|
import { HARNESSES, type Harness } from './harnesses.ts';
|
|
4
4
|
import { exists } from './register.ts';
|
|
5
5
|
|
|
6
|
+
/** Why a document this harness can hold is not what we ship, if it is not. */
|
|
7
|
+
export type DocumentState = 'current' | 'stale' | 'missing' | 'unreadable';
|
|
8
|
+
|
|
9
|
+
export interface ListedDocument {
|
|
10
|
+
readonly label: string;
|
|
11
|
+
readonly path: string;
|
|
12
|
+
readonly state: DocumentState;
|
|
13
|
+
/** Only for `unreadable` — what went wrong reading the bundled copy. */
|
|
14
|
+
readonly detail?: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface ListedHarness {
|
|
18
|
+
readonly id: string;
|
|
19
|
+
readonly label: string;
|
|
20
|
+
readonly installed: boolean;
|
|
21
|
+
/** The resolved binary, so a caller can see *which* claude answered. */
|
|
22
|
+
readonly binary: string | null;
|
|
23
|
+
readonly registered: boolean;
|
|
24
|
+
/**
|
|
25
|
+
* Empty when the harness is not installed, matching what the text rendering
|
|
26
|
+
* says: with no binary there is nothing to register the documents against, and
|
|
27
|
+
* reporting them would describe a setup that does not exist.
|
|
28
|
+
*/
|
|
29
|
+
readonly documents: readonly ListedDocument[];
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface McpListing {
|
|
33
|
+
readonly name: string;
|
|
34
|
+
readonly scope: string;
|
|
35
|
+
readonly harnesses: readonly ListedHarness[];
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface McpListFlags {
|
|
39
|
+
readonly name?: string | undefined;
|
|
40
|
+
readonly scope?: string | undefined;
|
|
41
|
+
readonly json?: boolean | undefined;
|
|
42
|
+
}
|
|
43
|
+
|
|
6
44
|
/**
|
|
7
45
|
* `lanes link mcp list` — where this endpoint is registered, and whether what
|
|
8
46
|
* each harness has been told about it is still what we ship.
|
|
@@ -11,58 +49,114 @@ import { exists } from './register.ts';
|
|
|
11
49
|
* different fixes. A registration without the skill is a working endpoint
|
|
12
50
|
* nobody reaches for; a skill against no registration is a document describing
|
|
13
51
|
* tools that are not there.
|
|
52
|
+
*
|
|
53
|
+
* The listing is gathered before anything is printed, so `--json` and the text
|
|
54
|
+
* rendering describe the same snapshot rather than two probes taken a moment
|
|
55
|
+
* apart. `--json` exists because this is the one command another program has a
|
|
56
|
+
* reason to read: it is how a UI decides between "add" and "re-add", and the
|
|
57
|
+
* `stale` state is the only signal that a re-run would do something.
|
|
14
58
|
*/
|
|
15
|
-
export async function mcpList(options:
|
|
59
|
+
export async function mcpList(options: McpListFlags): Promise<void> {
|
|
16
60
|
const name = options.name ?? 'lanes-link';
|
|
17
61
|
const scope = options.scope ?? 'user';
|
|
18
62
|
|
|
19
|
-
|
|
63
|
+
const listing = await listRegistrations(name, scope);
|
|
64
|
+
|
|
65
|
+
await emit(options.json, listing, () => render(listing));
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** The whole answer, gathered. No printing, so a caller can have it as data. */
|
|
69
|
+
export async function listRegistrations(name: string, scope: string): Promise<McpListing> {
|
|
70
|
+
const harnesses: ListedHarness[] = [];
|
|
20
71
|
|
|
21
72
|
for (const harness of HARNESSES) {
|
|
22
73
|
const binary = Bun.which(harness.binary);
|
|
23
74
|
|
|
24
75
|
if (!binary) {
|
|
25
|
-
|
|
76
|
+
harnesses.push({
|
|
77
|
+
id: harness.id,
|
|
78
|
+
label: harness.label,
|
|
79
|
+
installed: false,
|
|
80
|
+
binary: null,
|
|
81
|
+
registered: false,
|
|
82
|
+
documents: [],
|
|
83
|
+
});
|
|
26
84
|
continue;
|
|
27
85
|
}
|
|
28
86
|
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
for (const line of await documentLines(harness, scope)) print(` ${' '.repeat(14)} ${line}`);
|
|
87
|
+
harnesses.push({
|
|
88
|
+
id: harness.id,
|
|
89
|
+
label: harness.label,
|
|
90
|
+
installed: true,
|
|
91
|
+
binary,
|
|
92
|
+
registered: exists(binary, harness, name),
|
|
93
|
+
documents: await documents(harness, scope),
|
|
94
|
+
});
|
|
38
95
|
}
|
|
96
|
+
|
|
97
|
+
return { name, scope, harnesses };
|
|
39
98
|
}
|
|
40
99
|
|
|
41
|
-
/** One
|
|
42
|
-
async function
|
|
43
|
-
const
|
|
100
|
+
/** One entry per document this harness can hold, saying whether it is current. */
|
|
101
|
+
async function documents(harness: Harness, scope: string): Promise<ListedDocument[]> {
|
|
102
|
+
const listed: ListedDocument[] = [];
|
|
44
103
|
|
|
45
104
|
for (const plan of plannedAssets(harness, scope)) {
|
|
46
105
|
try {
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
state
|
|
51
|
-
|
|
52
|
-
: style.dim(
|
|
53
|
-
state === 'stale'
|
|
54
|
-
? `${plan.asset.label}: out of date — lanes link mcp add ${harness.id}`
|
|
55
|
-
: `${plan.asset.label}: not installed — lanes link mcp add ${harness.id}`,
|
|
56
|
-
),
|
|
57
|
-
);
|
|
106
|
+
listed.push({
|
|
107
|
+
label: plan.asset.label,
|
|
108
|
+
path: plan.path,
|
|
109
|
+
state: await assetState(plan, await readAsset(plan.asset)),
|
|
110
|
+
});
|
|
58
111
|
} catch (error) {
|
|
59
112
|
// A checkout without `instructions/` — a container image, say. Worth one
|
|
60
113
|
// line rather than a thrown error that hides the registration column.
|
|
61
|
-
|
|
114
|
+
listed.push({
|
|
115
|
+
label: plan.asset.label,
|
|
116
|
+
path: plan.path,
|
|
117
|
+
state: 'unreadable',
|
|
118
|
+
detail: message(error),
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
return listed;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function render(listing: McpListing): void {
|
|
127
|
+
heading(`Registered as ${style.bold(listing.name)}`);
|
|
128
|
+
|
|
129
|
+
for (const harness of listing.harnesses) {
|
|
130
|
+
if (!harness.installed) {
|
|
131
|
+
print(` ${harness.label.padEnd(14)} ${style.dim('not installed')}`);
|
|
132
|
+
continue;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
print(
|
|
136
|
+
` ${harness.label.padEnd(14)} ${
|
|
137
|
+
harness.registered
|
|
138
|
+
? style.green('registered')
|
|
139
|
+
: style.dim(`not registered — lanes link mcp add ${harness.id}`)
|
|
140
|
+
}`,
|
|
141
|
+
);
|
|
142
|
+
|
|
143
|
+
for (const document of harness.documents) {
|
|
144
|
+
print(` ${' '.repeat(14)} ${documentLine(harness.id, document)}`);
|
|
62
145
|
}
|
|
63
146
|
}
|
|
147
|
+
}
|
|
64
148
|
|
|
65
|
-
|
|
149
|
+
function documentLine(id: string, document: ListedDocument): string {
|
|
150
|
+
switch (document.state) {
|
|
151
|
+
case 'current':
|
|
152
|
+
return style.dim(`${document.label}: `) + style.green('up to date');
|
|
153
|
+
case 'stale':
|
|
154
|
+
return style.dim(`${document.label}: out of date — lanes link mcp add ${id}`);
|
|
155
|
+
case 'missing':
|
|
156
|
+
return style.dim(`${document.label}: not installed — lanes link mcp add ${id}`);
|
|
157
|
+
case 'unreadable':
|
|
158
|
+
return style.dim(`${document.label}: ${document.detail}`);
|
|
159
|
+
}
|
|
66
160
|
}
|
|
67
161
|
|
|
68
162
|
function message(error: unknown): string {
|
package/src/cli/main.ts
CHANGED
|
@@ -321,7 +321,11 @@ export async function run(argv: readonly string[]): Promise<void> {
|
|
|
321
321
|
return mcpStdio({ ...global, ...(flags['only'] === true ? { only: true } : {}) });
|
|
322
322
|
case 'list':
|
|
323
323
|
case undefined:
|
|
324
|
-
return mcpList({
|
|
324
|
+
return mcpList({
|
|
325
|
+
name: text(flags, 'name'),
|
|
326
|
+
scope: text(flags, 'scope'),
|
|
327
|
+
json: flags['json'] === true,
|
|
328
|
+
});
|
|
325
329
|
default:
|
|
326
330
|
throw new Error(`Unknown: ${PROGRAM} mcp ${second}`);
|
|
327
331
|
}
|
|
@@ -29,7 +29,9 @@ export const BUNQ_HINTS: Record<string, string> = {
|
|
|
29
29
|
UPDATE_DraftPayment_for_User_MonetaryAccount:
|
|
30
30
|
'Changes a draft that is still pending — status ACCEPTED sends it, REJECTED cancels it. ' +
|
|
31
31
|
'previous_updated_timestamp is required and comes from reading the draft first; it is what stops two ' +
|
|
32
|
-
'callers acting on the same draft.'
|
|
32
|
+
'callers acting on the same draft. Those two fields are the entire call — bunq refuses entries and ' +
|
|
33
|
+
'number_of_required_accepts here as superfluous — so changing what a draft pays means rejecting it and ' +
|
|
34
|
+
'creating another.',
|
|
33
35
|
|
|
34
36
|
CREATE_PaymentBatch_for_User_MonetaryAccount:
|
|
35
37
|
'Up to 350 payments in one call. Executes immediately, like a direct payment, and is all-or-nothing: bunq rejects ' +
|
|
@@ -51,14 +51,25 @@ export const BUNQ_REDACT: Record<string, string[]> = {
|
|
|
51
51
|
'status',
|
|
52
52
|
'schedule',
|
|
53
53
|
],
|
|
54
|
+
// Five, not seven: `entries` and `number_of_required_accepts` are no longer
|
|
55
|
+
// arguments at all. This is the one write here whose event cannot name an
|
|
56
|
+
// amount or a counterparty, because the call does not carry them — it says
|
|
57
|
+
// which draft was accepted and against which version of it, and that is
|
|
58
|
+
// everything there is to keep.
|
|
59
|
+
//
|
|
60
|
+
// Not everything a reader wants, though, and worth being honest that the gap
|
|
61
|
+
// is real rather than closed. The entries are on the event that *created* the
|
|
62
|
+
// draft, and nothing joins the two: an `AuditEvent` records arguments only,
|
|
63
|
+
// and bunq returns the draft id in the create's response. Reconstructing what
|
|
64
|
+
// an ACCEPTED draft paid means matching by hand. `context.audit.annotate`,
|
|
65
|
+
// which `gmail.send_message` uses to record resolved facts, is the shape of a
|
|
66
|
+
// fix and is a change to dispatch rather than to this list.
|
|
54
67
|
UPDATE_DraftPayment_for_User_MonetaryAccount: [
|
|
55
68
|
'userID',
|
|
56
69
|
'monetary-accountID',
|
|
57
70
|
'itemId',
|
|
58
71
|
'status',
|
|
59
|
-
'entries',
|
|
60
72
|
'previous_updated_timestamp',
|
|
61
|
-
'number_of_required_accepts',
|
|
62
73
|
],
|
|
63
74
|
CREATE_PaymentBatch_for_User_MonetaryAccount: ['userID', 'monetary-accountID', 'payments'],
|
|
64
75
|
};
|
|
@@ -144,7 +144,26 @@
|
|
|
144
144
|
"content": {
|
|
145
145
|
"application/json": {
|
|
146
146
|
"schema": {
|
|
147
|
-
"
|
|
147
|
+
"type": "object",
|
|
148
|
+
"description": "The whole of what this call takes. bunq refuses any other field here as superfluous — the rest of the schema it shares belongs to the call that creates one.",
|
|
149
|
+
"properties": {
|
|
150
|
+
"status": {
|
|
151
|
+
"type": "string",
|
|
152
|
+
"description": "The status of the DraftPayment.",
|
|
153
|
+
"readOnly": false,
|
|
154
|
+
"writeOnly": false
|
|
155
|
+
},
|
|
156
|
+
"previous_updated_timestamp": {
|
|
157
|
+
"type": "string",
|
|
158
|
+
"description": "The last updated_timestamp that you received for this DraftPayment. This needs to be provided to prevent race conditions.",
|
|
159
|
+
"readOnly": false,
|
|
160
|
+
"writeOnly": true
|
|
161
|
+
}
|
|
162
|
+
},
|
|
163
|
+
"required": [
|
|
164
|
+
"status",
|
|
165
|
+
"previous_updated_timestamp"
|
|
166
|
+
]
|
|
148
167
|
}
|
|
149
168
|
}
|
|
150
169
|
}
|
|
@@ -22,6 +22,7 @@ import { mkdir, writeFile } from 'node:fs/promises';
|
|
|
22
22
|
import { join } from 'node:path';
|
|
23
23
|
import { OpenAPIToolGenerator, type McpOpenAPITool } from 'mcp-from-openapi';
|
|
24
24
|
import { cutCycles, referenced, type Spec } from '../../shared/openapi.ts';
|
|
25
|
+
import { projectRequestBody } from '../../shared/vendor-operations.ts';
|
|
25
26
|
|
|
26
27
|
const SOURCE = 'https://raw.githubusercontent.com/bunq/doc/master/swagger.json';
|
|
27
28
|
const OUT = 'bunq.v1.json';
|
|
@@ -187,6 +188,34 @@ function dropReadOnly(node: unknown): number {
|
|
|
187
188
|
return dropped;
|
|
188
189
|
}
|
|
189
190
|
|
|
191
|
+
/**
|
|
192
|
+
* The operations bunq describes with the schema of a *different* operation.
|
|
193
|
+
*
|
|
194
|
+
* `PUT .../draft-payment/{itemId}` points at `DraftPayment`, the same schema as
|
|
195
|
+
* the `POST` that creates one, which requires `entries` and
|
|
196
|
+
* `number_of_required_accepts`. For a create those two *are* the payment. For an
|
|
197
|
+
* update bunq refuses both as superfluous — its own generated SDK sends only
|
|
198
|
+
* `status`, `previous_updated_timestamp` and `schedule` here — so the tool asked
|
|
199
|
+
* for exactly what the bank rejects and every accept failed. Worse than the
|
|
200
|
+
* error: made to send `entries` for a draft that has them, a model reaches for
|
|
201
|
+
* the array echoed back or for `[]`, and both ask bunq to rewrite what the draft
|
|
202
|
+
* pays on the way to approving it. A hint cannot fix a required argument, which
|
|
203
|
+
* is the difference between this and the `payments`-is-really-an-array note
|
|
204
|
+
* above: nothing stops an agent sending an array, and nothing lets it omit a
|
|
205
|
+
* required field. `required` is asserted here rather than projected because
|
|
206
|
+
* bunq's own is the one written for the create.
|
|
207
|
+
*
|
|
208
|
+
* `schedule` is deliberately not projected: it carries `recurrence_unit` and
|
|
209
|
+
* `recurrence_size`, so approving a one-off draft would be a place to acquire a
|
|
210
|
+
* standing order — the risk `OPERATIONS` gives for leaving the scheduling
|
|
211
|
+
* *endpoints* out. Not the whole of that risk, though: `CREATE_DraftPayment`
|
|
212
|
+
* still offers the same field, because it comes with bunq's create schema.
|
|
213
|
+
* Closing that changes what the provider can do rather than whether a call
|
|
214
|
+
* works, and is not done here.
|
|
215
|
+
*/
|
|
216
|
+
const UPDATE_BODIES: Record<string, readonly string[]> = {
|
|
217
|
+
UPDATE_DraftPayment_for_User_MonetaryAccount: ['status', 'previous_updated_timestamp'],
|
|
218
|
+
};
|
|
190
219
|
|
|
191
220
|
async function vendor(): Promise<void> {
|
|
192
221
|
const response = await fetch(SOURCE);
|
|
@@ -246,6 +275,34 @@ async function vendor(): Promise<void> {
|
|
|
246
275
|
}
|
|
247
276
|
|
|
248
277
|
const schemas = spec.components?.schemas ?? {};
|
|
278
|
+
|
|
279
|
+
const projected = new Set<string>();
|
|
280
|
+
for (const item of Object.values(paths)) {
|
|
281
|
+
for (const [method, operation] of Object.entries(item)) {
|
|
282
|
+
if (!METHODS.includes(method)) continue;
|
|
283
|
+
const fields = operation.operationId ? UPDATE_BODIES[operation.operationId] : undefined;
|
|
284
|
+
if (!fields || !operation.operationId) continue;
|
|
285
|
+
|
|
286
|
+
projectRequestBody(
|
|
287
|
+
operation as unknown as Record<string, unknown>,
|
|
288
|
+
operation.operationId,
|
|
289
|
+
schemas,
|
|
290
|
+
fields,
|
|
291
|
+
'The whole of what this call takes. bunq refuses any other field here as superfluous — the rest ' +
|
|
292
|
+
'of the schema it shares belongs to the call that creates one.',
|
|
293
|
+
);
|
|
294
|
+
projected.add(operation.operationId);
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
// The same refusal as `missing` above, and for a sharper reason: an entry that
|
|
299
|
+
// matches nothing narrows nothing, and what is left is the wide body this
|
|
300
|
+
// exists to remove — printed as a success.
|
|
301
|
+
const unprojected = Object.keys(UPDATE_BODIES).filter((id) => !projected.has(id));
|
|
302
|
+
if (unprojected.length > 0) {
|
|
303
|
+
throw new Error(`UPDATE_BODIES names operations the spec does not have — ${unprojected.join(', ')}`);
|
|
304
|
+
}
|
|
305
|
+
|
|
249
306
|
const keep = referenced(paths, schemas);
|
|
250
307
|
const trimmedSchemas = Object.fromEntries(
|
|
251
308
|
Object.entries(schemas).filter(([name]) => keep.has(name)),
|
|
@@ -295,7 +352,8 @@ async function vendor(): Promise<void> {
|
|
|
295
352
|
` bunq ${String(Object.keys(paths).length).padStart(2)} paths, ` +
|
|
296
353
|
`${seen.size} operations, ${Object.keys(trimmedSchemas).length} schemas, ` +
|
|
297
354
|
`${cuts} cycle${cuts === 1 ? '' : 's'} cut, ${headers} protocol params dropped, ` +
|
|
298
|
-
`${readOnly} read-only fields dropped, ${size}
|
|
355
|
+
`${readOnly} read-only fields dropped, ${projected.size} update ` +
|
|
356
|
+
`bod${projected.size === 1 ? 'y' : 'ies'} projected, ${size}KB`,
|
|
299
357
|
);
|
|
300
358
|
|
|
301
359
|
await report(trimmed);
|
|
@@ -96,3 +96,84 @@ export function narrowRequestBody(
|
|
|
96
96
|
body.content = Object.fromEntries(kept.map((type) => [type, body.content![type]]));
|
|
97
97
|
return before.length - kept.length;
|
|
98
98
|
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Replace a request body with a projection of the schema it points at.
|
|
102
|
+
*
|
|
103
|
+
* The third surgery on this axis, and the first about the body being *wrong*
|
|
104
|
+
* rather than too wide. A document that describes two operations with one schema
|
|
105
|
+
* describes at least one of them wrongly, and `required` is where it bites: the
|
|
106
|
+
* generated tool asks for arguments the vendor refuses on the call they are
|
|
107
|
+
* attached to, so there is no correct call to make. It fails at the vendor, on
|
|
108
|
+
* every attempt, and nothing before the request can see it.
|
|
109
|
+
*
|
|
110
|
+
* Projecting rather than hand-writing is what keeps this tied to the document.
|
|
111
|
+
* The named fields are copied out of the vendor's own schema with their types
|
|
112
|
+
* and descriptions, so the tool still says what the vendor says. Everything this
|
|
113
|
+
* cannot verify it refuses instead: a field that is gone, a field the document
|
|
114
|
+
* marks read-only, a body offering a content type this does not rewrite, a
|
|
115
|
+
* `$ref` that does not point into `components.schemas`. A vendor refresh should
|
|
116
|
+
* fail loudly here rather than quietly restore the body it was called to fix.
|
|
117
|
+
*
|
|
118
|
+
* `required` is the one thing NOT projected — it is the caller's assertion, and
|
|
119
|
+
* necessarily so, since the whole disease is a `required` written for the other
|
|
120
|
+
* operation. Say why in the caller.
|
|
121
|
+
*
|
|
122
|
+
* `note` becomes the body schema's description. It lands in the committed
|
|
123
|
+
* document for whoever reads it there; it does **not** reach the agent, because
|
|
124
|
+
* the generator flattens body properties to the top level and drops the body
|
|
125
|
+
* schema's own description. The sentence an agent needs goes in `hints`.
|
|
126
|
+
*
|
|
127
|
+
* Apply before reachability, so a schema the projection no longer reaches leaves
|
|
128
|
+
* the document rather than lingering unused.
|
|
129
|
+
*/
|
|
130
|
+
export function projectRequestBody(
|
|
131
|
+
operation: Record<string, unknown>,
|
|
132
|
+
operationId: string,
|
|
133
|
+
schemas: Record<string, unknown>,
|
|
134
|
+
fields: readonly string[],
|
|
135
|
+
note: string,
|
|
136
|
+
): void {
|
|
137
|
+
const content = (operation['requestBody'] as { content?: Record<string, { schema?: { $ref?: string } }> })
|
|
138
|
+
?.content;
|
|
139
|
+
const types = Object.keys(content ?? {});
|
|
140
|
+
|
|
141
|
+
const other = types.filter((type) => type !== 'application/json');
|
|
142
|
+
if (!content || types.length === 0 || other.length > 0) {
|
|
143
|
+
// A body left pointing at the wide schema on a second content type is the
|
|
144
|
+
// bug still present on whichever branch the generator happens to prefer.
|
|
145
|
+
throw new Error(
|
|
146
|
+
`${operationId}: expected a lone application/json request body to project, found ${types.join(', ') || 'none'}`,
|
|
147
|
+
);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
const json = content['application/json'];
|
|
151
|
+
const reference = json?.schema?.$ref;
|
|
152
|
+
if (!json || typeof reference !== 'string' || !reference.startsWith('#/components/schemas/')) {
|
|
153
|
+
throw new Error(`${operationId}: request body is ${reference ?? 'not a $ref'}, not a schema reference`);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
const name = reference.slice('#/components/schemas/'.length);
|
|
157
|
+
const source = (schemas[name] as { properties?: Record<string, unknown> } | undefined)?.properties ?? {};
|
|
158
|
+
|
|
159
|
+
const properties: Record<string, unknown> = {};
|
|
160
|
+
for (const field of fields) {
|
|
161
|
+
const property = source[field] as { readOnly?: boolean } | undefined;
|
|
162
|
+
if (!property) throw new Error(`${operationId}: ${name} no longer describes "${field}"`);
|
|
163
|
+
if (property.readOnly === true) {
|
|
164
|
+
// Projected fields are inlined into the path, where the read-only strip
|
|
165
|
+
// does not reach — so a field the vendor computes would survive here and
|
|
166
|
+
// be demanded as an argument it ignores.
|
|
167
|
+
throw new Error(`${operationId}: ${name}.${field} is read-only and cannot be a request field`);
|
|
168
|
+
}
|
|
169
|
+
properties[field] = property;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
json.schema = {
|
|
173
|
+
type: 'object',
|
|
174
|
+
description: note,
|
|
175
|
+
properties,
|
|
176
|
+
required: [...fields],
|
|
177
|
+
} as never;
|
|
178
|
+
}
|
|
179
|
+
|