@serfab/cadre-host 0.12.0 → 0.13.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.
- package/README.md +1 -1
- package/dist/orchestrator/host-process-orchestrator.js +6 -1
- package/dist/orchestrator/host-process-orchestrator.js.map +1 -1
- package/dist/server/routes/bootstrap-node-validation.d.ts +96 -0
- package/dist/server/routes/bootstrap-node-validation.js +178 -0
- package/dist/server/routes/bootstrap-node-validation.js.map +1 -0
- package/dist/server/routes/grants.js +8 -25
- package/dist/server/routes/grants.js.map +1 -1
- package/dist/server/routes/provision-request-validation.d.ts +45 -0
- package/dist/server/routes/provision-request-validation.js +97 -0
- package/dist/server/routes/provision-request-validation.js.map +1 -0
- package/package.json +5 -4
- package/src/__tests__/orchestrator.test.ts +951 -951
- package/src/donation/__tests__/donation-service.test.ts +1068 -1068
- package/src/donation/__tests__/donation-supervisor.test.ts +510 -510
- package/src/donation/__tests__/fake-orchestrator.test.ts +168 -168
- package/src/orchestrator/host-process-orchestrator.ts +6 -1
- package/src/server/__tests__/bootstrap-node-validation.test.ts +134 -0
- package/src/server/__tests__/grants-route.test.ts +326 -263
- package/src/server/routes/bootstrap-node-validation.ts +187 -0
- package/src/server/routes/grants.ts +9 -26
- package/src/server/routes/provision-request-validation.ts +105 -0
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shape validation for the requester-supplied `bootstrapNodes` of `POST /grants`
|
|
3
|
+
* — the requester's control-network addresses the donated node is started with
|
|
4
|
+
* (written into the child's `cadre.json` as `controlNetwork.bootstrapNodes` by
|
|
5
|
+
* `orchestrator/host-process-orchestrator.ts`) and hands to `@libp2p/bootstrap`.
|
|
6
|
+
*
|
|
7
|
+
* Its own module rather than part of `grants.ts`: the rule is a duplicate of
|
|
8
|
+
* cadre-provider's copy (`packages/cadre-provider/src/server/bootstrap-node-validation.ts`)
|
|
9
|
+
* and must stay identical to it, so it is worth reading, testing and changing on
|
|
10
|
+
* its own, without a routing file around it — the same arrangement cadre-provider
|
|
11
|
+
* uses for its restatement of cadre-core's owner-key rule.
|
|
12
|
+
*
|
|
13
|
+
* ## The rule, and why each clause is there
|
|
14
|
+
*
|
|
15
|
+
* An entry is usable exactly when it is a string, parses as a multiaddr, carries
|
|
16
|
+
* a `/p2p/<peerId>` component whose peer id decodes, and says where that peer is.
|
|
17
|
+
* Each clause corresponds to a distinct downstream failure the caller would
|
|
18
|
+
* otherwise never be told about — the value is forwarded verbatim into the spawned
|
|
19
|
+
* child, so every one of these surfaces where the caller cannot see it:
|
|
20
|
+
*
|
|
21
|
+
* - **Not parsable as a multiaddr → the child dies at boot.** `@libp2p/bootstrap`'s
|
|
22
|
+
* constructor maps `multiaddr()` over the whole list *before* filtering anything,
|
|
23
|
+
* and `multiaddr()` throws on a string that does not start with `/` (and on a
|
|
24
|
+
* non-string outright). libp2p construction throws and the node never starts.
|
|
25
|
+
* - **No `/p2p/<peerId>` → the node starts and never joins.** The quiet one.
|
|
26
|
+
* `/ip4/127.0.0.1/tcp/4001` parses fine, then `@libp2p/bootstrap` filters it out
|
|
27
|
+
* ("invalid bootstrap multiaddr without peer id", logged inside the child) and
|
|
28
|
+
* the node comes up with *zero* bootstrap peers: healthy-looking, permanently
|
|
29
|
+
* alone, never reaching the party it was created for. cadre-core's
|
|
30
|
+
* `getBootstrapPeerIds` (`packages/cadre-core/src/cadre-node.ts`) skips such an
|
|
31
|
+
* entry silently for the same reason.
|
|
32
|
+
* - **Malformed peer id → the child dies at boot.** `multiaddr()` does *not*
|
|
33
|
+
* validate the `/p2p/` value — parsing `/ip4/1.2.3.4/tcp/1/p2p/12D3KooReq` yields
|
|
34
|
+
* a `p2p` component whose value is the junk string unchanged — but
|
|
35
|
+
* `@libp2p/bootstrap` then calls `peerIdFromString` on it, unguarded, and it
|
|
36
|
+
* throws `Incorrect length`. A truncated copy-paste of a 52-character peer id is
|
|
37
|
+
* the likeliest human typo in this field.
|
|
38
|
+
* - **No location at all → the node starts and never joins.** `/p2p/<peerId>` (and
|
|
39
|
+
* `/p2p/<relay>/p2p-circuit/p2p/<target>`) parses and names a decodable peer, and
|
|
40
|
+
* `@libp2p/bootstrap` keeps it — but it says nothing about *where* the peer is, so
|
|
41
|
+
* libp2p finds no transport for it and the dial fails with no valid addresses.
|
|
42
|
+
* The node comes up with a bootstrap peer it can never reach, which looks the same
|
|
43
|
+
* from outside as having none. Only the total absence of a location is rejected:
|
|
44
|
+
* which transports the child can actually dial is the embedder's choice
|
|
45
|
+
* (`NetworkConfig.transports`), invisible from here, so a partial address like
|
|
46
|
+
* `/ip4/1.2.3.4/p2p/<peerId>` is deliberately left to the child.
|
|
47
|
+
*
|
|
48
|
+
* The peer id is read the way `@libp2p/bootstrap` reads it — the `p2p` components
|
|
49
|
+
* of `getComponents()` — rather than through the deprecated `getPeerId()`, so the
|
|
50
|
+
* two cannot disagree about which part of the address is the peer. Bootstrap uses
|
|
51
|
+
* only the *last* `p2p` component; this checks **every** one, so the relay half of
|
|
52
|
+
* a `…/p2p/<relay>/p2p-circuit/p2p/<target>` address must decode too. An address
|
|
53
|
+
* naming an undecodable relay is not dialable either, and bootstrap's own
|
|
54
|
+
* `P2P.matches` filter accepts any address carrying a `p2p` component, so nothing
|
|
55
|
+
* this accepts is dropped there.
|
|
56
|
+
*
|
|
57
|
+
* Requiring a multiaddr also closes a downstream ambiguity on the *other* side of
|
|
58
|
+
* this rule: cadre-provider joins its list with `,` into `CADRE_BOOTSTRAP_NODES`
|
|
59
|
+
* and cadre-cli's env loader splits it back on `,`, so an entry containing a comma
|
|
60
|
+
* would silently become two addresses there. This host writes JSON rather than an
|
|
61
|
+
* env var and so is not exposed to that, but the two rules are kept identical —
|
|
62
|
+
* see the note about manual upkeep below.
|
|
63
|
+
*
|
|
64
|
+
* Reachability is deliberately NOT checked: a well-formed address for a peer that
|
|
65
|
+
* happens to be down is indistinguishable here from one that is up, and no
|
|
66
|
+
* boundary check can tell them apart. This validates the *shape* the child must be
|
|
67
|
+
* able to parse, nothing more.
|
|
68
|
+
*
|
|
69
|
+
* Keeping the two copies in step is **manual** — neither package can see the
|
|
70
|
+
* other's rule, so no test can compare them. What the tests give instead is a
|
|
71
|
+
* tripwire on each side: `../__tests__/bootstrap-node-validation.test.ts` here and
|
|
72
|
+
* `packages/cadre-provider/src/server/__tests__/bootstrap-node-validation.test.ts`
|
|
73
|
+
* there each pin their own copy to the same accept/reject table, so changing
|
|
74
|
+
* either rule fails that package's own suite — and the comment above the rule you
|
|
75
|
+
* just changed is what points at the other copy.
|
|
76
|
+
*/
|
|
77
|
+
|
|
78
|
+
import debug from 'debug';
|
|
79
|
+
import { CODE_P2P, CODE_P2P_CIRCUIT, multiaddr, type Multiaddr } from '@multiformats/multiaddr';
|
|
80
|
+
import { peerIdFromString } from '@libp2p/peer-id';
|
|
81
|
+
|
|
82
|
+
const log = debug('cadre:host:bootstrap-nodes');
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Longest rejected address echoed back in an error message. Generous compared to
|
|
86
|
+
* the owner-key limit because a legitimate address is long: a plain
|
|
87
|
+
* `/ip4/…/tcp/…/p2p/<52 chars>` runs ~80 characters and a relayed
|
|
88
|
+
* `…/p2p-circuit/p2p/<52 chars>` about 150, so a smaller cap would truncate the
|
|
89
|
+
* very values an operator needs to read back.
|
|
90
|
+
*/
|
|
91
|
+
const REJECTED_ADDRESS_ECHO_LIMIT = 192;
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Render a rejected address for an error message, capped: `bootstrapNodes`
|
|
95
|
+
* arrives from a caller over the network, so an unbounded echo would let one junk
|
|
96
|
+
* string become a megabyte of log line.
|
|
97
|
+
*/
|
|
98
|
+
function describeRejectedAddress(value: string): string {
|
|
99
|
+
return value.length <= REJECTED_ADDRESS_ECHO_LIMIT
|
|
100
|
+
? value
|
|
101
|
+
: `${value.slice(0, REJECTED_ADDRESS_ECHO_LIMIT)}… (${value.length} chars)`;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** The message of a thrown parse/decode failure, whatever the thrower used. */
|
|
105
|
+
function reasonOf(error: unknown): string {
|
|
106
|
+
return error instanceof Error ? error.message : String(error);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Apply the rule above to one entry. Returns the trimmed value, so the create
|
|
111
|
+
* provision request carries exactly what was validated.
|
|
112
|
+
*/
|
|
113
|
+
function validateBootstrapNode(value: string): { node: string } | { error: string } {
|
|
114
|
+
const trimmed = value.trim();
|
|
115
|
+
if (trimmed.length === 0) {
|
|
116
|
+
return { error: 'bootstrapNodes entries must not be empty or whitespace-only' };
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
let address: Multiaddr;
|
|
120
|
+
try {
|
|
121
|
+
address = multiaddr(trimmed);
|
|
122
|
+
} catch (error) {
|
|
123
|
+
log('rejecting bootstrapNodes entry: %s', reasonOf(error));
|
|
124
|
+
return {
|
|
125
|
+
error: `bootstrapNodes entries must be multiaddrs (could not parse "${describeRejectedAddress(trimmed)}": ${reasonOf(error)})`,
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const components = address.getComponents();
|
|
130
|
+
const peerIds = components.filter(component => component.code === CODE_P2P);
|
|
131
|
+
if (peerIds.length === 0) {
|
|
132
|
+
return {
|
|
133
|
+
error: `bootstrapNodes entries must include a /p2p/<peerId> component ("${describeRejectedAddress(trimmed)}" names no peer, so the node would drop it and start with no bootstrap peers)`,
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
if (!components.some(component => component.code !== CODE_P2P && component.code !== CODE_P2P_CIRCUIT)) {
|
|
138
|
+
return {
|
|
139
|
+
error: `bootstrapNodes entries must name where to reach the peer ("${describeRejectedAddress(trimmed)}" names only peer ids, so no transport can dial it and the node would start with no reachable bootstrap peers)`,
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
for (const { value } of peerIds) {
|
|
144
|
+
try {
|
|
145
|
+
peerIdFromString(value ?? '');
|
|
146
|
+
} catch (error) {
|
|
147
|
+
log('rejecting bootstrapNodes peer id: %s', reasonOf(error));
|
|
148
|
+
return {
|
|
149
|
+
error: `bootstrapNodes entries must carry a decodable peer id ("${describeRejectedAddress(trimmed)}" names peer "${describeRejectedAddress(value ?? '')}", which does not decode: ${reasonOf(error)})`,
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
return { node: trimmed };
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Validate the required `bootstrapNodes` field of a provision request: a non-empty
|
|
159
|
+
* array of dialable control-network addresses (see {@link validateBootstrapNode}).
|
|
160
|
+
*
|
|
161
|
+
* Provisioning is the last point at which the requester can still fix a typo, so
|
|
162
|
+
* the shape is checked here: without it a bad address is answered 201 and then
|
|
163
|
+
* produces a donated node that either dies at boot or comes up permanently alone
|
|
164
|
+
* — the requester learning about it, if ever, from donation status rather than
|
|
165
|
+
* from a 400 naming the bad address.
|
|
166
|
+
*
|
|
167
|
+
* Every rejection names the offending entry: with several addresses in one
|
|
168
|
+
* request the message is the only thing that says WHICH one.
|
|
169
|
+
*
|
|
170
|
+
* @returns the trimmed addresses, so the provision request carries exactly what was validated.
|
|
171
|
+
*/
|
|
172
|
+
export function validateBootstrapNodes(value: unknown): { nodes: string[] } | { error: string } {
|
|
173
|
+
if (value === undefined || (Array.isArray(value) && value.length === 0)) {
|
|
174
|
+
return { error: 'bootstrapNodes is required' };
|
|
175
|
+
}
|
|
176
|
+
if (!Array.isArray(value) || !value.every((entry): entry is string => typeof entry === 'string')) {
|
|
177
|
+
return { error: 'bootstrapNodes must be an array of strings' };
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
const nodes: string[] = [];
|
|
181
|
+
for (const entry of value) {
|
|
182
|
+
const checked = validateBootstrapNode(entry);
|
|
183
|
+
if ('error' in checked) return checked;
|
|
184
|
+
nodes.push(checked.node);
|
|
185
|
+
}
|
|
186
|
+
return { nodes };
|
|
187
|
+
}
|
|
@@ -20,8 +20,9 @@
|
|
|
20
20
|
|
|
21
21
|
import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify';
|
|
22
22
|
|
|
23
|
-
import type { DonationService
|
|
23
|
+
import type { DonationService } from '../../donation/donation-service.js';
|
|
24
24
|
import type { DonationView, GrantValidator } from '../../donation/types.js';
|
|
25
|
+
import { validateProvisionRequest } from './provision-request-validation.js';
|
|
25
26
|
|
|
26
27
|
export interface GrantsRoutesOptions {
|
|
27
28
|
donations: DonationService;
|
|
@@ -109,33 +110,15 @@ export function registerGrantsRoutes(app: FastifyInstance, opts: GrantsRoutesOpt
|
|
|
109
110
|
return errorResponse(reply, 'unauthorized', 'Missing bearer grant token', 401);
|
|
110
111
|
}
|
|
111
112
|
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
}
|
|
116
|
-
if (
|
|
117
|
-
return errorResponse(reply, 'invalid_request',
|
|
118
|
-
}
|
|
119
|
-
if (!Array.isArray(body.ownerKeys) || body.ownerKeys.length === 0) {
|
|
120
|
-
return errorResponse(reply, 'invalid_request', 'ownerKeys is required', 400);
|
|
121
|
-
}
|
|
122
|
-
// Element types too, not just the array: `provision`'s shape check trims each
|
|
123
|
-
// entry, so a non-string reaches the caller as an internal `.trim is not a
|
|
124
|
-
// function` rather than as a message naming what is wrong with the request.
|
|
125
|
-
if (!body.ownerKeys.every(key => typeof key === 'string')) {
|
|
126
|
-
return errorResponse(reply, 'invalid_request', 'ownerKeys must be an array of strings', 400);
|
|
127
|
-
}
|
|
128
|
-
if (body.profile !== undefined && body.profile !== 'storage' && body.profile !== 'transaction') {
|
|
129
|
-
return errorResponse(reply, 'invalid_request', 'profile must be "storage" or "transaction"', 400);
|
|
113
|
+
// One validator owns the whole body — see `provision-request-validation.ts`
|
|
114
|
+
// for why no per-field check lives here. Nothing is provisioned on a
|
|
115
|
+
// rejection: this runs before the service is touched.
|
|
116
|
+
const validated = validateProvisionRequest(request.body ?? {}, token);
|
|
117
|
+
if ('error' in validated) {
|
|
118
|
+
return errorResponse(reply, 'invalid_request', validated.error, 400);
|
|
130
119
|
}
|
|
131
120
|
|
|
132
|
-
const donation = await donations.provision(
|
|
133
|
-
grantToken: token,
|
|
134
|
-
partyId: body.partyId,
|
|
135
|
-
bootstrapNodes: body.bootstrapNodes,
|
|
136
|
-
ownerKeys: body.ownerKeys,
|
|
137
|
-
...(body.profile ? { profile: body.profile } : {}),
|
|
138
|
-
});
|
|
121
|
+
const donation = await donations.provision(validated.request);
|
|
139
122
|
return reply.status(201).send({ ok: true, data: { donation } });
|
|
140
123
|
});
|
|
141
124
|
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The whole shape check for `POST /grants` — one call that either yields the
|
|
3
|
+
* typed {@link DonationProvisionRequest} the `DonationService` is handed, or the
|
|
4
|
+
* message the route answers 400 with.
|
|
5
|
+
*
|
|
6
|
+
* **Why a validator rather than a few more `if`s in the route.** Every field in
|
|
7
|
+
* this body is forwarded into a child process the requester cannot see:
|
|
8
|
+
* `partyId`, `bootstrapNodes` and `profile` are written into the child's
|
|
9
|
+
* `cadre.json` (`orchestrator/host-process-orchestrator.ts`) and `ownerKeys`
|
|
10
|
+
* become its cold-start seed-trust anchors. A field the route forgets to check is
|
|
11
|
+
* not a 400 — it is a donated node that dies at boot, or comes up subtly wrong,
|
|
12
|
+
* with a 201 already sent. Routing the body through one function that *constructs*
|
|
13
|
+
* the request object means a field can only reach the spawn path by having been
|
|
14
|
+
* through here; adding a field to `DonationProvisionRequest` without validating it
|
|
15
|
+
* stops compiling rather than silently shipping.
|
|
16
|
+
*
|
|
17
|
+
* **Division of labour with `DonationService.provision`.** The Ed25519 rule for
|
|
18
|
+
* `ownerKeys` stays in the service, where it is applied by cadre-core's own
|
|
19
|
+
* `requireEd25519PublicKeyB64` — the service is also reached by the respawn path,
|
|
20
|
+
* which replays a persisted record rather than an HTTP body, so the crypto rule
|
|
21
|
+
* has to live there to cover both. What this validator owns for `ownerKeys` is the
|
|
22
|
+
* *container* shape (present, non-empty, all strings), which the service's rule
|
|
23
|
+
* assumes: a number reaching it surfaces as an internal `.trim is not a function`
|
|
24
|
+
* rather than as a message naming what is wrong with the request.
|
|
25
|
+
*
|
|
26
|
+
* The multiaddr rule has no such home — it is duplicated in cadre-provider — so it
|
|
27
|
+
* lives in `bootstrap-node-validation.ts` next to the comment saying so.
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
import type { DonationProvisionRequest } from '../../donation/donation-service.js';
|
|
31
|
+
import { validateBootstrapNodes } from './bootstrap-node-validation.js';
|
|
32
|
+
|
|
33
|
+
/** `partyId` becomes `controlNetwork.partyId` in the child's config. */
|
|
34
|
+
function validatePartyId(value: unknown): { partyId: string } | { error: string } {
|
|
35
|
+
if (typeof value !== 'string' || value.trim().length === 0) {
|
|
36
|
+
return { error: 'partyId is required' };
|
|
37
|
+
}
|
|
38
|
+
return { partyId: value.trim() };
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Container shape only — see the module comment: the per-key Ed25519 rule is
|
|
43
|
+
* `DonationService.provision`'s, so the respawn path gets it too.
|
|
44
|
+
*/
|
|
45
|
+
function validateOwnerKeys(value: unknown): { ownerKeys: string[] } | { error: string } {
|
|
46
|
+
if (!Array.isArray(value) || value.length === 0) {
|
|
47
|
+
return { error: 'ownerKeys is required' };
|
|
48
|
+
}
|
|
49
|
+
if (!value.every((key): key is string => typeof key === 'string')) {
|
|
50
|
+
return { error: 'ownerKeys must be an array of strings' };
|
|
51
|
+
}
|
|
52
|
+
return { ownerKeys: value };
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Absent means `storage` — see {@link DonationProvisionRequest.profile}. */
|
|
56
|
+
function validateProfile(value: unknown): { profile?: DonationProvisionRequest['profile'] } | { error: string } {
|
|
57
|
+
if (value === undefined) return {};
|
|
58
|
+
if (value !== 'storage' && value !== 'transaction') {
|
|
59
|
+
return { error: 'profile must be "storage" or "transaction"' };
|
|
60
|
+
}
|
|
61
|
+
return { profile: value };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Validate a `POST /grants` body and build the request `DonationService.provision`
|
|
66
|
+
* is handed.
|
|
67
|
+
*
|
|
68
|
+
* `grantToken` comes from the `Authorization` bearer rather than the body — a
|
|
69
|
+
* requester does not get to name which grant pays for this — so it is a
|
|
70
|
+
* parameter, not a validated field. Whether that grant is live and within quota is
|
|
71
|
+
* `provision`'s call, not this function's.
|
|
72
|
+
*
|
|
73
|
+
* @returns the typed provision request, or the message to answer `invalid_request` with.
|
|
74
|
+
*/
|
|
75
|
+
export function validateProvisionRequest(
|
|
76
|
+
body: unknown,
|
|
77
|
+
grantToken: string,
|
|
78
|
+
): { request: DonationProvisionRequest } | { error: string } {
|
|
79
|
+
if (typeof body !== 'object' || body === null || Array.isArray(body)) {
|
|
80
|
+
return { error: 'request body must be a JSON object' };
|
|
81
|
+
}
|
|
82
|
+
const fields = body as Record<string, unknown>;
|
|
83
|
+
|
|
84
|
+
const partyId = validatePartyId(fields.partyId);
|
|
85
|
+
if ('error' in partyId) return partyId;
|
|
86
|
+
|
|
87
|
+
const bootstrap = validateBootstrapNodes(fields.bootstrapNodes);
|
|
88
|
+
if ('error' in bootstrap) return bootstrap;
|
|
89
|
+
|
|
90
|
+
const ownerKeys = validateOwnerKeys(fields.ownerKeys);
|
|
91
|
+
if ('error' in ownerKeys) return ownerKeys;
|
|
92
|
+
|
|
93
|
+
const profile = validateProfile(fields.profile);
|
|
94
|
+
if ('error' in profile) return profile;
|
|
95
|
+
|
|
96
|
+
return {
|
|
97
|
+
request: {
|
|
98
|
+
grantToken,
|
|
99
|
+
partyId: partyId.partyId,
|
|
100
|
+
bootstrapNodes: bootstrap.nodes,
|
|
101
|
+
ownerKeys: ownerKeys.ownerKeys,
|
|
102
|
+
...(profile.profile ? { profile: profile.profile } : {}),
|
|
103
|
+
},
|
|
104
|
+
};
|
|
105
|
+
}
|