@xemahq/xema 0.29.1 → 0.31.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 +60 -389
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +1 -1
- package/dist/cli.js.map +1 -1
- package/dist/commands/edition.js +3 -3
- package/dist/commands/edition.js.map +1 -1
- package/dist/dev/infra-preflight.js +1 -1
- package/dist/dev/infra-preflight.js.map +1 -1
- package/dist/edition/verify-image-evidence.bundle.mjs +10030 -1491
- package/dist/edition/verify-image-evidence.bundle.provenance.json +953 -419
- package/dist/runtime/runner/helm-cli.d.ts.map +1 -1
- package/dist/runtime/runner/helm-cli.js.map +1 -1
- package/dist/runtime/store-submit.d.ts.map +1 -1
- package/dist/runtime/store-submit.js.map +1 -1
- package/edition/README.md +50 -271
- package/edition/adapter-bundled-versions.mjs +107 -0
- package/edition/adapter-host-versions.mjs +41 -0
- package/edition/assemble-producer-input.mjs +1 -1
- package/edition/authorization-base.mjs +18 -9
- package/edition/biome-content.mjs +6 -0
- package/edition/build-platform-service-index.mjs +2 -0
- package/edition/build-runtime-image-source-index.mjs +97 -9
- package/edition/catalogue.mjs +20 -13
- package/edition/checkout-indexed-source-repositories.mjs +41 -6
- package/edition/customer-definition.mjs +3 -20
- package/edition/develop-customer-edition.mjs +2 -2
- package/edition/develop-edition-resolve.mjs +10 -2
- package/edition/develop-edition.mjs +56 -62
- package/edition/develop-freeze.mjs +14 -3
- package/edition/dispatched-runtime-components.mjs +82 -0
- package/edition/dist-check.mjs +1 -0
- package/edition/distribution-evidence-set.mjs +32 -5
- package/edition/edition-channel.mjs +15 -1
- package/edition/edition-locks.mjs +16 -6
- package/edition/edition-protocol.mjs +30 -8
- package/edition/edition-workspace.mjs +8 -0
- package/edition/external-source-walk.mjs +1 -1
- package/edition/image-evidence.mjs +134 -26
- package/edition/kernel-capability-domain.mjs +30 -0
- package/edition/kernel-package.mjs +193 -0
- package/edition/lib.mjs +192 -4
- package/edition/oci-evidence-verbs.mjs +2 -2
- package/edition/operation-grant-admission.mjs +70 -0
- package/edition/operation-requirements.mjs +128 -0
- package/edition/plan-image-evidence-refresh.mjs +2 -2
- package/edition/platform-lock-readability.mjs +2 -2
- package/edition/platform-service-class.mjs +42 -0
- package/edition/platform-service-ownership.mjs +78 -0
- package/edition/produce-distribution-completeness-evidence.mjs +5 -0
- package/edition/produce-image-delivery-entitlement-evidence.mjs +11 -1
- package/edition/release-catalogue.mjs +10 -3
- package/edition/remedy-command.mjs +2 -2
- package/edition/resolve-chart-set.mjs +1 -1
- package/edition/resolve-customer-edition.mjs +33 -34
- package/edition/resolve-distribution.mjs +70 -16
- package/edition/resolve-edition.mjs +3 -2
- package/edition/resolve-images.mjs +65 -4
- package/edition/seal-image-digests.mjs +17 -5
- package/edition/trust-policy-arming-verb.mjs +2 -10
- package/edition/verify-image-evidence.mjs +1 -0
- package/examples/README.md +3 -2
- package/package.json +4 -4
|
@@ -36,6 +36,7 @@ import { execFileSync } from 'node:child_process';
|
|
|
36
36
|
import { writeFileSync } from 'node:fs';
|
|
37
37
|
import { join, relative, sep } from 'node:path';
|
|
38
38
|
|
|
39
|
+
import { resolveDispatchedRuntimeComponents } from './dispatched-runtime-components.mjs';
|
|
39
40
|
import {
|
|
40
41
|
assertCanonicalDistributionId,
|
|
41
42
|
distributionOutputPath,
|
|
@@ -281,7 +282,7 @@ export function resolveDistributionRequiredPlatforms(schemas, id) {
|
|
|
281
282
|
* the fully-qualified `imageRef`. It cannot travel in the lock to reach the
|
|
282
283
|
* resolver — `DistributionLockSchema` is `.strict()` and learned that key only
|
|
283
284
|
* in `@xemahq/contracts` 0.10.0, so a pinned image resolving `^0.9.x` refuses
|
|
284
|
-
* the whole lock at boot. Persisting it took
|
|
285
|
+
* the whole lock at boot. Persisting it took a customer environment's three lock
|
|
285
286
|
* consumers into CrashLoopBackOff on 2026-09-17.
|
|
286
287
|
*
|
|
287
288
|
* So the producer reads its OWN declaration, and `requiredPlatforms` one
|
|
@@ -396,8 +397,11 @@ function missingArtifactDependencyMessage(lock, serviceName, binding, biomeIndex
|
|
|
396
397
|
* refuse. What DOES refuse is a SELECTED service whose declared artifact
|
|
397
398
|
* dependency is absent from the closure (see `missingArtifactDependencyMessage`).
|
|
398
399
|
*
|
|
399
|
-
* `options.biomeIndex` is
|
|
400
|
-
* biome declares
|
|
400
|
+
* `options.biomeIndex` is the first-party biome index. It names, in a refusal,
|
|
401
|
+
* which biome declares a missing artifact, and it carries each rostered biome's
|
|
402
|
+
* `dispatchesRuntimeComponents`: the runtime components that biome launches,
|
|
403
|
+
* whose images enter this roster as runtime-component rows although their
|
|
404
|
+
* biomes are not rostered (see `dispatched-runtime-components.mjs`).
|
|
401
405
|
*/
|
|
402
406
|
export function resolveImageStructure(
|
|
403
407
|
lock,
|
|
@@ -414,6 +418,12 @@ export function resolveImageStructure(
|
|
|
414
418
|
`and there is no default; a silent default is the bug this replaced`,
|
|
415
419
|
);
|
|
416
420
|
}
|
|
421
|
+
if (!Array.isArray(biomeIndex)) {
|
|
422
|
+
throw new Error(
|
|
423
|
+
`distribution "${lock.distributionId}" resolved its images without the biome index — the index carries ` +
|
|
424
|
+
'the runtime components a rostered biome dispatches, so a roster without it can omit images',
|
|
425
|
+
);
|
|
426
|
+
}
|
|
417
427
|
const subjects = new Map();
|
|
418
428
|
const webBiomes = [];
|
|
419
429
|
const platformServiceBindings = [];
|
|
@@ -505,7 +515,7 @@ export function resolveImageStructure(
|
|
|
505
515
|
// is `.strict()` and learned the key only in 0.10.0.
|
|
506
516
|
//
|
|
507
517
|
// Removing it from the lock WITHOUT moving this read is what composed
|
|
508
|
-
// `<customer-registry>/pai-core-api` instead of `<customer-registry
|
|
518
|
+
// `<customer-registry>/pai-core-api` instead of `<customer-registry>/<customer>-pai-core-api`:
|
|
509
519
|
// the `?? ''` swallowed the absence, so an unqualified name in a shared
|
|
510
520
|
// organisation namespace resolved silently. That is worse than a 404 —
|
|
511
521
|
// the first collision overwrites another product's tags.
|
|
@@ -604,6 +614,57 @@ export function resolveImageStructure(
|
|
|
604
614
|
}
|
|
605
615
|
}
|
|
606
616
|
|
|
617
|
+
// Runtime components a rostered biome DISPATCHES enter the roster as
|
|
618
|
+
// runtime-component rows built from the TARGET biome's index entry, at its
|
|
619
|
+
// repository pin, exactly like a rostered biome's own runtime component. The
|
|
620
|
+
// owner is the rostered biome that dispatches it: the selection that brings
|
|
621
|
+
// the image into this edition. The target biome stays unrostered.
|
|
622
|
+
//
|
|
623
|
+
// A rostered biome's edges are read from ITS index entry, which must describe
|
|
624
|
+
// the very biome the lock pins; one from a declared source is refused at
|
|
625
|
+
// distribution resolve if it dispatches, and the first-party index carries
|
|
626
|
+
// none, so it is not looked up here.
|
|
627
|
+
const dispatchers = lock.biomes
|
|
628
|
+
.filter((biome) => biome.source === undefined && biome.target === 'server')
|
|
629
|
+
.map((biome) => {
|
|
630
|
+
const entries = biomeIndex.filter(
|
|
631
|
+
(entry) =>
|
|
632
|
+
entry.id === biome.id &&
|
|
633
|
+
entry.target === biome.target &&
|
|
634
|
+
entry.repo === biome.repo &&
|
|
635
|
+
entry.source === undefined,
|
|
636
|
+
);
|
|
637
|
+
if (entries.length !== 1 || entries[0].sourceManifestHash !== biome.sourceManifestHash) {
|
|
638
|
+
throw new Error(
|
|
639
|
+
`distribution "${lock.distributionId}" biome "${biome.id}" (${biome.repo}, ${biome.sourceManifestHash}) ` +
|
|
640
|
+
`has ${entries.length === 1 ? 'a stale' : `${entries.length}`} biome index ${entries.length === 1 ? 'entry' : 'entries'}, ` +
|
|
641
|
+
'so the runtime components it dispatches cannot be read — re-resolve the edition from the current index',
|
|
642
|
+
);
|
|
643
|
+
}
|
|
644
|
+
return entries[0];
|
|
645
|
+
});
|
|
646
|
+
for (const dispatched of resolveDispatchedRuntimeComponents(
|
|
647
|
+
`distribution "${lock.distributionId}"`,
|
|
648
|
+
dispatchers,
|
|
649
|
+
biomeIndex,
|
|
650
|
+
)) {
|
|
651
|
+
const { target } = dispatched;
|
|
652
|
+
addSubject(subjects, {
|
|
653
|
+
kind: enums.RuntimeImageSubjectKind.RuntimeComponent,
|
|
654
|
+
id: dispatched.image,
|
|
655
|
+
imageRef: imageRefForSubject(dispatched.image, firstPartyImageRegistry(), ''),
|
|
656
|
+
repo: target.repo,
|
|
657
|
+
path: target.path,
|
|
658
|
+
sourceCommit: requireRepositoryPin(target.repo).sourceCommit,
|
|
659
|
+
sourceManifestHash: target.sourceManifestHash,
|
|
660
|
+
requiredPlatforms,
|
|
661
|
+
owners: dispatched.consumers.map((biomeId) => ({
|
|
662
|
+
kind: enums.RuntimeImageOwnerKind.Biome,
|
|
663
|
+
biomeId,
|
|
664
|
+
})),
|
|
665
|
+
});
|
|
666
|
+
}
|
|
667
|
+
|
|
607
668
|
const runtimeBindings = new Map(
|
|
608
669
|
runtimeSourceIndex.platformServiceBindings.map((binding) => [
|
|
609
670
|
binding.platformService,
|
|
@@ -112,18 +112,20 @@
|
|
|
112
112
|
import { readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs';
|
|
113
113
|
import { join } from 'node:path';
|
|
114
114
|
|
|
115
|
+
import { adapterHostRow, assertAdapterBundlesMatchHost, fetchRegistryManifest, lockMountsAdapters } from './adapter-bundled-versions.mjs';
|
|
115
116
|
import { sealLock, verifyBiomeContentHashes } from './biome-content.mjs';
|
|
116
117
|
import { fetchCatalogue } from './catalogue-fetch.mjs';
|
|
117
118
|
import { readCataloguePin } from './catalogue-pin.mjs';
|
|
118
119
|
import { CATALOGUE_TOKEN_ENV_FLAG, CATALOGUE_USERNAME_FLAG, DEFINITION_FLAG } from './customer-flags.mjs';
|
|
119
120
|
import { EditionChannel } from './edition-constants.mjs';
|
|
120
|
-
import { WorkspaceRoot, configureEditionWorkspaceFromProcess, editionsRoot, isEntryModule, runtimeImageSourceIndexPath } from './edition-workspace.mjs';
|
|
121
|
+
import { WorkspaceRoot, biomeIndexPath, configureEditionWorkspaceFromProcess, editionsRoot, isEntryModule, runtimeImageSourceIndexPath } from './edition-workspace.mjs';
|
|
121
122
|
import {
|
|
122
123
|
assertCoordinatesFinal,
|
|
123
124
|
collectFleetPlatformRequirements,
|
|
124
125
|
isNotFinalYet,
|
|
125
126
|
readFleetImageLocks,
|
|
126
127
|
} from './fleet-coordinate-finality.mjs';
|
|
128
|
+
import { adoptCatalogueKernel } from './kernel-package.mjs';
|
|
127
129
|
import { loadKernelDistributionSchemas, serializeJson } from './lib.mjs';
|
|
128
130
|
import { assertPlatformCanParseLock } from './platform-lock-readability.mjs';
|
|
129
131
|
import { assertReleaseEditionTree } from './release-channel-guard.mjs';
|
|
@@ -138,7 +140,8 @@ import {
|
|
|
138
140
|
if (isEntryModule(import.meta.url)) {
|
|
139
141
|
// The customer form (it names --definition) reads exactly these roots - the data root (the vendor's
|
|
140
142
|
// runtime-image-source-index.json), the editions root, the sources root (the readability check of the
|
|
141
|
-
// vendor repositories)
|
|
143
|
+
// vendor repositories) and the cache (external source trees, and the catalogue's kernel from npm) - and
|
|
144
|
+
// refuses any other, the kernel root included. A vendor seal takes the kernel root too.
|
|
142
145
|
let namesDefinition;
|
|
143
146
|
try {
|
|
144
147
|
namesDefinition = parseSealArgs(process.argv.slice(2)).catalogueFlags.has(DEFINITION_FLAG);
|
|
@@ -147,7 +150,9 @@ if (isEntryModule(import.meta.url)) {
|
|
|
147
150
|
process.exit(1);
|
|
148
151
|
}
|
|
149
152
|
configureEditionWorkspaceFromProcess(
|
|
150
|
-
|
|
153
|
+
namesDefinition
|
|
154
|
+
? [WorkspaceRoot.Data, WorkspaceRoot.Editions, WorkspaceRoot.Sources, WorkspaceRoot.Cache]
|
|
155
|
+
: [WorkspaceRoot.Data, WorkspaceRoot.Editions, WorkspaceRoot.Sources, WorkspaceRoot.Kernel, WorkspaceRoot.Cache],
|
|
151
156
|
{ refuseOthers: namesDefinition },
|
|
152
157
|
);
|
|
153
158
|
}
|
|
@@ -479,7 +484,7 @@ function replaceLocksTogether(documents) {
|
|
|
479
484
|
* can leave (images sealed, distribution unsealed) is refused by every reader of
|
|
480
485
|
* a sealed edition.
|
|
481
486
|
*/
|
|
482
|
-
export async function sealEditionLocks({ distributionId, root, digests = new Map(), check = false, label = 'dist:seal-digests', vendoredCommit, catalogue, catalogueDigest }) {
|
|
487
|
+
export async function sealEditionLocks({ distributionId, root, digests = new Map(), check = false, label = 'dist:seal-digests', vendoredCommit, catalogue, catalogueDigest, fetchManifest = fetchRegistryManifest }) {
|
|
483
488
|
const schemas = await loadKernelDistributionSchemas();
|
|
484
489
|
const lockPath = join(root, distributionId, 'distribution.lock.json');
|
|
485
490
|
const imagesPath = join(root, distributionId, 'images.lock.json');
|
|
@@ -505,6 +510,11 @@ export async function sealEditionLocks({ distributionId, root, digests = new Map
|
|
|
505
510
|
const imageLock = schemas.parseImageLock(rawImages);
|
|
506
511
|
const pinned = vendoredCommit ?? ((repo) => createPinnedSourceAuthority({ pins: committedRepositoryPins() }).vendoredSourceCommitForRepository(repo));
|
|
507
512
|
const lockDocument = raw !== null && typeof raw === 'object' && 'unsealedSchema' in raw ? schemas.parseUnsealedDistributionLock(raw) : schemas.parseDistributionLock(raw);
|
|
513
|
+
if (lockMountsAdapters(lockDocument)) {
|
|
514
|
+
// The adapter host's row: the verified catalogue's for a customer, the data root's biome index for the vendor.
|
|
515
|
+
const rows = catalogue === undefined ? JSON.parse(readFileSync(biomeIndexPath(), 'utf8')) : catalogue.biomes;
|
|
516
|
+
await assertAdapterBundlesMatchHost({ lock: lockDocument, hostRow: adapterHostRow(rows), fetchManifest });
|
|
517
|
+
}
|
|
508
518
|
const result = await sealOrVerifyLock({
|
|
509
519
|
schemas,
|
|
510
520
|
lock: lockDocument,
|
|
@@ -657,6 +667,8 @@ async function main() {
|
|
|
657
667
|
: undefined;
|
|
658
668
|
const catalogue = fetched?.catalogue;
|
|
659
669
|
const catalogueDigest = fetched?.digest;
|
|
670
|
+
// A customer seal reads the kernel of its catalogue (from npm), proved its contract authority, before any kernel schema is loaded.
|
|
671
|
+
if (catalogue !== undefined) {await adoptCatalogueKernel(catalogue);}
|
|
660
672
|
const imageLock = await loadImageLockForVerification(distributionId, root);
|
|
661
673
|
|
|
662
674
|
// Throws on ANY unresolvable subject or missing required platform, before a
|
|
@@ -691,7 +703,7 @@ async function main() {
|
|
|
691
703
|
// `DistributionLockSchema` is `.strict()` — so a key the plane knows and a
|
|
692
704
|
// pinned repository does not makes the whole artifact unreadable AT BOOT.
|
|
693
705
|
// That is not hypothetical: on 2026-09-17 `imageNamePrefix` did exactly this
|
|
694
|
-
// to three
|
|
706
|
+
// to three a customer environment services at once, and the edition had sealed,
|
|
695
707
|
// published and installed perfectly first.
|
|
696
708
|
//
|
|
697
709
|
// It is asked of every pinned repository that SHIPS AN IMAGE INTO THIS
|
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
// usage error. A data root that declares no policy is `unarmed`.
|
|
10
10
|
// It reads the policy the way `xema edition check` does and never writes.
|
|
11
11
|
|
|
12
|
-
import { EditionChannel } from './edition-channel.mjs';
|
|
12
|
+
import { EditionChannel, parseChannelArgs } from './edition-channel.mjs';
|
|
13
13
|
import {
|
|
14
14
|
WorkspaceRoot,
|
|
15
15
|
configureEditionWorkspaceFromArgv,
|
|
@@ -23,14 +23,6 @@ export const ARMING_USAGE = `usage: xema edition arming --channel <${Object.valu
|
|
|
23
23
|
/** The process exit codes of the verb. */
|
|
24
24
|
export const ArmingExit = Object.freeze({ Armed: 0, NotArmed: 1, Usage: 2 });
|
|
25
25
|
|
|
26
|
-
/** The channel of `--channel <channel>` among `rest`; anything else is a usage error. */
|
|
27
|
-
export function parseArmingArgs(rest) {
|
|
28
|
-
if (rest.length !== 2 || rest[0] !== '--channel' || !Object.values(EditionChannel).includes(rest[1])) {
|
|
29
|
-
throw new Error(`--channel <${Object.values(EditionChannel).join('|')}> is required and nothing else is accepted (got ${rest.length === 0 ? 'nothing' : JSON.stringify(rest.join(' '))})`);
|
|
30
|
-
}
|
|
31
|
-
return { channel: rest[1] };
|
|
32
|
-
}
|
|
33
|
-
|
|
34
26
|
/**
|
|
35
27
|
* Judge the configured data root's policy on `channel`, writing the one JSON
|
|
36
28
|
* line to `stdout` and each unmet entry to `stderr`. Returns the exit code.
|
|
@@ -54,7 +46,7 @@ async function main() {
|
|
|
54
46
|
let channel;
|
|
55
47
|
try {
|
|
56
48
|
const rest = configureEditionWorkspaceFromArgv(process.argv.slice(2), [WorkspaceRoot.Data, WorkspaceRoot.Kernel]);
|
|
57
|
-
({ channel } =
|
|
49
|
+
({ channel } = parseChannelArgs(rest));
|
|
58
50
|
} catch (error) {
|
|
59
51
|
process.stderr.write(`arming: ${error.message}\n${ARMING_USAGE}\n`);
|
|
60
52
|
process.exitCode = ArmingExit.Usage;
|
|
@@ -397,6 +397,7 @@ export async function verifyEvidence({ argv, env, bundlePath }) {
|
|
|
397
397
|
completenessStatementDigest: result.completenessStatementDigest,
|
|
398
398
|
customerId: options.customerId,
|
|
399
399
|
distributionId: options.distributionId,
|
|
400
|
+
distributionLock: result.distributionLock,
|
|
400
401
|
channel,
|
|
401
402
|
mode: options.mode,
|
|
402
403
|
targetRegistry: options.targetRegistry,
|
package/examples/README.md
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
# `xema.config.yaml` examples
|
|
2
2
|
|
|
3
|
+
|
|
3
4
|
Copy one of these to `xema.config.yaml` (or `~/.xema/config.yaml`) and edit:
|
|
4
5
|
|
|
5
6
|
```bash
|
|
@@ -14,6 +15,6 @@ xema up
|
|
|
14
15
|
| [`03-byo-infra.yaml`](./03-byo-infra.yaml) | Bring-your-own Postgres / Keycloak / object store; launcher runs only the rest. |
|
|
15
16
|
| [`04-subset-knowledge.yaml`](./04-subset-knowledge.yaml) | Run only a slice (tiers / services) to save resources. |
|
|
16
17
|
| [`05-appliance-helm.yaml`](./05-appliance-helm.yaml) | Deploy to Kubernetes / an appliance via Helm. |
|
|
18
|
+
| [`06-biome-sources.yaml`](./06-biome-sources.yaml) | Pull biomes from workspace, distribution and git sources. |
|
|
17
19
|
|
|
18
|
-
|
|
19
|
-
built-in defaults. Every value shown is the default unless noted.
|
|
20
|
+
Config precedence: `--config <path>`, then `./xema.config.yaml`, then `~/.xema/config.yaml`, then built-in defaults.
|
package/package.json
CHANGED
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@xemahq/xema",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.31.0",
|
|
4
4
|
"repository": {
|
|
5
5
|
"type": "git",
|
|
6
6
|
"url": "git+https://github.com/xema-dev/xema-cli.git",
|
|
7
7
|
"directory": "apps/xema-cli"
|
|
8
8
|
},
|
|
9
9
|
"license": "Apache-2.0",
|
|
10
|
-
"description": "Xema
|
|
10
|
+
"description": "Xema global CLI. Single binary day-0 onboarding: `npm i -g @xemahq/xema && xema dev` boots the whole stack; `xema biome scaffold` / `install` / `publish` / `lint` author and ship biomes; `xema run <capability>` invokes through the local capability-router. Multi-platform: works without pnpm or any monorepo clone.",
|
|
11
11
|
"type": "commonjs",
|
|
12
12
|
"main": "dist/cli.js",
|
|
13
13
|
"exports": {
|
|
@@ -54,7 +54,7 @@
|
|
|
54
54
|
"@xemahq/distribution-source-hash": "^0.3.0",
|
|
55
55
|
"@xemahq/dsl": "^0.23.0",
|
|
56
56
|
"@xemahq/identity-client": "^0.16.0",
|
|
57
|
-
"@xemahq/contracts": "^0.
|
|
57
|
+
"@xemahq/contracts": "^0.80.0",
|
|
58
58
|
"@xemahq/kernel-state-runtime": "^0.8.0",
|
|
59
59
|
"@xemahq/license-verifier": "^0.2.0",
|
|
60
60
|
"@xemahq/service-registry-nest": "^0.12.2",
|
|
@@ -92,6 +92,6 @@
|
|
|
92
92
|
"typecheck": "tsc --noEmit"
|
|
93
93
|
},
|
|
94
94
|
"xemaRelease": {
|
|
95
|
-
"sourceDigest": "sha256:
|
|
95
|
+
"sourceDigest": "sha256:2fc7a76e728752cf14eeb410921fb5c89cbffc9c3c398116a3c37dcd740587ea"
|
|
96
96
|
}
|
|
97
97
|
}
|