Row 76 round 2, owner Dewey, candidate manifest 04ecf1b5 on 729da72b.
Carries decisions 99 and 100: Darkwing's and Filbert's round 1 notes,
the README proxy contract line, and a 408 counted as unknown.
Approved by Filbert (27685) and Darkwing (27688); Sage's gate green
03:25-03:29Z.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
306 lines
20 KiB
JavaScript
306 lines
20 KiB
JavaScript
// Row 76 (#1570, lead decision 95): `serve --send`, a human message to a role from the Console.
|
|
// Only with the preview flags; POST /api/bus/message needs the header on this request, its own
|
|
// Origin, Sec-Fetch-Site same-origin, JSON, 16 KiB and only to, body and in_reply_to. The broker
|
|
// refuses `to` and the body. An outcome the transport can't confirm is unknown and never retried.
|
|
// Every other write is still 403 read-only. Nothing here starts the real human transport.
|
|
import { test } from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import { createServer, request } from 'node:http';
|
|
import { spawn, spawnSync } from 'node:child_process';
|
|
import { writeFileSync } from 'node:fs';
|
|
import { join } from 'node:path';
|
|
import { fileURLToPath } from 'node:url';
|
|
import { startServer } from '../src/serve.mjs';
|
|
import { BusReadError, humanCall } from '../src/bus.mjs';
|
|
import { close } from './fixture.mjs';
|
|
import { broker, fixture, tmp } from '../../cli/tests/helpers.mjs';
|
|
|
|
const NAME = 'mosaic-preview.example.com', HEADER = 'remote-user', ID = 'jason';
|
|
const preview = { host: NAME, header: HEADER, user: ID };
|
|
const CLI = fileURLToPath(new URL('../src/cli.mjs', import.meta.url));
|
|
const ok = { [HEADER]: ID, origin: `https://${NAME}`, 'sec-fetch-site': 'same-origin', 'content-type': 'application/json' };
|
|
const without = name => Object.fromEntries(Object.entries(ok).filter(([k]) => k !== name));
|
|
const msg = JSON.stringify({ to: 'coder', body: 'Please look at row 76.' });
|
|
|
|
// node:http, not fetch: fetch can't set Host or Sec-Fetch-Site.
|
|
function post(port, { method = 'POST', path = '/api/bus/message', headers = ok, body = msg } = {}) {
|
|
return new Promise((resolve, reject) => {
|
|
const length = body === undefined ? {} : { 'content-length': Buffer.byteLength(body) };
|
|
const req = request({ host: '127.0.0.1', port, method, path, headers: { host: NAME, ...length, ...headers } }, res => {
|
|
let text = '';
|
|
res.on('data', c => (text += c));
|
|
res.on('end', () => resolve({ status: res.statusCode, body: text && (res.headers['content-type'] ?? '').startsWith('application/json') ? JSON.parse(text) : text }));
|
|
});
|
|
req.on('error', reject);
|
|
req.end(body);
|
|
});
|
|
}
|
|
|
|
// A board that counts what reaches it, a bus that counts its reads, and a send that records
|
|
// each call and answers from `answer`.
|
|
async function setup(t, options = {}) {
|
|
const boardHits = [];
|
|
const upstream = createServer((req, res) => { boardHits.push(`${req.method} ${req.url}`); res.writeHead(200, { 'content-type': 'application/json' }); res.end('{"ok":true}'); });
|
|
await new Promise(r => upstream.listen(0, '127.0.0.1', r));
|
|
t.after(() => close(upstream));
|
|
const busCalls = [], sends = [], lines = [];
|
|
const bus = Object.fromEntries(['inbox', 'tasks', 'agents', 'trail', 'decisions'].map(v => [v, async arg => { busCalls.push([v, arg]); return [{ verb: v }]; }]));
|
|
const state = { answer: async () => ({ id: 'msg-0001' }) };
|
|
const send = async args => { sends.push(args); return state.answer(args); };
|
|
const web = await startServer({ port: 0, board: `http://127.0.0.1:${upstream.address().port}`, bus, preview, send, log: l => lines.push(l), ...options });
|
|
t.after(() => close(web));
|
|
return { port: web.address().port, boardHits, busCalls, sends, lines, state };
|
|
}
|
|
|
|
test('startServer takes send only as a function and only with the preview mode', async () => {
|
|
for (const options of [{ send: async () => ({}) }, { send: async () => ({}), preview: null }, { send: 'yes', preview }, { send: {}, preview }]) {
|
|
const result = await startServer({ port: 0, ...options }).catch(err => err);
|
|
if (!(result instanceof Error)) { await close(result); assert.fail(`served with ${Object.keys(options)}`); }
|
|
assert.match(result.message, /--send needs the preview mode/);
|
|
}
|
|
});
|
|
|
|
test('the CLI refuses --send without the preview flags, and without a configured bus', t => {
|
|
const root = tmp(t, 'webui-send-cli-');
|
|
const env = { ...process.env, MOSAIC_CONFIG: join(root, 'no-such-config.json') };
|
|
for (const [flags, why] of [
|
|
[['--send'], /^refused: --send needs the preview mode/],
|
|
[['--send', '--public-host', NAME, '--auth-header', HEADER], /^refused: preview mode needs/],
|
|
[['--public-host', NAME, '--auth-header', HEADER, '--allow-user', ID, '--send'], /^refused: --send needs the bus configured \(/],
|
|
[['--send', '--public-host', NAME, '--auth-header', HEADER, '--allow-user', ID], /^refused: --send needs the bus configured \(/],
|
|
]) {
|
|
const result = spawnSync(process.execPath, [CLI, 'serve', '--port', '0', ...flags], { encoding: 'utf8', timeout: 10000, env });
|
|
assert.equal(result.status, 2, flags.join(' '));
|
|
assert.match(result.stderr, why, flags.join(' '));
|
|
assert.equal(result.stderr.includes(root), false, 'the refusal names the config path');
|
|
assert.equal(result.stdout, '');
|
|
}
|
|
});
|
|
|
|
test('the CLI with --send and a configured bus sends through the human transport and logs one line', async t => {
|
|
// No bus host is running, so the send is a definite refusal: no-bus-host, nothing written.
|
|
const root = tmp(t, 'webui-send-cli-');
|
|
const { env } = fixture(root);
|
|
const child = spawn(process.execPath, [CLI, 'serve', '--port', '0', '--board', 'http://127.0.0.1:9', '--public-host', NAME, '--auth-header', HEADER, '--allow-user', ID, '--send'], { env, stdio: ['ignore', 'pipe', 'pipe'] });
|
|
t.after(() => child.kill());
|
|
let out = '';
|
|
const port = await new Promise((resolve, reject) => {
|
|
child.stdout.on('data', c => { out += c; const m = out.match(/^Mosaic Console: http:\/\/127\.0\.0\.1:(\d+)\/$/m); if (m && out.includes('Ctrl-C')) resolve(Number(m[1])); });
|
|
child.on('exit', code => reject(new Error(`exited ${code}: ${out}`)));
|
|
});
|
|
assert.match(out, new RegExp(`^Preview: https://${NAME}/ through the proxy, signed-in user from ${HEADER}, reads and human messages to roles \\(--send\\); one line per send below$`, 'm'));
|
|
const r = await post(port, { body: JSON.stringify({ to: 'coder', body: 'secret-body-text' }) });
|
|
assert.equal(r.status, 503);
|
|
assert.deepEqual(r.body, { error: 'no-bus-host', message: 'not sent: the bus refused it (no-bus-host)', sent: false });
|
|
const SEND = /^\d{4}-\d\d-\d\dT[\d:.]+Z send /m;
|
|
for (let i = 0; i < 100 && !SEND.test(out); i++) await new Promise(res => setTimeout(res, 20));
|
|
const sendLines = out.split('\n').filter(l => SEND.test(l));
|
|
assert.equal(sendLines.length, 1, out);
|
|
assert.match(sendLines[0], /^\d{4}-\d\d-\d\dT[\d:.]+Z send user="jason" to="coder" error=no-bus-host$/);
|
|
assert.equal(out.includes('secret-body-text'), false);
|
|
});
|
|
|
|
test('without --send the route is read-only like every other write, and config says no send', async t => {
|
|
const { port, sends, lines } = await setup(t, { send: null });
|
|
const r = await post(port);
|
|
assert.equal(r.status, 403); assert.deepEqual(r.body, { error: 'read-only', message: 'the Console preview is read-only' });
|
|
const config = await post(port, { method: 'GET', path: '/api/config', headers: { [HEADER]: ID }, body: undefined });
|
|
assert.equal(config.body.send, false);
|
|
assert.deepEqual(sends, []); assert.deepEqual(lines, []);
|
|
});
|
|
|
|
test('with --send every other write is still 403 read-only, and nothing reaches the board or bus', async t => {
|
|
const { port, sends, lines, boardHits, busCalls } = await setup(t);
|
|
for (const [method, path] of [['POST', '/api/seen'], ['POST', '/api/reply'], ['POST', '/api/bus/inbox'], ['POST', '/api/bus/message/x'], ['POST', '/api/bus/messages'], ['POST', '/'], ['PUT', '/api/seen'], ['DELETE', '/api/reply'], ['PATCH', '/api/bus/inbox']]) {
|
|
const r = await post(port, { method, path });
|
|
assert.equal(r.status, 403, `${method} ${path}`); assert.deepEqual(r.body, { error: 'read-only', message: 'the Console preview is read-only' }, `${method} ${path}`);
|
|
}
|
|
assert.deepEqual([sends, lines, boardHits, busCalls], [[], [], [], []]);
|
|
});
|
|
|
|
test('config says send only on the public name with --send on', async t => {
|
|
const { port } = await setup(t);
|
|
assert.equal((await post(port, { method: 'GET', path: '/api/config', headers: { [HEADER]: ID }, body: undefined })).body.send, true);
|
|
assert.equal((await post(port, { method: 'GET', path: '/api/config', headers: { host: `127.0.0.1:${port}` }, body: undefined })).body.send, false);
|
|
});
|
|
|
|
test('a send without the signed-in user is 401 and is neither sent nor logged, loopback included', async t => {
|
|
const { port, sends, lines } = await setup(t);
|
|
for (const [why, headers] of [
|
|
['no header', without(HEADER)],
|
|
['another user', { ...ok, [HEADER]: 'mallory' }],
|
|
['the id with a suffix', { ...ok, [HEADER]: ID + 'x' }],
|
|
['two values', { ...ok, [HEADER]: [ID, ID] }],
|
|
['loopback without the header', { ...without(HEADER), host: `127.0.0.1:${port}`, origin: `http://127.0.0.1:${port}` }],
|
|
['localhost, no Origin, no header', { 'content-type': 'application/json', 'sec-fetch-site': 'same-origin', host: `localhost:${port}` }],
|
|
]) {
|
|
const r = await post(port, { headers });
|
|
assert.equal(r.status, 401, why); assert.deepEqual(r.body, { error: 'sign-in required' }, why);
|
|
}
|
|
// Loopback with the header is a proxy that rewrote Host: refused before the route (row 74).
|
|
const rewritten = await post(port, { headers: { ...ok, host: `127.0.0.1:${port}` } });
|
|
assert.equal(rewritten.status, 403); assert.equal(rewritten.body.error, 'proxy Host refused');
|
|
assert.deepEqual(sends, []); assert.deepEqual(lines, []);
|
|
});
|
|
|
|
test('each request check refuses before anything is sent, and logs the refusal without the body', async t => {
|
|
const { port, sends, lines } = await setup(t);
|
|
const cases = [
|
|
['no Origin', { headers: without('origin') }, 403, 'cross-origin request refused'],
|
|
['no Sec-Fetch-Site', { headers: without('sec-fetch-site') }, 403, 'cross-site request refused'],
|
|
...['cross-site', 'same-site', 'none', 'Same-Origin', 'same-origin, same-origin'].map(v => [`Sec-Fetch-Site ${v}`, { headers: { ...ok, 'sec-fetch-site': v } }, 403, 'cross-site request refused']),
|
|
['two Sec-Fetch-Site', { headers: { ...ok, 'sec-fetch-site': ['same-origin', 'same-origin'] } }, 403, 'cross-site request refused'],
|
|
['no Content-Type', { headers: without('content-type') }, 400, 'invalid-request'],
|
|
['text/plain', { headers: { ...ok, 'content-type': 'text/plain' } }, 400, 'invalid-request'],
|
|
['form', { headers: { ...ok, 'content-type': 'application/x-www-form-urlencoded' }, body: 'to=coder&body=hi' }, 400, 'invalid-request'],
|
|
['oversize', { body: JSON.stringify({ to: 'coder', body: 'x'.repeat(16384) }) }, 400, 'invalid-request'],
|
|
['an extra key', { body: JSON.stringify({ to: 'coder', body: 'secret-body-text', class: 'REQUEST' }) }, 400, 'invalid-request'],
|
|
['the business key', { body: JSON.stringify({ to: 'coder', body: 'secret-body-text', business: 'other' }) }, 400, 'invalid-request'],
|
|
['no to', { body: JSON.stringify({ body: 'secret-body-text' }) }, 400, 'invalid-request'],
|
|
['no body', { body: JSON.stringify({ to: 'coder' }) }, 400, 'invalid-request'],
|
|
['not JSON', { body: '{"to":' }, 400, 'invalid-request'],
|
|
['null', { body: 'null' }, 400, 'invalid-request'],
|
|
['an array', { body: '["coder","hi"]' }, 400, 'invalid-request'],
|
|
['a string', { body: '"hi"' }, 400, 'invalid-request'],
|
|
];
|
|
for (const [why, req, status, error] of cases) {
|
|
const r = await post(port, req);
|
|
assert.equal(r.status, status, why); assert.equal(r.body.error, error, why); assert.equal(r.body.sent, false, why);
|
|
}
|
|
// An Origin that isn't this one is refused by the shared check, before the route.
|
|
for (const origin of [`http://${NAME}`, 'https://evil.example', `https://${NAME}:443`, 'null']) {
|
|
const r = await post(port, { headers: { ...ok, origin } });
|
|
assert.equal(r.status, 403, origin); assert.deepEqual(r.body, { error: 'cross-origin request refused' }, origin);
|
|
}
|
|
assert.equal((await post(port, { method: 'GET', body: undefined })).status, 405);
|
|
assert.equal((await post(port, { method: 'PUT' })).status, 405);
|
|
assert.deepEqual(sends, []);
|
|
assert.equal(lines.length, cases.length);
|
|
for (const l of lines) assert.match(l, /^\d{4}-\d\d-\d\dT[\d:.]+Z send user="jason" to=("coder"|none) error=[a-z -]+$/, l);
|
|
assert.equal(lines.some(l => l.includes('secret-body-text')), false);
|
|
// 16 KiB fits: the body limit is the broker's beyond that.
|
|
const fits = JSON.stringify({ to: 'coder', body: 'x'.repeat(16384 - JSON.stringify({ to: 'coder', body: '' }).length) });
|
|
assert.equal(Buffer.byteLength(fits), 16384);
|
|
assert.equal((await post(port, { body: fits })).status, 200);
|
|
});
|
|
|
|
test('a send passes exactly to, body and in_reply_to, answers the id, and logs it', async t => {
|
|
const { port, sends, lines, boardHits, busCalls } = await setup(t);
|
|
const r = await post(port, { body: JSON.stringify({ body: 'secret-body-text', to: 'coder' }) });
|
|
assert.equal(r.status, 200);
|
|
assert.equal(r.body.id, 'msg-0001'); assert.match(r.body.at, /^\d{4}-\d\d-\d\dT/); assert.deepEqual(Object.keys(r.body), ['id', 'at']);
|
|
const reply = await post(port, { body: JSON.stringify({ to: 'pm', body: 'yes', in_reply_to: 'msg-0000' }) });
|
|
assert.equal(reply.status, 200);
|
|
// Values go to the broker as given, whatever their type: it is the one that refuses them.
|
|
assert.equal((await post(port, { body: JSON.stringify({ to: 7, body: ['x'] }) })).status, 200);
|
|
assert.equal((await post(port, { body: JSON.stringify({ to: 'pm', body: 'yes', in_reply_to: '' }) })).status, 200);
|
|
assert.deepEqual(sends, [{ to: 'coder', body: 'secret-body-text' }, { to: 'pm', body: 'yes', in_reply_to: 'msg-0000' }, { to: 7, body: ['x'] }, { to: 'pm', body: 'yes', in_reply_to: '' }]);
|
|
assert.deepEqual(sends.map(Object.keys), [['to', 'body'], ['to', 'body', 'in_reply_to'], ['to', 'body'], ['to', 'body', 'in_reply_to']]);
|
|
assert.equal(lines.length, 4);
|
|
assert.match(lines[0], /^\d{4}-\d\d-\d\dT[\d:.]+Z send user="jason" to="coder" id=msg-0001$/);
|
|
assert.match(lines[2], / to=none id=msg-0001$/);
|
|
assert.equal(lines.some(l => l.includes('secret-body-text')), false);
|
|
assert.deepEqual(boardHits, []); assert.deepEqual(busCalls, []);
|
|
});
|
|
|
|
test('a crafted role name cannot forge a log line', async t => {
|
|
const { port, lines, state } = await setup(t);
|
|
state.answer = async () => { throw new BusReadError('unknown-role'); };
|
|
const to = 'coder error=x\n2026-01-01T00:00:00.000Z send user="jason" to="pm" id=forged' + 'y'.repeat(100);
|
|
await post(port, { body: JSON.stringify({ to, body: 'hi' }) });
|
|
assert.equal(lines.length, 1);
|
|
assert.equal(lines[0].includes('\n'), false);
|
|
assert.match(lines[0], / error=unknown-role$/);
|
|
assert.ok(lines[0].includes(` to=${JSON.stringify(to.slice(0, 64))} error=`));
|
|
// The cut is at 64 characters: a 65-character role loses its last one.
|
|
await post(port, { body: JSON.stringify({ to: 'r'.repeat(64) + 's', body: 'hi' }) });
|
|
assert.match(lines[1], new RegExp(` to="${'r'.repeat(64)}" error=unknown-role$`));
|
|
});
|
|
|
|
test('a bus refusal is not sent, with its status; an outcome the bus could not confirm is unknown and never retried', async t => {
|
|
const { port, sends, lines, state } = await setup(t);
|
|
for (const [answer, status, error, sent] of [
|
|
[new BusReadError('unknown-role'), 400, 'unknown-role', false],
|
|
[new BusReadError('invalid-request'), 400, 'invalid-request', false],
|
|
[new BusReadError('message-not-found'), 400, 'message-not-found', false],
|
|
[new BusReadError('request-too-large'), 400, 'request-too-large', false],
|
|
[new BusReadError('human-required'), 403, 'human-required', false],
|
|
[new BusReadError('read-only'), 403, 'read-only', false],
|
|
[new BusReadError('no-bus-host'), 503, 'no-bus-host', false],
|
|
[new BusReadError('something-new'), 502, 'something-new', false],
|
|
[new BusReadError('outcome-unknown'), 503, 'outcome-unknown', 'unknown'],
|
|
[new BusReadError('invalid-response'), 503, 'outcome-unknown', 'unknown'],
|
|
[new BusReadError('response-too-large'), 503, 'outcome-unknown', 'unknown'],
|
|
[Object.assign(new Error('ENOENT: /home/someone/.mosaic-dev/bus/broker.sock'), { code: 'ENOENT' }), 503, 'outcome-unknown', 'unknown'],
|
|
[{ id: '' }, 503, 'outcome-unknown', 'unknown'],
|
|
[null, 503, 'outcome-unknown', 'unknown'],
|
|
[{ id: '../x' }, 503, 'outcome-unknown', 'unknown'],
|
|
]) {
|
|
const before = sends.length;
|
|
state.answer = async () => { if (answer instanceof Error) throw answer; return answer; };
|
|
const r = await post(port);
|
|
const label = answer instanceof Error ? answer.code ?? answer.message : JSON.stringify(answer);
|
|
assert.equal(sends.length, before + 1, `${label}: sent ${sends.length - before} times`);
|
|
assert.equal(r.status, status, label); assert.equal(r.body.error, error, label); assert.equal(r.body.sent, sent, label);
|
|
assert.doesNotMatch(JSON.stringify(r.body), /home|mosaic-dev|\.sock/);
|
|
if (sent === 'unknown') assert.match(r.body.message, /may have been sent; nothing was retried|may have been sent/, label);
|
|
assert.match(lines.at(-1), new RegExp(` error=${error}$`), label);
|
|
}
|
|
});
|
|
|
|
// The same protocol as the real transport: a script standing in for human-cli.mjs.
|
|
const FAKE = `
|
|
import { readFileSync, appendFileSync } from 'node:fs';
|
|
const request = JSON.parse(readFileSync(0, 'utf8'));
|
|
appendFileSync(process.env.FAKE_LOG, JSON.stringify(request) + '\\n');
|
|
if (request.args.to === 'nobody') { process.stderr.write('unknown-role\\n'); process.exit(2); }
|
|
if (request.args.to === 'crash') process.exit(1);
|
|
process.stdout.write(JSON.stringify({ id: 'msg-fake-1', request: 'req-1' }));
|
|
`;
|
|
test('through the human transport the request is exactly the business, message.send and the keys', async t => {
|
|
const root = tmp(t, 'webui-send-transport-');
|
|
const cli = join(root, 'fake-human-cli.mjs'), log = join(root, 'requests.jsonl');
|
|
writeFileSync(cli, FAKE);
|
|
const call = humanCall({ socket: '/run/fake.sock', business: 'demo', cli, env: { ...process.env, FAKE_LOG: log }, timeoutMs: 5000 });
|
|
const { port, lines } = await setup(t, { send: args => call('message.send', args) });
|
|
const r = await post(port);
|
|
assert.equal(r.status, 200); assert.equal(r.body.id, 'msg-fake-1');
|
|
const refused = await post(port, { body: JSON.stringify({ to: 'nobody', body: 'hi' }) });
|
|
assert.deepEqual([refused.status, refused.body.error, refused.body.sent], [400, 'unknown-role', false]);
|
|
const crashed = await post(port, { body: JSON.stringify({ to: 'crash', body: 'hi' }) });
|
|
assert.deepEqual([crashed.status, crashed.body.error, crashed.body.sent], [503, 'outcome-unknown', 'unknown']);
|
|
const { readFileSync } = await import('node:fs');
|
|
const requests = readFileSync(log, 'utf8').trim().split('\n').map(l => JSON.parse(l));
|
|
assert.deepEqual(requests, [
|
|
{ business: 'demo', verb: 'message.send', args: { to: 'coder', body: 'Please look at row 76.' } },
|
|
{ business: 'demo', verb: 'message.send', args: { to: 'nobody', body: 'hi' } },
|
|
{ business: 'demo', verb: 'message.send', args: { to: 'crash', body: 'hi' } },
|
|
]);
|
|
assert.equal(lines.length, 3);
|
|
});
|
|
|
|
test('against a real broker: the message is written once as a human send, and its refusals map', async t => {
|
|
const { b, human, transport } = broker(t);
|
|
const call = transport('demo');
|
|
// The transport's refusal shape, as humanCall gives it.
|
|
const send = async args => { try { return await call('message.send', args); } catch (e) { throw new BusReadError(e.code); } };
|
|
const { port } = await setup(t, { send });
|
|
const r = await post(port, { body: JSON.stringify({ to: 'coder', body: 'Please look at row 76.' }) });
|
|
assert.equal(r.status, 200);
|
|
const messages = () => b.request(human, { verb: 'trail', args: { subject: r.body.id } }).filter(row => row.table === 'messages');
|
|
assert.deepEqual(messages().map(m => [m.id, m.from_role, m.from_run, m.to_role, m.class, m.body]), [[r.body.id, 'human', 'human-cli', 'coder', 'INFO', 'Please look at row 76.']]);
|
|
for (const [body, status, error] of [
|
|
[{ to: 'no-such-role', body: 'hi' }, 400, 'unknown-role'],
|
|
[{ to: 'coder', body: 'x'.repeat(32769) }, 400, 'invalid-request'],
|
|
[{ to: 'coder', body: '' }, 400, 'invalid-request'],
|
|
[{ to: 7, body: 'hi' }, 400, 'unknown-role'],
|
|
[{ to: 'coder', body: 'hi', in_reply_to: 'msg-none' }, 400, 'message-not-found'],
|
|
]) {
|
|
// The 32769-character body is past the Console's 16 KiB too; the Console refuses that one first.
|
|
const res = await post(port, { body: JSON.stringify(body) });
|
|
assert.deepEqual([res.status, res.body.error, res.body.sent], [status, error, false], JSON.stringify(body).slice(0, 60));
|
|
}
|
|
assert.equal(messages().length, 1);
|
|
assert.equal(b.request(human, { verb: 'trail', args: { subject: 'msg-none' } }).length, 0);
|
|
});
|