velocious 1.0.576 → 1.0.577
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 +46 -1
- package/build/background-jobs/main.js +13 -2
- package/build/background-jobs/scheduler.js +11 -7
- package/build/background-jobs/store.js +18 -13
- package/build/environment-handlers/node/cli/commands/test.js +2 -0
- package/build/environment-handlers/node/source-peer-package.js +172 -0
- package/build/routes/resolver.js +3 -50
- package/build/src/background-jobs/main.d.ts.map +1 -1
- package/build/src/background-jobs/main.js +13 -3
- package/build/src/background-jobs/scheduler.d.ts +2 -2
- package/build/src/background-jobs/scheduler.d.ts.map +1 -1
- package/build/src/background-jobs/scheduler.js +11 -8
- package/build/src/background-jobs/store.d.ts +5 -4
- package/build/src/background-jobs/store.d.ts.map +1 -1
- package/build/src/background-jobs/store.js +17 -12
- package/build/src/environment-handlers/node/cli/commands/test.d.ts.map +1 -1
- package/build/src/environment-handlers/node/cli/commands/test.js +3 -1
- package/build/src/environment-handlers/node/source-peer-package.d.ts +46 -0
- package/build/src/environment-handlers/node/source-peer-package.d.ts.map +1 -0
- package/build/src/environment-handlers/node/source-peer-package.js +148 -0
- package/build/src/routes/resolver.d.ts.map +1 -1
- package/build/src/routes/resolver.js +4 -45
- package/build/tsconfig.tsbuildinfo +1 -1
- package/package.json +2 -1
- package/scripts/run-tests.js +61 -20
- package/scripts/test-browser.js +6 -1
- package/src/background-jobs/main.js +13 -2
- package/src/background-jobs/scheduler.js +11 -7
- package/src/background-jobs/store.js +18 -13
- package/src/environment-handlers/node/cli/commands/test.js +2 -0
- package/src/environment-handlers/node/source-peer-package.js +172 -0
- package/src/routes/resolver.js +3 -50
- package/build/deployment-api/controller.js +0 -437
- package/build/deployment-api/index.js +0 -210
- package/build/deployment-api/path-matcher.js +0 -45
- package/build/deployment-api/registry.js +0 -84
- package/build/deployment-api/run-store.js +0 -798
- package/build/deployment-api/sanitize.js +0 -114
- package/build/src/deployment-api/controller.d.ts +0 -117
- package/build/src/deployment-api/controller.d.ts.map +0 -1
- package/build/src/deployment-api/controller.js +0 -384
- package/build/src/deployment-api/index.d.ts +0 -46
- package/build/src/deployment-api/index.d.ts.map +0 -1
- package/build/src/deployment-api/index.js +0 -178
- package/build/src/deployment-api/path-matcher.d.ts +0 -31
- package/build/src/deployment-api/path-matcher.d.ts.map +0 -1
- package/build/src/deployment-api/path-matcher.js +0 -39
- package/build/src/deployment-api/registry.d.ts +0 -106
- package/build/src/deployment-api/registry.d.ts.map +0 -1
- package/build/src/deployment-api/registry.js +0 -74
- package/build/src/deployment-api/run-store.d.ts +0 -402
- package/build/src/deployment-api/run-store.d.ts.map +0 -1
- package/build/src/deployment-api/run-store.js +0 -711
- package/build/src/deployment-api/sanitize.d.ts +0 -27
- package/build/src/deployment-api/sanitize.d.ts.map +0 -1
- package/build/src/deployment-api/sanitize.js +0 -100
- package/src/deployment-api/controller.js +0 -437
- package/src/deployment-api/index.js +0 -210
- package/src/deployment-api/path-matcher.js +0 -45
- package/src/deployment-api/registry.js +0 -84
- package/src/deployment-api/run-store.js +0 -798
- package/src/deployment-api/sanitize.js +0 -114
|
@@ -1,210 +0,0 @@
|
|
|
1
|
-
// @ts-check
|
|
2
|
-
|
|
3
|
-
import VelociousDeploymentApiController from "./controller.js"
|
|
4
|
-
import {matchDeploymentApiPath} from "./path-matcher.js"
|
|
5
|
-
import {normalizeMountPrefix} from "../utils/mount-prefix.js"
|
|
6
|
-
import {deploymentMountIdentifier, registerDeploymentMount} from "./registry.js"
|
|
7
|
-
|
|
8
|
-
const IDENTIFIER_PATTERN = /^[a-z0-9][a-z0-9_-]*$/
|
|
9
|
-
const MAX_IDENTIFIER_LENGTH = 64
|
|
10
|
-
const BRANCH_PATTERN = /^[a-zA-Z0-9][a-zA-Z0-9._-]*(\/[a-zA-Z0-9][a-zA-Z0-9._-]*)*$/
|
|
11
|
-
|
|
12
|
-
/**
|
|
13
|
-
* Validates one allowlist identifier (project or stage). Bounded identifiers
|
|
14
|
-
* keep every value the API handles safe to pass to the adapter as data.
|
|
15
|
-
* @param {?} value - Raw identifier.
|
|
16
|
-
* @param {string} name - Human-readable name for error messages.
|
|
17
|
-
* @returns {string} - The validated identifier.
|
|
18
|
-
*/
|
|
19
|
-
function validateIdentifier(value, name) {
|
|
20
|
-
if (typeof value !== "string" || value.length > MAX_IDENTIFIER_LENGTH || !IDENTIFIER_PATTERN.test(value)) {
|
|
21
|
-
throw new Error(`Invalid ${name} identifier: ${String(value)}`)
|
|
22
|
-
}
|
|
23
|
-
|
|
24
|
-
return value
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
/**
|
|
28
|
-
* Validates the projects allowlist and returns a normalized copy.
|
|
29
|
-
* @param {?} projects - Raw projects option.
|
|
30
|
-
* @returns {Record<string, import("./registry.js").DeploymentProjectOptions>} - Normalized allowlist.
|
|
31
|
-
*/
|
|
32
|
-
function validateProjects(projects) {
|
|
33
|
-
if (!projects || typeof projects !== "object" || Array.isArray(projects)) {
|
|
34
|
-
throw new Error("VelociousDeploymentApi requires a 'projects' allowlist object")
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
// Null-prototype maps so request-controlled names like "__proto__" or
|
|
38
|
-
// "constructor" can never resolve inherited properties as allowlisted
|
|
39
|
-
// projects/stages.
|
|
40
|
-
/** @type {Record<string, import("./registry.js").DeploymentProjectOptions>} */
|
|
41
|
-
const normalized = Object.create(null)
|
|
42
|
-
|
|
43
|
-
for (const [project, projectOptions] of Object.entries(projects)) {
|
|
44
|
-
validateIdentifier(project, "project")
|
|
45
|
-
|
|
46
|
-
const stages = /** @type {Record<string, ?>} */ (projectOptions)?.stages
|
|
47
|
-
|
|
48
|
-
if (!stages || typeof stages !== "object" || Array.isArray(stages) || Object.keys(stages).length === 0) {
|
|
49
|
-
throw new Error(`Project ${project} must allowlist at least one stage`)
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
/** @type {Record<string, import("./registry.js").DeploymentStageOptions>} */
|
|
53
|
-
const normalizedStages = Object.create(null)
|
|
54
|
-
|
|
55
|
-
for (const [stage, stageOptions] of Object.entries(stages)) {
|
|
56
|
-
validateIdentifier(stage, "stage")
|
|
57
|
-
|
|
58
|
-
const releaseBranch = /** @type {Record<string, ?>} */ (stageOptions)?.releaseBranch
|
|
59
|
-
|
|
60
|
-
if (typeof releaseBranch !== "string" || !BRANCH_PATTERN.test(releaseBranch) || releaseBranch.includes("..")) {
|
|
61
|
-
throw new Error(`Invalid release branch for ${project}/${stage}: ${String(releaseBranch)}`)
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
normalizedStages[stage] = {releaseBranch}
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
normalized[project] = {stages: normalizedStages}
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
if (Object.keys(normalized).length === 0) {
|
|
71
|
-
throw new Error("VelociousDeploymentApi requires at least one allowlisted project")
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
return normalized
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
/**
|
|
78
|
-
* Validates the adapter contract so misconfiguration fails at boot rather than
|
|
79
|
-
* on the first request.
|
|
80
|
-
* @param {?} adapter - Raw adapter option.
|
|
81
|
-
* @returns {import("./registry.js").DeploymentAdapter} - The validated adapter.
|
|
82
|
-
*/
|
|
83
|
-
function validateAdapter(adapter) {
|
|
84
|
-
if (!adapter || typeof adapter !== "object") {
|
|
85
|
-
throw new Error("VelociousDeploymentApi requires an 'adapter' object")
|
|
86
|
-
}
|
|
87
|
-
|
|
88
|
-
const candidate = /** @type {Record<string, ?>} */ (adapter)
|
|
89
|
-
|
|
90
|
-
for (const methodName of ["validateRevision", "deploy"]) {
|
|
91
|
-
if (typeof candidate[methodName] !== "function") {
|
|
92
|
-
throw new TypeError(`VelociousDeploymentApi adapter must respond to ${methodName}()`)
|
|
93
|
-
}
|
|
94
|
-
}
|
|
95
|
-
|
|
96
|
-
if (candidate.readStatus !== undefined && typeof candidate.readStatus !== "function") {
|
|
97
|
-
throw new TypeError("VelociousDeploymentApi adapter readStatus must be a function when given")
|
|
98
|
-
}
|
|
99
|
-
|
|
100
|
-
return /** @type {import("./registry.js").DeploymentAdapter} */ (adapter)
|
|
101
|
-
}
|
|
102
|
-
|
|
103
|
-
/**
|
|
104
|
-
* Validates the access tokens. The API fails closed: without at least one
|
|
105
|
-
* configured token every request is unauthorized, so mounting without tokens
|
|
106
|
-
* is a configuration error.
|
|
107
|
-
* @param {?} accessTokens - Raw access tokens option.
|
|
108
|
-
* @returns {string[]} - The validated tokens.
|
|
109
|
-
*/
|
|
110
|
-
function validateAccessTokens(accessTokens) {
|
|
111
|
-
if (!Array.isArray(accessTokens)) {
|
|
112
|
-
throw new Error("VelociousDeploymentApi requires an 'accessTokens' array with at least one token")
|
|
113
|
-
}
|
|
114
|
-
|
|
115
|
-
for (const token of accessTokens) {
|
|
116
|
-
if (typeof token !== "string" || token.length === 0) {
|
|
117
|
-
throw new Error("VelociousDeploymentApi access tokens must all be non-empty strings")
|
|
118
|
-
}
|
|
119
|
-
}
|
|
120
|
-
|
|
121
|
-
if (accessTokens.length === 0) {
|
|
122
|
-
throw new Error("VelociousDeploymentApi requires at least one non-empty access token; the API fails closed without one")
|
|
123
|
-
}
|
|
124
|
-
|
|
125
|
-
return [...accessTokens]
|
|
126
|
-
}
|
|
127
|
-
|
|
128
|
-
const DEFAULT_STALE_RUN_TIMEOUT_MS = 60000
|
|
129
|
-
|
|
130
|
-
/**
|
|
131
|
-
* Validates the stale-run lease timeout: after this many milliseconds without
|
|
132
|
-
* an ownership heartbeat, a later request reconciles pending work as
|
|
133
|
-
* interrupted and running work as requiring operator reconciliation.
|
|
134
|
-
* @param {?} value - Raw option value.
|
|
135
|
-
* @returns {number} - The timeout in milliseconds.
|
|
136
|
-
*/
|
|
137
|
-
function validateStaleRunTimeoutMs(value) {
|
|
138
|
-
if (value === undefined) return DEFAULT_STALE_RUN_TIMEOUT_MS
|
|
139
|
-
|
|
140
|
-
if (typeof value !== "number" || !Number.isSafeInteger(value) || value < 1) {
|
|
141
|
-
throw new Error(`VelociousDeploymentApi staleRunTimeoutMs must be a positive integer, got: ${String(value)}`)
|
|
142
|
-
}
|
|
143
|
-
|
|
144
|
-
return value
|
|
145
|
-
}
|
|
146
|
-
|
|
147
|
-
/**
|
|
148
|
-
* Mountable authenticated deployment API. A narrowly configured consumer
|
|
149
|
-
* mounts it in its routes file and supplies an adapter owned by the deployment
|
|
150
|
-
* integration (e.g. Rampway) that performs the actual lock/build/release/
|
|
151
|
-
* health/rollback/cleanup work:
|
|
152
|
-
*
|
|
153
|
-
* ```js
|
|
154
|
-
* routes.draw((route) => {
|
|
155
|
-
* route.mount(VelociousDeploymentApi, {
|
|
156
|
-
* at: "/velocious/deployments",
|
|
157
|
-
* accessTokens: [secrets.deploymentApiToken],
|
|
158
|
-
* adapter: rampwayDeploymentAdapter,
|
|
159
|
-
* projects: {
|
|
160
|
-
* "my-app": {stages: {production: {releaseBranch: "master"}}}
|
|
161
|
-
* }
|
|
162
|
-
* })
|
|
163
|
-
* })
|
|
164
|
-
* ```
|
|
165
|
-
*/
|
|
166
|
-
export default class VelociousDeploymentApi {
|
|
167
|
-
/**
|
|
168
|
-
* Registers the deployment API under `at`. Implemented as a route-resolver
|
|
169
|
-
* hook so the controller can live inside the velocious package rather than
|
|
170
|
-
* the host app's `src/routes` directory. Invoked by the routing layer for
|
|
171
|
-
* each `route.mount(...)` registration.
|
|
172
|
-
* @param {object} args - Options.
|
|
173
|
-
* @param {import("../configuration.js").default} args.configuration - Configuration instance.
|
|
174
|
-
* @param {string} args.at - Mount path prefix (e.g. "/velocious/deployments").
|
|
175
|
-
* @param {string[]} args.accessTokens - Accepted bearer tokens; requests authenticate with `Authorization: Bearer <token>` only.
|
|
176
|
-
* @param {import("./registry.js").DeploymentAdapter} args.adapter - Deployment integration adapter that owns execution.
|
|
177
|
-
* @param {Record<string, import("./registry.js").DeploymentProjectOptions>} args.projects - Allowlisted projects/stages with their approved release branches.
|
|
178
|
-
* @param {string} [args.databaseIdentifier] - Database identifier the run store reads from.
|
|
179
|
-
* @param {number} [args.staleRunTimeoutMs] - Lease timeout after which an active run without a heartbeat is reconciled according to its execution state; defaults to 60000.
|
|
180
|
-
* @returns {void} - No return value.
|
|
181
|
-
*/
|
|
182
|
-
static mountInto({accessTokens, adapter, at, configuration, databaseIdentifier, projects, staleRunTimeoutMs}) {
|
|
183
|
-
if (!configuration) throw new Error("No configuration given")
|
|
184
|
-
|
|
185
|
-
const prefix = normalizeMountPrefix(at)
|
|
186
|
-
const options = {
|
|
187
|
-
accessTokens: validateAccessTokens(accessTokens),
|
|
188
|
-
adapter: validateAdapter(adapter),
|
|
189
|
-
databaseIdentifier,
|
|
190
|
-
mountIdentifier: deploymentMountIdentifier(prefix),
|
|
191
|
-
projects: validateProjects(projects),
|
|
192
|
-
staleRunTimeoutMs: validateStaleRunTimeoutMs(staleRunTimeoutMs)
|
|
193
|
-
}
|
|
194
|
-
|
|
195
|
-
registerDeploymentMount(configuration, prefix, options)
|
|
196
|
-
|
|
197
|
-
configuration.addRouteResolverHook(({currentPath, request}) => {
|
|
198
|
-
const match = matchDeploymentApiPath({method: request.httpMethod(), path: currentPath, prefix})
|
|
199
|
-
|
|
200
|
-
if (!match) return null
|
|
201
|
-
|
|
202
|
-
return {
|
|
203
|
-
action: match.action,
|
|
204
|
-
controller: "velociousDeploymentApi",
|
|
205
|
-
controllerClass: VelociousDeploymentApiController,
|
|
206
|
-
params: {...match.params, velociousDeploymentMountAt: prefix}
|
|
207
|
-
}
|
|
208
|
-
})
|
|
209
|
-
}
|
|
210
|
-
}
|
|
@@ -1,45 +0,0 @@
|
|
|
1
|
-
// @ts-check
|
|
2
|
-
|
|
3
|
-
import {mountSubPath} from "../utils/mount-prefix.js"
|
|
4
|
-
|
|
5
|
-
/**
|
|
6
|
-
* @typedef {object} DeploymentApiMatch
|
|
7
|
-
* @property {string} action - Controller action to run.
|
|
8
|
-
* @property {Record<string, string>} params - Extra params extracted from the path.
|
|
9
|
-
*/
|
|
10
|
-
|
|
11
|
-
/**
|
|
12
|
-
* Matches an incoming request against the deployment API routes that live
|
|
13
|
-
* under the mount prefix. Returns the controller action plus any extracted
|
|
14
|
-
* params, or null when the path/method isn't part of the deployment API.
|
|
15
|
-
* @param {object} args - Options.
|
|
16
|
-
* @param {string} args.prefix - Normalized mount prefix.
|
|
17
|
-
* @param {string} args.path - Request path without query string.
|
|
18
|
-
* @param {string} args.method - HTTP method.
|
|
19
|
-
* @returns {DeploymentApiMatch | null} - Matched action or null.
|
|
20
|
-
*/
|
|
21
|
-
export function matchDeploymentApiPath({prefix, path, method}) {
|
|
22
|
-
const subPath = mountSubPath({prefix, path})
|
|
23
|
-
|
|
24
|
-
if (subPath === null) return null
|
|
25
|
-
|
|
26
|
-
if (method === "POST" && subPath === "/runs") return {action: "create", params: {}}
|
|
27
|
-
|
|
28
|
-
if (method === "GET") {
|
|
29
|
-
const runMatch = subPath.match(/^\/runs\/([^/]+)$/)
|
|
30
|
-
|
|
31
|
-
if (runMatch) {
|
|
32
|
-
let id
|
|
33
|
-
|
|
34
|
-
try {
|
|
35
|
-
id = decodeURIComponent(runMatch[1])
|
|
36
|
-
} catch {
|
|
37
|
-
return null
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
return {action: "show", params: {id}}
|
|
41
|
-
}
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
return null
|
|
45
|
-
}
|
|
@@ -1,84 +0,0 @@
|
|
|
1
|
-
// @ts-check
|
|
2
|
-
|
|
3
|
-
import {createHash} from "node:crypto"
|
|
4
|
-
|
|
5
|
-
/**
|
|
6
|
-
* DeploymentAdapter type. The configured deployment integration (e.g. Rampway)
|
|
7
|
-
* owns the implementation; Velocious only calls this contract. All values are
|
|
8
|
-
* passed as data — the framework never builds commands, paths, or refs.
|
|
9
|
-
* @typedef {object} DeploymentAdapter
|
|
10
|
-
* @property {(args: {configuration: import("../configuration.js").default, project: string, releaseBranch: string, revision: string, stage: string}) => Promise<boolean>} validateRevision - Returns whether the full revision is reachable from the approved release branch.
|
|
11
|
-
* @property {(args: {configuration: import("../configuration.js").default, project: string, releaseBranch: string, revision: string, runId: string, stage: string}) => Promise<Record<string, ?>>} deploy - Runs the integration's normal lock/build/migrate/release/health/rollback/cleanup deploy. The report shape is integration-owned and gets JSON-sanitized and redacted before persistence.
|
|
12
|
-
* @property {(args: {configuration: import("../configuration.js").default, project: string, stage: string}) => Promise<Record<string, ?>>} [readStatus] - Optional bounded readback of the live release (active revision, current/previous release).
|
|
13
|
-
*/
|
|
14
|
-
|
|
15
|
-
/**
|
|
16
|
-
* DeploymentStageOptions type.
|
|
17
|
-
* @typedef {object} DeploymentStageOptions
|
|
18
|
-
* @property {string} releaseBranch - Approved release branch the requested revision must be reachable from.
|
|
19
|
-
*/
|
|
20
|
-
|
|
21
|
-
/**
|
|
22
|
-
* DeploymentProjectOptions type.
|
|
23
|
-
* @typedef {object} DeploymentProjectOptions
|
|
24
|
-
* @property {Record<string, DeploymentStageOptions>} stages - Allowlisted stages keyed by identifier.
|
|
25
|
-
*/
|
|
26
|
-
|
|
27
|
-
/**
|
|
28
|
-
* DeploymentMountOptions type.
|
|
29
|
-
* @typedef {object} DeploymentMountOptions
|
|
30
|
-
* @property {string[]} accessTokens - Accepted bearer tokens (required, fail closed when empty).
|
|
31
|
-
* @property {DeploymentAdapter} adapter - Deployment integration adapter that owns execution.
|
|
32
|
-
* @property {string} mountIdentifier - Stable hash of the normalized mount path used to scope persisted state.
|
|
33
|
-
* @property {Record<string, DeploymentProjectOptions>} projects - Allowlisted projects keyed by identifier (null-prototype map).
|
|
34
|
-
* @property {string} [databaseIdentifier] - Database identifier the run store reads from.
|
|
35
|
-
* @property {number} staleRunTimeoutMs - Lease timeout after which an active run without a heartbeat is reconciled according to its execution state.
|
|
36
|
-
*/
|
|
37
|
-
|
|
38
|
-
/**
|
|
39
|
-
* Mount options are keyed by configuration so multiple configurations (e.g.
|
|
40
|
-
* across tests) never share state, and by mount path so a single configuration
|
|
41
|
-
* can mount the API at more than one prefix. Functions in the options (the
|
|
42
|
-
* adapter) can't travel through route params, so the controller looks them up
|
|
43
|
-
* here using the plain `at` string it receives.
|
|
44
|
-
* @type {WeakMap<import("../configuration.js").default, Map<string, DeploymentMountOptions>>}
|
|
45
|
-
*/
|
|
46
|
-
const registry = new WeakMap()
|
|
47
|
-
|
|
48
|
-
/**
|
|
49
|
-
* Builds the stable, bounded identifier persisted for one normalized mount.
|
|
50
|
-
* Hashing keeps composite indexes portable even when the URL prefix is long.
|
|
51
|
-
* @param {string} at - Normalized mount path.
|
|
52
|
-
* @returns {string} - Stable mount identifier.
|
|
53
|
-
*/
|
|
54
|
-
export function deploymentMountIdentifier(at) {
|
|
55
|
-
return createHash("sha256").update(at).digest("hex")
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
/**
|
|
59
|
-
* Registers mount options for a configuration and mount path.
|
|
60
|
-
* @param {import("../configuration.js").default} configuration - Configuration instance.
|
|
61
|
-
* @param {string} at - Normalized mount path.
|
|
62
|
-
* @param {DeploymentMountOptions} options - Mount options.
|
|
63
|
-
* @returns {void} - No return value.
|
|
64
|
-
*/
|
|
65
|
-
export function registerDeploymentMount(configuration, at, options) {
|
|
66
|
-
let byPath = registry.get(configuration)
|
|
67
|
-
|
|
68
|
-
if (!byPath) {
|
|
69
|
-
byPath = new Map()
|
|
70
|
-
registry.set(configuration, byPath)
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
byPath.set(at, options)
|
|
74
|
-
}
|
|
75
|
-
|
|
76
|
-
/**
|
|
77
|
-
* Returns the mount options for a configuration and mount path.
|
|
78
|
-
* @param {import("../configuration.js").default} configuration - Configuration instance.
|
|
79
|
-
* @param {string} at - Normalized mount path.
|
|
80
|
-
* @returns {DeploymentMountOptions | undefined} - Mount options if registered.
|
|
81
|
-
*/
|
|
82
|
-
export function getDeploymentMount(configuration, at) {
|
|
83
|
-
return registry.get(configuration)?.get(at)
|
|
84
|
-
}
|