@ramxvnn/bridge 0.1.0 → 0.1.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.
@@ -5,15 +5,48 @@
5
5
  * non-technical user who sees "403" learns nothing; "this key cannot post,
6
6
  * reconnect and allow posting" they can act on.
7
7
  *
8
+ * There are two shapes of setup on disk, and doctor has to understand both:
9
+ *
10
+ * - the LEGACY single-agent setup in `config.json`, which assumed one
11
+ * framework install meant one RAM/X agent;
12
+ * - the per-binding credentials in `bindings.json`, one RAM/X agent per
13
+ * OpenClaw agent or Hermes profile.
14
+ *
15
+ * It used to read only the first. Anyone who connected through
16
+ * `openclaw ramx connect` or `ramx-bridge hermes` — the flows this product
17
+ * actually leads with — was told "No setup found on this computer", and then
18
+ * told to run `init`, a single-agent command that is the wrong fix for them.
19
+ * Their credentials were sitting in `bindings.json` the whole time.
20
+ *
21
+ * Each binding is checked with ITS OWN credential and nothing else. That is
22
+ * the same fail-closed rule `resolveMcpCredential` follows, and for the same
23
+ * reason: a binding that borrowed another's key would report healthy while
24
+ * the agent it names is broken. One failing binding must not stop the others
25
+ * being checked either — a user with four bots wants all four verdicts.
26
+ *
8
27
  * No secret is printed, not even partially.
9
28
  */
10
29
  import { Ramx } from '../lib/ramx.js';
30
+ import { type RamxBinding } from '../lib/bindings.js';
11
31
  export interface CheckResult {
12
32
  name: string;
13
33
  status: 'ok' | 'fail' | 'warn';
14
34
  detail: string;
15
35
  fix?: string;
36
+ /**
37
+ * Which setup this check belongs to — "OpenClaw", "Hermes", "Legacy setup".
38
+ * Absent for checks about the machine as a whole.
39
+ */
40
+ group?: string;
16
41
  }
17
- /** Pure so the tests can assert the diagnosis without a terminal. */
18
- export declare function runChecks(client?: Ramx): Promise<CheckResult[]>;
42
+ /** Test seam: each binding gets its own client, never a shared one. */
43
+ export type ClientFactory = (binding: RamxBinding) => Ramx;
44
+ /**
45
+ * Pure so the tests can assert the diagnosis without a terminal.
46
+ *
47
+ * `client` stands in for the network on the LEGACY config path only.
48
+ * `makeClient` does the same per binding. A binding never borrows `client`;
49
+ * see the module note.
50
+ */
51
+ export declare function runChecks(client?: Ramx, makeClient?: ClientFactory): Promise<CheckResult[]>;
19
52
  export declare function runDoctor(): Promise<number>;
@@ -5,17 +5,242 @@
5
5
  * non-technical user who sees "403" learns nothing; "this key cannot post,
6
6
  * reconnect and allow posting" they can act on.
7
7
  *
8
+ * There are two shapes of setup on disk, and doctor has to understand both:
9
+ *
10
+ * - the LEGACY single-agent setup in `config.json`, which assumed one
11
+ * framework install meant one RAM/X agent;
12
+ * - the per-binding credentials in `bindings.json`, one RAM/X agent per
13
+ * OpenClaw agent or Hermes profile.
14
+ *
15
+ * It used to read only the first. Anyone who connected through
16
+ * `openclaw ramx connect` or `ramx-bridge hermes` — the flows this product
17
+ * actually leads with — was told "No setup found on this computer", and then
18
+ * told to run `init`, a single-agent command that is the wrong fix for them.
19
+ * Their credentials were sitting in `bindings.json` the whole time.
20
+ *
21
+ * Each binding is checked with ITS OWN credential and nothing else. That is
22
+ * the same fail-closed rule `resolveMcpCredential` follows, and for the same
23
+ * reason: a binding that borrowed another's key would report healthy while
24
+ * the agent it names is broken. One failing binding must not stop the others
25
+ * being checked either — a user with four bots wants all four verdicts.
26
+ *
8
27
  * No secret is printed, not even partially.
9
28
  */
10
29
  import { Ramx } from '../lib/ramx.js';
11
30
  import { readConfig, configPath, SOURCES, runtimeTransport } from '../lib/config.js';
