@onlineapps/conn-orch-validator 3.3.1 → 4.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,488 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Deploy contract checks R1-R7 for a biz service repository.
5
+ *
6
+ * Requirements: api/docs/operations/biz-rework-deployment-requirements.md
7
+ * Database rules: ADR 0006 (api/docs/biz/80-decisions/)
8
+ *
9
+ * R1 the production compose pins its image immutably
10
+ * R2 the deploy resets to origin/production rather than merging
11
+ * R3 CI Node major == Dockerfile Node major == engines.node major
12
+ * R4 no published ports — the only public entrypoint is doorman
13
+ * R5 images tagged with the full commit SHA, never the short one
14
+ * R7 the CI database engine is the one the service actually talks to
15
+ * R8 tenant / workspace identity has exactly one source
16
+ *
17
+ * Pure module: reads the repository, returns a structured result. It renders
18
+ * nothing and exits nothing — the CLI owns presentation, this owns the rules.
19
+ * That split is what lets the api-side cross-repo audit reuse the same logic
20
+ * instead of keeping a second copy that drifts.
21
+ *
22
+ * Every violated requirement is reported, not just the first, so a repo can be
23
+ * fixed in one pass.
24
+ */
25
+
26
+ const fs = require('fs');
27
+ const path = require('path');
28
+
29
+ const IDENTIFIER_ONLY = /^[A-Za-z_][A-Za-z0-9_]*$/;
30
+ const REQUIRED_MARKER = /[@:]\$\{[A-Za-z_][A-Za-z0-9_]*:\?[^}]*\}$/;
31
+ const BARE_VARIABLE = /\$\{[A-Za-z_][A-Za-z0-9_]*(:-[^}]*)?\}$/;
32
+
33
+ function readIfExists(file) {
34
+ try {
35
+ return fs.readFileSync(file, 'utf8');
36
+ } catch {
37
+ return null;
38
+ }
39
+ }
40
+
41
+ /** Strip surrounding whitespace, then surrounding quotes — internal spaces stay,
42
+ * because they live inside the ${VAR:?message} marker. */
43
+ function unquote(value) {
44
+ const trimmed = value.trim();
45
+ if (trimmed.length >= 2) {
46
+ const first = trimmed[0];
47
+ const last = trimmed[trimmed.length - 1];
48
+ if ((first === '"' && last === '"') || (first === "'" && last === "'")) {
49
+ return trimmed.slice(1, -1);
50
+ }
51
+ }
52
+ return trimmed;
53
+ }
54
+
55
+ function nodeMajor(text) {
56
+ const match = /(\d+)/.exec(text);
57
+ return match ? match[1] : null;
58
+ }
59
+
60
+ function checkImagePins(compose, add) {
61
+ const ownImages = compose
62
+ .split('\n')
63
+ .filter((line) => /^\s*image:/.test(line) && /onlineapps/.test(line));
64
+
65
+ if (ownImages.length === 0) {
66
+ add('R1', 'No own-registry image found in docker-compose.production.yml. '
67
+ + 'Fix: the service must reference the image its CI builds.');
68
+ return;
69
+ }
70
+
71
+ for (const line of ownImages) {
72
+ const ref = unquote(line.slice(line.indexOf('image:') + 'image:'.length));
73
+
74
+ if (ref.includes('@sha256:') || REQUIRED_MARKER.test(ref)) continue;
75
+
76
+ if (ref.includes(':latest') || ref.includes(':-latest}')) {
77
+ add('R1', `Image resolves to the mutable tag 'latest': ${ref}\n`
78
+ + ' Two concurrent pipelines make it nondeterministic between push and pull, and rollback has no target.\n'
79
+ + ' Fix: pin by digest, e.g. image: <repo>@${BIZ_IMAGE_DIGEST:?BIZ_IMAGE_DIGEST is required}');
80
+ } else if (BARE_VARIABLE.test(ref)) {
81
+ add('R1', `Image variable has no required-marker: ${ref}\n`
82
+ + ' A bare ${VAR} resolves to empty when unset, producing a nameless image instead of an error.\n'
83
+ + ' Fix: use ${VAR:?VAR is required} so a missing value fails immediately with the key name.');
84
+ } else {
85
+ add('R1', `Image is pinned to a literal mutable tag: ${ref}\n`
86
+ + ' Fix: pin by digest, e.g. image: <repo>@${BIZ_IMAGE_DIGEST:?BIZ_IMAGE_DIGEST is required}');
87
+ }
88
+ }
89
+ }
90
+
91
+ function checkPublishedPorts(compose, add) {
92
+ const ports = compose.split('\n').filter((line) => /^\s*ports:/.test(line));
93
+ if (ports.length > 0) {
94
+ add('R4', 'Published ports found in docker-compose.production.yml.\n'
95
+ + ' Docker publishes past UFW, so a published port is reachable from the internet regardless of firewall rules.\n'
96
+ + ' Fix: remove the ports block; biz services talk over internal docker networks and the only public entrypoint is doorman.');
97
+ }
98
+ }
99
+
100
+ function checkDeploySequence(ci, add) {
101
+ if (/(^|[^\w-])git\s+pull([^\w-]|$)/m.test(ci)) {
102
+ add('R2', "'git pull' in the deploy path.\n"
103
+ + ' A local modification on the server turns the merge into a conflict and aborts the deploy halfway.\n'
104
+ + ' Fix: git fetch origin production && git reset --hard origin/production');
105
+ }
106
+ if (!/git\s+reset\s+--hard\s+origin\/production/.test(ci)) {
107
+ add('R2', "No 'git reset --hard origin/production' found in .gitlab-ci.yml.\n"
108
+ + ' Fix: the deploy must force the working tree to the pushed commit, matching the infra contract.');
109
+ }
110
+ }
111
+
112
+ function checkCommitShaScheme(ci, add) {
113
+ if (ci.includes('CI_COMMIT_SHORT_SHA')) {
114
+ add('R5', 'CI_COMMIT_SHORT_SHA used for image identity.\n'
115
+ + ' Infra tags with the full $CI_COMMIT_SHA; two lengths mean two schemes and the release manifest needs one.\n'
116
+ + ' Fix: use $CI_COMMIT_SHA.');
117
+ }
118
+ }
119
+
120
+ function checkNodeMajors(serviceRoot, ci, add) {
121
+ const pkgRaw = readIfExists(path.join(serviceRoot, 'package.json'));
122
+ if (pkgRaw === null) {
123
+ add('R3', 'package.json not found — the Node major cannot be checked.');
124
+ return;
125
+ }
126
+
127
+ let declared;
128
+ try {
129
+ declared = JSON.parse(pkgRaw).engines?.node;
130
+ } catch (err) {
131
+ add('R3', `package.json is not valid JSON - ${err.message}`);
132
+ return;
133
+ }
134
+ if (!declared) {
135
+ add('R3', 'package.json declares no engines.node.\n'
136
+ + ' Fix: declare the supported major explicitly, e.g. "engines": { "node": ">=22.0.0 <23" }; '
137
+ + 'it is the reference the CI image and Dockerfile are checked against.');
138
+ return;
139
+ }
140
+
141
+ const expected = nodeMajor(declared);
142
+
143
+ const dockerfile = readIfExists(path.join(serviceRoot, 'Dockerfile'));
144
+ if (dockerfile !== null) {
145
+ for (const line of dockerfile.split('\n')) {
146
+ if (!/^\s*FROM\s+node:/.test(line)) continue;
147
+ const major = nodeMajor(line.slice(line.indexOf('node:') + 'node:'.length));
148
+ if (major !== expected) {
149
+ add('R3', `Dockerfile ships Node ${major} but engines.node requires ${expected}: ${line.trim()}\n`
150
+ + ' Fix: align the base image with engines.node.');
151
+ }
152
+ }
153
+ }
154
+
155
+ for (const line of ci.split('\n')) {
156
+ if (!/^\s*image:\s*node:/.test(line)) continue;
157
+ const major = nodeMajor(line.slice(line.indexOf('node:') + 'node:'.length));
158
+ if (major !== expected) {
159
+ add('R3', `CI tests on Node ${major} but the service ships and declares ${expected}: ${line.trim()}\n`
160
+ + ' Testing on a different major than production runs means a passing pipeline proves nothing about the shipped runtime.\n'
161
+ + ` Fix: set the CI image to node:${expected}-alpine.`);
162
+ }
163
+ }
164
+ }
165
+
166
+ /** DB_HOST as declared in the service's env templates, or null. */
167
+ function declaredDbHost(serviceRoot) {
168
+ const templatesDir = path.join(serviceRoot, 'config', 'env-templates');
169
+ if (!fs.existsSync(templatesDir)) return null;
170
+
171
+ for (const entry of fs.readdirSync(templatesDir).sort()) {
172
+ if (!entry.endsWith('.env')) continue;
173
+ const content = readIfExists(path.join(templatesDir, entry));
174
+ if (content === null) continue;
175
+ const match = /^DB_HOST=(.*)$/m.exec(content);
176
+ if (match) return unquote(match[1]);
177
+ }
178
+ return null;
179
+ }
180
+
181
+ function engineFamilyOfHost(host) {
182
+ const lower = (host ?? '').toLowerCase();
183
+ if (lower.includes('maria')) return 'mariadb';
184
+ if (lower.includes('mysql')) return 'mysql';
185
+ return null;
186
+ }
187
+
188
+ /** Database service images the CI file declares, e.g. {"mariadb:10.5"}. */
189
+ function ciEngineImages(ci) {
190
+ const engines = new Set();
191
+ const pattern = /\b(mariadb|mysql):[0-9][0-9.]*/g;
192
+ let hit;
193
+ while ((hit = pattern.exec(ci)) !== null) engines.add(hit[0]);
194
+ return engines;
195
+ }
196
+
197
+ /**
198
+ * ADR 0006 §1 — the CI engine must be the one the service actually talks to.
199
+ *
200
+ * Two sources, in order of authority:
201
+ *
202
+ * database.engine in integration-contract.json declares engine AND version,
203
+ * so CI must run exactly that image. This is the target state (F1/F4).
204
+ *
205
+ * DB_HOST in config/env-templates/ only implies a family, which is all that
206
+ * can be inferred from a hostname. It governs repos that have not declared
207
+ * yet, and it is not a fallback for a declared one — where both exist they
208
+ * must agree, and disagreement is itself reported.
209
+ */
210
+ function checkDatabaseEngine(serviceRoot, ci, add) {
211
+ const host = declaredDbHost(serviceRoot);
212
+ const hostFamily = engineFamilyOfHost(host);
213
+
214
+ const contractRaw = readIfExists(path.join(serviceRoot, 'config', 'service', 'integration-contract.json'));
215
+ let declaredEngine = null;
216
+ if (contractRaw !== null) {
217
+ try {
218
+ declaredEngine = JSON.parse(contractRaw).database?.engine ?? null;
219
+ } catch {
220
+ // A contract that cannot be parsed must not silently disable the check;
221
+ // the engine falls back to nothing and the host-based path still applies.
222
+ add('R7', 'config/service/integration-contract.json is not valid JSON — the declared database engine cannot be read.\n'
223
+ + ' Fix: repair the contract; an unreadable declaration must never leave a check silently disabled.');
224
+ }
225
+ }
226
+
227
+ const images = ciEngineImages(ci);
228
+
229
+ if (declaredEngine) {
230
+ const declaredFamily = declaredEngine.split(':')[0];
231
+
232
+ if (hostFamily && hostFamily !== declaredFamily) {
233
+ add('R7', `Contradictory declaration: the contract declares ${declaredEngine} but DB_HOST is "${host}" (a ${hostFamily} host).\n`
234
+ + ' Two statements about the same fact must agree; the contract does not overrule the environment the service connects to.\n'
235
+ + ' Fix: correct database.engine in the integration contract, or DB_HOST in config/env-templates/.');
236
+ }
237
+
238
+ if (images.size === 0) {
239
+ add('R7', `The contract declares ${declaredEngine} but .gitlab-ci.yml declares no database service.\n`
240
+ + ' The schema cannot be built, so the integration suite would run against nothing.\n'
241
+ + ` Fix: add a service block with image ${declaredEngine}.`);
242
+ return;
243
+ }
244
+
245
+ for (const image of images) {
246
+ if (image !== declaredEngine) {
247
+ add('R7', `CI runs ${image} but the contract declares ${declaredEngine}.\n`
248
+ + ' The declaration pins the version as well as the vendor: a schema built on one version is not proven by a run on another.\n'
249
+ + ` Fix: set the CI service image to ${declaredEngine}, or correct database.engine in the integration contract.`);
250
+ }
251
+ }
252
+ return;
253
+ }
254
+
255
+ // No declaration yet (pre-F4). A service with no database says nothing about
256
+ // engines, so stay silent; otherwise compare families only.
257
+ if (!hostFamily) return;
258
+
259
+ for (const image of images) {
260
+ if (!image.startsWith(`${hostFamily}:`)) {
261
+ add('R7', `CI runs ${image} but the service is configured against "${host}" (expects ${hostFamily}).\n`
262
+ + ' Testing on an engine the service never talks to proves nothing about its SQL: MariaDB-only syntax passes there and fails here, or the reverse.\n'
263
+ + ` Fix: set the CI service image to ${hostFamily}, matching DB_HOST in config/env-templates/.`);
264
+ }
265
+ }
266
+ }
267
+
268
+ // ── R8 — one source for tenant / workspace identity ────────────────────────
269
+ //
270
+ // Two incidents define this requirement.
271
+ //
272
+ // biz-property (and biz-converter) overrode TESTING_TENANT_ID in their own env
273
+ // template to point at the LIVE tenant, because that is where their cookbook
274
+ // fixtures happened to exist. The Tier-1 runner executes real handler dispatch
275
+ // at every service start, production included, so every restart wrote into real
276
+ // data — and did, verifiably.
277
+ //
278
+ // One layer down, 24 of biz-property's 26 integration tests carried their own
279
+ // `const TENANT = 100` and inserted and deleted under it. Identity that should
280
+ // arrive from ctx or from the platform env, frozen into code where nothing can
281
+ // redirect it.
282
+ //
283
+ // Scope is deliberate rather than maximal:
284
+ //
285
+ // src/, scripts/ no literal — production code and operational
286
+ // scripts take identity from ctx / arguments / env.
287
+ // tests/integration/ must take the namespace from the repo's shared
288
+ // test-namespace module. A positive rule, because the
289
+ // literal hides behind any name a test invents
290
+ // (`const T = 100`) and pattern-hunting loses.
291
+ // config/env-templates/ only shared.env may name the runner namespace.
292
+ // tests/unit/ exempt — pure in-memory mocks reach no database,
293
+ // so a literal there is not a safety boundary, and
294
+ // banning it would only teach people to obfuscate it.
295
+ // migrations/, docs/ exempt — seed data legitimately names its tenant.
296
+
297
+ const NAMESPACE_ENV_KEYS = ['TESTING_TENANT_ID', 'TESTING_WORKSPACE_ID'];
298
+
299
+ /** `tenant_id: 100`, `workspace_id = 1` — an identity frozen to a number. */
300
+ const LITERAL_NAMESPACE = /\b(tenant_id|workspace_id)\s*(?::|={1,3})\s*\d+/;
301
+ /** `const TENANT_ID = 100`, `let testWorkspaceId = 1` — the same thing, named. */
302
+ const LITERAL_NAMESPACE_CONST =
303
+ /\b(?:const|let|var)\s+[A-Za-z_$][A-Za-z0-9_$]*(?:tenant|workspace)[A-Za-z0-9_$]*\s*=\s*\d+/i;
304
+
305
+ const SCANNED_EXTENSIONS = new Set(['.js', '.mjs', '.cjs', '.json']);
306
+ const SKIPPED_DIRECTORIES = new Set(['node_modules', '.git', 'coverage', 'dist', 'build']);
307
+
308
+ /** True for the repo's shared test-namespace module, whatever it is named. */
309
+ function isNamespaceModule(relativePath) {
310
+ return /(^|\/)(test-?namespace)[^/]*\.js$/i.test(relativePath);
311
+ }
312
+
313
+ /**
314
+ * True when the file actually CALLS the shared helper, on a line that executes.
315
+ *
316
+ * The permit used to be a whole-file text match, so a comment saying "TODO:
317
+ * switch to getTestNamespace()" excused the file from R8 entirely — a rule that
318
+ * a mention satisfies checks nothing (`automation-gates.md` §5). Nothing in any
319
+ * biz repo relied on the loose form: the two files that contain the word are the
320
+ * repo-local namespace modules, and those are handled above and never reach here.
321
+ */
322
+ function callsNamespaceHelper(text) {
323
+ return text
324
+ .split('\n')
325
+ .some((line) => !isCommentLine(line) && /\bgetTestNamespace\s*\(/.test(line));
326
+ }
327
+
328
+ /** A literal inside a comment is prose — it cannot reach a database. */
329
+ function isCommentLine(line) {
330
+ const trimmed = line.trim();
331
+ return trimmed.startsWith('//') || trimmed.startsWith('*') || trimmed.startsWith('/*');
332
+ }
333
+
334
+ function walkFiles(dir, base, out) {
335
+ let entries;
336
+ try {
337
+ entries = fs.readdirSync(dir, { withFileTypes: true });
338
+ } catch {
339
+ return out;
340
+ }
341
+ for (const entry of entries) {
342
+ if (SKIPPED_DIRECTORIES.has(entry.name)) continue;
343
+ const full = path.join(dir, entry.name);
344
+ if (entry.isDirectory()) {
345
+ walkFiles(full, base, out);
346
+ } else if (SCANNED_EXTENSIONS.has(path.extname(entry.name))) {
347
+ out.push({ full, relative: path.relative(base, full).split(path.sep).join('/') });
348
+ }
349
+ }
350
+ return out;
351
+ }
352
+
353
+ /** Only shared.env may name the runner namespace; a per-service override is the incident. */
354
+ function checkNamespaceEnvOverride(serviceRoot, add) {
355
+ const templates = path.join(serviceRoot, 'config', 'env-templates');
356
+ let entries;
357
+ try {
358
+ entries = fs.readdirSync(templates);
359
+ } catch {
360
+ return;
361
+ }
362
+ for (const name of entries) {
363
+ if (!name.endsWith('.env') || name === 'shared.env') continue;
364
+ const text = readIfExists(path.join(templates, name));
365
+ if (text === null) continue;
366
+ for (const key of NAMESPACE_ENV_KEYS) {
367
+ const match = new RegExp(`^\\s*${key}\\s*=(.*)$`, 'm').exec(text);
368
+ if (!match) continue;
369
+ add('R8', `config/env-templates/${name} overrides ${key}=${match[1].trim()}.\n`
370
+ + ' The Tier-1 cookbook runner fires at every service start, production\n'
371
+ + ' included, and writes into whatever namespace this names. Pointing it\n'
372
+ + ' at a workspace that holds real data means every restart writes there.\n'
373
+ + ` Fix: delete the override and let shared.env's platform default apply;\n`
374
+ + ' seed the runner namespace from migrations/ instead.');
375
+ }
376
+ }
377
+ }
378
+
379
+ /** No literal identity in code that can reach a real database. */
380
+ function checkNamespaceLiterals(serviceRoot, add) {
381
+ for (const dir of ['src', 'scripts']) {
382
+ for (const file of walkFiles(path.join(serviceRoot, dir), serviceRoot, [])) {
383
+ const text = readIfExists(file.full);
384
+ if (text === null) continue;
385
+ const lines = text.split('\n');
386
+ for (let i = 0; i < lines.length; i += 1) {
387
+ if (isCommentLine(lines[i])) continue;
388
+ if (!LITERAL_NAMESPACE.test(lines[i]) && !LITERAL_NAMESPACE_CONST.test(lines[i])) continue;
389
+ add('R8', `${file.relative}:${i + 1} freezes a tenant / workspace id into code:\n`
390
+ + ` ${lines[i].trim()}\n`
391
+ + ' Fix: take it from ctx (handlers), from a CLI argument (scripts),\n'
392
+ + ' or from the platform env — never from a literal.');
393
+ break; // one report per file is enough to act on
394
+ }
395
+ }
396
+ }
397
+ }
398
+
399
+ /** Integration tests reach a real database, so their namespace has one owner. */
400
+ function checkIntegrationNamespaceSource(serviceRoot, add) {
401
+ const root = path.join(serviceRoot, 'tests', 'integration');
402
+ for (const file of walkFiles(root, serviceRoot, [])) {
403
+ const text = readIfExists(file.full);
404
+ if (text === null) continue;
405
+
406
+ // The namespace module is the one place allowed to name the namespace — so
407
+ // it is also the one place that must not freeze it. It reads the platform
408
+ // env; otherwise the single source is merely a single hardcoding.
409
+ if (isNamespaceModule(file.relative)) {
410
+ const lines = text.split('\n');
411
+ for (let i = 0; i < lines.length; i += 1) {
412
+ if (isCommentLine(lines[i])) continue;
413
+ if (!LITERAL_NAMESPACE.test(lines[i]) && !LITERAL_NAMESPACE_CONST.test(lines[i])) continue;
414
+ add('R8', `${file.relative}:${i + 1} freezes the test namespace into a literal:\n`
415
+ + ` ${lines[i].trim()}\n`
416
+ + ' This module exists so the namespace has one source; that source must\n'
417
+ + ' be the platform env, not a number typed here.\n'
418
+ + ' Fix: read TESTING_* from process.env and fail fast when it is absent.');
419
+ break;
420
+ }
421
+ continue;
422
+ }
423
+
424
+ if (!/tenant_id|workspace_id/i.test(text)) continue;
425
+ // Either the shared helper from conn-orch-validator, or a repo-local module
426
+ // that itself reads the platform env (checked above).
427
+ if (callsNamespaceHelper(text)) continue;
428
+ if (/require\([^)]*test-?namespace[^)]*\)/i.test(text)) continue;
429
+ add('R8', `${file.relative} names a tenant / workspace without taking it from the\n`
430
+ + ' shared test-namespace module.\n'
431
+ + ' An integration test writes into a real database, so the namespace it\n'
432
+ + ' targets is a safety boundary — and a boundary rewritten per file is a\n'
433
+ + ' boundary that drifts onto a live tenant.\n'
434
+ + ' Fix: require the repo\'s tests/integration/testNamespace.js and use it.');
435
+ }
436
+ }
437
+
438
+ /**
439
+ * Verify one biz repository against R1-R8.
440
+ *
441
+ * @param {string} serviceRoot absolute path to the biz repo
442
+ * @returns {{service: string, ok: boolean, violations: Array<{requirement: string, message: string}>}}
443
+ */
444
+ function verifyDeployContract(serviceRoot) {
445
+ if (!serviceRoot || typeof serviceRoot !== 'string') {
446
+ throw new Error('[DeployContract] Missing serviceRoot - Expected the path to a biz repository. '
447
+ + 'Fix: pass --service-root <path> (defaults to the current working directory).');
448
+ }
449
+ if (!fs.existsSync(serviceRoot)) {
450
+ throw new Error(`[DeployContract] Service root not found: ${serviceRoot}`);
451
+ }
452
+
453
+ const violations = [];
454
+ const add = (requirement, message) => violations.push({ requirement, message });
455
+
456
+ const compose = readIfExists(path.join(serviceRoot, 'docker-compose.production.yml'));
457
+ if (compose === null) {
458
+ add('R1', 'docker-compose.production.yml not found — R1 and R4 cannot be checked.\n'
459
+ + ' Fix: this file is part of the deployable contract; restore it.');
460
+ } else {
461
+ checkImagePins(compose, add);
462
+ checkPublishedPorts(compose, add);
463
+ }
464
+
465
+ const ci = readIfExists(path.join(serviceRoot, '.gitlab-ci.yml'));
466
+ if (ci === null) {
467
+ add('R2', '.gitlab-ci.yml not found — R2, R3, R5 and R7 cannot be checked.\n'
468
+ + ' Fix: this file is part of the deployable contract; restore it.');
469
+ } else {
470
+ checkDeploySequence(ci, add);
471
+ checkCommitShaScheme(ci, add);
472
+ checkNodeMajors(serviceRoot, ci, add);
473
+ checkDatabaseEngine(serviceRoot, ci, add);
474
+ }
475
+
476
+ // R8 reads the repository tree directly — it depends on neither compose nor CI.
477
+ checkNamespaceEnvOverride(serviceRoot, add);
478
+ checkNamespaceLiterals(serviceRoot, add);
479
+ checkIntegrationNamespaceSource(serviceRoot, add);
480
+
481
+ return {
482
+ service: path.basename(path.resolve(serviceRoot)),
483
+ ok: violations.length === 0,
484
+ violations
485
+ };
486
+ }
487
+
488
+ module.exports = { verifyDeployContract };