fix(portal): 藏書地圖看得到自己的知識——租戶字串改從「寫入端」來,不再拿環境變數預設值(Arcrun#108)

leo 2026-08-12 實撞:藏書地圖回 0 個庫,同一分鐘 KBDB 裡有 1854 條三元組,
`arcrun_whoami` 顯示 admin/全部知識庫、`kbdb_search` 也查得到——只有地圖那格是空的。

病根(不是資料掉了,是讀寫兩端各拿一個來源):
  寫入端 owner_id = `~/.arcrun/config.yaml` 的 `api_key`(CLI push/小幫手上傳/MCP,
    leo = `bfezv28v`)
  讀取端過濾 = `portalTenant(env) = env.CONSOLE_TENANT || "leo"`
    ——repo toml 帶的**官方 prod 值**,而 `acr` 從來不注入 CONSOLE_TENANT
  ⇒ 那個 `"leo"` 不是理論邊角,是每台 self-hosted 實例的實際行為,1854 條全被濾掉。

與 #105(`env.MCP_OWNER_NAMESPACE || "leo"`)同一句話,換一個檔案。

租戶字串該從哪裡來(本票的核心判斷):
  **從「寫入這批知識的那一方」來,不是從一份手抄的環境變數預設值來。**
  不是「掛到每個帳號上」——portal 帳號共用同一台實例的知識庫(design D-2),
  帳號之間的差別是 libraries 權限不是 owner_id;複製一份到帳號上只是多一個會過期的副本。
  #105 真正的教訓是:過濾用的租戶字串要有單一權威來源、解析不到要誠實失敗、且要能機械驗證。

修法:
1. 唯一產地 `cypher-executor/src/lib/tenant.ts`
   - `knowledgeOwner(env)` → branded `TenantId`:`ARCRUN_NAMESPACE` → `CONSOLE_TENANT` →
     丟 `TenantUnresolvedError`。**沒有字面預設值**——`|| 'leo'` 正是把「這台機器沒設定」
     偽裝成「你沒有資料」的元凶。
   - `accountTenant(env)` → 普通 `string`(帳號子 namespace `{tenant}::portal` 與 cypher
     自己寫的設定用它)。**回 string 是刻意的**:型別上就不可能流進知識資料面。
   - 資料面過濾一律經 `ownerQuery()` / `ownerField()`,只吃 `TenantId`。
2. 值的正解由 CLI 從真相源導出:`acr update` 把 config 的 `api_key` 注入成 `ARCRUN_NAMESPACE`,
   但**先驗再寫**(`GET /kbdb/map?owner_id=<api_key>` 查得到庫才寫;查不到/問不到就一個字
   都不動)。無條件覆蓋會把「知識本來就在 CONSOLE_TENANT 底下」的一鍵安裝實例指向空的那一格
   ——那是 #97/#106 那類「更新一次把人家的東西弄不見」,比原本的 bug 更糟。
   未注入時回退 CONSOLE_TENANT ⇒ 對官方 prod 與未更新的實例,這次改動是惰性的。
3. 空地圖分四態(沿 #100「讀不到就說讀不到」):no_library_grant/filtered_out/
   scope_mismatch/confirmed_empty。scope_mismatch 以前不存在,所以設定錯誤被畫成
   「你沒有資料」。回應仍不含租戶字串(design §3.3 紅線)。
4. 同族一起修(同一道閘一次抓到):console-dashboard 4 處、console-auth 1 處
   ——console 首頁的規模數字與藏書地圖對 leo 也一直是空的。

留下的閘(規則存在但沒機制驗證=會再犯第三次):
  · 型別閘:TenantId 只能由 tenant.ts 產出 → 拿隨手一個 string 去過濾,tsc 當場不給過。
  · 出貨閘:scripts/build-worker-artifacts.mjs 編 tier2 成品前先掃,違規 → 編不出成品。
  · 閘自己可測:規則是純函式(tenant-source-rules.mjs),tests/tenant-gate.test.ts
    逐條驗「5 種壞例子會擋」+「11 種合法寫法零誤攔」;掃描範圍只有 src/,擋不到自己。
  規範寫入 .claude/rules/02-forbidden.md 第六類、system-dev/wiki/mistakes.md #26。

沒動:庫權限過濾(一字未改,回歸測試釘住)、帳號資料落點、任何金鑰、租戶字串仍不下發前端。

驗證:
  cypher   vitest 441 綠 / 14 紅,14 紅與 base commit e05518a 逐字相同(既有)
           tsc 5 個既有錯誤,零新增
  cli      node:test 60/60 綠(含本次新增 12 條);tsc 零錯誤
  閘       壞例子實跑 exit 1;build 實跑「建置中止」;乾淨時實跑通過
  端到端   ◐ 未驗:需部署到 leo21c,那道閘要 leo 親手解(見 PR ③)

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
uncle6me-web
2026-08-13 00:15:58 +08:00
parent e05518a2b4
commit b223a69884
19 changed files with 1290 additions and 55 deletions
@@ -0,0 +1,261 @@
/**
* Arcrun#108 — 藏書地圖看得到自己的知識(租戶字串來源收斂)。
*
* 釘住的事實:
* 1. 資料面 owner_id 來自 `knowledgeOwner(env)``ARCRUN_NAMESPACE` 優先、`CONSOLE_TENANT` 回退、
* 兩者皆無 → 丟 TenantUnresolvedError**沒有 `|| 'leo'` 這種靜默預設值**)。
* 2. `/portal/data/map` 真的拿那個值去打 KBDBleo 的情境:ARCRUN_NAMESPACE=bfezv28v
* → 打 `owner_id=bfezv28v` 拿回 9 個庫,而不是打 `owner_id=leo` 拿回 0 個)。
* 3. **權限沒有被拿掉**:同一份 KBDB 回應,庫權限 ["kb"] 的帳號只看得到 kb。
* 4. 空地圖分得出四種成因(#100 那條「讀不到就說讀不到」延伸到藏書地圖):
* no_library_grant / filtered_out / scope_mismatch / confirmed_empty。
* 5. 回應**不含租戶字串**(design §3.3 紅線:前端拿到就能繞過庫過濾直打 /kbdb/*)。
*/
import { env, fetchMock } from 'cloudflare:test';
import { beforeAll, afterEach, describe, it, expect } from 'vitest';
import { knowledgeOwner, accountTenant, TenantUnresolvedError, ownerQuery, censusQueryAllTenants } from '../src/lib/tenant';
import { portalDataRouter } from '../src/routes/portal-data';
import type { Bindings } from '../src/types';
const KBDB = 'https://kbdb.test';
/** leo 的真實命名空間(2026-08-11 回灌時定名,見 Leo/mira#8)。 */
const LEO_NS = 'bfezv28v';
beforeAll(() => {
fetchMock.activate();
fetchMock.disableNetConnect();
});
afterEach(() => fetchMock.assertNoPendingInterceptors());
/**
* 直接餵 router 一份 env(不是 SELF.fetch)——`cloudflare:test` 的 `env` 物件改了不會傳進
* SELF 那個 worker(實測:改 ARCRUN_BUNDLE_VERSION 後 /health 仍回舊值),
* 而本票要驗的正是「換一個命名空間,查詢就跟著換」。Hono router 吃 env 參數,
* 走的是同一支 handler、同一條 KBDB fetch,只有 env 這一項是測試給的。
*/
const ctx = { waitUntil: () => {}, passThroughOnException: () => {} } as unknown as ExecutionContext;
async function seedSession(token: string, recordId: string) {
await env.SESSIONS_KV.put(`portal_sess:${token}`, JSON.stringify({ record_id: recordId }));
}
function mockGetRecord(recordId: string, libraries: string) {
fetchMock
.get(KBDB)
.intercept({ path: `/records/${recordId}`, method: 'GET' })
.reply(200, {
success: true,
record: {
record_id: recordId,
template_id: 'tpl_pu',
values: {
email: 'leo@example.com',
display_name: 'leo',
status: 'active',
role: 'admin',
password_hash: 'pbkdf2-sha256$600000$AA$BB',
libraries,
created_at: '2026-08-12T00:00:00.000Z',
updated_at: '2026-08-12T00:00:00.000Z',
},
},
});
}
/** 攔 `/map`,同時把「實際被查詢的 owner_id」記下來給斷言用。 */
function mockMap(libraries: { library: string; triplet_count: number }[], seen: string[]) {
fetchMock
.get(KBDB)
.intercept({
path: (p: string) => {
if (!p.startsWith('/map')) return false;
seen.push(new URL(p, KBDB).searchParams.get('owner_id') ?? '');
return true;
},
method: 'GET',
})
.reply(200, { success: true, libraries, count: libraries.length });
}
function mockTripletStats(match: (ownerId: string) => boolean, tripletCount: number) {
fetchMock
.get(KBDB)
.intercept({
path: (p: string) =>
p.startsWith('/records/triplet-stats') && match(new URL(p, KBDB).searchParams.get('owner_id') ?? ''),
method: 'GET',
})
.reply(200, { success: true, stats: [{ library: 'kb', triplet_count: tripletCount }] });
}
async function getMap(token: string, overrides: Partial<Bindings> = {}) {
const res = await portalDataRouter.fetch(
new Request('http://localhost/portal/data/map', { headers: { authorization: `Bearer ${token}` } }),
{ ...env, ...overrides } as Bindings,
ctx,
);
return { status: res.status, body: (await res.json()) as Record<string, unknown> };
}
/** undici 的 path matcher 可能被呼叫多次 → 比對前先去重(我們在意的是「查了哪些 owner_id」)。 */
const distinct = (xs: string[]): string[] => [...new Set(xs)];
// ── ① 唯一產地的解析順序 ───────────────────────────────────────────────────────
describe('knowledgeOwner:租戶字串只有一個產地,且沒有靜默預設值', () => {
it('ARCRUN_NAMESPACE 優先(=acr update 從 ~/.arcrun/config.yaml 的 api_key 注入的那個值)', () => {
expect(knowledgeOwner({ ARCRUN_NAMESPACE: LEO_NS, CONSOLE_TENANT: 'leo' } as Bindings)).toBe(LEO_NS);
});
it('沒注入 → 回退 CONSOLE_TENANT(官方 prod 與尚未 acr update 的實例,行為一字不變)', () => {
expect(knowledgeOwner({ CONSOLE_TENANT: 'leo' } as Bindings)).toBe('leo');
});
it('空字串不算數(部署把 var 設成空字串 ≠ 有設定)', () => {
expect(knowledgeOwner({ ARCRUN_NAMESPACE: ' ', CONSOLE_TENANT: 'leo' } as Bindings)).toBe('leo');
});
it('兩個都沒有 → 丟 TenantUnresolvedError**不回 "leo"**(靜默預設值正是本票的病)', () => {
expect(() => knowledgeOwner({} as Bindings)).toThrow(TenantUnresolvedError);
});
it('帳號層 accountTenant 不受影響(改它會讓舊實例登不進去,所以刻意不動)', () => {
expect(accountTenant({ ARCRUN_NAMESPACE: LEO_NS, CONSOLE_TENANT: 'leo' } as Bindings)).toBe('leo');
expect(accountTenant({} as Bindings)).toBe('leo');
});
it('過濾片段只有兩種形狀:帶租戶的 ownerQuery,與明著喊全庫的普查', () => {
expect(ownerQuery(knowledgeOwner({ ARCRUN_NAMESPACE: 'a b' } as Bindings))).toBe('owner_id=a%20b');
expect(censusQueryAllTenants()).toBe('owner_id=');
});
});
// ── ② 地圖真的用那個 owner_id 去查 ─────────────────────────────────────────────
describe('GET /portal/data/map — leo 的情境(1854 條 → 看得到,不是 0 個庫)', () => {
it('注入 ARCRUN_NAMESPACE 後,KBDB 收到的 owner_id 是它,而且庫都回得來', async () => {
await seedSession('t-map-1', 'rec_leo');
mockGetRecord('rec_leo', '["*"]');
const seen: string[] = [];
mockMap(
[
{ library: 'kb', triplet_count: 1851 },
{ library: 'general', triplet_count: 3 },
],
seen,
);
const { status, body } = await getMap('t-map-1', { ARCRUN_NAMESPACE: LEO_NS });
expect(status).toBe(200);
expect(distinct(seen)).toEqual([LEO_NS]); // ← 這一行就是本票:以前送出去的是 'leo'
expect(body.count).toBe(2);
expect((body.libraries as { library: string; triplet_count: number }[]).map((l) => l.triplet_count))
.toEqual([1851, 3]);
expect(body.empty_reason).toBeNull();
});
it('回應不含租戶字串(前端拿到就能繞過庫過濾直打 /kbdb/*——design §3.3 紅線)', async () => {
await seedSession('t-map-2', 'rec_leo2');
mockGetRecord('rec_leo2', '["*"]');
mockMap([{ library: 'kb', triplet_count: 1851 }], []);
const { body } = await getMap('t-map-2', { ARCRUN_NAMESPACE: LEO_NS });
expect(JSON.stringify(body)).not.toContain(LEO_NS);
expect(JSON.stringify(body)).not.toContain('ARCRUN_NAMESPACE');
});
it('沒注入時沿用 CONSOLE_TENANT(未跑 acr update 的實例行為不變,這次改動對它是惰性的)', async () => {
await seedSession('t-map-3', 'rec_leo3');
mockGetRecord('rec_leo3', '["*"]');
const seen: string[] = [];
mockMap([{ library: 'kb', triplet_count: 1 }], seen);
await getMap('t-map-3');
expect(distinct(seen)).toEqual(['leo']); // wrangler.test.toml CONSOLE_TENANT
});
});
// ── ③ 權限沒有被拿掉(紅線:修這題不准把 owner_id 過濾或庫過濾拆掉)──────────────
describe('權限:只被授權部分庫的帳號,只看得到那幾個庫', () => {
it('libraries=["kb"] → 同一份 KBDB 回應裡只剩 kb', async () => {
await seedSession('t-perm-1', 'rec_partial');
mockGetRecord('rec_partial', '["kb"]');
mockMap(
[
{ library: 'kb', triplet_count: 1851 },
{ library: 'finance', triplet_count: 42 },
{ library: 'general', triplet_count: 3 },
],
[],
);
const { body } = await getMap('t-perm-1', { ARCRUN_NAMESPACE: LEO_NS });
expect((body.libraries as { library: string }[]).map((l) => l.library)).toEqual(['kb']);
expect(body.count).toBe(1);
});
it('一個庫都沒被授權 → 不打 KBDB,誠實說是權限問題', async () => {
await seedSession('t-perm-2', 'rec_nolib');
mockGetRecord('rec_nolib', '[]');
const { body } = await getMap('t-perm-2'); // 沒有 mockMap:打了就會 assertNoPendingInterceptors 失敗
expect(body.count).toBe(0);
expect(body.empty_reason).toBe('no_library_grant');
expect(body.empty_confirmed).toBe(true);
});
it('實例有庫但都不在權限內 → filtered_out(是隔離正常,不是資料不見)', async () => {
await seedSession('t-perm-3', 'rec_other');
mockGetRecord('rec_other', '["finance"]');
mockMap([{ library: 'kb', triplet_count: 1851 }], []);
const { body } = await getMap('t-perm-3', { ARCRUN_NAMESPACE: LEO_NS });
expect(body.empty_reason).toBe('filtered_out');
expect(body.empty_confirmed).toBe(true);
});
});
// ── ④ 空地圖的四種成因分得出來(不再把設定錯誤畫成「你沒有資料」)────────────────
describe('空地圖:分得出「讀不到」與「沒有」', () => {
it('命名空間對不上(本租戶 0、整台實例有)→ scope_mismatch,並指出該跑 acr update', async () => {
await seedSession('t-empty-1', 'rec_e1');
mockGetRecord('rec_e1', '["*"]');
mockMap([], []);
mockTripletStats((o) => o === 'wrong-ns', 0); // 本租戶 0
mockTripletStats((o) => o === '', 1854); // 全庫普查:有 1854 條
const { body } = await getMap('t-empty-1', { ARCRUN_NAMESPACE: 'wrong-ns' });
expect(body.empty_reason).toBe('scope_mismatch');
expect(body.empty_confirmed).toBe(false); // 🔴 絕不宣稱「你沒有資料」
expect(body.instance_triplet_count).toBe(1854);
expect(String(body.note)).toContain('acr update');
expect(JSON.stringify(body)).not.toContain('wrong-ns'); // 仍不下發租戶字串
});
it('整台實例真的空 → confirmed_empty(此時、也只有此時,才准說「還沒有內容」)', async () => {
await seedSession('t-empty-2', 'rec_e2');
mockGetRecord('rec_e2', '["*"]');
mockMap([], []);
mockTripletStats((o) => o === 'leo', 0);
mockTripletStats((o) => o === '', 0);
const { body } = await getMap('t-empty-2');
expect(body.empty_reason).toBe('confirmed_empty');
expect(body.empty_confirmed).toBe(true);
});
it('連統計都讀不到 → unreadable(不假裝是空庫)', async () => {
await seedSession('t-empty-3', 'rec_e3');
mockGetRecord('rec_e3', '["*"]');
mockMap([], []);
fetchMock
.get(KBDB)
.intercept({ path: (p: string) => p.startsWith('/records/triplet-stats'), method: 'GET' })
.reply(500, { error: 'boom' });
const { body } = await getMap('t-empty-3');
expect(body.empty_reason).toBe('unreadable');
expect(body.empty_confirmed).toBe(false);
});
});
+118
View File
@@ -0,0 +1,118 @@
/**
* 「靜態租戶字串不得用於資料面過濾」這道閘**自己**的測試(Arcrun#108)。
*
* 收工標準明列三條,這裡逐條釘:
* ① 會擋,不是只提醒 → 壞例子必須產出違規(CLI 端據此 exit 1、hook 據此 exit 2
* ② 判準看「有沒有在做那件事」 → 一整組「長得像但沒在做」的合法寫法必須零誤攔
* ③ 閘自己要能被測試 → 規則是純函式,這裡直接餵字串;不需要跑檔案系統、也擋不到自己
*
* 外加一條回歸:現行 src/ 必須是乾淨的(`?raw` 讀真原始碼,不是讀我編的假字串)。
*/
import { describe, it, expect } from 'vitest';
// @ts-expect-error -- 純規則模組(.mjs,零 node 相依),型別非本檔關注重點
import { scanSource, TENANT_SOURCE_FILE } from '../scripts/tenant-source-rules.mjs';
// @ts-expect-error -- vite ?rawbuild-time 讀檔,runtime 是純字串(Workers 沒有 node:fs
import tenantLibSource from '../src/lib/tenant.ts?raw';
// @ts-expect-error -- 同上
import portalDataSource from '../src/routes/portal-data.ts?raw';
// @ts-expect-error -- 同上
import portalSource from '../src/routes/portal.ts?raw';
// @ts-expect-error -- 同上
import consoleAuthSource from '../src/routes/console-auth.ts?raw';
// @ts-expect-error -- 同上
import consoleDashboardSource from '../src/routes/console-dashboard.ts?raw';
type Violation = { rule: string; line: number; text: string; message: string };
const scan = (path: string, text: string): Violation[] => scanSource(path, text) as Violation[];
const rulesOf = (v: Violation[]): string[] => [...new Set(v.map((x) => x.rule))].sort();
const FILE = 'src/routes/portal-data.ts';
describe('閘會擋:三種「靜態租戶字串進資料面」的真實形狀', () => {
it('T1 — 在 tenant.ts 以外讀租戶環境變數(#105/#108 的原句)', () => {
const bad = `export function portalTenant(env: Bindings): string {\n return env.CONSOLE_TENANT || 'leo';\n}`;
const v = scan('src/routes/portal.ts', bad);
expect(rulesOf(v)).toContain('T1');
expect(v[0].message).toContain('knowledgeOwner');
});
it('T1 — `c.env.ARCRUN_NAMESPACE` 也一樣(換一個變數名不是換一個做法)', () => {
const v = scan('src/routes/console-dashboard.ts', `const t = c.env.ARCRUN_NAMESPACE || 'leo';`);
expect(rulesOf(v)).toEqual(['T1']);
});
it('T2 — 繞過唯一產地自己 cast 一個 TenantId', () => {
const v = scan(FILE, `const tenant = (c.env.SOMETHING ?? '') as TenantId;`);
expect(rulesOf(v)).toContain('T2');
});
it('T2 — 連在 tenant.ts 裡都不准把「字面字串」當成租戶識別(那就是 `|| "leo"` 的原形)', () => {
const v = scan(TENANT_SOURCE_FILE, ` return 'leo' as TenantId;`);
expect(rulesOf(v)).toEqual(['T2']);
expect(v[0].message).toContain('TenantUnresolvedError');
});
it('T3 — 拿帳號層字串去組知識資料面的 owner_id(#108 那一行,逐字)', () => {
const bad = " const res = await kbdbFetch(c.env, `/map?owner_id=${encodeURIComponent(portalTenant(c.env))}`);";
const v = scan(FILE, bad);
expect(rulesOf(v)).toContain('T3');
expect(v[0].message).toContain('1854');
});
it('T3 — 換成物件屬性寫法一樣擋(`owner_id: portalTenant(c.env)`', () => {
const v = scan(FILE, ` body: JSON.stringify({ values, owner_id: portalTenant(c.env) }),`);
expect(rulesOf(v)).toContain('T3');
});
it('T3 — accountTenant() 這個新名字也擋(規則盯的是「這是帳號層的值」,不是某個函式名字的拼法)', () => {
const v = scan(FILE, " kbdbFetch(env, `/entries?owner_id=${accountTenant(env)}`);");
expect(rulesOf(v)).toContain('T3');
});
});
describe('閘不誤攔:長得像、但沒有在做那件事的合法寫法', () => {
const legit: [string, string, string][] = [
['讀取別人回傳的 owner_id(不是在組過濾)', FILE, ` if (!isOwnedBy(entry.owner_id, knowledgeOwner(c.env))) return notFound(c);`],
['型別宣告裡的 owner_id 欄位', FILE, ` | { record?: { values?: Record<string, unknown>; owner_id?: string | null } }`],
['走唯一入口組過濾', FILE, " const res = await kbdbFetch(c.env, `/map?${ownerQuery(tenant)}`);"],
['走唯一入口填 body', FILE, ` body: JSON.stringify({ template, values, owner_id: ownerField(tenant) }),`],
['帳號子 namespace 的過濾(`{tenant}::portal`,那是 cypher 自己寫的資料)', 'src/routes/portal.ts', ` const res = await kbdbFetch(env, \`/records/by-template/x?owner_id=\${encodeURIComponent(ns)}\`);`],
['請求自帶的租戶(webhooks-named 慣例:呼叫端就是租戶)', 'src/routes/webhooks-named.ts', ` owner_id: apiKey,`],
['註解裡整句在講 CONSOLE_TENANT 與 owner_id(文件不是行為)', FILE, `// 之前的病:owner_id 拿 env.CONSOLE_TENANTportalTenant(c.env) 那條路整個空掉`],
['JSDoc 區塊裡出現同樣的字', FILE, ` * 舊寫法 owner_id=\${portalTenant(env)} 已廢除,改走 knowledgeOwner。`],
['行末註解裡出現(程式碼本身乾淨)', FILE, ` const tenant = knowledgeOwner(c.env); // 不是 portalTenant(c.env),也不是 owner_id=leo`],
['tenant.ts 自己讀環境變數(它就是唯一產地)', TENANT_SOURCE_FILE, ` const injected = (env.ARCRUN_NAMESPACE ?? '').trim();`],
['types.ts 只宣告型別不取值', 'src/types.ts', ` CONSOLE_TENANT?: string;`],
];
for (const [name, path, line] of legit) {
it(`零誤攔:${name}`, () => {
expect(scan(path, line)).toEqual([]);
});
}
});
describe('回歸:現行原始碼是乾淨的(讀真檔,不是讀我編的字串)', () => {
const files: [string, string][] = [
[TENANT_SOURCE_FILE, tenantLibSource as string],
['src/routes/portal-data.ts', portalDataSource as string],
['src/routes/portal.ts', portalSource as string],
['src/routes/console-auth.ts', consoleAuthSource as string],
['src/routes/console-dashboard.ts', consoleDashboardSource as string],
];
for (const [path, text] of files) {
it(`${path} 零違規`, () => {
expect(scan(path, text)).toEqual([]);
});
}
it('唯一產地本身沒有字面預設值(knowledgeOwner 解析不到要用丟的,不是回 "leo"', () => {
const body = (tenantLibSource as string).slice(
(tenantLibSource as string).indexOf('export function knowledgeOwner'),
(tenantLibSource as string).indexOf('export function tenantFromApiKey'),
);
expect(body).toContain('TenantUnresolvedError');
// 解析路徑只准回 env 讀到的值;任何 `|| '...'` / `?? '...'` 形式的字面 fallback 都是本票的病本身
expect(body).not.toMatch(/(\|\||\?\?)\s*['"][^'"]+['"]/);
});
});