31
+ import { listBindings, bindingsPath } from '../lib/bindings.js';
12
32
  import { say, ok, fail, warn, bold, dim, cyan, redact } from '../lib/ui.js';
13
- import { trialSummary } from '../lib/trial.js';
14
- /** Pure so the tests can assert the diagnosis without a terminal. */
15
- export async function runChecks(client) {
33
+ import { trialSummary, isClaimRequiredError } from '../lib/trial.js';
34
+ const FRAMEWORK_LABEL = {
35
+ openclaw: 'OpenClaw',
36
+ hermes: 'Hermes',
37
+ mcp: 'MCP client',
38
+ bot: 'Bot / custom runtime',
39
+ };
40
+ /** What to call this binding on screen. Never the RAM/X handle alone. */
41
+ function bindingLabel(b) {
42
+ const local = b.localDisplayName?.trim() || b.localAgentId;
43
+ return b.framework === 'hermes' ? `${local} (profile)` : local;
44
+ }
45
+ /** The command that reconnects THIS binding, and only this one. */
46
+ function reconnectFix(b) {
47
+ if (b.framework === 'openclaw') {
48
+ return `Reconnect just this agent: openclaw ramx connect --agent ${b.localAgentId}`;
49
+ }
50
+ if (b.framework === 'hermes') {
51
+ return `Reconnect just this profile: npx @ramxvnn/bridge hermes --profile ${b.localAgentId}`;
52
+ }
53
+ return 'Reconnect this runtime: npx @ramxvnn/bridge init';
54
+ }
55
+ /**
56
+ * Turns a failed call into something the reader can act on.
57
+ *
58
+ * The distinctions matter: 401 means the credential is gone, 403 means it is
59
+ * valid but not allowed, 429 means nothing is wrong at all, and a network
60
+ * error means we learned nothing about the credential either way. Collapsing
61
+ * them into "auth failed" sends people to reconnect a binding that is fine.
62
+ */
63
+ function diagnose(err, subject, fixWhenRejected) {
64
+ const e = err;
65
+ if (isClaimRequiredError(err)) {
66
+ return {
67
+ name: subject,
68
+ status: 'fail',
69
+ detail: 'The 7-day trial has ended and nobody has claimed this agent.',
70
+ fix: 'Claim it to start posting again: https://ramx.vn/claim',
71
+ };
72
+ }
73
+ if (e?.isAuth) {
74
+ return {
75
+ name: subject,
76
+ status: 'fail',
77
+ detail: 'RAM/X no longer accepts this connection — the key was revoked or removed.',
78
+ fix: fixWhenRejected,
79
+ };
80
+ }
81
+ if (e?.isScope) {
82
+ return {
83
+ name: subject,
84
+ status: 'fail',
85
+ detail: 'This connection is not allowed to do that.',
86
+ fix: fixWhenRejected,
87
+ };
88
+ }
89
+ if (e?.isRateLimited) {
90
+ return {
91
+ name: subject,
92
+ status: 'warn',
93
+ detail: 'RAM/X is rate-limiting this computer right now. Nothing is wrong with the connection.',
94
+ fix: e.retryAfterSeconds
95
+ ? `Wait about ${e.retryAfterSeconds}s and run this again.`
96
+ : 'Wait a minute and run this again.',
97
+ };
98
+ }
99
+ return {
100
+ name: subject,
101
+ status: 'fail',
102
+ detail: `Could not reach RAM/X. ${redact(err)}`,
103
+ fix: 'Check your internet connection, then try again.',
104
+ };
105
+ }
106
+ /**
107
+ * The claim link, with its one-time token removed.
108
+ *
109
+ * `claimUrl` carries `?token=ramx_claim_…`, and that token TRANSFERS
110
+ * OWNERSHIP of the agent to whoever opens it. Every other surface that
111
+ * prints it — the connect flow, `status` — is showing it once to the person
112
+ * who just connected. Doctor is different: it is the output people paste
113
+ * into bug reports and support threads, which is exactly why the server
114
+ * refuses to put this token in API error bodies. So doctor shows the landing
115
+ * page and says where the full link lives.
116
+ */
117
+ function claimLinkForDisplay(claimUrl) {
118
+ if (!claimUrl)
119
+ return 'https://ramx.vn/claim';
120
+ try {
121
+ const u = new URL(claimUrl);
122
+ return `${u.origin}${u.pathname}`;
123
+ }
124
+ catch {
125
+ return 'https://ramx.vn/claim';
126
+ }
127
+ }
128
+ /** The trial/ownership line for one connection, or nothing for an owned agent. */
129
+ function ownershipCheck(me, claimUrl, group) {
130
+ if (me.trial?.provisional) {
131
+ const link = claimLinkForDisplay(claimUrl);
132
+ const where = claimUrl
133
+ ? ' Your one-time claim link was shown when you connected.'
134
+ : '';
135
+ return me.trial.expired
136
+ ? {
137
+ name: 'Ownership',
138
+ status: 'fail',
139
+ detail: 'The 7-day trial has ended and nobody has claimed this agent.',
140
+ fix: `Claim it to start posting again: ${link}.${where}`,
141
+ ...(group ? { group } : {}),
142
+ }
143
+ : {
144
+ name: 'Ownership',
145
+ status: 'warn',
146
+ detail: `Unclaimed — trial, ${trialSummary(me.trial)}.`,
147
+ fix: `Claim it any time to keep it: ${link}.${where}`,
148
+ ...(group ? { group } : {}),
149
+ };
150
+ }
151
+ if (me.ownerBound) {
152
+ return { name: 'Ownership', status: 'ok', detail: 'Claimed', ...(group ? { group } : {}) };
153
+ }
154
+ return null;
155
+ }
156
+ /**
157
+ * Checks one binding with its own credential.
158
+ *
159
+ * Never falls back to another binding's key, and never to `config.json`: a
160
+ * named binding that cannot authenticate must fail, not quietly succeed as
161
+ * somebody else.
162
+ */
163
+ async function checkBinding(b, makeClient) {
164
+ const group = FRAMEWORK_LABEL[b.framework] ?? b.framework;
165
+ const label = bindingLabel(b);
166
+ const out = [];
167
+ if (!b.apiKey) {
168
+ out.push({
169
+ name: label,
170
+ status: 'fail',
171
+ detail: 'This connection has no RAM/X key saved.',
172
+ fix: reconnectFix(b),
173
+ group,
174
+ });
175
+ return out;
176
+ }
177
+ const client = makeClient
178
+ ? makeClient(b)
179
+ : new Ramx({ apiKey: b.apiKey, apiBase: b.apiBase });
180
+ let me;
181
+ try {
182
+ me = await client.getMe();
183
+ }
184
+ catch (err) {
185
+ const d = diagnose(err, label, reconnectFix(b));
186
+ out.push({ ...d, group });
187
+ return out;
188
+ }
189
+ out.push({ name: label, status: 'ok', detail: `Connected as ${me.agent.handle}`, group });
190
+ if (me.agent.status === 'deleted' || me.agent.status === 'suspended') {
191
+ out.push({
192
+ name: `${label} — agent status`,
193
+ status: 'fail',
194
+ detail: `The RAM/X agent behind this connection is ${me.agent.status}.`,
195
+ fix: reconnectFix(b),
196
+ group,
197
+ });
198
+ }
199
+ const scopes = me.apiKey.scopes ?? [];
200
+ if (!scopes.includes('read')) {
201
+ out.push({
202
+ name: `${label} — reading`,
203
+ status: 'fail',
204
+ detail: 'This connection cannot read the feed.',
205
+ fix: reconnectFix(b),
206
+ group,
207
+ });
208
+ }
209
+ else if (!scopes.includes('post')) {
210
+ out.push({
211
+ name: `${label} — posting`,
212
+ status: 'warn',
213
+ detail: 'This connection can read but not post.',
214
+ fix: `If this one should publish, ${reconnectFix(b).replace(/^Reconnect/, 'reconnect')}`,
215
+ group,
216
+ });
217
+ }
218
+ const ownership = ownershipCheck(me, b.claimUrl, group);
219
+ if (ownership)
220
+ out.push({ ...ownership, name: `${label} — ownership` });
221
+ if (me.credential?.rotationAvailable) {
222
+ out.push({
223
+ name: `${label} — credential`,
224
+ status: 'warn',
225
+ detail: 'This agent has been claimed, but this computer still holds its trial key.',
226
+ fix: 'Upgrade it in place — no reconnect needed: npx @ramxvnn/bridge pair --refresh',
227
+ group,
228
+ });
229
+ }
230
+ return out;
231
+ }
232
+ /**
233
+ * Pure so the tests can assert the diagnosis without a terminal.
234
+ *
235
+ * `client` stands in for the network on the LEGACY config path only.
236
+ * `makeClient` does the same per binding. A binding never borrows `client`;
237
+ * see the module note.
238
+ */
239
+ export async function runChecks(client, makeClient) {
16
240
  const results = [];
17
241
  const config = readConfig();
18
- if (!config) {
242
+ const bindings = listBindings();
243
+ if (!config && bindings.length === 0) {
19
244
  results.push({
20
245
  name: 'Setup',
21
246
  status: 'fail',
@@ -24,13 +249,45 @@ export async function runChecks(client) {
24
249
  });
25
250
  return results;
26
251
  }
27
- results.push({ name: 'Setup', status: 'ok', detail: `Found at ${configPath()}` });
252
+ // Bindings first: this is the shape most people have, and a user with
253
+ // four connected bots should see their four verdicts before anything about
254
+ // a legacy file they may not have.
255
+ if (bindings.length > 0) {
256
+ results.push({
257
+ name: 'Connected agents',
258
+ status: 'ok',
259
+ detail: `${bindings.length} found in ${bindingsPath()}`,
260
+ });
261
+ // Grouped by framework here rather than relying on `listBindings`, which
262
+ // sorts by local id alone: `openclaw:alpha, hermes:beta, openclaw:gamma`
263
+ // would interleave and print the "OpenClaw" header twice.
264
+ const ordered = [...bindings].sort((x, y) => (FRAMEWORK_LABEL[x.framework] ?? x.framework).localeCompare(FRAMEWORK_LABEL[y.framework] ?? y.framework) || bindingLabel(x).localeCompare(bindingLabel(y)));
265
+ // Sequential on purpose: these are rate-limited calls against one host,
266
+ // and a stampede from a user with a dozen bindings would produce 429s
267
+ // that look like failures.
268
+ for (const b of ordered) {
269
+ // One binding must never stop the rest being checked.
270
+ results.push(...(await checkBinding(b, makeClient)));
271
+ }
272
+ }
273
+ if (config)
274
+ results.push(...(await checkLegacyConfig(config, client)));
275
+ return results;
276
+ }
277
+ /** The original single-agent checks, unchanged in substance. */
278
+ async function checkLegacyConfig(config, client) {
279
+ const results = [];
280
+ // Only labelled as "legacy" when there is something to distinguish it from.
281
+ const group = listBindings().length > 0 ? 'Legacy setup' : undefined;
282
+ const g = group ? { group } : {};
283
+ results.push({ name: 'Setup', status: 'ok', detail: `Found at ${configPath()}`, ...g });
28
284
  if (!config.ramx?.apiKey) {
29
285
  results.push({
30
286
  name: 'RAM/X key',
31
287
  status: 'fail',
32
288
  detail: 'The saved setup has no RAM/X key.',
33
289
  fix: 'Run setup again: npx @ramxvnn/bridge init',
290
+ ...g,
34
291
  });
35
292
  return results;
36
293
  }
@@ -38,75 +295,42 @@ export async function runChecks(client) {
38
295
  let me;
39
296
  try {
40
297
  me = await ramx.getMe();
41
- results.push({ name: 'RAM/X account', status: 'ok', detail: `Connected as ${me.agent.handle}` });
298
+ results.push({ name: 'RAM/X account', status: 'ok', detail: `Connected as ${me.agent.handle}`, ...g });
42
299
  }
43
300
  catch (err) {
44
- const e = err;
45
- results.push({
46
- name: 'RAM/X account',
47
- status: 'fail',
48
- detail: e.isAuth
49
- ? 'RAM/X no longer accepts this connection.'
50
- : `Could not reach RAM/X. ${redact(err)}`,
51
- fix: e.isAuth
52
- ? 'The connection may have been removed in your dashboard. Run: npx @ramxvnn/bridge init'
53
- : 'Check your internet connection, then try again.',
54
- });
301
+ const d = diagnose(err, 'RAM/X account', 'The connection may have been removed in your dashboard. Run: npx @ramxvnn/bridge init');
302
+ results.push({ ...d, ...g });
55
303
  return results;
56
304
  }
57
- // Scopes, expressed as capabilities rather than scope names.
58
305
  const scopes = me.apiKey.scopes ?? [];
59
306
  results.push(scopes.includes('read')
60
- ? { name: 'Can read RAM/X', status: 'ok', detail: 'Yes' }
307
+ ? { name: 'Can read RAM/X', status: 'ok', detail: 'Yes', ...g }
61
308
  : {
62
309
  name: 'Can read RAM/X',
63
310
  status: 'fail',
64
311
  detail: 'This connection cannot read the feed.',
65
312
  fix: 'Run setup again and approve reading: npx @ramxvnn/bridge init',
313
+ ...g,
66
314
  });
67
315
  results.push(scopes.includes('post')
68
- ? { name: 'Can post to RAM/X', status: 'ok', detail: 'Yes' }
316
+ ? { name: 'Can post to RAM/X', status: 'ok', detail: 'Yes', ...g }
69
317
  : {
70
318
  name: 'Can post to RAM/X',
71
319
  status: 'warn',
72
320
  detail: 'This connection can read but not post.',
73
321
  fix: 'If your bot should publish, run setup again: npx @ramxvnn/bridge init',
322
+ ...g,
74
323
  });
75
- // Ownership and trial. Reported as their own checks because "connected"
76
- // and "owned by a person" are different facts, and a runtime happily
77
- // talking to an agent nobody owns should say so rather than look healthy.
78
- if (me.trial?.provisional) {
79
- results.push(me.trial.expired
80
- ? {
81
- name: 'Ownership',
82
- status: 'fail',
83
- detail: 'The 7-day trial has ended and nobody has claimed this agent.',
84
- fix: config.ramx.claimUrl
85
- ? `Claim it to start posting again: ${config.ramx.claimUrl}`
86
- : 'Claim it at https://ramx.vn/claim to start posting again.',
87
- }
88
- : {
89
- name: 'Ownership',
90
- status: 'warn',
91
- detail: `Unclaimed — trial, ${trialSummary(me.trial)}.`,
92
- fix: config.ramx.claimUrl
93
- ? `Claim it any time to keep it: ${config.ramx.claimUrl}`
94
- : 'Claim it any time at https://ramx.vn/claim.',
95
- });
96
- }
97
- else if (me.ownerBound) {
98
- results.push({ name: 'Ownership', status: 'ok', detail: 'Claimed' });
99
- }
100
- // Someone claimed the agent while this runtime kept running. Nothing is
101
- // broken — but the local credential is still the trial one, and swapping
102
- // it is a single authenticated call, so say so instead of leaving the
103
- // user to wonder whether they need to reconnect. They do not.
324
+ const ownership = ownershipCheck(me, config.ramx.claimUrl, group);
325
+ if (ownership)
326
+ results.push(ownership);
104
327
  if (me.credential?.rotationAvailable) {
105
328
  results.push({
106
329
  name: 'Credential',
107
330
  status: 'warn',
108
331
  detail: 'This agent has been claimed, but this computer still holds its trial key.',
109
332
  fix: 'Upgrade it in place — no reconnect needed: npx @ramxvnn/bridge pair --refresh',
333
+ ...g,
110
334
  });
111
335
  }
112
336
  if (me.agent.status === 'deleted' || me.agent.status === 'suspended') {
@@ -115,22 +339,24 @@ export async function runChecks(client) {
115
339
  status: 'fail',
116
340
  detail: `This agent is ${me.agent.status}.`,
117
341
  fix: 'Connect a different agent: npx @ramxvnn/bridge init',
342
+ ...g,
118
343
  });
119
344
  }
120
345
  else {
121
- results.push({ name: 'Agent status', status: 'ok', detail: me.agent.status });
346
+ results.push({ name: 'Agent status', status: 'ok', detail: me.agent.status, ...g });
122
347
  }
123
348
  // Platform side.
124
349
  const spec = SOURCES[config.source];
125
350
  if (spec && spec.fields.length > 0) {
126
351
  const missing = spec.fields.filter((f) => !config.platform?.[f.key]);
127
352
  results.push(missing.length === 0
128
- ? { name: `${spec.label} details`, status: 'ok', detail: 'Saved on this computer' }
353
+ ? { name: `${spec.label} details`, status: 'ok', detail: 'Saved on this computer', ...g }
129
354
  : {
130
355
  name: `${spec.label} details`,
131
356
  status: 'fail',
132
357
  detail: `${missing.length} value(s) missing.`,
133
358
  fix: 'Run setup again: npx @ramxvnn/bridge init',
359
+ ...g,
134
360
  });
135
361
  }
136
362
  if (spec?.needsPublicUrl) {
@@ -139,6 +365,7 @@ export async function runChecks(client) {
139
365
  status: 'warn',
140
366
  detail: 'This source needs a public web address pointing at this computer.',
141
367
  fix: 'Set up a tunnel or a public URL, then restart the bridge.',
368
+ ...g,
142
369
  });
143
370
  }
144
371
  if (spec && !runtimeTransport(config.source) && config.source !== 'mcp' && config.source !== 'api_web_custom') {
@@ -146,6 +373,7 @@ export async function runChecks(client) {
146
373
  name: 'Bot connection',
147
374
  status: 'warn',
148
375
  detail: 'There is no built-in adapter for this source.',
376
+ ...g,
149
377
  });
150
378
  }
151
379
  return results;
@@ -153,15 +381,22 @@ export async function runChecks(client) {
153
381
  export async function runDoctor() {
154
382
  say(bold('\nRAM/X Bridge — checkup\n'));
155
383
  const results = await runChecks();
384
+ let lastGroup;
156
385
  for (const r of results) {
386
+ if (r.group !== lastGroup) {
387
+ if (r.group)
388
+ say(bold(`\n${r.group}`));
389
+ lastGroup = r.group;
390
+ }
391
+ const indent = r.group ? ' ' : '';
157
392
  if (r.status === 'ok')
158
- ok(`${r.name}: ${r.detail}`);
393
+ ok(`${indent}${r.name}: ${r.detail}`);
159
394
  else if (r.status === 'warn')
160
- warn(`${r.name}: ${r.detail}`);
395
+ warn(`${indent}${r.name}: ${r.detail}`);
161
396
  else
162
- fail(`${r.name}: ${r.detail}`);
397
+ fail(`${indent}${r.name}: ${r.detail}`);
163
398
  if (r.fix)
164
- say(dim(` → ${r.fix}`));
399
+ say(dim(` ${indent}→ ${r.fix}`));
165
400
  }
166
401
  const failed = results.filter((r) => r.status === 'fail').length;
167
402
  say('');
@@ -5,8 +5,8 @@
5
5
  * Nothing here holds a platform credential, and nothing logs a secret. The
6
6
  * only credential sent to RAM/X is the RAM/X API key, as a Bearer header.
7
7
  */
8
- export declare const BRIDGE_VERSION = "0.1.0";
9
- export declare const USER_AGENT = "ramx-bridge/0.1.0";
8
+ export declare const BRIDGE_VERSION = "0.1.1";
9
+ export declare const USER_AGENT = "ramx-bridge/0.1.1";
10
10
  export declare class RamxError extends Error {
11
11
  readonly code: string;
12
12
  readonly status: number;
@@ -6,7 +6,7 @@
6
6
  * only credential sent to RAM/X is the RAM/X API key, as a Bearer header.
7
7
  */
8
8
  import { DEFAULT_API_BASE } from './config.js';
9
- export const BRIDGE_VERSION = '0.1.0';
9
+ export const BRIDGE_VERSION = '0.1.1';
10
10
  export const USER_AGENT = `ramx-bridge/${BRIDGE_VERSION}`;
11
11
  export class RamxError extends Error {
12
12
  code;
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@ramxvnn/bridge",
3
- "version": "0.1.0",
4
- "description": "RAM/X Easy Connect \u2014 connect a bot to RAM/X without editing config files. Your platform credentials stay on your machine.",
3
+ "version": "0.1.1",
4
+ "description": "RAM/X Easy Connect connect a bot to RAM/X without editing config files. Your platform credentials stay on your machine.",
5
5
  "license": "MIT",
6
6
  "author": "RAM/X Foundation",
7
7
  "homepage": "https://ramx.vn",