@openpalm/lib 0.10.1 → 0.11.0-beta.1
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 +2 -2
- package/package.json +7 -3
- package/src/control-plane/admin-token.ts +73 -0
- package/src/control-plane/akm-vault.test.ts +108 -0
- package/src/control-plane/akm-vault.ts +307 -0
- package/src/control-plane/audit.ts +3 -2
- package/src/control-plane/channels.ts +3 -3
- package/src/control-plane/cleanup-guardrails.test.ts +8 -9
- package/src/control-plane/compose-args.test.ts +25 -21
- package/src/control-plane/config-persistence.ts +103 -64
- package/src/control-plane/core-assets.test.ts +104 -0
- package/src/control-plane/core-assets.ts +54 -57
- package/src/control-plane/docker.ts +55 -21
- package/src/control-plane/env.test.ts +25 -1
- package/src/control-plane/env.ts +80 -0
- package/src/control-plane/home.ts +66 -69
- package/src/control-plane/host-opencode.test.ts +263 -0
- package/src/control-plane/host-opencode.ts +229 -0
- package/src/control-plane/install-edge-cases.test.ts +182 -244
- package/src/control-plane/install-lock.ts +157 -0
- package/src/control-plane/lifecycle.ts +57 -56
- package/src/control-plane/markdown-task.ts +200 -0
- package/src/control-plane/paths.ts +75 -0
- package/src/control-plane/provider-config.ts +2 -2
- package/src/control-plane/provider-models.ts +154 -0
- package/src/control-plane/registry-components.test.ts +102 -25
- package/src/control-plane/registry.test.ts +49 -47
- package/src/control-plane/registry.ts +71 -50
- package/src/control-plane/rollback.ts +17 -16
- package/src/control-plane/scheduler.ts +75 -262
- package/src/control-plane/secret-backend.test.ts +98 -108
- package/src/control-plane/secret-backend.ts +221 -181
- package/src/control-plane/secret-mappings.ts +3 -6
- package/src/control-plane/secrets.ts +83 -47
- package/src/control-plane/setup-config.schema.json +2 -14
- package/src/control-plane/setup-status.ts +4 -29
- package/src/control-plane/setup-validation.ts +21 -21
- package/src/control-plane/setup.test.ts +122 -227
- package/src/control-plane/setup.ts +224 -125
- package/src/control-plane/skeleton-guardrail.test.ts +151 -0
- package/src/control-plane/spec-to-env.test.ts +59 -58
- package/src/control-plane/spec-to-env.ts +39 -140
- package/src/control-plane/spec-validator.ts +2 -99
- package/src/control-plane/stack-spec.test.ts +21 -77
- package/src/control-plane/stack-spec.ts +7 -83
- package/src/control-plane/types.ts +17 -15
- package/src/control-plane/ui-assets.ts +349 -0
- package/src/control-plane/validate.ts +44 -79
- package/src/index.ts +77 -44
- package/src/logger.test.ts +228 -0
- package/src/logger.ts +71 -1
- package/src/provider-constants.ts +22 -1
- package/src/control-plane/env-schema-validation.test.ts +0 -118
- package/src/control-plane/memory-config.ts +0 -298
- package/src/control-plane/redact-schema.ts +0 -50
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/** Secrets and capability key management. */
|
|
2
|
-
import { mkdirSync, writeFileSync, readFileSync, existsSync, chmodSync, lstatSync, rmSync } from "node:fs";
|
|
2
|
+
import { mkdirSync, writeFileSync, readFileSync, existsSync, chmodSync, lstatSync, rmSync, renameSync } from "node:fs";
|
|
3
3
|
import { randomBytes } from "node:crypto";
|
|
4
4
|
import { createLogger } from "../logger.js";
|
|
5
5
|
import { parseEnvFile, mergeEnvContent } from './env.js';
|
|
@@ -54,19 +54,16 @@ function mergeVaultEnvFile(path: string, updates: Record<string, string>, uncomm
|
|
|
54
54
|
}
|
|
55
55
|
|
|
56
56
|
function ensureSystemSecrets(state: ControlPlaneState): void {
|
|
57
|
-
const systemEnvPath = `${state.
|
|
57
|
+
const systemEnvPath = `${state.stackDir}/stack.env`;
|
|
58
58
|
const existing = existsSync(systemEnvPath) ? parseEnvFile(systemEnvPath) : {};
|
|
59
59
|
const updates: Record<string, string> = {};
|
|
60
60
|
|
|
61
|
-
if (!existing.
|
|
62
|
-
updates.
|
|
61
|
+
if (!existing.OP_UI_TOKEN && state.adminToken) {
|
|
62
|
+
updates.OP_UI_TOKEN = state.adminToken;
|
|
63
63
|
}
|
|
64
64
|
if (!existing.OP_ASSISTANT_TOKEN) {
|
|
65
65
|
updates.OP_ASSISTANT_TOKEN = randomBytes(32).toString("hex");
|
|
66
66
|
}
|
|
67
|
-
if (!existing.OP_MEMORY_TOKEN) {
|
|
68
|
-
updates.OP_MEMORY_TOKEN = randomBytes(32).toString("hex");
|
|
69
|
-
}
|
|
70
67
|
|
|
71
68
|
if (!existsSync(systemEnvPath)) {
|
|
72
69
|
const header = [
|
|
@@ -74,11 +71,10 @@ function ensureSystemSecrets(state: ControlPlaneState): void {
|
|
|
74
71
|
"# All secrets and configuration live here. Advanced users may edit directly.",
|
|
75
72
|
"",
|
|
76
73
|
"# ── Authentication ──────────────────────────────────────────────────",
|
|
77
|
-
"
|
|
74
|
+
"OP_UI_TOKEN=",
|
|
78
75
|
"OP_ASSISTANT_TOKEN=",
|
|
79
76
|
"",
|
|
80
77
|
"# ── Service Auth ─────────────────────────────────────────────────────",
|
|
81
|
-
"OP_MEMORY_TOKEN=",
|
|
82
78
|
"OP_OPENCODE_PASSWORD=",
|
|
83
79
|
"",
|
|
84
80
|
"# ── Provider API Keys ────────────────────────────────────────────────",
|
|
@@ -88,7 +84,6 @@ function ensureSystemSecrets(state: ControlPlaneState): void {
|
|
|
88
84
|
"GROQ_API_KEY=",
|
|
89
85
|
"MISTRAL_API_KEY=",
|
|
90
86
|
"GOOGLE_API_KEY=",
|
|
91
|
-
"OPENVIKING_API_KEY=",
|
|
92
87
|
"MCP_API_KEY=",
|
|
93
88
|
"EMBEDDING_API_KEY=",
|
|
94
89
|
"LMSTUDIO_API_KEY=",
|
|
@@ -107,37 +102,21 @@ function ensureSystemSecrets(state: ControlPlaneState): void {
|
|
|
107
102
|
}
|
|
108
103
|
|
|
109
104
|
export function ensureSecrets(state: ControlPlaneState): void {
|
|
110
|
-
enforceVaultDirMode(state.
|
|
111
|
-
mkdirSync(`${state.vaultDir}/stack`, { recursive: true, mode: VAULT_DIR_MODE });
|
|
112
|
-
mkdirSync(`${state.vaultDir}/user`, { recursive: true, mode: VAULT_DIR_MODE });
|
|
113
|
-
|
|
114
|
-
// user.env is an empty placeholder — users can add custom vars here.
|
|
115
|
-
// All standard config lives in stack.env.
|
|
116
|
-
const userEnvPath = `${state.vaultDir}/user/user.env`;
|
|
117
|
-
if (!existsSync(userEnvPath)) {
|
|
118
|
-
writeVaultFile(userEnvPath, [
|
|
119
|
-
"# OpenPalm — User Extensions",
|
|
120
|
-
"# Add any custom environment variables here.",
|
|
121
|
-
"# These are loaded by compose alongside stack.env.",
|
|
122
|
-
"",
|
|
123
|
-
].join("\n"));
|
|
124
|
-
} else {
|
|
125
|
-
try { chmodSync(userEnvPath, VAULT_FILE_MODE); } catch { /* best-effort */ }
|
|
126
|
-
}
|
|
105
|
+
enforceVaultDirMode(state.stackDir);
|
|
127
106
|
|
|
128
107
|
ensureSystemSecrets(state);
|
|
129
|
-
ensureGuardianEnv(state.
|
|
130
|
-
ensureAuthJson(state.
|
|
108
|
+
ensureGuardianEnv(state.stackDir);
|
|
109
|
+
ensureAuthJson(state.configDir);
|
|
131
110
|
}
|
|
132
111
|
|
|
133
112
|
/**
|
|
134
|
-
* Ensure
|
|
113
|
+
* Ensure config/stack/guardian.env exists.
|
|
135
114
|
* Channel HMAC secrets (CHANNEL_<NAME>_SECRET) live here exclusively.
|
|
136
115
|
* This file is loaded by the guardian as an env_file and via GUARDIAN_SECRETS_PATH.
|
|
137
116
|
*/
|
|
138
|
-
function ensureGuardianEnv(
|
|
139
|
-
const guardianEnvPath = `${
|
|
140
|
-
mkdirSync(
|
|
117
|
+
function ensureGuardianEnv(stackDir: string): void {
|
|
118
|
+
const guardianEnvPath = `${stackDir}/guardian.env`;
|
|
119
|
+
mkdirSync(stackDir, { recursive: true, mode: VAULT_DIR_MODE });
|
|
141
120
|
if (!existsSync(guardianEnvPath)) {
|
|
142
121
|
writeVaultFile(guardianEnvPath, [
|
|
143
122
|
"# Guardian channel HMAC secrets — managed by openpalm",
|
|
@@ -149,9 +128,9 @@ function ensureGuardianEnv(vaultDir: string): void {
|
|
|
149
128
|
}
|
|
150
129
|
}
|
|
151
130
|
|
|
152
|
-
function ensureAuthJson(
|
|
153
|
-
const authJsonPath = `${
|
|
154
|
-
mkdirSync(
|
|
131
|
+
function ensureAuthJson(configDir: string): void {
|
|
132
|
+
const authJsonPath = `${configDir}/auth.json`;
|
|
133
|
+
mkdirSync(configDir, { recursive: true, mode: VAULT_DIR_MODE });
|
|
155
134
|
|
|
156
135
|
if (existsSync(authJsonPath)) {
|
|
157
136
|
try {
|
|
@@ -177,25 +156,82 @@ export function updateSecretsEnv(
|
|
|
177
156
|
state: ControlPlaneState,
|
|
178
157
|
updates: Record<string, string>
|
|
179
158
|
): void {
|
|
180
|
-
const stackEnvPath = `${state.
|
|
159
|
+
const stackEnvPath = `${state.stackDir}/stack.env`;
|
|
181
160
|
if (!existsSync(stackEnvPath)) {
|
|
182
|
-
throw new Error("
|
|
161
|
+
throw new Error("config/stack/stack.env does not exist — run setup first");
|
|
183
162
|
}
|
|
184
163
|
|
|
185
164
|
mergeVaultEnvFile(stackEnvPath, updates, true);
|
|
186
165
|
}
|
|
187
166
|
|
|
188
|
-
/**
|
|
189
|
-
|
|
190
|
-
|
|
167
|
+
/**
|
|
168
|
+
* Merge-write provider API keys into OpenCode's auth.json at
|
|
169
|
+
* `${configDir}/auth.json`. Each entry uses OpenCode's schema for
|
|
170
|
+
* api-key auth: `{ <providerId>: { type: "api", key: "..." } }`.
|
|
171
|
+
*
|
|
172
|
+
* This file is bind-mounted into the assistant container so the chat
|
|
173
|
+
* assistant picks up new credentials on its next OpenCode restart —
|
|
174
|
+
* see core.compose.yml.
|
|
175
|
+
*
|
|
176
|
+
* Existing entries (OAuth tokens, other providers) are preserved.
|
|
177
|
+
* Empty values DELETE the corresponding entry.
|
|
178
|
+
*/
|
|
179
|
+
export function writeAuthJsonProviderKeys(
|
|
180
|
+
state: ControlPlaneState,
|
|
181
|
+
providerKeys: Record<string, string>
|
|
182
|
+
): void {
|
|
183
|
+
if (Object.keys(providerKeys).length === 0) return;
|
|
184
|
+
|
|
185
|
+
const authJsonPath = `${state.configDir}/auth.json`;
|
|
186
|
+
mkdirSync(state.configDir, { recursive: true, mode: VAULT_DIR_MODE });
|
|
187
|
+
|
|
188
|
+
let current: Record<string, unknown> = {};
|
|
189
|
+
if (existsSync(authJsonPath)) {
|
|
190
|
+
try {
|
|
191
|
+
const raw = readFileSync(authJsonPath, "utf-8").trim();
|
|
192
|
+
if (raw && raw !== "{}") current = JSON.parse(raw) as Record<string, unknown>;
|
|
193
|
+
} catch {
|
|
194
|
+
// Corrupt auth.json — rename it so the operator can recover, then start fresh.
|
|
195
|
+
const timestamp = new Date().toISOString().replace(/[:.]/g, "-");
|
|
196
|
+
const corruptPath = `${authJsonPath}.corrupt-${timestamp}`;
|
|
197
|
+
try {
|
|
198
|
+
renameSync(authJsonPath, corruptPath);
|
|
199
|
+
logger.warn("corrupt auth.json renamed for recovery", {
|
|
200
|
+
original: authJsonPath,
|
|
201
|
+
renamed: corruptPath,
|
|
202
|
+
});
|
|
203
|
+
} catch (renameErr) {
|
|
204
|
+
logger.warn("could not rename corrupt auth.json; starting fresh", {
|
|
205
|
+
path: authJsonPath,
|
|
206
|
+
error: renameErr instanceof Error ? renameErr.message : String(renameErr),
|
|
207
|
+
});
|
|
208
|
+
}
|
|
209
|
+
current = {};
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
for (const [providerId, key] of Object.entries(providerKeys)) {
|
|
214
|
+
if (key) {
|
|
215
|
+
current[providerId] = { type: "api", key };
|
|
216
|
+
} else {
|
|
217
|
+
delete current[providerId];
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
writeVaultFile(authJsonPath, JSON.stringify(current, null, 2) + "\n");
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/** Read and parse config/stack/stack.env. Returns {} if the file does not exist. */
|
|
225
|
+
export function readStackEnv(stackDir: string): Record<string, string> {
|
|
226
|
+
return parseEnvFile(`${stackDir}/stack.env`);
|
|
191
227
|
}
|
|
192
228
|
|
|
193
229
|
export function updateSystemSecretsEnv(
|
|
194
230
|
state: ControlPlaneState,
|
|
195
231
|
updates: Record<string, string>
|
|
196
232
|
): void {
|
|
197
|
-
const systemEnvPath = `${state.
|
|
198
|
-
enforceVaultDirMode(state.
|
|
233
|
+
const systemEnvPath = `${state.stackDir}/stack.env`;
|
|
234
|
+
enforceVaultDirMode(state.stackDir);
|
|
199
235
|
if (!existsSync(systemEnvPath)) {
|
|
200
236
|
ensureSystemSecrets(state);
|
|
201
237
|
}
|
|
@@ -203,14 +239,14 @@ export function updateSystemSecretsEnv(
|
|
|
203
239
|
}
|
|
204
240
|
|
|
205
241
|
export function patchSecretsEnvFile(
|
|
206
|
-
|
|
242
|
+
stackDir: string,
|
|
207
243
|
patches: Record<string, string>
|
|
208
244
|
): void {
|
|
209
245
|
if (Object.keys(patches).length === 0) return;
|
|
210
246
|
|
|
211
|
-
const stackEnvPath = `${
|
|
212
|
-
enforceVaultDirMode(
|
|
213
|
-
mkdirSync(
|
|
247
|
+
const stackEnvPath = `${stackDir}/stack.env`;
|
|
248
|
+
enforceVaultDirMode(stackDir);
|
|
249
|
+
mkdirSync(stackDir, { recursive: true, mode: VAULT_DIR_MODE });
|
|
214
250
|
|
|
215
251
|
let existingContent = "";
|
|
216
252
|
try {
|
|
@@ -51,18 +51,6 @@
|
|
|
51
51
|
"assignments": {
|
|
52
52
|
"$ref": "#/$defs/SetupConfigAssignments"
|
|
53
53
|
},
|
|
54
|
-
"memory": {
|
|
55
|
-
"type": "object",
|
|
56
|
-
"description": "Optional memory subsystem configuration.",
|
|
57
|
-
"additionalProperties": false,
|
|
58
|
-
"properties": {
|
|
59
|
-
"userId": {
|
|
60
|
-
"type": "string",
|
|
61
|
-
"pattern": "^[A-Za-z0-9_]+$",
|
|
62
|
-
"description": "User ID for the memory service. Alphanumeric and underscores only. Defaults to 'default_user' if omitted."
|
|
63
|
-
}
|
|
64
|
-
}
|
|
65
|
-
},
|
|
66
54
|
"channels": {
|
|
67
55
|
"type": "object",
|
|
68
56
|
"description": "Optional channel configurations. Keys are channel identifiers (e.g. 'chat', 'discord', 'api'). Values can be a boolean to enable (true) or skip (false) installation, or a credential object.",
|
|
@@ -162,13 +150,13 @@
|
|
|
162
150
|
},
|
|
163
151
|
"smallModel": {
|
|
164
152
|
"type": "string",
|
|
165
|
-
"description": "Optional smaller/faster model for lightweight
|
|
153
|
+
"description": "Optional smaller/faster model for lightweight akm operations."
|
|
166
154
|
}
|
|
167
155
|
}
|
|
168
156
|
},
|
|
169
157
|
"embeddings": {
|
|
170
158
|
"type": "object",
|
|
171
|
-
"description": "Embedding model assignment for
|
|
159
|
+
"description": "Embedding model assignment for akm and semantic operations.",
|
|
172
160
|
"required": ["capabilityId", "model"],
|
|
173
161
|
"additionalProperties": false,
|
|
174
162
|
"properties": {
|
|
@@ -1,38 +1,13 @@
|
|
|
1
|
-
import { userInfo } from "node:os";
|
|
2
1
|
import { parseEnvFile } from './env.js';
|
|
3
2
|
|
|
4
|
-
export function readSecretsKeys(vaultDir: string): Record<string, boolean> {
|
|
5
|
-
// System scope wins on overlap because vault/stack/stack.env is the
|
|
6
|
-
// authoritative source for system-managed credentials and flags.
|
|
7
|
-
const parsed = {
|
|
8
|
-
...parseEnvFile(`${vaultDir}/user/user.env`),
|
|
9
|
-
...parseEnvFile(`${vaultDir}/stack/stack.env`),
|
|
10
|
-
};
|
|
11
|
-
const result: Record<string, boolean> = {};
|
|
12
|
-
for (const [key, value] of Object.entries(parsed)) {
|
|
13
|
-
result[key] = value.length > 0;
|
|
14
|
-
}
|
|
15
|
-
return result;
|
|
16
|
-
}
|
|
17
|
-
|
|
18
|
-
export function detectUserId(): string {
|
|
19
|
-
const envUser = process.env.USER ?? process.env.LOGNAME ?? "";
|
|
20
|
-
if (envUser) return envUser;
|
|
21
|
-
try {
|
|
22
|
-
return userInfo().username || "default_user";
|
|
23
|
-
} catch {
|
|
24
|
-
return "default_user";
|
|
25
|
-
}
|
|
26
|
-
}
|
|
27
|
-
|
|
28
3
|
/**
|
|
29
|
-
* Check if setup is complete by reading
|
|
4
|
+
* Check if setup is complete by reading config/stack/stack.env.
|
|
30
5
|
*/
|
|
31
|
-
export function isSetupComplete(
|
|
32
|
-
const parsed = parseEnvFile(`${
|
|
6
|
+
export function isSetupComplete(stackDir: string): boolean {
|
|
7
|
+
const parsed = parseEnvFile(`${stackDir}/stack.env`);
|
|
33
8
|
if ("OP_SETUP_COMPLETE" in parsed) {
|
|
34
9
|
return parsed.OP_SETUP_COMPLETE.toLowerCase() === "true";
|
|
35
10
|
}
|
|
36
11
|
|
|
37
|
-
return (parsed.
|
|
12
|
+
return (parsed.OP_UI_TOKEN ?? "").length > 0;
|
|
38
13
|
}
|
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Validation logic for SetupSpec inputs.
|
|
3
|
-
* Extracted from setup.ts to reduce per-file complexity.
|
|
4
3
|
*/
|
|
5
4
|
|
|
6
5
|
const CAPABILITY_ID_RE = /^[A-Za-z0-9][A-Za-z0-9_-]*$/;
|
|
@@ -20,10 +19,12 @@ export function validateSetupSpec(input: unknown): { valid: boolean; errors: str
|
|
|
20
19
|
const body = requireObj(input, "Input must be a non-null object", errors);
|
|
21
20
|
if (!body) return { valid: false, errors };
|
|
22
21
|
|
|
22
|
+
if (body.version !== 2) errors.push("version must be 2");
|
|
23
23
|
validateSecurity(body, errors);
|
|
24
24
|
validateOwner(body, errors);
|
|
25
25
|
validateConnectionsArray(body.connections, errors);
|
|
26
|
-
|
|
26
|
+
validateLlm(body, errors);
|
|
27
|
+
validateEmbedding(body, errors);
|
|
27
28
|
if (body.channelCredentials !== undefined && (typeof body.channelCredentials !== "object" || body.channelCredentials === null)) {
|
|
28
29
|
errors.push("channelCredentials must be an object if provided");
|
|
29
30
|
}
|
|
@@ -39,29 +40,28 @@ function validateSecurity(body: Record<string, unknown>, errors: string[]): void
|
|
|
39
40
|
|
|
40
41
|
function validateOwner(body: Record<string, unknown>, errors: string[]): void {
|
|
41
42
|
const owner = body.owner as Record<string, unknown> | undefined;
|
|
42
|
-
if (!owner) return;
|
|
43
|
+
if (!owner) return;
|
|
43
44
|
if (owner.name !== undefined && typeof owner.name !== "string") errors.push("owner.name must be a string");
|
|
44
45
|
if (owner.email !== undefined && typeof owner.email !== "string") errors.push("owner.email must be a string");
|
|
45
46
|
}
|
|
46
47
|
|
|
47
|
-
function
|
|
48
|
-
if (body.
|
|
49
|
-
const
|
|
50
|
-
if (!
|
|
51
|
-
requireStr(
|
|
52
|
-
|
|
53
|
-
if (
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
if (
|
|
63
|
-
|
|
64
|
-
errors.push("capabilities.memory.userId contains invalid characters (alphanumeric and underscores only)");
|
|
48
|
+
function validateLlm(body: Record<string, unknown>, errors: string[]): void {
|
|
49
|
+
if (body.llm === undefined) return;
|
|
50
|
+
const llm = requireObj(body.llm, "llm must be an object if provided", errors);
|
|
51
|
+
if (!llm) return;
|
|
52
|
+
requireStr(llm, "provider", "llm.provider is required", errors);
|
|
53
|
+
requireStr(llm, "model", "llm.model is required", errors);
|
|
54
|
+
if (llm.baseUrl !== undefined && typeof llm.baseUrl !== "string") errors.push("llm.baseUrl must be a string");
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function validateEmbedding(body: Record<string, unknown>, errors: string[]): void {
|
|
58
|
+
if (body.embedding === undefined) return;
|
|
59
|
+
const emb = requireObj(body.embedding, "embedding must be an object if provided", errors);
|
|
60
|
+
if (!emb) return;
|
|
61
|
+
requireStr(emb, "provider", "embedding.provider is required", errors);
|
|
62
|
+
requireStr(emb, "model", "embedding.model is required", errors);
|
|
63
|
+
if (emb.dims !== undefined && (typeof emb.dims !== "number" || !Number.isInteger(emb.dims) || emb.dims < 1)) {
|
|
64
|
+
errors.push("embedding.dims must be a positive integer");
|
|
65
65
|
}
|
|
66
66
|
}
|
|
67
67
|
|