@atlastodo/client-core 0.1.11-rc.8 → 0.1.11-rc.9
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/dist/fieldPolicy.d.ts +4 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.js +120 -10
- package/dist/sealedPersistence.d.ts +39 -0
- package/package.json +1 -1
- package/test-vectors/field_policy_vectors.json +5 -5
package/dist/fieldPolicy.d.ts
CHANGED
|
@@ -18,6 +18,8 @@ export interface TokenPolicy {
|
|
|
18
18
|
max_lifetime_days: number | null;
|
|
19
19
|
/** Whether a token may write when its creator does not say. */
|
|
20
20
|
write_by_default: boolean;
|
|
21
|
+
/** A token including the app may include no other app (the server refuses the mix). */
|
|
22
|
+
exclusive: boolean;
|
|
21
23
|
}
|
|
22
24
|
/** A bundled app manifest: its id, version, the entity kinds it syncs and its token policy. */
|
|
23
25
|
export interface AppManifest {
|
|
@@ -39,7 +41,8 @@ export declare const BUNDLED_MANIFESTS: readonly AppManifest[];
|
|
|
39
41
|
/**
|
|
40
42
|
* The policy of a token for all of `apps` (bundled ids), the strictest of theirs as the server
|
|
41
43
|
* takes it: an expiry required if any requires one, the shortest lifetime, and read-only by
|
|
42
|
-
* default if any is. A client making a token reads it to ask for an expiry
|
|
44
|
+
* default if any is, and exclusive if any is. A client making a token reads it to ask for an expiry
|
|
45
|
+
* and write access, and to offer an exclusive app's token on its own.
|
|
43
46
|
*/
|
|
44
47
|
export declare function tokenPolicyFor(apps: readonly string[]): TokenPolicy;
|
|
45
48
|
/** The bundled app owning the kind with this wire name, if any. */
|
package/dist/index.d.ts
CHANGED
|
@@ -15,6 +15,7 @@ export * from "./trust.js";
|
|
|
15
15
|
export * from "./store.js";
|
|
16
16
|
export * from "./opLogFile.js";
|
|
17
17
|
export * from "./persistence.js";
|
|
18
|
+
export * from "./sealedPersistence.js";
|
|
18
19
|
export * from "./attachments.js";
|
|
19
20
|
export * from "./indexeddb.js";
|
|
20
21
|
export * from "./expo-sqlite.js";
|
package/dist/index.js
CHANGED
|
@@ -1478,10 +1478,10 @@ var todo_default = {
|
|
|
1478
1478
|
};
|
|
1479
1479
|
// ../../manifests/vault.json
|
|
1480
1480
|
var vault_default = {
|
|
1481
|
-
_doc: "The Atlas Vault app manifest: a password manager, end-to-end encrypted, held to a stricter threat model than todo. Its kinds are namespaced `vault.<kind>`. Vault v1 is personal: the server never fans its ops out to another account and reads none of their fields, so every field value is encrypted (no plaintext_fields), including the item type, its folder and its timestamps. `vault.item` is one item (login, note, card, identity, ssh_key, api_key: name, typed fields, urls, notes, tags, folder, favorite, secret field history), `vault.folder` one folder, `vault.preference` one of the app's settings. `api_tokens` is the policy for API tokens that include the app, which the server enforces: an expiry is required, at most `max_lifetime_days` after creation,
|
|
1481
|
+
_doc: "The Atlas Vault app manifest: a password manager, end-to-end encrypted, held to a stricter threat model than todo. Its kinds are namespaced `vault.<kind>`. Vault v1 is personal: the server never fans its ops out to another account and reads none of their fields, so every field value is encrypted (no plaintext_fields), including the item type, its folder and its timestamps. `vault.item` is one item (login, note, card, identity, ssh_key, api_key: name, typed fields, urls, notes, tags, folder, favorite, secret field history), `vault.folder` one folder, `vault.preference` one of the app's settings. `api_tokens` is the policy for API tokens that include the app, which the server enforces: an expiry is required, at most `max_lifetime_days` after creation, a token is read-only unless its creator opts in to writes, and it is for the vault alone (`exclusive`: no other app's key travels with a vault secret). Bump `version` whenever the kinds, their plaintext fields or the token policy change.",
|
|
1482
1482
|
app: "vault",
|
|
1483
1483
|
name: "Atlas Vault",
|
|
1484
|
-
version:
|
|
1484
|
+
version: 2,
|
|
1485
1485
|
kinds: {
|
|
1486
1486
|
"vault.item": { plaintext_fields: [] },
|
|
1487
1487
|
"vault.folder": { plaintext_fields: [] },
|
|
@@ -1490,7 +1490,8 @@ var vault_default = {
|
|
|
1490
1490
|
api_tokens: {
|
|
1491
1491
|
expiry_required: true,
|
|
1492
1492
|
max_lifetime_days: 365,
|
|
1493
|
-
write_by_default: false
|
|
1493
|
+
write_by_default: false,
|
|
1494
|
+
exclusive: true
|
|
1494
1495
|
}
|
|
1495
1496
|
};
|
|
1496
1497
|
|
|
@@ -1504,7 +1505,8 @@ function tokenPolicyFor(apps) {
|
|
|
1504
1505
|
const policy = {
|
|
1505
1506
|
expiry_required: false,
|
|
1506
1507
|
max_lifetime_days: null,
|
|
1507
|
-
write_by_default: true
|
|
1508
|
+
write_by_default: true,
|
|
1509
|
+
exclusive: false
|
|
1508
1510
|
};
|
|
1509
1511
|
for (const app of apps) {
|
|
1510
1512
|
const p = BUNDLED_MANIFESTS.find((m) => m.app === app)?.api_tokens;
|
|
@@ -1517,6 +1519,8 @@ function tokenPolicyFor(apps) {
|
|
|
1517
1519
|
}
|
|
1518
1520
|
if (p.write_by_default === false)
|
|
1519
1521
|
policy.write_by_default = false;
|
|
1522
|
+
if (p.exclusive)
|
|
1523
|
+
policy.exclusive = true;
|
|
1520
1524
|
}
|
|
1521
1525
|
return policy;
|
|
1522
1526
|
}
|
|
@@ -4699,8 +4703,110 @@ function rowToOp(r) {
|
|
|
4699
4703
|
value: r.value_json === null ? undefined : JSON.parse(r.value_json)
|
|
4700
4704
|
};
|
|
4701
4705
|
}
|
|
4702
|
-
// src/
|
|
4706
|
+
// src/sealedPersistence.ts
|
|
4707
|
+
import { hkdf as hkdf7 } from "@noble/hashes/hkdf.js";
|
|
4703
4708
|
import { sha256 as sha2568 } from "@noble/hashes/sha2.js";
|
|
4709
|
+
var LOCAL_STORE_KEY_ID = "local-v1";
|
|
4710
|
+
var SCOPE = { kind: "personal" };
|
|
4711
|
+
function deriveLocalStoreKey(appKey, userId) {
|
|
4712
|
+
if (appKey.length !== 32)
|
|
4713
|
+
throw new Error("the app key must be 32 bytes");
|
|
4714
|
+
return hkdf7(sha2568, appKey, utf8ToBytes(userId), utf8ToBytes("atlas-local-store-v1"), 32);
|
|
4715
|
+
}
|
|
4716
|
+
|
|
4717
|
+
class SealedStoreError extends Error {
|
|
4718
|
+
constructor(message) {
|
|
4719
|
+
super(message);
|
|
4720
|
+
this.name = "SealedStoreError";
|
|
4721
|
+
}
|
|
4722
|
+
}
|
|
4723
|
+
function seal(key, entity, entityId, field, value) {
|
|
4724
|
+
return encryptField(key, LOCAL_STORE_KEY_ID, SCOPE, { entity, entityId, field }, value);
|
|
4725
|
+
}
|
|
4726
|
+
function open(key, entity, entityId, field, stored) {
|
|
4727
|
+
if (!isFieldEnvelopeV2(stored) || stored.kid !== LOCAL_STORE_KEY_ID) {
|
|
4728
|
+
throw new SealedStoreError(`a stored value of ${entity}.${field} is not sealed`);
|
|
4729
|
+
}
|
|
4730
|
+
try {
|
|
4731
|
+
return decryptField(key, SCOPE, { entity, entityId, field }, stored);
|
|
4732
|
+
} catch {
|
|
4733
|
+
throw new SealedStoreError(`a stored value of ${entity}.${field} does not open with this key`);
|
|
4734
|
+
}
|
|
4735
|
+
}
|
|
4736
|
+
function sealOp(key, op) {
|
|
4737
|
+
if (op.op !== "set")
|
|
4738
|
+
return op;
|
|
4739
|
+
return { ...op, value: seal(key, op.entity, op.entityId, op.field, op.value) };
|
|
4740
|
+
}
|
|
4741
|
+
function openOp(key, op) {
|
|
4742
|
+
if (op.op !== "set")
|
|
4743
|
+
return op;
|
|
4744
|
+
return { ...op, value: open(key, op.entity, op.entityId, op.field, op.value) };
|
|
4745
|
+
}
|
|
4746
|
+
function sealRepairs(key, repairs) {
|
|
4747
|
+
return repairs.map((r) => ({ ...r, value: seal(key, r.entity, r.entityId, r.field, r.value) }));
|
|
4748
|
+
}
|
|
4749
|
+
function openRepairs(key, repairs) {
|
|
4750
|
+
return repairs.map((r) => ({ ...r, value: open(key, r.entity, r.entityId, r.field, r.value) }));
|
|
4751
|
+
}
|
|
4752
|
+
function sealPersistence(inner, key) {
|
|
4753
|
+
if (key.length !== 32)
|
|
4754
|
+
throw new Error("the local store key must be 32 bytes");
|
|
4755
|
+
const sealed = {
|
|
4756
|
+
load: async () => (await inner.load()).map((p) => ({ ...p, op: openOp(key, p.op) })),
|
|
4757
|
+
append: (op, synced) => inner.append(sealOp(key, op), synced),
|
|
4758
|
+
markSynced: (opIds) => inner.markSynced(opIds),
|
|
4759
|
+
getCursor: () => inner.getCursor(),
|
|
4760
|
+
setCursor: (cursor) => inner.setCursor(cursor)
|
|
4761
|
+
};
|
|
4762
|
+
if (inner.appendBatch) {
|
|
4763
|
+
const appendBatch = inner.appendBatch.bind(inner);
|
|
4764
|
+
sealed.appendBatch = (ops, synced) => appendBatch(ops.map((op) => sealOp(key, op)), synced);
|
|
4765
|
+
}
|
|
4766
|
+
if (inner.getBootstrap) {
|
|
4767
|
+
const getBootstrap = inner.getBootstrap.bind(inner);
|
|
4768
|
+
sealed.getBootstrap = async () => {
|
|
4769
|
+
const progress = await getBootstrap();
|
|
4770
|
+
if (!progress?.repairs)
|
|
4771
|
+
return progress;
|
|
4772
|
+
return { ...progress, repairs: openRepairs(key, progress.repairs) };
|
|
4773
|
+
};
|
|
4774
|
+
}
|
|
4775
|
+
if (inner.setBootstrap) {
|
|
4776
|
+
const setBootstrap = inner.setBootstrap.bind(inner);
|
|
4777
|
+
sealed.setBootstrap = (progress, page) => setBootstrap(progress && progress.repairs ? { ...progress, repairs: sealRepairs(key, progress.repairs) } : progress, page ? { ...page, repairs: sealRepairs(key, page.repairs) } : page);
|
|
4778
|
+
}
|
|
4779
|
+
if (inner.commitSyncBatch) {
|
|
4780
|
+
const commit = inner.commitSyncBatch.bind(inner);
|
|
4781
|
+
sealed.commitSyncBatch = (ops, cursor) => commit(ops.map((op) => sealOp(key, op)), cursor);
|
|
4782
|
+
}
|
|
4783
|
+
if (inner.restamp) {
|
|
4784
|
+
const restamp = inner.restamp.bind(inner);
|
|
4785
|
+
sealed.restamp = (ops) => restamp(ops.map((op) => sealOp(key, op)));
|
|
4786
|
+
}
|
|
4787
|
+
const passThrough = [
|
|
4788
|
+
"clear",
|
|
4789
|
+
"clearSynced",
|
|
4790
|
+
"compact",
|
|
4791
|
+
"deleteOps",
|
|
4792
|
+
"close",
|
|
4793
|
+
"loadAttachmentQueue",
|
|
4794
|
+
"putAttachmentUpload",
|
|
4795
|
+
"deleteAttachmentUpload",
|
|
4796
|
+
"listAttachmentQueue",
|
|
4797
|
+
"loadAttachmentCiphertext",
|
|
4798
|
+
"updateAttachmentUpload"
|
|
4799
|
+
];
|
|
4800
|
+
for (const name of passThrough) {
|
|
4801
|
+
const fn = inner[name];
|
|
4802
|
+
if (typeof fn === "function") {
|
|
4803
|
+
sealed[name] = fn.bind(inner);
|
|
4804
|
+
}
|
|
4805
|
+
}
|
|
4806
|
+
return sealed;
|
|
4807
|
+
}
|
|
4808
|
+
// src/attachments.ts
|
|
4809
|
+
import { sha256 as sha2569 } from "@noble/hashes/sha2.js";
|
|
4704
4810
|
var DEFAULT_MAX_BLOB_BYTES = 25 * 1024 * 1024;
|
|
4705
4811
|
function maxAttachmentBytes(maxBlobBytes = DEFAULT_MAX_BLOB_BYTES) {
|
|
4706
4812
|
return maxPlainSize(maxBlobBytes);
|
|
@@ -4718,7 +4824,7 @@ function generateAek() {
|
|
|
4718
4824
|
return randomBytes(32);
|
|
4719
4825
|
}
|
|
4720
4826
|
function sha256Hex(bytes) {
|
|
4721
|
-
return bytesToHex(
|
|
4827
|
+
return bytesToHex(sha2569(bytes));
|
|
4722
4828
|
}
|
|
4723
4829
|
function bytesSource(bytes) {
|
|
4724
4830
|
return {
|
|
@@ -4777,8 +4883,8 @@ async function sealSource(aek, source, opts = {}) {
|
|
|
4777
4883
|
const expected = sealedBlobSize(total);
|
|
4778
4884
|
const sink = opts.asBlob ? new BlobSink : new BufferSink(expected);
|
|
4779
4885
|
const sealer = new BlobSealer(aek);
|
|
4780
|
-
const blobHash =
|
|
4781
|
-
const plainHash =
|
|
4886
|
+
const blobHash = sha2569.create();
|
|
4887
|
+
const plainHash = sha2569.create();
|
|
4782
4888
|
let size = 0;
|
|
4783
4889
|
const emit = (bytes) => {
|
|
4784
4890
|
blobHash.update(bytes);
|
|
@@ -4841,7 +4947,7 @@ class PlainCollector {
|
|
|
4841
4947
|
out;
|
|
4842
4948
|
parts = [];
|
|
4843
4949
|
length = 0;
|
|
4844
|
-
hash =
|
|
4950
|
+
hash = sha2569.create();
|
|
4845
4951
|
constructor(blobSize) {
|
|
4846
4952
|
this.out = blobSize !== null && blobSize <= MAX_PREALLOCATION ? new Uint8Array(blobSize) : null;
|
|
4847
4953
|
}
|
|
@@ -4919,7 +5025,7 @@ async function fetchBlob(transport, sha, aek, plainSha) {
|
|
|
4919
5025
|
const download = body instanceof Uint8Array ? { size: body.length, chunks: [body] } : body;
|
|
4920
5026
|
const opener = new BlobOpener(aek);
|
|
4921
5027
|
const plain = new PlainCollector(download.size);
|
|
4922
|
-
const received =
|
|
5028
|
+
const received = sha2569.create();
|
|
4923
5029
|
for await (const piece of download.chunks) {
|
|
4924
5030
|
received.update(piece);
|
|
4925
5031
|
for (const chunk of opener.push(piece))
|
|
@@ -6690,6 +6796,7 @@ export {
|
|
|
6690
6796
|
Keyring,
|
|
6691
6797
|
LEGACY_DISCOVERY,
|
|
6692
6798
|
LEGACY_KDF,
|
|
6799
|
+
LOCAL_STORE_KEY_ID,
|
|
6693
6800
|
LocalStore,
|
|
6694
6801
|
MAX_CLOCK_DRIFT_MS,
|
|
6695
6802
|
MAX_COUNTER,
|
|
@@ -6716,6 +6823,7 @@ export {
|
|
|
6716
6823
|
SNAPSHOT_PAGE_KEYS,
|
|
6717
6824
|
SYNC_APPS_HEADER,
|
|
6718
6825
|
SYNC_PROTOCOL,
|
|
6826
|
+
SealedStoreError,
|
|
6719
6827
|
ServerIncompatibleError,
|
|
6720
6828
|
SqlitePersistence,
|
|
6721
6829
|
SyncClient,
|
|
@@ -6765,6 +6873,7 @@ export {
|
|
|
6765
6873
|
deriveApiTokenKeys,
|
|
6766
6874
|
deriveAuthAndMek,
|
|
6767
6875
|
deriveAuthAndMekAsync,
|
|
6876
|
+
deriveLocalStoreKey,
|
|
6768
6877
|
derivePasskeySlotKey,
|
|
6769
6878
|
deriveRecoveryAuthKeypair,
|
|
6770
6879
|
deriveRecoveryKey,
|
|
@@ -6866,6 +6975,7 @@ export {
|
|
|
6866
6975
|
sameKeyScope,
|
|
6867
6976
|
sealDelivery,
|
|
6868
6977
|
sealKey,
|
|
6978
|
+
sealPersistence,
|
|
6869
6979
|
sealRootKey,
|
|
6870
6980
|
sealSource,
|
|
6871
6981
|
sealedBlobSize,
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import type { Persistence } from "./persistence.js";
|
|
2
|
+
/**
|
|
3
|
+
* A {@link Persistence} that keeps every op's value encrypted at rest.
|
|
4
|
+
*
|
|
5
|
+
* The local op log holds what the store merged: values already decrypted from the wire. For most
|
|
6
|
+
* apps that is the device's own business, but a client that locks (a password manager) must not
|
|
7
|
+
* leave its data readable on disk while it is locked. `sealPersistence(inner, key)` wraps any
|
|
8
|
+
* backend (IndexedDB, SQLite, memory) so that each `set` op's value, and each bootstrap repair's,
|
|
9
|
+
* is stored as a field envelope under `key` instead; loading opens them again. Everything else
|
|
10
|
+
* (op ids, entities, entity ids, field names, clocks, the cursor) is stored as before: the same
|
|
11
|
+
* metadata the server sees.
|
|
12
|
+
*
|
|
13
|
+
* The envelope is the wire's v2 field envelope (AES-256-GCM, padded, associated data naming the
|
|
14
|
+
* entity, field and scope) with its own key id ({@link LOCAL_STORE_KEY_ID}), so a value sealed
|
|
15
|
+
* here can neither be opened at another field nor pass for a wire envelope. Use a key of its own
|
|
16
|
+
* ({@link deriveLocalStoreKey}), not the app key itself.
|
|
17
|
+
*
|
|
18
|
+
* Every stored value must be sealed: a backend written without the wrapper, or under another key,
|
|
19
|
+
* fails to load with {@link SealedStoreError} rather than half-load (start over with `clear()`).
|
|
20
|
+
* Attachment queue rows pass through unchanged (their blob is already ciphertext; their metadata
|
|
21
|
+
* is not sealed): a client that needs them sealed must not queue attachments through this.
|
|
22
|
+
*/
|
|
23
|
+
/** The key id of a value sealed at rest; never one the wire uses. */
|
|
24
|
+
export declare const LOCAL_STORE_KEY_ID = "local-v1";
|
|
25
|
+
/**
|
|
26
|
+
* The at-rest key for a user's local store, derived from an app key the client holds only while
|
|
27
|
+
* unlocked (HKDF-SHA256, salted with the user id, info `atlas-local-store-v1`).
|
|
28
|
+
*/
|
|
29
|
+
export declare function deriveLocalStoreKey(appKey: Uint8Array, userId: string): Uint8Array;
|
|
30
|
+
/** A stored value that does not open under this key (another key, tampering, or never sealed). */
|
|
31
|
+
export declare class SealedStoreError extends Error {
|
|
32
|
+
constructor(message: string);
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* `inner` with every value sealed under `key` (32 bytes). The wrapper offers exactly the optional
|
|
36
|
+
* methods `inner` does, so a store's behaviour (batching, restamping, bootstrap resume) is the
|
|
37
|
+
* inner backend's. Keep a reference to `key` only while the client is unlocked.
|
|
38
|
+
*/
|
|
39
|
+
export declare function sealPersistence(inner: Persistence, key: Uint8Array): Persistence;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@atlastodo/client-core",
|
|
3
|
-
"version": "0.1.11-rc.
|
|
3
|
+
"version": "0.1.11-rc.9",
|
|
4
4
|
"description": "Atlas core client: hybrid logical clocks, the op log and per-field LWW merge, end-to-end encryption, the local store and the sync client",
|
|
5
5
|
"license": "AGPL-3.0-only",
|
|
6
6
|
"homepage": "https://github.com/atlastodo/atlas/tree/main/packages/client-core",
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
{
|
|
2
|
-
"_doc": "What the bundled app manifests (manifests/*.json) decide, asserted by packages/client-core/src/fieldPolicy.test.ts and crates/atlas-client (field_policy.rs). `fields`: whether a value of `entity`.`field` leaves the device end-to-end encrypted (`encrypted`); a kind no manifest names, and any field a manifest does not list, is encrypted. Todo keeps only the ids the server routes and checks access by in plaintext; journal and vault keep nothing, so the server sees no vault item's type, name, folder, URL, timestamp or secret. `token_policies`: the API token policy of a token for `apps`, the strictest of their manifests' `api_tokens` (`max_lifetime_days` null is no limit).",
|
|
2
|
+
"_doc": "What the bundled app manifests (manifests/*.json) decide, asserted by packages/client-core/src/fieldPolicy.test.ts and crates/atlas-client (field_policy.rs). `fields`: whether a value of `entity`.`field` leaves the device end-to-end encrypted (`encrypted`); a kind no manifest names, and any field a manifest does not list, is encrypted. Todo keeps only the ids the server routes and checks access by in plaintext; journal and vault keep nothing, so the server sees no vault item's type, name, folder, URL, timestamp or secret. `token_policies`: the API token policy of a token for `apps`, the strictest of their manifests' `api_tokens` (`max_lifetime_days` null is no limit; `exclusive`: the server refuses a token naming such an app with any other).",
|
|
3
3
|
"fields": [
|
|
4
4
|
{ "entity": "task", "field": "title", "encrypted": true },
|
|
5
5
|
{ "entity": "task", "field": "project_id", "encrypted": false },
|
|
@@ -34,19 +34,19 @@
|
|
|
34
34
|
"token_policies": [
|
|
35
35
|
{
|
|
36
36
|
"apps": ["todo"],
|
|
37
|
-
"policy": { "expiry_required": false, "max_lifetime_days": null, "write_by_default": true }
|
|
37
|
+
"policy": { "expiry_required": false, "max_lifetime_days": null, "write_by_default": true, "exclusive": false }
|
|
38
38
|
},
|
|
39
39
|
{
|
|
40
40
|
"apps": ["journal"],
|
|
41
|
-
"policy": { "expiry_required": false, "max_lifetime_days": null, "write_by_default": true }
|
|
41
|
+
"policy": { "expiry_required": false, "max_lifetime_days": null, "write_by_default": true, "exclusive": false }
|
|
42
42
|
},
|
|
43
43
|
{
|
|
44
44
|
"apps": ["vault"],
|
|
45
|
-
"policy": { "expiry_required": true, "max_lifetime_days": 365, "write_by_default": false }
|
|
45
|
+
"policy": { "expiry_required": true, "max_lifetime_days": 365, "write_by_default": false, "exclusive": true }
|
|
46
46
|
},
|
|
47
47
|
{
|
|
48
48
|
"apps": ["todo", "journal", "vault"],
|
|
49
|
-
"policy": { "expiry_required": true, "max_lifetime_days": 365, "write_by_default": false }
|
|
49
|
+
"policy": { "expiry_required": true, "max_lifetime_days": 365, "write_by_default": false, "exclusive": true }
|
|
50
50
|
}
|
|
51
51
|
]
|
|
52
52
|
}
|