From 8ac508037413776681ca5c477a5a214d474428d6 Mon Sep 17 00:00:00 2001 From: Kevin Nzeng Essimengane Date: Sun, 27 Sep 2026 21:58:35 +0200 Subject: [PATCH 1/3] feat(pilot): capture existing Product access without another payment --- docs/backlog/implementation/evidence/W13.md | 25 ++++++ docs/operations/deployment-configuration.md | 8 ++ docs/operations/product-devnet-deployment.md | 23 ++++- web/README.md | 12 +++ .../product-devnet-journey-harness.mjs | 64 +++++++++++-- .../product-devnet-journey-harness.test.mjs | 54 +++++++++++ .../productHost/productAccessReadback.test.ts | 74 +++++++++++++++ .../productHost/productAccessReadback.ts | 49 ++++++++++ .../productCdmHostSmokeEvidence.test.ts | 66 ++++++++++++++ .../productCdmHostSmokeEvidence.ts | 89 +++++++++++++++++-- web/src/hooks/useCatalog.ts | 25 +++++- 11 files changed, 470 insertions(+), 19 deletions(-) create mode 100644 web/src/features/productHost/productAccessReadback.test.ts create mode 100644 web/src/features/productHost/productAccessReadback.ts diff --git a/docs/backlog/implementation/evidence/W13.md b/docs/backlog/implementation/evidence/W13.md index bbef21df..406e2589 100644 --- a/docs/backlog/implementation/evidence/W13.md +++ b/docs/backlog/implementation/evidence/W13.md @@ -41,6 +41,31 @@ ## Result and decisions +### Existing-access capture follow-up - 2026-09-27 + +The payment recovery UX merged in PR #219 at starting dev SHA +`9c25093d7508e643258fd3a66b576a8cea312707`. Its Dev Quality Gates passed. +The follow-up branch `feat/pilot-access-readback` adds an operator-only +read-only entitlement capture when selecting a Classic track after binding +the deployed candidate. Previously that path could play an existing purchase +without exporting its fresh runtime read-back. + +The new event is deliberately distinct from payment evidence. It records +paid/playable access without a transaction hash or amount, discards reads +after account/selection/candidate changes, and records unknown values when +RPC reads fail. Browser and CLI key evidence now require the same account, +network, runtime and content hash after the corresponding access read-back. +Signing/value-forwarding gates remain blocked without actual payment evidence. +Capture stays opt-in, bounded and session-local; account/release identifiers +belong in private operator diagnostics, never aggregate participant evidence. + +Implementation/tested SHA and validation results are attached to the follow-up +PR after committing. No deployment, transfer, rollback rehearsal or physical +device test is performed by this change. W13 remains `hold`; the next live +step is to publish an authorized candidate and capture existing entitlement, +key delivery and walletless room listening against its exact SHA/version/CID. + + W13 adds a concrete release gate instead of a checklist-only pilot plan. The new `npm run smoke:pilot-release` command reconciles: diff --git a/docs/operations/deployment-configuration.md b/docs/operations/deployment-configuration.md index d08565ff..e3f37507 100644 --- a/docs/operations/deployment-configuration.md +++ b/docs/operations/deployment-configuration.md @@ -568,6 +568,14 @@ settlement ledger. Enable `VITE_DOTIFY_DEBUG_PANEL=true` only on that smoke build to export the safe browser-side evidence bundle with `amountPlanck`, payment read-back, Product sr25519 key/session outcomes, and the operator-marked host approval observation. +For W13, first bind the deployed candidate and open an already-paid Classic +track. A separate `access-readback` event captures current paid/playable access +without submitting another transaction. Backend-key evidence must follow a +read for the same account, network, runtime and content hash. Existing access +does not satisfy host approval, native transfer or transaction read-back gates. +No new configuration is needed. Capture remains bounded session storage; +reset it after use and keep account-linked diagnostic exports separate from +aggregate pilot evidence. See the Product deployment runbook for the sequence. Validate Product protected playback through host smoke tests after each Product publication before treating Product identity as production-ready for gated listening. diff --git a/docs/operations/product-devnet-deployment.md b/docs/operations/product-devnet-deployment.md index d2343951..22b962dc 100644 --- a/docs/operations/product-devnet-deployment.md +++ b/docs/operations/product-devnet-deployment.md @@ -718,9 +718,26 @@ npm run deploy:product-devnet malformed CIDs with the standards-compliant IPFS parser, and clears events whenever SHA, Product appVersion, or CID changes. - Use a funded Product account that has not already paid for the target - Classic track. Do not use this as the default `dotify-test01.dot` release - gate until it has passed once end to end. Verify: + First reuse an existing paid Classic entitlement: open that track with the + connected Product account after binding the deployment. This captures a + read-only `access-readback` event (`hasPaid`, `canAccess`, chain, listener, + runtime and content hash), followed by the normal backend key request. + The browser and CLI require that the key follows the read and matches its + account, network, runtime and track. An RPC failure records unknown access; + changing account, track or capture while reading discards the stale result. + The collector makes no extra reads without a bound capture for this build. + + This proves current entitlement/key delivery only. It does not fabricate a + transaction hash, paid amount or host approval. The host-approval, + native-value and payment-readback gates remain blocked in the CLI when + only this read-only evidence is available. Do not pay again merely to make + those gates green. Reset the capture when finished; keep the diagnostic + export private because it links an account to a release, and never merge + it into aggregate participant evidence. + + A separately authorized new-payment test still needs a funded Product + account without access to the target Classic track. Do not use this as the + default `dotify-test01.dot` release gate until it has passed end to end. Verify: - the connected Dotify Product account and the host-selected signer expose the same public key; - deriving `pallet-revive` H160 from that public key gives the same H160 diff --git a/web/README.md b/web/README.md index 0c1a55cc..2137a42f 100644 --- a/web/README.md +++ b/web/README.md @@ -348,6 +348,18 @@ room capture reads creation, stream, peer, listener, and canonical-link facts from the active host session; the operator confirms walletless guest arrival, audible audio, and sync on the guest device before export. +For W13, bind the Product deployment first, then open a Classic track that the +connected Product account already paid for. The operator capture reads +`hasPaid` and `canAccess` and exports an `access-readback` event before the key +request. It never submits a payment, and makes no extra reads outside a bound +capture for the current build. Only a subsequent key release for the same +account, network, runtime and track satisfies the backend-key check. +An existing entitlement does not prove a new transaction: approval, native +value and transaction read-back gates remain blocked when only read-only +evidence is supplied. Keep the operator export private because it includes +account/release identifiers; do not include it in aggregate pilot data. Reset +the capture after the check to stop collecting diagnostic events. + ### Production Troubleshooting | Symptom | Likely cause | Check | Fix | diff --git a/web/scripts/product-devnet-journey-harness.mjs b/web/scripts/product-devnet-journey-harness.mjs index 1801dfe5..5121cf26 100644 --- a/web/scripts/product-devnet-journey-harness.mjs +++ b/web/scripts/product-devnet-journey-harness.mjs @@ -439,7 +439,9 @@ function latestPaymentEvent(events) { return events.filter(event => event?.kind === 'payment').at(-1) ?? null; } -function latestAllowedKeyEvent(events, payment, context) { +function latestAllowedKeyEvent(events, access, context) { + const latestKey = events.filter(event => event?.kind === 'key').at(-1); + if (!access || latestKey?.phase === 'key-denied' || latestKey?.phase === 'key-error') return null; return ( events.find( event => @@ -451,7 +453,12 @@ function latestAllowedKeyEvent(events, payment, context) { event.playbackMode === 'full' && sameValue(event.address, context?.listenerAddress) && sameValue(event.productPublicKey, context?.productPublicKey) && - (!payment || (sameValue(event.contentHash, payment.contentHash) && sameValue(event.runtime, payment.runtimeAddress))) + Number.isFinite(event.timestamp) && + Number.isFinite(access.timestamp) && + event.timestamp >= access.timestamp && + sameValue(event.contentHash, access.contentHash) && + sameValue(event.runtime, access.runtimeAddress) && + sameValue(event.address, access.listenerAddress) ) ?? null ); } @@ -462,10 +469,10 @@ function hasExplicitHostApproval(events) { function allSmokeIdentitiesMatch(events, context) { if (!context?.listenerAddress || !context?.productPublicKey) return false; - const identityEvents = events.filter(event => event?.kind === 'payment' || event?.kind === 'key'); + const identityEvents = events.filter(event => event?.kind === 'payment' || event?.kind === 'access-readback' || event?.kind === 'key'); if (identityEvents.length === 0) return false; return identityEvents.every(event => { - if (event.kind === 'payment') return sameValue(event.listenerAddress, context.listenerAddress); + if (event.kind === 'payment' || event.kind === 'access-readback') return sameValue(event.listenerAddress, context.listenerAddress); return sameValue(event.address, context.listenerAddress) && sameValue(event.productPublicKey, context.productPublicKey); }); } @@ -492,7 +499,9 @@ export function evaluateProductCdmSmokeEvidence(evidence, options = {}) { const candidate = evidence.candidate ?? {}; const events = smokeEvents(evidence); const payment = latestPaymentEvent(events); - const allowedKey = latestAllowedKeyEvent(events, payment, context); + const accessReadback = events.filter(event => event?.kind === 'access-readback').at(-1); + const access = events.filter(event => event?.kind === 'payment' || event?.kind === 'access-readback').at(-1); + const allowedKey = latestAllowedKeyEvent(events, access, context); if (evidence.schemaVersion !== 2) { fail(gates, 'smoke-schema', 'Smoke evidence schema', `Expected schemaVersion 2, found ${evidence.schemaVersion ?? 'missing'}.`, 'Product host JSON'); @@ -598,13 +607,25 @@ export function evaluateProductCdmSmokeEvidence(evidence, options = {}) { if (hasExplicitHostApproval(events)) { pass(gates, 'smoke:host-approval', 'Host approval', 'Operator recorded an explicit Product host approval prompt.', 'Product host JSON'); } else { - fail(gates, 'smoke:host-approval', 'Host approval', 'Expected an operator-observation event with host-approval-explicit=true.', 'Product host JSON'); + (accessReadback && !payment ? blocked : fail)( + gates, + 'smoke:host-approval', + 'Host approval', + 'No explicit transaction approval captured. A read-only access check does not exercise signing.', + 'Product host JSON' + ); } if (payment && isPositivePlanck(payment.amountPlanck)) { pass(gates, 'smoke:native-value', 'Native value', `amountPlanck=${payment.amountPlanck}.`, 'Product host JSON'); } else { - fail(gates, 'smoke:native-value', 'Native value', 'Expected a payment event with a non-zero native amountPlanck.', 'Product host JSON'); + (accessReadback && !payment ? blocked : fail)( + gates, + 'smoke:native-value', + 'Native value', + 'Expected a payment event with a non-zero native amountPlanck.', + 'Product host JSON' + ); } if ( @@ -621,7 +642,7 @@ export function evaluateProductCdmSmokeEvidence(evidence, options = {}) { ) { pass(gates, 'smoke:payment-readback', 'Payment read-back', `Access read-back passed after ${payment.attempts} attempts.`, 'Product host JSON'); } else { - fail( + (accessReadback && !payment ? blocked : fail)( gates, 'smoke:payment-readback', 'Payment read-back', @@ -630,6 +651,32 @@ export function evaluateProductCdmSmokeEvidence(evidence, options = {}) { ); } + if ( + access && + access.hasPaid === true && + access.canAccess === true && + isHexAddress(access.runtimeAddress) && + isHexHash(access.contentHash) && + sameValue(access.listenerAddress, context.listenerAddress) && + (access.kind === 'access-readback' ? access.chainId === EXPECTED_PRODUCT_DEVNET.chainId : access.ok === true) + ) { + pass( + gates, + 'smoke:access-readback', + 'Existing paid access', + 'The runtime confirms paid access. This alone proves neither a new transfer nor host approval.', + 'Product host JSON' + ); + } else { + fail( + gates, + 'smoke:access-readback', + 'Existing paid access', + 'Expected paid and playable access for the connected account, network, runtime and track.', + 'Product host JSON' + ); + } + if (allowedKey) { pass( gates, @@ -834,6 +881,7 @@ export function buildSurfaceMatrix({ commit, appVersion, productSmokeGates, room 'smoke:host-approval', 'smoke:native-value', 'smoke:payment-readback', + 'smoke:access-readback', 'smoke:backend-key', 'smoke:same-identity' ]); diff --git a/web/scripts/product-devnet-journey-harness.test.mjs b/web/scripts/product-devnet-journey-harness.test.mjs index 0d0d1047..142ec0f4 100644 --- a/web/scripts/product-devnet-journey-harness.test.mjs +++ b/web/scripts/product-devnet-journey-harness.test.mjs @@ -175,6 +175,60 @@ function completeRoomEvidence(overrides = {}) { }; } +test('existing entitlement and matching key are useful evidence but cannot certify a new Product write', () => { + const evidence = completeSmokeEvidence(); + evidence.events = [ + { + kind: 'access-readback', + runtimeAddress: RUNTIME, + contentHash: CONTENT_HASH, + listenerAddress: ADDRESS, + chainId: EXPECTED_PRODUCT_DEVNET.chainId, + hasPaid: true, + canAccess: true, + timestamp: 1_000 + }, + evidence.events[2] + ]; + const gates = evaluateProductCdmSmokeEvidence(evidence); + for (const id of ['smoke:access-readback', 'smoke:backend-key', 'smoke:same-identity']) { + assert.equal(gates.find(gate => gate.id === id)?.status, 'pass', id); + } + for (const id of ['smoke:host-approval', 'smoke:native-value', 'smoke:payment-readback']) { + assert.equal(gates.find(gate => gate.id === id)?.status, 'blocked', id); + } +}); + +test('a different network or unpaid access does not satisfy the entitlement gate', () => { + for (const patch of [{ chainId: 1 }, { hasPaid: false }, { canAccess: false }, { hasPaid: null }, { listenerAddress: RUNTIME }]) { + const evidence = completeSmokeEvidence(); + evidence.events.unshift({ + kind: 'access-readback', + runtimeAddress: RUNTIME, + contentHash: CONTENT_HASH, + listenerAddress: ADDRESS, + chainId: EXPECTED_PRODUCT_DEVNET.chainId, + hasPaid: true, + canAccess: true, + timestamp: 1_000, + ...patch + }); + evidence.events = evidence.events.filter(event => event.kind !== 'payment'); + assert.equal(evaluateProductCdmSmokeEvidence(evidence).find(gate => gate.id === 'smoke:access-readback')?.status, 'fail'); + } +}); + +test('an old key or later denial cannot certify the selected entitlement', () => { + for (const patch of [{ timestamp: 500 }, { runtime: ADDRESS }, { contentHash: TX_HASH }, { chainId: 1 }]) { + const evidence = completeSmokeEvidence(); + Object.assign(evidence.events[2], patch); + assert.equal(evaluateProductCdmSmokeEvidence(evidence).find(gate => gate.id === 'smoke:backend-key')?.status, 'fail'); + } + const evidence = completeSmokeEvidence(); + evidence.events.push({ ...evidence.events[2], phase: 'key-denied', timestamp: 3_000 }); + assert.equal(evaluateProductCdmSmokeEvidence(evidence).find(gate => gate.id === 'smoke:backend-key')?.status, 'fail'); +}); + test('parseEnvFile ignores comments and preserves empty values', () => { assert.deepEqual( parseEnvFile(` diff --git a/web/src/features/productHost/productAccessReadback.test.ts b/web/src/features/productHost/productAccessReadback.test.ts new file mode 100644 index 00000000..5cc33d46 --- /dev/null +++ b/web/src/features/productHost/productAccessReadback.test.ts @@ -0,0 +1,74 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import * as evidence from './productCdmHostSmokeEvidence'; +import { captureProductAccessReadback } from './productAccessReadback'; + +const candidate = { gitSha: 'a'.repeat(40), productAppVersion: '[0, 1, 29]', deployedCid: 'test-candidate' }; +function fixture() { + const session: evidence.ProductCdmHostSmokeSession = { schemaVersion: 2, startedAt: '2026-09-27T12:00:00Z', candidate, events: [] }; + const getSession = vi.spyOn(evidence, 'getProductCdmHostSmokeSession').mockReturnValue(session); + const publish = vi.spyOn(evidence, 'publishProductAccessReadbackMetric').mockImplementation(() => {}); + const input = { + reader: { hasPaid: vi.fn(async () => true), canAccess: vi.fn(async () => true) }, + buildSha: candidate.gitSha, + productAppVersion: candidate.productAppVersion, + runtimeAddress: `0x${'11'.repeat(20)}` as const, + contentHash: `0x${'22'.repeat(32)}` as const, + listenerAddress: `0x${'33'.repeat(20)}` as const, + chainId: 420420417, + isCurrent: vi.fn(() => true) + }; + return { session, getSession, publish, input }; +} +afterEach(() => vi.restoreAllMocks()); + +describe('Product read-only access capture', () => { + it('reads the exact release without a writer and exports no payment amount or hash', async () => { + const { input, publish } = fixture(); + await captureProductAccessReadback(input); + for (const read of [input.reader.hasPaid, input.reader.canAccess]) { + expect(read).toHaveBeenCalledExactlyOnceWith(input.runtimeAddress, input.contentHash, input.listenerAddress); + } + expect(publish).toHaveBeenCalledExactlyOnceWith({ + runtimeAddress: input.runtimeAddress, + contentHash: input.contentHash, + listenerAddress: input.listenerAddress, + chainId: input.chainId, + hasPaid: true, + canAccess: true, + timestamp: expect.any(Number) + }); + }); + + it.each(['unbound', 'other-build', 'other-version', 'stale-selection'])('does not read or collect outside a current capture: %s', reason => { + const { input, getSession, publish } = fixture(); + if (reason === 'unbound') getSession.mockReturnValue(null); + if (reason === 'other-build') input.buildSha = 'b'.repeat(40); + if (reason === 'other-version') input.productAppVersion = '[0, 1, 28]'; + if (reason === 'stale-selection') input.isCurrent.mockReturnValue(false); + return captureProductAccessReadback(input).then(() => { + expect(input.reader.hasPaid).not.toHaveBeenCalled(); + expect(input.reader.canAccess).not.toHaveBeenCalled(); + expect(publish).not.toHaveBeenCalled(); + }); + }); + + it.each(['account', 'reset', 'rebound'])('discards a read when the %s changes while pending', async change => { + const { input, publish, session, getSession } = fixture(); + input.reader.hasPaid.mockImplementation(async () => { + if (change === 'account') input.isCurrent.mockReturnValue(false); + if (change === 'reset') getSession.mockReturnValue(null); + if (change === 'rebound') getSession.mockReturnValue({ ...session, candidate: { ...candidate, deployedCid: 'another-candidate' } }); + return true; + }); + await captureProductAccessReadback(input); + expect(publish).not.toHaveBeenCalled(); + }); + + it('records unknown results on RPC failure without exposing the raw error or preventing playback', async () => { + const { input, publish } = fixture(); + input.reader.hasPaid.mockRejectedValue(new Error('private endpoint details')); + await expect(captureProductAccessReadback(input)).resolves.toBeUndefined(); + expect(publish).toHaveBeenCalledWith(expect.objectContaining({ hasPaid: null, canAccess: null })); + expect(JSON.stringify(publish.mock.calls)).not.toContain('private endpoint details'); + }); +}); diff --git a/web/src/features/productHost/productAccessReadback.ts b/web/src/features/productHost/productAccessReadback.ts new file mode 100644 index 00000000..0c077d79 --- /dev/null +++ b/web/src/features/productHost/productAccessReadback.ts @@ -0,0 +1,49 @@ +import type { RuntimeReadPort } from '../runtime/runtimePorts'; +import { getProductCdmHostSmokeSession, publishProductAccessReadbackMetric, type ProductAccessReadbackMetric } from './productCdmHostSmokeEvidence'; + +export async function captureProductAccessReadback(input: { + reader: Pick; + buildSha: string | undefined; + productAppVersion: string | undefined; + chainId: number; + runtimeAddress: `0x${string}`; + contentHash: `0x${string}`; + listenerAddress: `0x${string}`; + isCurrent: () => boolean; +}): Promise { + const session = getProductCdmHostSmokeSession(); + if (!session || session.candidate.gitSha !== input.buildSha || session.candidate.productAppVersion !== input.productAppVersion || !input.isCurrent()) return; + + let hasPaid: boolean | null = null; + let canAccess: boolean | null = null; + try { + [hasPaid, canAccess] = await Promise.all([ + input.reader.hasPaid(input.runtimeAddress, input.contentHash, input.listenerAddress), + input.reader.canAccess(input.runtimeAddress, input.contentHash, input.listenerAddress) + ]); + } catch { + // Unknown evidence never grants access or changes ordinary playback. + } + + const current = getProductCdmHostSmokeSession(); + if ( + !input.isCurrent() || + !current || + current.startedAt !== session.startedAt || + current.candidate.gitSha !== session.candidate.gitSha || + current.candidate.productAppVersion !== session.candidate.productAppVersion || + current.candidate.deployedCid !== session.candidate.deployedCid + ) + return; + + const metric: ProductAccessReadbackMetric = { + runtimeAddress: input.runtimeAddress, + contentHash: input.contentHash, + listenerAddress: input.listenerAddress, + chainId: input.chainId, + hasPaid, + canAccess, + timestamp: Date.now() + }; + publishProductAccessReadbackMetric(metric); +} diff --git a/web/src/features/productHost/productCdmHostSmokeEvidence.test.ts b/web/src/features/productHost/productCdmHostSmokeEvidence.test.ts index 2712a58d..1395a45e 100644 --- a/web/src/features/productHost/productCdmHostSmokeEvidence.test.ts +++ b/web/src/features/productHost/productCdmHostSmokeEvidence.test.ts @@ -121,6 +121,7 @@ describe('product CDM host smoke evidence', () => { ['host-approval', 'ok'], ['native-value', 'ok'], ['payment-readback', 'ok'], + ['access-readback', 'ok'], ['backend-key', 'ok'], ['same-identity', 'ok'] ]); @@ -135,6 +136,71 @@ describe('product CDM host smoke evidence', () => { expect(invalid.checks.find(check => check.id === 'candidate-identity')?.tone).toBe('error'); }); + it('exports existing paid access without claiming a new payment or approval', () => { + const events: ProductCdmHostSmokeEvent[] = [ + { + kind: 'access-readback', + runtimeAddress: RUNTIME, + contentHash: CONTENT_HASH, + listenerAddress: ADDRESS, + chainId: 420420417, + hasPaid: true, + canAccess: true, + timestamp: 2_000 + }, + completeEvents()[3] + ]; + const { storage } = memoryStorage(); + bindProductCdmHostSmokeCandidate(smokeContext(), storage); + for (const event of events) recordProductCdmHostSmokeEvent(event, storage); + const evidence = buildProductCdmHostSmokeEvidence(smokeContext(), readProductCdmHostSmokeEvents(storage)); + expect(evidence.summary.tone).toBe('warning'); + for (const id of ['access-readback', 'backend-key', 'same-identity']) { + expect(evidence.checks.find(check => check.id === id)?.tone).toBe('ok'); + } + for (const id of ['host-approval', 'native-value', 'payment-readback']) { + expect(evidence.checks.find(check => check.id === id)?.tone).toBe('unknown'); + } + expect(serializeProductCdmHostSmokeEvidence(evidence)).not.toContain('txHash'); + }); + + it.each([ + { runtime: ADDRESS }, + { contentHash: TX_HASH }, + { address: RUNTIME }, + { productPublicKey: TX_HASH }, + { chainId: 1 }, + { timestamp: 1_000 }, + { access: 'denied' as const }, + { playbackMode: undefined } + ])('does not accept an unrelated or earlier key release: %j', patch => { + const events = completeEvents(); + events[3] = { ...events[3], ...patch } as ProductCdmHostSmokeEvent; + const evidence = buildProductCdmHostSmokeEvidence(smokeContext(), events); + expect(evidence.checks.find(check => check.id === 'backend-key')?.tone).not.toBe('ok'); + expect(evidence.summary.tone).not.toBe('ok'); + }); + + it.each([ + [false, true], + [true, false], + [null, null] + ] as const)('does not turn an unpaid, denied or failed read into paid access (%s, %s)', (hasPaid, canAccess) => { + const evidence = buildProductCdmHostSmokeEvidence(smokeContext(), [ + { + kind: 'access-readback', + runtimeAddress: RUNTIME, + contentHash: CONTENT_HASH, + listenerAddress: ADDRESS, + chainId: 420420417, + hasPaid, + canAccess, + timestamp: 2_000 + } + ]); + expect(evidence.checks.find(check => check.id === 'access-readback')?.tone).toBe('warning'); + }); + it('normalizes Product key events by allowlist so secrets are not persisted', () => { const detail = normalizeProductHostKeySmokeDetail({ phase: 'key-allowed', diff --git a/web/src/features/productHost/productCdmHostSmokeEvidence.ts b/web/src/features/productHost/productCdmHostSmokeEvidence.ts index 8782fede..00804a21 100644 --- a/web/src/features/productHost/productCdmHostSmokeEvidence.ts +++ b/web/src/features/productHost/productCdmHostSmokeEvidence.ts @@ -50,6 +50,17 @@ export type ProductHostKeySmokeMetric = { timestamp: number; }; +// An entitlement read proves neither a new transfer nor host approval. +export type ProductAccessReadbackMetric = { + runtimeAddress: `0x${string}`; + contentHash: `0x${string}`; + listenerAddress: `0x${string}`; + chainId: number; + hasPaid: boolean | null; + canAccess: boolean | null; + timestamp: number; +}; + export type ProductCdmHostOperatorObservation = { kind: 'operator-observation'; observation: 'host-approval-explicit'; @@ -59,6 +70,7 @@ export type ProductCdmHostOperatorObservation = { export type ProductCdmHostSmokeEvent = | ({ kind: 'payment' } & ProductCdmPaymentSmokeMetric) + | ({ kind: 'access-readback' } & ProductAccessReadbackMetric) | ({ kind: 'key' } & ProductHostKeySmokeMetric) | ProductCdmHostOperatorObservation; @@ -265,6 +277,33 @@ function normalizeOperatorObservation(detail: unknown): ProductCdmHostOperatorOb function normalizeSmokeEvent(event: unknown): ProductCdmHostSmokeEvent | null { if (!isRecord(event)) return null; + if (event.kind === 'access-readback') { + if ( + typeof event.runtimeAddress !== 'string' || + !/^0x[\da-f]{40}$/i.test(event.runtimeAddress) || + typeof event.listenerAddress !== 'string' || + !/^0x[\da-f]{40}$/i.test(event.listenerAddress) || + typeof event.contentHash !== 'string' || + !/^0x[\da-f]{64}$/i.test(event.contentHash) || + typeof event.chainId !== 'number' || + !Number.isSafeInteger(event.chainId) || + event.chainId <= 0 || + typeof event.timestamp !== 'number' || + !Number.isFinite(event.timestamp) || + event.timestamp < 0 + ) + return null; + return { + kind: 'access-readback', + runtimeAddress: event.runtimeAddress as `0x${string}`, + contentHash: event.contentHash as `0x${string}`, + listenerAddress: event.listenerAddress as `0x${string}`, + chainId: event.chainId, + hasPaid: nullableBoolean(event.hasPaid), + canAccess: nullableBoolean(event.canAccess), + timestamp: event.timestamp + }; + } if (event.kind === 'payment') { const metric = normalizeProductCdmPaymentSmokeDetail(event); return metric ? { kind: 'payment', ...metric } : null; @@ -427,6 +466,11 @@ export function publishProductHostKeySmokeMetric(metric: ProductHostKeySmokeMetr } } +export function publishProductAccessReadbackMetric(metric: ProductAccessReadbackMetric): void { + recordProductCdmHostSmokeEvent({ kind: 'access-readback', ...metric }); + dispatchSmokeEvent(PRODUCT_CDM_HOST_SMOKE_EVIDENCE_EVENT, { kind: 'access-readback' }); +} + export function recordProductCdmHostApprovalObservation(ok: boolean): ProductCdmHostOperatorObservation { const event: ProductCdmHostOperatorObservation = { kind: 'operator-observation', @@ -487,7 +531,11 @@ function paymentReadbackCheck(payment: Extract | null): ProductCdmHostSmokeCheck { +function keyReleaseCheck( + events: ProductCdmHostSmokeEvent[], + access: ProductAccessReadbackMetric | ProductCdmPaymentSmokeMetric | null, + context: ProductCdmHostSmokeContext +): ProductCdmHostSmokeCheck { const keys = events.filter((event): event is Extract => event.kind === 'key'); const latest = keys.length > 0 ? keys[keys.length - 1] : null; if (!latest) { @@ -509,8 +557,16 @@ function keyReleaseCheck(events: ProductCdmHostSmokeEvent[], payment: Extract event.phase === 'key-allowed' && - (!payment || event.timestamp >= payment.timestamp) && - (!payment || (sameAddress(event.address, payment.listenerAddress) && event.contentHash?.toLowerCase() === payment.contentHash.toLowerCase())) + event.access === 'allowed' && + event.playbackMode === 'full' && + event.chainId === context.expectedChainId && + sameAddress(event.address, context.listenerAddress) && + sameAddress(event.productPublicKey, context.productPublicKey) && + access && + event.timestamp >= access.timestamp && + sameAddress(event.address, access.listenerAddress) && + sameAddress(event.runtime, access.runtimeAddress) && + sameAddress(event.contentHash, access.contentHash) ); if (matchingAllowedKey) { return { @@ -524,8 +580,8 @@ function keyReleaseCheck(events: ProductCdmHostSmokeEvent[], payment: Extract event.kind === 'payment' || event.kind === 'access-readback') as + | ProductCdmPaymentSmokeMetric + | ProductAccessReadbackMetric + | undefined; const latestKey = latestKeyEvent(events); const latestObservation = latestOperatorObservation(events); const eventAddresses = events.flatMap(event => { - if (event.kind === 'payment') return [event.listenerAddress]; + if (event.kind === 'payment' || event.kind === 'access-readback') return [event.listenerAddress]; if (event.kind === 'key') return [event.address]; return []; }); @@ -600,7 +660,22 @@ export function summarizeProductCdmHostSmokeChecks(context: ProductCdmHostSmokeC detail: payment ? `Smoke event recorded amountPlanck=${payment.amountPlanck} for musicRoyPayAccess.` : 'No Product CDM payment amount captured yet.' }, paymentReadbackCheck(payment), - keyReleaseCheck(events, payment), + { + id: 'access-readback', + label: 'Existing paid access', + tone: !access + ? 'unknown' + : access.hasPaid === true && + access.canAccess === true && + sameAddress(access.listenerAddress, context.listenerAddress) && + ('chainId' in access ? access.chainId === context.expectedChainId : access.ok) + ? 'ok' + : 'warning', + detail: access + ? `Runtime read-back: paid=${access.hasPaid}, access=${access.canAccess}. This alone does not prove a new payment or host approval.` + : 'No runtime access read-back captured.' + }, + keyReleaseCheck(events, access ?? null, context), { id: 'same-identity', label: 'Same identity', diff --git a/web/src/hooks/useCatalog.ts b/web/src/hooks/useCatalog.ts index 4ea43c50..656061de 100644 --- a/web/src/hooks/useCatalog.ts +++ b/web/src/hooks/useCatalog.ts @@ -45,12 +45,13 @@ import { } from '../features/payments/paymentModel'; import { type RuntimeAccessPaymentVerificationResult } from '../features/payments/paymentReadback'; import { decodeAccessMode, decodePersonhood } from '../features/runtime/accessEncoding'; -import { resolveRuntimeAdapterConfig } from '../features/runtime/runtimeAdapterConfig'; +import { PRODUCT_DEVNET_EVM_CHAIN_ID, resolveRuntimeAdapterConfig } from '../features/runtime/runtimeAdapterConfig'; import { createRuntimeReader } from '../features/runtime/runtimeReaderProvider'; import { createRuntimeWriter } from '../features/runtime/runtimeWriterProvider'; import type { RuntimeReadPort, RuntimeTrackSnapshot } from '../features/runtime/runtimePorts'; import { resolveProductHostConfig } from '../features/productHost/productHost'; import { publishProductCdmPaymentSmokeMetric, type ProductCdmPaymentSmokeMetric } from '../features/productHost/productCdmHostSmokeEvidence'; +import { captureProductAccessReadback } from '../features/productHost/productAccessReadback'; import { audioV2StartupPhaseLabel, publishHostAudioStartupMetric, @@ -1252,6 +1253,28 @@ export function useCatalog(deps: UseCatalogDeps) { if (isPolicyManagedTrack(track)) { hasAccess = await checkTrackAccess(track, listenerEvmAddress); if (!isTrackSelectionCurrent(selection)) return { playbackMode: 'full', audioSource: audioSourceRef.current }; + const smokeRuntime = runtimeAddressFromTrackId(track); + if ( + runtimeAdapterConfig.kind === 'product-cdm' && + connectedWallet?.method === 'product-host' && + track.accessMode === 'classic' && + listenerEvmAddress && + smokeRuntime + ) { + const accountAtRead = supportAccountRef.current; + await captureProductAccessReadback({ + reader: runtimeReader, + buildSha: import.meta.env.VITE_DOTIFY_BUILD_SHA, + productAppVersion: import.meta.env.VITE_DOTIFY_PRODUCT_APP_VERSION, + chainId: PRODUCT_DEVNET_EVM_CHAIN_ID, + runtimeAddress: smokeRuntime, + contentHash: track.hash, + listenerAddress: listenerEvmAddress, + isCurrent: () => isTrackSelectionCurrent(selection) && supportAccountRef.current === accountAtRead + }); + if (!isTrackSelectionCurrent(selection) || supportAccountRef.current !== accountAtRead) + return { playbackMode: 'full', audioSource: audioSourceRef.current }; + } setCatalogAccessByTrackId(previous => ({ ...previous, [track.id]: hasAccess })); if (!hasAccess) { // A room host has just chosen this release from the lineup, so the From 0e0ad673ebd36d3a35ee8cbf135f36d81af7bdba Mon Sep 17 00:00:00 2001 From: Kevin Nzeng Essimengane Date: Sun, 27 Sep 2026 22:41:54 +0200 Subject: [PATCH 2/3] ci: disable automatic Claude review --- .github/workflows/claude-code-review.yml | 11 +++-------- 1 file changed, 3 insertions(+), 8 deletions(-) diff --git a/.github/workflows/claude-code-review.yml b/.github/workflows/claude-code-review.yml index e07ad6d3..9b300792 100644 --- a/.github/workflows/claude-code-review.yml +++ b/.github/workflows/claude-code-review.yml @@ -1,14 +1,9 @@ name: Claude Code Review on: - pull_request: - types: [opened, synchronize, ready_for_review, reopened] - # Optional: Only run on specific file changes - # paths: - # - "src/**/*.ts" - # - "src/**/*.tsx" - # - "src/**/*.js" - # - "src/**/*.jsx" + # Disabled for pull_request CI while Claude Code OAuth is not reliable. + # Keep the workflow available for manual runs after the repository secret is fixed. + workflow_dispatch: jobs: claude-review: From 1cf29abccdd193bcedb75f4684ed4a00b1c5c709 Mon Sep 17 00:00:00 2001 From: Kevin Nzeng Essimengane Date: Mon, 28 Sep 2026 11:30:25 +0200 Subject: [PATCH 3/3] ci: make manual Claude review target explicit --- .github/workflows/claude-code-review.yml | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/.github/workflows/claude-code-review.yml b/.github/workflows/claude-code-review.yml index 9b300792..dbdc3461 100644 --- a/.github/workflows/claude-code-review.yml +++ b/.github/workflows/claude-code-review.yml @@ -4,6 +4,11 @@ on: # Disabled for pull_request CI while Claude Code OAuth is not reliable. # Keep the workflow available for manual runs after the repository secret is fixed. workflow_dispatch: + inputs: + pr_number: + description: "Pull request number to review" + required: true + type: number jobs: claude-review: @@ -32,7 +37,7 @@ jobs: with: claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} prompt: | - Review ${{ github.repository }}/pull/${{ github.event.pull_request.number }}. + Review ${{ github.repository }}/pull/${{ inputs.pr_number }}. Prioritize concrete bugs, security issues, behavioral regressions, and missing tests. Keep summaries brief, do not modify files, and