fix: add scoped rendered-card ledger readers

This commit is contained in:
YBF
2026-09-15 14:44:41 +08:00
parent 330b40b0dd
commit c19e709c93
21 changed files with 1623 additions and 308 deletions
@@ -0,0 +1,291 @@
# OneTalk Rendered Card Readers Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use `subagent-driven-development` (recommended) or `executing-plans` to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Safely observe and persist normalized order, inquiry, and product card content from OneTalk React Fiber while removing the non-existent `storeImageUrl` concept.
**Architecture:** Extract a contract-owned canonical product-reference module shared by base-message and rendered-card validation. Split the MAIN-world card reader from its MutationObserver lifecycle, use one strict dispatcher and three bounded typed projections, then forward the unchanged normalized observation through the existing bridge and durable coordinator.
**Tech Stack:** TypeScript, Node test runner, Chrome MV3 content scripts, React Fiber runtime data, pnpm workspace.
**Spec:** `.trellis/tasks/09-15-onetalk-rendered-card-readers/{prd.md,design.md,implement.md}`
## Global Constraints
- MAIN world may output only whitelisted normalized plain data; raw props, action params, callbacks, trace data, DOM/Fiber references, credentials, query strings, and fragments never cross the bridge.
- Identity remains `channelAccountId + conversationId + messageId`; no DOM text, ordering, or CSS inference is permitted.
- Unknown card templates fail closed. No generic props crawl or active page interaction is allowed.
- Existing bridge, ledger, ACK/conflict state machine, base-message sync, database schema, and Mind events retain their current behavior.
- `storeImageUrl` is removed completely. Products retain only `product.imageUrl`.
- Before each shared-symbol edit, run GitNexus upstream impact; report HIGH/CRITICAL risk before proceeding.
- The accepted prerequisite repair must preserve the IndexedDB index-refactor invariant: rendered-card pending recovery is account-scoped and index-backed, never a restored full-store scan.
### Task 0: Repair the rendered-card ledger consumer omitted by the IndexedDB index refactor
**Files:**
- Modify: `apps/chrome-extension/src/onetalk/service-worker/storage.ts`
- Test: `apps/chrome-extension/test/onetalk-sync-storage.test.js`
- Test: `apps/chrome-extension/test/onetalk-rendered-card-coordinator.test.js` only if its ledger fake must reflect the required account argument
**Root cause:** `6be3b839` introduced rendered-card `listPending()` using `readAll`; `61df41d` replaced `readAll` with indexed readers but missed this consumer. This is a prerequisite because it blocks extension typecheck.
- [ ] Write a failing storage test that calls rendered-card `listPending("account-1")` across records for two accounts and terminal/pending states. It must expect only account-1 `pending_ack` records and assert the ledger object store made no `getAll()` call.
- [ ] Extend the existing old-version migration fixture with a v9 rendered-card record; run the focused test and record the failure caused by unresolved `readAll`.
- [ ] Increment `ONE_TALK_SYNC_DATABASE_VERSION` to v10. In `onupgradeneeded`, create only `[channelAccountId, status]` and `[channelAccountId, conversationId]` indexes on `onetalk_rendered_card_ledger`, without record rewrite, clear, or backfill.
- [ ] Make `listPending(channelAccountId: string)` use `readByIndex(..., [channelAccountId, "pending_ack"])`. It must not accept an omitted account or reintroduce `readAll`.
- [ ] Include the rendered-card ledger in `clearConversationHistory`'s existing index-key deletion loop using its account/conversation index. This restores the pre-existing cleanup test and does not change the transaction's scope or status-machine behavior.
- [ ] Run focused storage/coordinator tests and `pnpm exec tsc --noEmit -p apps/chrome-extension/tsconfig.json` before accepting the prerequisite. Preserve durable-first, first-content-wins, ACK and reconnect behavior.
---
### Task 1: Share canonical product URL validation across content contracts
**Files:**
- Create: `packages/onetalk-contract/src/product-url.ts`
- Modify: `packages/onetalk-contract/src/content.ts`
- Modify: `packages/onetalk-contract/src/index.ts`
- Test: `packages/onetalk-contract/test/contract.test.ts`
**Consumes:** The current `normalizeOneTalkProductUrl` host/path/ID rules in `content.ts`.
**Produces:**
```ts
export type OneTalkProductReference = { sourceUrl: string; productId: string };
export const normalizeOneTalkProductUrl = (value: unknown): OneTalkProductReference | null;
export const isOneTalkProductReference = (value: unknown): value is OneTalkProductReference;
```
- [ ] **Step 1: Add failing contract tests for a canonical reference and invalid variants.**
```ts
assert.deepEqual(normalizeOneTalkProductUrl(validProductUrl), {
sourceUrl: validProductUrl,
productId: "1601456609478",
});
assert.equal(isOneTalkProductReference({ sourceUrl: validProductUrl, productId: "wrong" }), false);
assert.equal(normalizeOneTalkProductUrl(urlWithFragment), null);
```
- [ ] **Step 2: Run the contract test before refactoring.**
Run: `pnpm --filter @trade-message-center/onetalk-contract test`
Expected: the new reference-validator assertions fail because no standalone product-reference validator exists.
- [ ] **Step 3: Move only the current canonical URL parse logic into `product-url.ts`.**
Keep the current `chinese.alibaba.com/product-detail/...` path rule and query stripping semantics unchanged. Define `isOneTalkProductReference` by validating the exact two keys and checking that `normalizeOneTalkProductUrl(sourceUrl)` returns the same `sourceUrl` and `productId`.
- [ ] **Step 4: Import the normalizer from `content.ts` and export the new module through `index.ts`.**
The base `kind: "product"` validation must keep its public behavior. Do not import `content.ts` from `rendered-cards.ts`, because `content.ts` already consumes the rendered-card union.
- [ ] **Step 5: Re-run the contract suite.**
Run: `pnpm --filter @trade-message-center/onetalk-contract test`
Expected: all base-product URL tests and new canonical-reference tests pass.
### Task 2: Narrow rendered-product JSON to the approved product image and reference
**Files:**
- Modify: `packages/onetalk-contract/src/rendered-cards.ts`
- Modify: `packages/onetalk-contract/src/index.ts`
- Test: `packages/onetalk-contract/test/contract.test.ts`
- Test: `apps/chrome-extension/test/onetalk-page-bridge.test.js`
- Test: `apps/chrome-extension/test/onetalk-rendered-card-coordinator.test.js`
**Consumes:** `OneTalkProductReference` and the existing card image/text/quantity validators.
**Produces:**
```ts
type OneTalkRenderedProductContent = {
version: 1;
kind: "rendered_product";
product: OneTalkRenderedCardImage & OneTalkProductReference;
priceDisplay: string;
minimumOrder: OneTalkRenderedCardQuantity;
serviceBadges: string[];
};
```
- [ ] **Step 1: Add failing strict-shape tests.**
```ts
assert.equal(isOneTalkRenderedCardContent(validRenderedProduct), true);
assert.equal(isOneTalkRenderedCardContent({ ...validRenderedProduct, storeImageUrl: null }), false);
assert.equal(
isOneTalkRenderedCardContent({
...validRenderedProduct,
product: { ...validRenderedProduct.product, sourceUrl: "https://img.alicdn.com/item.jpg" },
}),
false,
);
```
- [ ] **Step 2: Run focused contract tests to establish failure.**
Run: `pnpm --filter @trade-message-center/onetalk-contract test`
Expected: the valid canonical product reference is rejected by the current image-host validator, and the old shape still accepts `storeImageUrl`.
- [ ] **Step 3: Remove `storeImageUrl` from the type and exact-key check.**
Replace `isOneTalkRenderedCardImageUrl(product.sourceUrl)` with `isOneTalkProductReference({ sourceUrl: product.sourceUrl, productId: product.productId })`, after the full product object has separately passed its exact-key, image, and title validation. This retains the exact two-key product-reference contract without allowing the rendered product's additional fields to bypass it.
- [ ] **Step 4: Update every rendered-product fixture or assertion.**
Search the entire workspace for `storeImageUrl` and remove it from valid payloads. Add one negative case at each strict decoder boundary so the removed key cannot silently re-enter through bridge or coordinator fixtures.
- [ ] **Step 5: Run contract and downstream targeted tests.**
Run:
```text
pnpm --filter @trade-message-center/onetalk-contract test
pnpm --filter @trade-message-center/chrome-extension test -- onetalk-page-bridge onetalk-rendered-card-coordinator
```
Expected: valid product payloads survive cloning/fingerprinting; any `storeImageUrl`, invalid URL, or mismatched product ID is rejected.
### Task 3: Make the Fiber reader independently testable
**Files:**
- Create: `apps/chrome-extension/src/onetalk/main-page/card-observer/react-card-reader.ts`
- Modify: `apps/chrome-extension/src/onetalk/main-page/card-observer/entry.ts`
- Create: `apps/chrome-extension/test/onetalk-rendered-card-reader.test.js`
- Modify: `apps/chrome-extension/test/onetalk-rendered-card-observer.test.js`
**Consumes:** `OneTalkRenderedCardContent`, `createOneTalkRenderedCardContentFingerprint`, `normalizeOneTalkProductUrl`, `readConversationSelection`, and `OneTalkPageRenderedCardBaseEvidence`.
**Produces:**
```ts
export type OneTalkRenderedCardRead = {
observation: OneTalkRenderedCardObservation;
baseEvidence: OneTalkPageRenderedCardBaseEvidence;
};
export const readOneTalkRenderedCard = (
pageWindow: OneTalkPageWindow,
wrapper: Element,
): OneTalkRenderedCardRead | null;
```
- [ ] **Step 1: Write fixtures that model bounded React Fiber links.**
The order fixture must use `productName`; inquiry must use `cardType: 6`, `inquiryCardDTO`, `displayProducts`, `inquiryContent`, and `inquiryID`; product must use `msgType: 101`, descendant `cardType: 54`, `productAction.actionParams.url`, and visible benefit/promotion strings. Each fixture must expose a selected `data-cid` matching `conversationCode`.
- [ ] **Step 2: Add failing reader assertions for all three valid shapes.**
```ts
assert.equal(
readOneTalkRenderedCard(page, orderWrapper)?.observation.content.kind,
"rendered_order",
);
assert.equal(
readOneTalkRenderedCard(page, inquiryWrapper)?.observation.content.kind,
"rendered_inquiry",
);
assert.equal(
readOneTalkRenderedCard(page, productWrapper)?.observation.content.kind,
"rendered_product",
);
```
Assert the full approved output shape, including inquiry action label order and product badge order. Assert that product output has no `storeImageUrl`, `hsfImg`, action params, trace data, or raw DTO fields.
- [ ] **Step 3: Add failing rejection cases.**
Cover `cardType: 12`, unknown 10010 cards, `msgType: 101` without renderer `cardType: 54`, missing template fields, unsafe image/reference URL, incomplete identity, and a selected conversation that changes between before/after reads.
- [ ] **Step 4: Implement bounded common Fiber and identity helpers.**
Move `FiberRecord`, `fiberFor`, `itemDataFor`, text/scalar/image guards, the fixed traversal bounds, and before/after conversation checks into `react-card-reader.ts`. Return both observation and base evidence so `entry.ts` does not re-read Fiber state after normalization.
- [ ] **Step 5: Implement an explicit classifier and three direct template predicates.**
```ts
const classifier = classifyRenderedCard(item, fiber);
switch (classifier.kind) {
case "order":
return readOrderTemplate(classifier.data);
case "inquiry":
return readInquiryTemplate(classifier.data);
case "product":
return readProductTemplate(classifier.data);
default:
return null;
}
```
Do not add a generic record walk. Product renderer detection remains bounded to the same Fiber subtree. Deduplicate badges in fixed observed order and only emit nonempty validated text.
- [ ] **Step 6: Keep `entry.ts` lifecycle-only and run reader tests.**
`installOneTalkRenderedCardObserver` continues to own WeakSet deduplication, `setTimeout(0)`, root rebinding, `pagehide`, and mutation discovery. It calls the new reader once and forwards paired observation/base evidence.
Run:
```text
pnpm --filter @trade-message-center/chrome-extension test -- onetalk-rendered-card-reader onetalk-rendered-card-observer
```
Expected: all valid fixtures normalize exactly once; every lookalike produces no observation.
### Task 4: Verify end-to-end boundaries and finalize the change
**Files:**
- Modify only test files shown by the `storeImageUrl` and reader-symbol searches.
- Modify task artifacts only if implementation changes a documented invariant.
**Consumes:** Completed contract and reader tests.
**Produces:** A reviewed, type-safe, buildable reader change with documented runtime evidence limits.
- [ ] **Step 1: Run targeted bridge and coordinator regressions.**
Run:
```text
pnpm --filter @trade-message-center/chrome-extension test -- onetalk-page-bridge onetalk-rendered-card-coordinator onetalk-sync-storage
```
Expected: normalized observations remain durable-first and are sent only after matching base candidate confirmation.
- [ ] **Step 2: Run workspace static and build validation.**
Run:
```text
pnpm format:check
pnpm typecheck
pnpm build
git diff --check
```
Expected: no formatting/type/build failure; no server migration or generated artifact change.
- [ ] **Step 3: Perform GitNexus review and code review.**
Run `detect_changes()` against the active worktree, inspect all changed symbols and affected flows, and check that no change escapes the contract/MAIN reader/test scope. Review the diff for raw-prop leakage, URL fallback, duplicated validation, hidden success paths, and `storeImageUrl` remnants.
- [ ] **Step 4: Request permission before user-visible runtime refresh.**
Only if runtime confirmation needs new code loaded, ask the user to authorize extension reload and reopening the OneTalk tab. After authorization, use Chrome read-only probes to confirm the exact three classifiers/template predicates and the normalized bridge/ledger count. Do not click, navigate, synchronize, or read sensitive card values.
- [ ] **Step 5: Record validation and commit one cohesive change.**
Use the repository commit style after all checks pass. Include contract, reader, test, and task documentation changes in one commit; do not include generated extension artifacts.
## Plan self-review
- Spec coverage: Tasks 1-2 implement contract URL safety and field removal; Task 3 implements all three strict readers; Task 4 protects downstream semantics and runtime validation.
- Placeholder scan: no TODO/TBD steps remain.
- Type consistency: Task 1 defines `OneTalkProductReference`; Task 2 consumes it in the rendered product contract; Task 3 consumes the public normalizer and returns one paired observation/evidence value; Task 4 exercises existing bridge/coordinator interfaces without changing them.