diff --git a/src/lib/chain/backlogRules.test.ts b/src/lib/chain/backlogRules.test.ts index 2a8f85d..1cd8e99 100644 --- a/src/lib/chain/backlogRules.test.ts +++ b/src/lib/chain/backlogRules.test.ts @@ -4,9 +4,16 @@ const lists: Record = {}; vi.mock('$lib/obp/requests', () => ({ obp_requests: { + // The backlog reads the v7.0.0 endpoints, whose list items are wrapped as + // `{ "": record, "metadata": ... }`. get: async (endpoint: string) => { - const entity = endpoint.replace(/^\/obp\/dynamic-entity\/(banks\/[^/]+\/)?/, ''); - return { [`${entity}_list`]: lists[entity] ?? [] }; + const entity = endpoint.replace(/^\/obp\/v7\.0\.0\/banks\/[^/]+\/dynamic-entities\//, ''); + return { + [`${entity}_list`]: (lists[entity] ?? []).map((record) => ({ + [entity]: record, + metadata: {} + })) + }; } } })); @@ -52,7 +59,9 @@ describe('credit batch backlog', () => { { activity_id: 'a1', status_code: 'verified' }, { activity_id: 'a2', status_code: 'verified' } ]; - lists['certificate_of_compliance'] = [{ certificate_of_compliance_id: 'c1', activity_id: 'a1' }]; + lists['certificate_of_compliance'] = [ + { certificate_of_compliance_id: 'c1', activity_id: 'a1' } + ]; lists['certification_on_chain'] = [{ certificate_of_compliance_id: 'c1' }]; const b = await getTokenizationBacklog('token'); diff --git a/src/lib/chain/onChain.test.ts b/src/lib/chain/onChain.test.ts index 3cbd186..0c7b693 100644 --- a/src/lib/chain/onChain.test.ts +++ b/src/lib/chain/onChain.test.ts @@ -7,15 +7,22 @@ const lists: Record = {}; vi.mock('$lib/obp/requests', () => ({ obp_requests: { + // The chain page reads the public v7.0.0 endpoints, whose list items are wrapped + // as `{ "": record, "metadata": ... }`. get: async (endpoint: string) => { - const entity = endpoint.replace(/^\/obp\/dynamic-entity\/(banks\/[^/]+\/)?/, ''); + const entity = endpoint.replace( + /^\/obp\/v7\.0\.0\/banks\/[^/]+\/dynamic-entities\/public\//, + '' + ); if (lists[entity] === undefined) throw new Error(`no fixture for ${entity}`); - return { [`${entity}_list`]: lists[entity] }; + return { + [`${entity}_list`]: lists[entity].map((record) => ({ [entity]: record, metadata: {} })) + }; } } })); -const { getRecentChainEvents, getChainSyncStatus } = await import('./onChain'); +const { getChainTokens, getChainSyncStatus, formatUnits, idFromUri } = await import('./onChain'); beforeEach(() => { for (const k of Object.keys(lists)) delete lists[k]; @@ -23,94 +30,150 @@ beforeEach(() => { lists['activity_on_chain'] = []; lists['certification_on_chain'] = []; lists['carbon_credit_batch_on_chain'] = []; + lists['carbon_credit_balance_on_chain'] = []; lists['chain_sync_status'] = []; }); -describe('getRecentChainEvents', () => { - it('links an activity token to its page in this app', async () => { +describe('getChainTokens', () => { + const section = async (key: string) => (await getChainTokens()).find((s) => s.key === key)!; + + it('returns a section per type, in chain-of-records order', async () => { + expect((await getChainTokens()).map((s) => s.key)).toEqual([ + 'parcel', + 'activity', + 'certification', + 'batch', + 'balance' + ]); + }); + + it('links an activity token to its registry pages, scheme and parcel token', async () => { + lists['parcel_on_chain'] = [{ parcel_id: 'p1', token_id: '4', block_number: '1' }]; lists['activity_on_chain'] = [ { + activity_on_chain_id: 'aoc_1', activity_id: 'activity_local_1', - token_id: '1', name: 'Local Fixture Activity 1', - activity_url: 'http://localhost:8080/obp/dynamic-entity/activity/activity_local_1', - owner_address: '0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266', - tx_hash: '0x20ec', + operator_id: 'op_1', + operator_name: 'Op One', + certification_scheme_id: 'cs_1', + parcel_ids: 'p1, p2', + token_id: '1', block_number: '2' } ]; - const [e] = await getRecentChainEvents('token'); - expect(e.kind).toBe('Activity'); - expect(e.href).toBe('/activities/activity_local_1'); - expect(e.blockNumber).toBe(2); - expect(e.tokenId).toBe(1); + const [t] = (await section('activity')).tokens; + expect(t.anchor).toBe('activity-token-1'); + expect(t.title).toBe('Local Fixture Activity 1'); + expect(t.publicRecordUrl).toMatch(/\/dynamic-entities\/public\/activity_on_chain\/aoc_1$/); + const byLabel = (label: string) => t.links.filter((l) => l.label === label); + expect(byLabel('Activity')[0].href).toBe('/registry/activities/activity_local_1'); + expect(byLabel('Operator')[0]).toMatchObject({ + text: 'Op One', + href: '/registry/operators/op_1' + }); + expect(byLabel('Certification scheme')[0].href).toBe('/registry/certification-schemes/cs_1'); + // Both link to the (login-only) parcel page; only p1 has a token on the page. + expect(byLabel('Parcel').map((l) => l.href)).toEqual(['/parcels/p1', '/parcels/p2']); + expect(byLabel('Parcel token')).toEqual([ + { label: 'Parcel token', text: 'p1', href: '#parcel-token-4', external: false } + ]); }); - it('links a credit batch to its activity page via the activity_id it carries', async () => { - lists['carbon_credit_batch_on_chain'] = [ + it('links a certification to its certificate page and activity token, audit report as text', async () => { + lists['activity_on_chain'] = [{ activity_id: 'a1', token_id: '2', block_number: '1' }]; + lists['certification_on_chain'] = [ { - carbon_credit_batch_id: 'mv1:permanent_removal', - batch_name: 'Permanent Removals Carbon Credit Batch', - unit_type: 'Permanent Removal', + certificate_of_compliance_id: 'coc_1', + activity_id: 'a1', + activity_nft_id: '2', + certification_body_name: 'Body', + certification_body_uri: 'http://obp/obp/dynamic-entity/banks/ogcr/certification_body/cb_1', + audit_report_uri: 'http://obp/obp/dynamic-entity/banks/ogcr/audit_report/ar_1', token_id: '1', - activity_id: 'activity_local_1', - activity_nft_id: '1', - certificate_of_compliance_uri: - 'http://localhost:8080/obp/dynamic-entity/certificate_of_compliance/coc_local_1', - block_number: '2' + block_number: '3' } ]; - const batch = (await getRecentChainEvents('token')).find((e) => e.kind === 'Credit Batch')!; - expect(batch.label).toBe('Permanent Removals Carbon Credit Batch'); - expect(batch.href).toBe('/activities/activity_local_1'); - expect(batch.registryUrl).toContain('coc_local_1'); + const [t] = (await section('certification')).tokens; + const byLabel = (label: string) => t.links.find((l) => l.label === label)!; + expect(byLabel('Certificate').href).toBe('/registry/certificates/coc_1'); + expect(byLabel('Activity token').href).toBe('#activity-token-2'); + expect(byLabel('Certification body')).toMatchObject({ + text: 'Body', + href: '/registry/certification-bodies/cb_1' + }); + expect(byLabel('Audit report')).toMatchObject({ text: 'ar_1', href: null }); }); - it('leaves href null when a batch carries no activity_id', async () => { + it('does not link to a token that is not on the page', async () => { lists['carbon_credit_batch_on_chain'] = [ - { carbon_credit_batch_id: 'mv9:x', unit_type: 'x', token_id: '9', block_number: '1' } + { carbon_credit_batch_id: 'b1', activity_nft_id: '9', token_id: '1', block_number: '1' } ]; - const [e] = await getRecentChainEvents('token'); - expect(e.href).toBeNull(); + const [t] = (await section('batch')).tokens; + expect(t.links.find((l) => l.label === 'Activity token')).toBeUndefined(); }); - it('falls back to the on-chain registry URL for records with no page', async () => { - lists['parcel_on_chain'] = [ + it('shows batch amounts in tonnes and links a balance to its batch token', async () => { + lists['carbon_credit_batch_on_chain'] = [ { - parcel_id: 'parcel_local_2', - token_id: '2', - parcel_uri: 'http://localhost:8080/obp/dynamic-entity/parcel/parcel_local_2', + carbon_credit_batch_id: 'b1', + amount: '6500000000000000000', + credit_decimals: '18', + token_id: '3', + block_number: '1' + } + ]; + lists['carbon_credit_balance_on_chain'] = [ + { + balance: '5500000000000000000', + decimals: '18', + symbol: 'CC', + batch_token_id: '3', block_number: '2' } ]; - const [e] = await getRecentChainEvents('token'); - expect(e.kind).toBe('Parcel'); - expect(e.href).toBeNull(); - expect(e.registryUrl).toBe('http://localhost:8080/obp/dynamic-entity/parcel/parcel_local_2'); + const [batch] = (await section('batch')).tokens; + expect(batch.details).toContainEqual({ label: 'Issued', value: '6.5 tCO₂e' }); + const [balance] = (await section('balance')).tokens; + expect(balance.title).toBe('5.5 CC'); + expect(balance.links[0]).toMatchObject({ label: 'Batch token', href: '#batch-token-3' }); }); - it('orders newest block first and respects the limit', async () => { + it('orders each section newest block first', async () => { lists['parcel_on_chain'] = [ { parcel_id: 'a', token_id: '1', block_number: '5' }, { parcel_id: 'b', token_id: '2', block_number: '9' }, { parcel_id: 'c', token_id: '3', block_number: '7' } ]; - const events = await getRecentChainEvents('token', 2); - expect(events.map((e) => e.blockNumber)).toEqual([9, 7]); + expect((await section('parcel')).tokens.map((t) => t.blockNumber)).toEqual([9, 7, 5]); + }); + + it('leaves a section empty when its mirror cannot be read', async () => { + delete lists['certification_on_chain']; + expect((await section('certification')).tokens).toEqual([]); }); +}); + +describe('formatUnits', () => { + it('scales base units by the decimals and trims trailing zeros', () => { + expect(formatUnits('6500000000000000000', 18)).toBe('6.5'); + expect(formatUnits('1000000000000000000', '18')).toBe('1'); + expect(formatUnits('0', 18)).toBe('0'); + expect(formatUnits(null, 18)).toBeNull(); + }); +}); - it('still returns the other mirrors when one is unavailable', async () => { - delete lists['parcel_on_chain']; // makes that fetch throw - lists['activity_on_chain'] = [{ activity_id: 'a1', token_id: '1', block_number: '3' }]; - const events = await getRecentChainEvents('token'); - expect(events).toHaveLength(1); - expect(events[0].kind).toBe('Activity'); +describe('idFromUri', () => { + it('takes the last path segment', () => { + expect(idFromUri('https://x/obp/dynamic-entity/banks/ogcr/operator/op_1')).toBe('op_1'); + expect(idFromUri('not a url')).toBeNull(); + expect(idFromUri(undefined)).toBeNull(); }); }); describe('getChainSyncStatus', () => { it('returns null when the mirror has never run', async () => { - expect(await getChainSyncStatus('token')).toBeNull(); + expect(await getChainSyncStatus()).toBeNull(); }); it('picks the most recently synced record if several ever exist', async () => { @@ -118,7 +181,7 @@ describe('getChainSyncStatus', () => { { sync_key: 'chain-2025', synced_at: '2026-09-09T05:00:00Z' }, { sync_key: 'chain-2025', synced_at: '2026-09-09T05:30:43Z' } ]; - const s = await getChainSyncStatus('token'); + const s = await getChainSyncStatus(); expect(s?.synced_at).toBe('2026-09-09T05:30:43Z'); }); }); diff --git a/src/lib/chain/onChain.ts b/src/lib/chain/onChain.ts index f1cb1a9..3f2d296 100644 --- a/src/lib/chain/onChain.ts +++ b/src/lib/chain/onChain.ts @@ -5,6 +5,7 @@ * never write them: they are a mirror of the chain, and the chain is the truth. */ +import { env } from '$env/dynamic/public'; import { obp_requests } from '$lib/obp/requests'; import { ENTITY_CHAIN_SYNC_STATUS, @@ -12,40 +13,76 @@ import { ENTITY_ACTIVITY_ON_CHAIN, ENTITY_CERTIFICATION_ON_CHAIN, ENTITY_CARBON_CREDIT_BATCH_ON_CHAIN, + ENTITY_CARBON_CREDIT_BALANCE_ON_CHAIN, ENTITY_PARCEL_OWNERSHIP_VERIFICATION, ENTITY_ACTIVITY_VERIFICATION, ENTITY_CERTIFICATE_OF_COMPLIANCE, ENTITY_ACTIVITY_MONITORING_PERIOD_VERIFICATION, - entityPath + entityPathV7, + publicEntityPathV7, + unwrapV7List } from '$lib/constants/entities'; import type { ChainSyncStatus } from '$lib/chain/heartbeat'; +import { certificationBodyHref, certificationSchemeHref } from '$lib/registry/certificationLinks'; import { diffIds, summarizeBacklog, type Backlog } from '$lib/chain/backlog'; -/** One mirrored token, flattened to what a "recent activity" row needs. */ -export interface ChainEvent { - /** Which mirror it came from, e.g. "Parcel", "Credit Batch". */ - kind: string; +/** A link from a token to a record it refers to. `href` is null for a record that is + * not public: its id is shown as plain text. */ +export interface ChainLink { + /** What the record is, e.g. "Activity", "Certification body". */ + label: string; + text: string; + href: string | null; + /** True for an OBP URL (raw JSON), false for a page in this app or an anchor on this page. */ + external: boolean; +} + +/** One mirrored token, as a row in its type's section. */ +export interface ChainToken { + /** Element id other rows link to, e.g. `activity-token-2`; null for a record with no token id. */ + anchor: string | null; /** The token's own id on its contract. */ tokenId: number | null; - /** Human-facing identifier: the registry id this token is keyed to. */ - label: string; + /** Human-facing name: the record's name, else the registry id the token is keyed to. */ + title: string; + /** A few facts the token records, beyond its links. */ + details: { label: string; value: string }[]; + /** The records this token refers to: other tokens on this page, and off-chain records. */ + links: ChainLink[]; + /** Current holder of the token (for a balance, the holder of the credits). */ + ownerAddress: string | null; blockNumber: number | null; txHash: string | null; - /** Current holder of the token, when the mirror records one. */ - ownerAddress: string | null; - /** A page inside this app, when one exists for this record. */ - href: string | null; /** - * The registry URL stored on-chain, used when the app has no page of its own. - * This is the link the token itself carries back to OBP, so it is the most - * faithful "what does this identifier refer to" answer available. + * The token's record in OBP's public chain mirror. Not the `*_uri` the token carries + * on-chain: that points at the private registry record, which needs a login to read. */ - registryUrl: string | null; + publicRecordUrl: string | null; +} + +export type ChainSectionKey = 'parcel' | 'activity' | 'certification' | 'batch' | 'balance'; + +/** Every token of one type, newest block first. */ +export interface ChainSection { + key: ChainSectionKey; + title: string; + /** The chain the tokens were read from, when the records say. */ + chainId: number | null; + /** The contract the tokens live on, when the records say. */ + contractAddress: string | null; + tokens: ChainToken[]; } async function listEntity(entity: string, accessToken: string): Promise[]> { - const response = await obp_requests.get(entityPath(entity), accessToken); - return (response[`${entity}_list`] ?? []) as Record[]; + const response = await obp_requests.get(entityPathV7(entity), accessToken); + return unwrapV7List>(response, entity); +} + +/** Anonymous read of an entity's public records. The chain mirrors and the sync status + * all have public access, so the chain page needs no login. */ +async function listPublicEntity(entity: string): Promise[]> { + const response = await obp_requests.get(publicEntityPathV7(entity)); + return unwrapV7List>(response, entity); } /** @@ -53,8 +90,8 @@ async function listEntity(entity: string, accessToken: string): Promise { - const rows = (await listEntity(ENTITY_CHAIN_SYNC_STATUS, accessToken)) as ChainSyncStatus[]; +export async function getChainSyncStatus(): Promise { + const rows = (await listPublicEntity(ENTITY_CHAIN_SYNC_STATUS)) as ChainSyncStatus[]; if (rows.length === 0) return null; return rows.reduce((newest, row) => (row.synced_at ?? '') > (newest.synced_at ?? '') ? row : newest @@ -66,91 +103,278 @@ function toNumber(v: unknown): number | null { return typeof n === 'number' && Number.isFinite(n) ? n : null; } -function activityHref(activityId: string | null | undefined): string | null { - return activityId ? `/activities/${encodeURIComponent(activityId)}` : null; +/** An absolute OBP URL for one public record. */ +function publicEntityUrl(entity: string, id: string): string { + const base = env.PUBLIC_OBP_BASE_URL?.replace(/\/$/, '') ?? ''; + return `${base}${publicEntityPathV7(entity)}/${encodeURIComponent(id)}`; +} + +/** The public chain mirror record `/_id`. */ +function publicRecordUrl(entity: string, record: Record): string | null { + const id = record[`${entity}_id`]; + return id ? publicEntityUrl(entity, id) : null; +} + +/** The record id at the end of a registry URI the token carries, e.g. + * `…/banks/ogcr/operator/op_01` -> `op_01`. Some tokens carry only the URI, not the id. */ +export function idFromUri(uri: unknown): string | null { + if (typeof uri !== 'string' || !uri) return null; + try { + const last = new URL(uri).pathname.split('/').filter(Boolean).pop(); + return last ? decodeURIComponent(last) : null; + } catch { + return null; + } +} + +/** A base-unit integer string as a decimal, e.g. 6500000000000000000 at 18 decimals -> "6.5". */ +export function formatUnits(raw: unknown, decimals: unknown): string | null { + if (raw === null || raw === undefined || raw === '') return null; + const places = toNumber(decimals) ?? 0; + let value: bigint; + try { + value = BigInt(String(raw)); + } catch { + return String(raw); + } + const negative = value < 0n; + if (negative) value = -value; + const scale = 10n ** BigInt(places); + const whole = value / scale; + const fraction = (value % scale).toString().padStart(places, '0').replace(/0+$/, ''); + return `${negative ? '-' : ''}${whole}${fraction ? `.${fraction}` : ''}`; +} + +/** An app page link, or plain text when there is no id. */ +function pageLink(label: string, text: unknown, href: string | null): ChainLink | null { + if (!text) return null; + return { label, text: String(text), href, external: false }; +} + +/** A record that is not public: its id, unlinked. */ +function textLink(label: string, id: string | null): ChainLink | null { + return id ? { label, text: id, href: null, external: false } : null; +} + +/** A link to another token's row on this page, when that token is on this page. */ +function tokenLink(label: string, anchor: string, present: Set, text: string): ChainLink | null { + return present.has(anchor) ? { label, text, href: `#${anchor}`, external: false } : null; +} + +function anchorFor(key: ChainSectionKey, tokenId: unknown): string | null { + const n = toNumber(tokenId); + return n === null ? null : `${key}-token-${n}`; +} + +const activityPage = (id: unknown) => + id ? `/registry/activities/${encodeURIComponent(String(id))}` : null; +const certificatePage = (id: unknown) => + id ? `/registry/certificates/${encodeURIComponent(String(id))}` : null; +// Parcels are not public: this page needs a login. +const parcelPage = (id: unknown) => (id ? `/parcels/${encodeURIComponent(String(id))}` : null); +const operatorPage = (id: unknown) => + id ? `/registry/operators/${encodeURIComponent(String(id))}` : null; + +function compact(items: (T | null)[]): T[] { + return items.filter((item): item is T => item !== null); +} + +function details(pairs: [string, unknown][]): { label: string; value: string }[] { + return pairs + .filter(([, value]) => value !== null && value !== undefined && value !== '') + .map(([label, value]) => ({ label, value: String(value) })); } /** - * Recent chain activity: the newest mirrored tokens across the mirrors, newest - * block first. + * Every mirrored token, one section per type, read anonymously from the public chain + * mirrors. Each token links to the tokens it refers to on this page, and to the + * off-chain records it refers to: an app page where there is one (public registry pages, + * and the login-only parcel page), else (for a record that is not public) its id as + * plain text. * - * This is the secondary signal, not the heartbeat. A short list here with an old - * newest block is a perfectly healthy quiet chain, which is exactly why - * freshness is read from the sync status instead. + * A mirror that cannot be read leaves its section empty rather than failing the page. */ -export async function getRecentChainEvents( - accessToken: string, - limit = 10 -): Promise { - // Fetched together so the newest tokens across every mirror can be merged - // into one list. - const [parcels, activities, certifications, batches] = await Promise.all([ - listEntity(ENTITY_PARCEL_ON_CHAIN, accessToken).catch(() => []), - listEntity(ENTITY_ACTIVITY_ON_CHAIN, accessToken).catch(() => []), - listEntity(ENTITY_CERTIFICATION_ON_CHAIN, accessToken).catch(() => []), - listEntity(ENTITY_CARBON_CREDIT_BATCH_ON_CHAIN, accessToken).catch(() => []) +export async function getChainTokens(): Promise { + const [parcels, activities, certifications, batches, balances] = await Promise.all([ + listPublicEntity(ENTITY_PARCEL_ON_CHAIN).catch(() => []), + listPublicEntity(ENTITY_ACTIVITY_ON_CHAIN).catch(() => []), + listPublicEntity(ENTITY_CERTIFICATION_ON_CHAIN).catch(() => []), + listPublicEntity(ENTITY_CARBON_CREDIT_BATCH_ON_CHAIN).catch(() => []), + listPublicEntity(ENTITY_CARBON_CREDIT_BALANCE_ON_CHAIN).catch(() => []) ]); - const events: ChainEvent[] = []; - - for (const b of batches) { - events.push({ - kind: 'Credit Batch', - tokenId: toNumber(b.token_id), - label: b.batch_name || b.unit_type || b.carbon_credit_batch_id || '', - blockNumber: toNumber(b.block_number), - txHash: b.tx_hash ?? null, - ownerAddress: b.owner_address ?? null, - // A batch has no page of its own, but it belongs to an activity that - // does, which is the more useful destination anyway. - href: activityHref(b.activity_id), - registryUrl: b.certificate_of_compliance_uri || b.activity_url || null - }); + // Which token rows exist, so links only point at rows that are on the page. + const present = new Set( + compact([ + ...parcels.map((r) => anchorFor('parcel', r.token_id)), + ...activities.map((r) => anchorFor('activity', r.token_id)), + ...batches.map((r) => anchorFor('batch', r.token_id)) + ]) + ); + const parcelAnchors = new Map(); + for (const r of parcels) { + const anchor = anchorFor('parcel', r.token_id); + if (r.parcel_id && anchor) parcelAnchors.set(r.parcel_id, anchor); } - for (const c of certifications) { - events.push({ - kind: 'Certification', - tokenId: toNumber(c.token_id), - label: c.certificate_of_compliance_id ?? '', - blockNumber: toNumber(c.block_number), - txHash: c.tx_hash ?? null, - ownerAddress: c.owner_address ?? null, - href: activityHref(c.activity_id), - registryUrl: c.certificate_of_compliance_uri || null - }); - } + const base = (key: ChainSectionKey, entity: string, r: Record) => ({ + anchor: anchorFor(key, r.token_id), + tokenId: toNumber(r.token_id), + ownerAddress: r.owner_address ?? null, + blockNumber: toNumber(r.block_number), + txHash: r.tx_hash ?? null, + publicRecordUrl: publicRecordUrl(entity, r) + }); - for (const a of activities) { - events.push({ - kind: 'Activity', - tokenId: toNumber(a.token_id), - label: a.name || a.activity_id || '', - blockNumber: toNumber(a.block_number), - txHash: a.tx_hash ?? null, - ownerAddress: a.owner_address ?? null, - href: activityHref(a.activity_id), - registryUrl: a.activity_url || null - }); - } + const parcelTokens: ChainToken[] = parcels.map((r) => ({ + ...base('parcel', ENTITY_PARCEL_ON_CHAIN, r), + title: r.parcel_id ?? '', + details: [], + links: compact([pageLink('Parcel', r.parcel_id, parcelPage(r.parcel_id))]) + })); - for (const p of parcels) { - events.push({ - kind: 'Parcel', - tokenId: toNumber(p.token_id), - label: p.parcel_id ?? '', - blockNumber: toNumber(p.block_number), - txHash: p.tx_hash ?? null, - ownerAddress: p.owner_address ?? null, - // No parcel page in the app yet, so the on-chain registry URL is the - // only honest destination. - href: null, - registryUrl: p.parcel_uri || null - }); - } + const activityTokens: ChainToken[] = activities.map((r) => { + const parcelIds = String(r.parcel_ids ?? '') + .split(/[\s,]+/) + .filter(Boolean); + return { + ...base('activity', ENTITY_ACTIVITY_ON_CHAIN, r), + title: r.name || r.activity_id || '', + details: details([ + ['Type', r.activity_type], + ['Unit types', r.unit_types], + ['Period', r.start_date && r.end_date ? `${r.start_date} – ${r.end_date}` : null] + ]), + links: compact([ + pageLink('Activity', r.activity_id, activityPage(r.activity_id)), + pageLink('Operator', r.operator_name || r.operator_id, operatorPage(r.operator_id)), + pageLink( + 'Certification scheme', + r.certification_scheme_id, + certificationSchemeHref(r.certification_scheme_id) + ), + ...parcelIds.flatMap((id) => { + const anchor = parcelAnchors.get(id); + return compact([ + pageLink('Parcel', id, parcelPage(id)), + anchor ? { label: 'Parcel token', text: id, href: `#${anchor}`, external: false } : null + ]); + }) + ]) + }; + }); + + const certificationTokens: ChainToken[] = certifications.map((r) => ({ + ...base('certification', ENTITY_CERTIFICATION_ON_CHAIN, r), + title: r.certificate_of_compliance_id ?? '', + details: details([ + ['Status', r.certification_status], + ['Issued', r.issue_date], + ['Expires', r.expiry_date] + ]), + links: compact([ + pageLink( + 'Certificate', + r.certificate_of_compliance_id, + certificatePage(r.certificate_of_compliance_id) + ), + tokenLink( + 'Activity token', + `activity-token-${toNumber(r.activity_nft_id)}`, + present, + `#${r.activity_nft_id}` + ), + pageLink('Activity', r.activity_name || r.activity_id, activityPage(r.activity_id)), + pageLink( + 'Certification scheme', + r.certification_scheme_name || idFromUri(r.certification_scheme_uri), + certificationSchemeHref(idFromUri(r.certification_scheme_uri)) + ), + pageLink( + 'Certification body', + r.certification_body_name || idFromUri(r.certification_body_uri), + certificationBodyHref(idFromUri(r.certification_body_uri)) + ), + textLink('Audit report', idFromUri(r.audit_report_uri)) + ]) + })); + + const batchTokens: ChainToken[] = batches.map((r) => { + const certificateId = idFromUri(r.certificate_of_compliance_uri); + const operatorId = idFromUri(r.operator_url); + const amount = formatUnits(r.amount, r.credit_decimals ?? 18); + const balance = formatUnits(r.credit_balance, r.credit_decimals ?? 18); + return { + ...base('batch', ENTITY_CARBON_CREDIT_BATCH_ON_CHAIN, r), + title: r.batch_name || r.unit_type || r.carbon_credit_batch_id || '', + details: details([ + ['Unit type', r.unit_type], + ['Issued', amount === null ? null : `${amount} tCO₂e`], + ['Balance', balance === null ? null : `${balance} tCO₂e`], + ['Status', r.status_code], + ['Batch account', r.token_bound_account] + ]), + links: compact([ + tokenLink( + 'Activity token', + `activity-token-${toNumber(r.activity_nft_id)}`, + present, + `#${r.activity_nft_id}` + ), + pageLink('Activity', r.activity_name || r.activity_id, activityPage(r.activity_id)), + pageLink('Operator', operatorId, operatorPage(operatorId)), + pageLink('Certificate', certificateId, certificatePage(certificateId)), + pageLink( + 'Certification scheme', + idFromUri(r.certification_scheme_url), + certificationSchemeHref(idFromUri(r.certification_scheme_url)) + ), + pageLink( + 'Certification body', + idFromUri(r.certification_body_url), + certificationBodyHref(idFromUri(r.certification_body_url)) + ) + ]) + }; + }); + + const balanceTokens: ChainToken[] = balances.map((r) => { + const amount = formatUnits(r.balance, r.decimals ?? 18); + return { + anchor: null, + tokenId: null, + title: amount === null ? '' : `${amount} ${r.symbol || 'CC'}`, + details: details([['Holder', r.holder_type]]), + links: compact([ + tokenLink('Batch token', `batch-token-${toNumber(r.batch_token_id)}`, present, `#${r.batch_token_id}`) + ]), + ownerAddress: r.owner_address ?? null, + blockNumber: toNumber(r.block_number), + txHash: null, + publicRecordUrl: publicRecordUrl(ENTITY_CARBON_CREDIT_BALANCE_ON_CHAIN, r) + }; + }); + + const section = ( + key: ChainSectionKey, + title: string, + records: Record[], + tokens: ChainToken[] + ): ChainSection => ({ + key, + title, + chainId: toNumber(records.find((r) => r.chain_id != null)?.chain_id), + contractAddress: records.find((r) => r.contract_address)?.contract_address ?? null, + tokens: [...tokens].sort((a, b) => (b.blockNumber ?? -1) - (a.blockNumber ?? -1)) + }); - events.sort((a, b) => (b.blockNumber ?? -1) - (a.blockNumber ?? -1)); - return events.slice(0, limit); + return [ + section('parcel', 'Parcels', parcels, parcelTokens), + section('activity', 'Activities', activities, activityTokens), + section('certification', 'Certifications', certifications, certificationTokens), + section('batch', 'Credit batches', batches, batchTokens), + section('balance', 'Credit balances', balances, balanceTokens) + ]; } /** The four benefit fields a monitoring verification can carry, and the suffix diff --git a/src/lib/components/SystemStatusPage.svelte b/src/lib/components/SystemStatusPage.svelte index 7bb5f73..780455a 100644 --- a/src/lib/components/SystemStatusPage.svelte +++ b/src/lib/components/SystemStatusPage.svelte @@ -11,11 +11,11 @@ }: { /** * The health summary, optionally extended with per-request checks the - * background registry cannot make. `chainMirrorChecked` is false when the - * viewer has no OBP credentials, so the chain mirror could not be read. + * background registry cannot make. `backlogChecked` is false when the + * viewer has no OBP credentials, so the tokenization backlog could not be read. */ data: HealthSummary & { - chainMirrorChecked?: boolean; + backlogChecked?: boolean; backlog?: Backlog | null; backlogError?: string | null; }; @@ -461,21 +461,20 @@ {/if} - {#if data.chainMirrorChecked === false} - -
+

+ Chain detail, what each chain status means, and every token on chain: + the chain page. +

+ + {#if data.backlogChecked === false} + +

- The chain mirror is not listed because checking it needs your OBP credentials. - Log in to include it, or open - the chain page for detail. + The tokenization backlog is not shown because it reads registry records that need + your OBP credentials. Log in to include it.

- {:else if data.chainMirrorChecked} -

- Chain detail, what each chain status means, and recent on-chain activity: - the chain page. -

{/if} {#if data.backlog} diff --git a/src/lib/constants/entities.ts b/src/lib/constants/entities.ts index 7f1c184..0399f8a 100644 --- a/src/lib/constants/entities.ts +++ b/src/lib/constants/entities.ts @@ -20,6 +20,14 @@ export function entityPath(entity: string): string { return `/obp/dynamic-entity${space}/${entity}`; } +/** v7.0.0 path of an entity's public (anonymous, read-only) records in the configured + * space, e.g. `/obp/v7.0.0/banks/ogcr/dynamic-entities/public/parcel_on_chain`. Only + * entities with public access have one. List items come back wrapped, as for + * `entityPathV7()`, so read them with `unwrapV7List()`. */ +export function publicEntityPathV7(entity: string): string { + return `/obp/v7.0.0/banks/${encodeURIComponent(ENTITY_ROLE_BANK_ID)}/dynamic-entities/public/${entity}`; +} + /** * v7.0.0 path of an entity's records in the configured space, e.g. * `/obp/v7.0.0/banks/ogcr/dynamic-entities/activity` (`SYS` for system level entities). @@ -50,9 +58,10 @@ export const ENTITY_COUNTRY = `${ENTITY_PREFIX}country`; // stores the `technologies_practices_processes_id` of one of these records. export const ENTITY_TECHNOLOGIES_PRACTICES_PROCESSES = `${ENTITY_PREFIX}technologies_practices_processes`; export const ENTITY_PARCEL_OWNERSHIP_VERIFICATION = `${ENTITY_PREFIX}parcel_owner_verification`; -export const ENTITY_ACTIVITY_PARCEL_VERIFICATION = `${ENTITY_PREFIX}activity_parcel_verification`; +// Junction linking parcels to activities: `parcel_id` + `activity_id`. +export const ENTITY_PARCEL_ACTIVITY = `${ENTITY_PREFIX}parcel_activity`; +// Carries the `parcel_id` it verifies as well as the `activity_id`. export const ENTITY_ACTIVITY_VERIFICATION = `${ENTITY_PREFIX}activity_verification`; -export const ENTITY_PARCEL_MONITORING_PERIOD_VERIFICATION = `${ENTITY_PREFIX}parcel_monitoring_period_verification`; export const ENTITY_ACTIVITY_MONITORING_PERIOD_VERIFICATION = `${ENTITY_PREFIX}activity_monitoring_period_verification`; // Chain mirrors, written by OGCR-chain-cache. These are deliberately NOT @@ -77,9 +86,8 @@ export const ENTITY_CONSTANTS = { ENTITY_COUNTRY, ENTITY_TECHNOLOGIES_PRACTICES_PROCESSES, ENTITY_PARCEL_OWNERSHIP_VERIFICATION, - ENTITY_ACTIVITY_PARCEL_VERIFICATION, + ENTITY_PARCEL_ACTIVITY, ENTITY_ACTIVITY_VERIFICATION, - ENTITY_PARCEL_MONITORING_PERIOD_VERIFICATION, ENTITY_ACTIVITY_MONITORING_PERIOD_VERIFICATION, ENTITY_PARCEL_ON_CHAIN, ENTITY_ACTIVITY_ON_CHAIN, diff --git a/src/lib/obp/domainApi.test.ts b/src/lib/obp/domainApi.test.ts new file mode 100644 index 0000000..3e22746 --- /dev/null +++ b/src/lib/obp/domainApi.test.ts @@ -0,0 +1,33 @@ +import { describe, expect, it } from 'vitest'; +import { publicGets } from './domainApi'; + +const login = [{ DirectLogin: [] }, { OAuth2: [] }]; + +describe('publicGets', () => { + it('keeps the GETs without a security requirement or a path parameter', () => { + const gets = publicGets({ + paths: { + '/public/country/{countryid}': { get: { summary: 'Get country by id' } }, + '/public/country': { get: { summary: 'Get countries' } }, + '/operator': { get: { summary: 'Get operators', security: login } }, + '/registry/activity-query': { get: { summary: 'Get one activity' } }, + '/public/activity': { post: { summary: 'not a GET' } } as never + } + }); + expect(gets).toEqual([ + { path: '/public/country', summary: 'Get countries' }, + { path: '/registry/activity-query', summary: 'Get one activity' } + ]); + }); + + it("falls back to the document's security, and an empty one means public", () => { + expect(publicGets({ security: login, paths: { '/a': { get: {} } } })).toEqual([]); + expect(publicGets({ security: login, paths: { '/a': { get: { security: [] } } } })).toEqual([ + { path: '/a', summary: '' } + ]); + }); + + it('copes with a document without paths', () => { + expect(publicGets({})).toEqual([]); + }); +}); diff --git a/src/lib/obp/domainApi.ts b/src/lib/obp/domainApi.ts new file mode 100644 index 0000000..cf283b1 --- /dev/null +++ b/src/lib/obp/domainApi.ts @@ -0,0 +1,34 @@ +/** + * Reading a Domain API's OpenAPI document (BASE_PATH/openapi.json), which OBP serves with only + * that Domain API's endpoints, at their published paths. + */ + +/** A GET endpoint anyone may call, at its path under the Domain API's base path. */ +export type PublicGet = { + path: string; + summary: string; +}; + +type Operation = { summary?: string; security?: unknown[] }; +type OpenApiDocument = { + security?: unknown[]; + paths?: Record; +}; + +/** + * The GET endpoints that need no login and no path parameter, so each can be opened as it + * stands. OBP leaves `security` out of an operation anyone may call and lists its login schemes + * on the others; an operation without its own falls back to the document's, as OpenAPI defines. + * Sorted by path. + */ +export function publicGets(document: OpenApiDocument): PublicGet[] { + return Object.entries(document.paths ?? {}) + .flatMap(([path, item]) => { + const get = item?.get; + if (!get || /\{[^}]+\}/.test(path)) return []; + const security = get.security ?? document.security; + if (security && security.length > 0) return []; + return [{ path, summary: get.summary ?? '' }]; + }) + .sort((a, b) => a.path.localeCompare(b.path)); +} diff --git a/src/lib/obp/entityPages.ts b/src/lib/obp/entityPages.ts index 25934ab..09546b3 100644 --- a/src/lib/obp/entityPages.ts +++ b/src/lib/obp/entityPages.ts @@ -19,10 +19,9 @@ const PAGES: Record = { activity: ACTIVITIES, activity_verification: ACTIVITIES, activity_monitoring_period_verification: ACTIVITIES, - activity_parcel_verification: ACTIVITIES, + parcel_activity: ACTIVITIES, parcel: ACTIVITIES, parcel_owner_verification: ACTIVITIES, - parcel_monitoring_period_verification: ACTIVITIES, operator: OPERATORS, user_operator_relationship: OPERATORS, diff --git a/src/lib/registry/certification.ts b/src/lib/registry/certification.ts new file mode 100644 index 0000000..7829a50 --- /dev/null +++ b/src/lib/registry/certification.ts @@ -0,0 +1,75 @@ +import { env } from '$env/dynamic/public'; +import { publicEntityPathV7, unwrapV7List } from '$lib/constants/entities'; + +/** + * The public certification scheme and certification body pages + * (/registry/certification-schemes/, /registry/certification-bodies/). + * + * Both entities have public access, so these read their public v7.0.0 dynamic entity + * endpoints anonymously, as do the certificates and activities listed under them. Each + * list degrades on its own: one that cannot be read leaves its section empty. + */ + +export const ENTITY_CERTIFICATION_SCHEME = 'certification_scheme'; +export const ENTITY_CERTIFICATION_BODY = 'certification_body'; +const ENTITY_CERTIFICATE_OF_COMPLIANCE = 'certificate_of_compliance'; +const ENTITY_ACTIVITY = 'activity'; + +type Row = Record; + +function url(path: string): string { + return `${env.PUBLIC_OBP_BASE_URL?.replace(/\/$/, '') ?? ''}${path}`; +} + +/** One public record, or an error message when it cannot be read. */ +async function getRecord( + fetchFn: typeof fetch, + entity: string, + id: string +): Promise<{ record: Row | null; error: string | null }> { + try { + const response = await fetchFn(url(`${publicEntityPathV7(entity)}/${encodeURIComponent(id)}`)); + const body = await response.json().catch(() => null); + if (response.status === 404) return { record: null, error: null }; + if (!response.ok) { + return { record: null, error: body?.message ?? `OBP answered ${response.status}` }; + } + return { record: (body?.[entity] as Row) ?? null, error: null }; + } catch (error) { + return { record: null, error: error instanceof Error ? error.message : 'Unknown error' }; + } +} + +/** Public records whose `field` equals `value`; empty when the list cannot be read. */ +async function listWhere( + fetchFn: typeof fetch, + entity: string, + field: string, + value: string +): Promise { + try { + const query = `?obp_filter[${field}]=eq:${encodeURIComponent(value)}`; + const response = await fetchFn(url(`${publicEntityPathV7(entity)}${query}`)); + if (!response.ok) return []; + return unwrapV7List(await response.json(), entity); + } catch { + return []; + } +} + +export async function getCertificationScheme(fetchFn: typeof fetch, id: string) { + const [{ record, error }, certificates, activities] = await Promise.all([ + getRecord(fetchFn, ENTITY_CERTIFICATION_SCHEME, id), + listWhere(fetchFn, ENTITY_CERTIFICATE_OF_COMPLIANCE, 'certification_scheme_id', id), + listWhere(fetchFn, ENTITY_ACTIVITY, 'certification_scheme_id', id) + ]); + return { scheme: record, error, certificates, activities }; +} + +export async function getCertificationBody(fetchFn: typeof fetch, id: string) { + const [{ record, error }, certificates] = await Promise.all([ + getRecord(fetchFn, ENTITY_CERTIFICATION_BODY, id), + listWhere(fetchFn, ENTITY_CERTIFICATE_OF_COMPLIANCE, 'certification_body_id', id) + ]); + return { body: record, error, certificates }; +} diff --git a/src/lib/registry/certificationLinks.ts b/src/lib/registry/certificationLinks.ts new file mode 100644 index 0000000..4db513e --- /dev/null +++ b/src/lib/registry/certificationLinks.ts @@ -0,0 +1,7 @@ +/** Links to the public certification scheme and body pages. Kept apart from + * ./certification.ts, which reads private env, so pages can import them in the browser. */ + +export const certificationSchemeHref = (id: string | null | undefined) => + id ? `/registry/certification-schemes/${encodeURIComponent(id)}` : null; +export const certificationBodyHref = (id: string | null | undefined) => + id ? `/registry/certification-bodies/${encodeURIComponent(id)}` : null; diff --git a/src/lib/server/roleChecker.ts b/src/lib/server/roleChecker.ts index d7c7b17..c5f50ab 100644 --- a/src/lib/server/roleChecker.ts +++ b/src/lib/server/roleChecker.ts @@ -5,6 +5,7 @@ import { ENTITY_ACTIVITY, ENTITY_OPERATOR, + ENTITY_PARCEL, ENTITY_USER_OPERATOR_RELATIONSHIP, ENTITY_ROLE_BANK_ID } from '$lib/constants/entities'; @@ -28,6 +29,9 @@ export const SITE_MAP: Record = { '/activities': { required: [{ role: `CanGetDynamicEntityRecord_${ENTITY_ACTIVITY}`, bankId: ENTITY_ROLE_BANK_ID }] }, + '/parcels': { + required: [{ role: `CanGetDynamicEntityRecord_${ENTITY_PARCEL}`, bankId: ENTITY_ROLE_BANK_ID }] + }, '/operators': { required: [{ role: `CanGetDynamicEntityRecord_${ENTITY_OPERATOR}`, bankId: ENTITY_ROLE_BANK_ID }] }, diff --git a/src/lib/server/systemStatus.ts b/src/lib/server/systemStatus.ts index a1f328d..966308a 100644 --- a/src/lib/server/systemStatus.ts +++ b/src/lib/server/systemStatus.ts @@ -14,7 +14,8 @@ import { deriveHeartbeat } from '$lib/chain/heartbeat'; import { chainMirrorSnapshot, chainMirrorUncheckable } from '$lib/chain/mirrorHealth'; export type SystemStatusData = HealthSummary & { - chainMirrorChecked: boolean; + /** False when the viewer has no OBP credentials, so the backlog could not be read. */ + backlogChecked: boolean; backlog: Backlog | null; backlogError: string | null; }; @@ -22,26 +23,25 @@ export type SystemStatusData = HealthSummary & { export async function buildSystemStatus(accessToken: string | undefined): Promise { const snapshots = { ...healthCheckRegistry.getSnapshots() }; - // The chain mirror cannot be polled in the background like the other checks: - // reading its status record needs OBP credentials, which only a request - // carries. So it is evaluated here, per request, and only when we have a - // token to do it with. Adding an always-unknown entry for logged-out - // visitors would drag the whole page's overall status to unknown while - // telling them nothing. + // The chain mirror's sync status is a public dynamic entity, so it is checked + // for every visitor, logged in or not. It is read here, per request, so the + // page always shows the mirror's latest run. + try { + const status = await getChainSyncStatus(); + const snapshot = chainMirrorSnapshot(deriveHeartbeat(status)); + snapshots[snapshot.service] = snapshot; + } catch (error) { + const message = error instanceof Error ? error.message : 'Unknown error'; + const snapshot = chainMirrorUncheckable(`Could not read the chain mirror: ${message}`); + snapshots[snapshot.service] = snapshot; + } + + // The backlog still needs OBP credentials: it compares the chain mirror with + // registry records (verifications, certificates) that are not public. let backlog: Backlog | null = null; let backlogError: string | null = null; if (accessToken) { - try { - const status = await getChainSyncStatus(accessToken); - const snapshot = chainMirrorSnapshot(deriveHeartbeat(status)); - snapshots[snapshot.service] = snapshot; - } catch (error) { - const message = error instanceof Error ? error.message : 'Unknown error'; - const snapshot = chainMirrorUncheckable(`Could not read the chain mirror: ${message}`); - snapshots[snapshot.service] = snapshot; - } - // Deliberately NOT folded into summarizeHealth. A backlog is a measurement, // not a health state: a snapshot cannot tell a queue being worked through // from one that is stuck, so forcing it into healthy/unhealthy would either @@ -55,7 +55,7 @@ export async function buildSystemStatus(accessToken: string | undefined): Promis return { ...summarizeHealth(snapshots), - chainMirrorChecked: !!accessToken, + backlogChecked: !!accessToken, backlog, backlogError }; diff --git a/src/routes/+page.svelte b/src/routes/+page.svelte index 06fd87b..29c7aae 100644 --- a/src/routes/+page.svelte +++ b/src/routes/+page.svelte @@ -15,7 +15,8 @@ KeyRound, Globe, MessageSquare, - Users + Users, + FileCode } from '@lucide/svelte'; let { data }: { data: PageData } = $props(); @@ -55,6 +56,12 @@ title: 'Chain', description: 'Inspect on-chain state and the sync status.' }, + { + href: '/domain-api', + icon: FileCode, + title: 'Domain API', + description: "The space's endpoints under its own base path, with their OpenAPI spec." + }, { href: data.portalUrl, icon: Globe, diff --git a/src/routes/activities/[id]/+page.server.ts b/src/routes/activities/[id]/+page.server.ts index 2efe71e..47c864e 100644 --- a/src/routes/activities/[id]/+page.server.ts +++ b/src/routes/activities/[id]/+page.server.ts @@ -5,10 +5,9 @@ import { ENTITY_OPERATOR, ENTITY_PARCEL, ENTITY_PARCEL_OWNERSHIP_VERIFICATION, - ENTITY_PARCEL_MONITORING_PERIOD_VERIFICATION, ENTITY_ACTIVITY_VERIFICATION, ENTITY_ACTIVITY_MONITORING_PERIOD_VERIFICATION, - ENTITY_ACTIVITY_PARCEL_VERIFICATION, + ENTITY_PARCEL_ACTIVITY, entityPathV7, unwrapV7List } from '$lib/constants/entities'; @@ -36,13 +35,13 @@ export const load: PageServerLoad = async ({ locals, params }) => { // Fetch activity and activity-level verifications in parallel const [ activityResponse, - activityParcelVerResponse, + parcelActivityResponse, activityVerResponse, activityMonitoringVerResponse ] = await Promise.all([ obp_requests.get(`${entityPathV7(ENTITY_ACTIVITY)}/${activityId}`, accessToken), obp_requests.get( - `${entityPathV7(ENTITY_ACTIVITY_PARCEL_VERIFICATION)}?${activityIdFilter}`, + `${entityPathV7(ENTITY_PARCEL_ACTIVITY)}?${activityIdFilter}`, accessToken ), obp_requests.get( @@ -82,40 +81,27 @@ export const load: PageServerLoad = async ({ locals, params }) => { ENTITY_ACTIVITY_MONITORING_PERIOD_VERIFICATION ); - // Get parcel IDs from activity_parcel_verification records - const activityParcelVers = unwrapV7List( - activityParcelVerResponse, - ENTITY_ACTIVITY_PARCEL_VERIFICATION - ); + // The activity's parcels, from the parcel_activity links + const parcelLinks = unwrapV7List(parcelActivityResponse, ENTITY_PARCEL_ACTIVITY); const parcelIds = [ - ...new Set( - activityParcelVers.map((v: Record) => v.parcel_id as string) - ) + ...new Set(parcelLinks.map((v: Record) => v.parcel_id as string)) ]; - // Fetch each parcel and parcel-level verifications + // Fetch each parcel and its ownership verifications const parcelPromises = parcelIds.map(async (parcelId: string) => { - const [parcelResponse, ownerVerResponse, monitoringVerResponse] = await Promise.all([ + const [parcelResponse, ownerVerResponse] = await Promise.all([ obp_requests.get(`${entityPathV7(ENTITY_PARCEL)}/${parcelId}`, accessToken), obp_requests.get( `${entityPathV7(ENTITY_PARCEL_OWNERSHIP_VERIFICATION)}?parcel_id=${parcelId}`, accessToken - ), - obp_requests.get( - `${entityPathV7(ENTITY_PARCEL_MONITORING_PERIOD_VERIFICATION)}?parcel_id=${parcelId}`, - accessToken ) ]); const parcel = parcelResponse[ENTITY_PARCEL] || parcelResponse; const ownerVerifications = unwrapV7List(ownerVerResponse, ENTITY_PARCEL_OWNERSHIP_VERIFICATION); - const monitoringVerifications = unwrapV7List( - monitoringVerResponse, - ENTITY_PARCEL_MONITORING_PERIOD_VERIFICATION - ); - // Find activity_parcel_verification records for this parcel - const parcelActivityVers = activityParcelVers.filter( + // The activity verifications made for this parcel + const parcelActivityVers = activityVerifications.filter( (v: Record) => v.parcel_id === parcelId ); @@ -123,8 +109,7 @@ export const load: PageServerLoad = async ({ locals, params }) => { ...parcel, parcel_id: parcelId, owner_verifications: ownerVerifications, - monitoring_verifications: monitoringVerifications, - activity_parcel_verifications: parcelActivityVers + activity_verifications: parcelActivityVers }; }); diff --git a/src/routes/activities/[id]/+page.svelte b/src/routes/activities/[id]/+page.svelte index 9e4356b..376dfaa 100644 --- a/src/routes/activities/[id]/+page.svelte +++ b/src/routes/activities/[id]/+page.svelte @@ -396,14 +396,12 @@ {/if}
- {#if parcel.activity_parcel_verifications && parcel.activity_parcel_verifications.length > 0} + {#if parcel.activity_verifications && parcel.activity_verifications.length > 0}
-

Activity Parcel Verifications

+

Activity Verifications

- {#each parcel.activity_parcel_verifications as ver} - - {ver.status_code}{#if ver.amount !== undefined} ({ver.amount}){/if} - + {#each parcel.activity_verifications as ver} + {ver.status_code} {/each}
@@ -422,20 +420,6 @@

None.

{/if} -
-

Monitoring Verifications

- {#if parcel.monitoring_verifications && parcel.monitoring_verifications.length > 0} -
- {#each parcel.monitoring_verifications as ver} - - {ver.status_code}{#if ver.amount !== undefined} ({ver.amount}){/if} - - {/each} -
- {:else} -

None.

- {/if} -
{/each} diff --git a/src/routes/chain/+page.server.ts b/src/routes/chain/+page.server.ts index 33fa288..aad26ab 100644 --- a/src/routes/chain/+page.server.ts +++ b/src/routes/chain/+page.server.ts @@ -1,38 +1,29 @@ import type { PageServerLoad } from './$types'; import { OBPRequestError } from '$lib/obp/errors'; -import { getChainSyncStatus, getRecentChainEvents, type ChainEvent } from '$lib/chain/onChain'; +import { getChainSyncStatus, getChainTokens, type ChainSection } from '$lib/chain/onChain'; import { deriveHeartbeat, type Heartbeat } from '$lib/chain/heartbeat'; import { env } from '$env/dynamic/public'; import { normalizeExplorerBase } from '$lib/chain/explorer'; -export const load: PageServerLoad = async ({ locals }) => { - const accessToken = locals.session.data.oauth?.access_token; - +/** Public: the chain mirror and its sync status are public dynamic entities, so the + * page is read anonymously and needs no login. */ +export const load: PageServerLoad = async () => { // A private chain has no explorer unless someone is running one, so // transaction links are opt-in configuration rather than an assumption. const explorerBase = normalizeExplorerBase(env.PUBLIC_CHAIN_EXPLORER_URL); - if (!accessToken) { - return { - isAuthenticated: false, - heartbeat: deriveHeartbeat(null) as Heartbeat, - events: [] as ChainEvent[], - explorerBase, - error: null - }; - } - try { // The status is what the heartbeat is built on, so a failure to read it is - // a page-level error. Recent events are decoration and are fetched - // separately below so they cannot take the heartbeat down with them. - const status = await getChainSyncStatus(accessToken); - const events = await getRecentChainEvents(accessToken).catch(() => [] as ChainEvent[]); + // a page-level error. The tokens are read separately so a mirror that cannot + // be read cannot take the heartbeat down with it. + const [status, sections] = await Promise.all([ + getChainSyncStatus(), + getChainTokens().catch(() => [] as ChainSection[]) + ]); return { - isAuthenticated: true, heartbeat: deriveHeartbeat(status), - events, + sections, explorerBase, error: null }; @@ -44,9 +35,8 @@ export const load: PageServerLoad = async ({ locals }) => { ? error.message : 'Unknown error'; return { - isAuthenticated: true, - heartbeat: deriveHeartbeat(null), - events: [] as ChainEvent[], + heartbeat: deriveHeartbeat(null) as Heartbeat, + sections: [] as ChainSection[], explorerBase, error: message, errorDetails: error instanceof OBPRequestError ? error.toJSON() : undefined diff --git a/src/routes/chain/+page.svelte b/src/routes/chain/+page.svelte index f09123d..22b7ae1 100644 --- a/src/routes/chain/+page.svelte +++ b/src/routes/chain/+page.svelte @@ -3,11 +3,14 @@ import { Link2, RefreshCw, ExternalLink, HelpCircle } from '@lucide/svelte'; import ChainHeartbeat from '$lib/components/ChainHeartbeat.svelte'; import Timestamp from '$lib/components/Timestamp.svelte'; - import { formatAge, HEARTBEAT_STATES } from '$lib/chain/heartbeat'; + import { HEARTBEAT_STATES } from '$lib/chain/heartbeat'; import { totalMirrored } from '$lib/chain/heartbeat'; import { explorerLinks, shortenHex } from '$lib/chain/explorer'; import { invalidateAll } from '$app/navigation'; import { onMount } from 'svelte'; + import Card from '$lib/components/Card.svelte'; + import Table, { type TableColumn } from '$lib/components/Table.svelte'; + import type { ChainSection, ChainSectionKey, ChainToken } from '$lib/chain/onChain'; let { data }: { data: PageData } = $props(); @@ -35,6 +38,32 @@ } } + // Balances have no token id and no transaction of their own: the mirror records the + // holder and the block the balance was read at. + function tokenColumns(key: ChainSectionKey): TableColumn[] { + const balance = key === 'balance'; + return [ + ...(balance ? [] : [{ key: 'token', header: 'Token', numeric: true, sortable: true }]), + { key: 'record', header: balance ? 'Balance' : 'Record', sortable: true }, + { key: 'refers', header: 'Refers to' }, + { key: 'owner', header: balance ? 'Holder' : 'Owner' }, + { key: 'block', header: 'Block', numeric: true, sortable: true }, + ...(balance ? [] : [{ key: 'tx', header: 'Transaction' }]) + ]; + } + + // Says where each section's rows come from: the blockchain itself, via the mirror + // OGCR-chain-cache keeps of it in OBP, not the registry's own records. + function chainSubtitle(section: ChainSection): string { + return [ + `Read from the blockchain${section.chainId !== null ? ` (chain id ${section.chainId})` : ''}`, + section.contractAddress ? `contract ${section.contractAddress}` : null, + 'via the OGCR-chain-cache mirror' + ] + .filter(Boolean) + .join(', '); + } + onMount(() => { const seconds = Math.max(status?.interval_seconds || 30, 15); const timer = setInterval(refresh, seconds * 1000); @@ -42,6 +71,85 @@ }); +{#snippet cell(row: Record, column: TableColumn)} + {@const t = row._token as ChainToken} + {#if column.key === 'token'} + {t.tokenId ?? '—'} + {:else if column.key === 'record'} +
+ {t.title || '—'} + {#each t.details as d (d.label)} + {d.label}: {d.value} + {/each} + {#if t.publicRecordUrl} + + On-chain record + + {/if} +
+ {:else if column.key === 'refers'} + + {:else if column.key === 'owner'} + {#if explorer.address(t.ownerAddress)} + + {shortenHex(t.ownerAddress, 6, 4)} + + {:else} + {shortenHex(t.ownerAddress, 6, 4)} + {/if} + {:else if column.key === 'block'} + {#if explorer.block(t.blockNumber)} + + {t.blockNumber} + + {:else} + {t.blockNumber ?? '—'} + {/if} + {:else if column.key === 'tx'} + {#if explorer.tx(t.txHash)} + + {shortenHex(t.txHash)} + + {:else} + + {shortenHex(t.txHash)} + {/if} + {/if} +{/snippet} +
@@ -55,189 +163,222 @@
- {#if !data.isAuthenticated} -
-

Authentication Required

-

Please log in to see the chain connection.

- Login + {#if data.error} +
+

Could not read the chain mirror

+
{data.error}
- {:else} - {#if data.error} -
-

Could not read the chain mirror

-
{data.error}
-
- {/if} + {/if} - - -
-
-

Last mirror run

- {#if status?.synced_at} -
- - {#if data.heartbeat.ageSeconds !== null} - · {formatAge(data.heartbeat.ageSeconds)} ago - {/if} -
- {/if} + + +
+
+ +

{data.heartbeat.message}

+
+
Head block
{status?.head_block ?? '—'}
+
Chain
{status?.chain_id ?? '—'}
+
Errors last run
{status?.error_count ?? '—'}
+
Stalled after
{data.heartbeat.staleAfterSeconds}s
+
-

- Records written by OGCR-chain-cache on its most recent pass. +

+

+ Last mirror run + {#if status?.synced_at} + + {/if} +

+
+ {#each counts as c (c.label)} +
{c.label}
{c.value ?? '—'}
+ {/each} +
Total
{totalMirrored(status)}
+
{#if status?.mirrored_types} - Mirrors that ran: {status.mirrored_types.split(',').join(', ')}. +

+ Mirrors that ran: {status.mirrored_types.split(',').join(', ')}. +

{/if} -

-
- {#each counts as c} -
-
{c.label}
-
{c.value ?? '—'}
-
- {/each}
-

{totalMirrored(status)} records in total.

+ -
-

Recent chain activity

-

- Newest mirrored tokens, by the block they were minted in. A short list with an - old newest block is a quiet chain, not a broken one — the connection status - above is the thing to trust for that. -

- - {#if !explorer.available} -

- Transactions and blocks are shown as plain values because no block explorer is - configured for this chain. Set PUBLIC_CHAIN_EXPLORER_URL to a - Blockscout or Etherscan-style base URL to turn them into links. -

- {/if} + + {#if !explorer.available} +

+ Transactions, blocks and addresses are shown as plain values because no block explorer + is configured for this chain. Set PUBLIC_CHAIN_EXPLORER_URL to a Blockscout or + Etherscan-style base URL to turn them into links. +

+ {/if} - {#if data.events.length === 0} -

No tokens have been mirrored yet.

- {:else} -
- - - - - - - - - - - - - {#each data.events as e} - - - - - - - - - {/each} - -
TypeIdentifierTokenOwnerBlockTransaction
{e.kind} - {#if e.href} - - {e.label} - {:else if e.registryUrl} - - - {e.label} - - - {:else} - {e.label} - {/if} - {e.tokenId ?? '—'} - {#if explorer.address(e.ownerAddress)} - - {shortenHex(e.ownerAddress, 6, 4)} - - {:else} - {shortenHex(e.ownerAddress, 6, 4)} - {/if} - - {#if explorer.block(e.blockNumber)} - - {e.blockNumber} - - {:else} - {e.blockNumber ?? '—'} - {/if} - - {#if explorer.tx(e.txHash)} - - {shortenHex(e.txHash)} - - - {:else} - - {shortenHex(e.txHash)} - {/if} -
-
- {/if} -
+ {#each data.sections as section (section.key)} + + ({ + token: t.tokenId, + record: t.title, + refers: t.links.map((l) => l.text).join(' '), + owner: t.ownerAddress, + block: t.blockNumber, + tx: t.txHash, + _token: t + }))} + regionLabel="{section.title} on chain" + emptyMessage="None on chain yet" + {cell} + /> + + {/each} - -
-

What the chain status means

-

- The app does not talk to the chain directly. OGCR-chain-cache copies chain state into - OBP on a schedule and writes a sync record at the end of every run; the chain status is - read from that record. -

-
- {#each Object.entries(HEARTBEAT_STATES) as [state, { label, meaning }] (state)} - {@const current = !data.error && state === data.heartbeat.state} -
-
- {label} - {#if current}(current){/if} -
-
{meaning}
-
- {/each} -
-
- {/if} + +
+

What the chain status means

+

+ The app does not talk to the chain directly. OGCR-chain-cache copies chain state into + OBP on a schedule and writes a sync record at the end of every run; the chain status is + read from that record. +

+
+ {#each Object.entries(HEARTBEAT_STATES) as [state, { label, meaning }] (state)} + {@const current = !data.error && state === data.heartbeat.state} +
+
+ {label} + {#if current}(current){/if} +
+
{meaning}
+
+ {/each} +
+
+ + diff --git a/src/routes/domain-api/+page.server.ts b/src/routes/domain-api/+page.server.ts new file mode 100644 index 0000000..cd3debc --- /dev/null +++ b/src/routes/domain-api/+page.server.ts @@ -0,0 +1,60 @@ +import type { PageServerLoad } from './$types'; +import { env } from '$env/dynamic/public'; +import { ENTITY_ROLE_BANK_ID } from '$lib/constants/entities'; +import { publicGets, type PublicGet } from '$lib/obp/domainApi'; + +/** A Domain API as OBP lists it publicly at GET /obp/v7.0.0/banks/BANK_ID/domain-apis. */ +export type DomainApi = { + base_path: string; + version: string; + title: string; + description: string; + /** Where it is published: OBP's host followed by the base path. */ + url: string; +}; + +/** + * The Domain APIs of the space (OBP_ENTITY_SPACE_ID): its Dynamic Entities and Dynamic + * Resource Docs published under a base path of their own, each with an OpenAPI document + * carrying the Domain API's own title and description. Public: OBP lists the ones whose + * owners made them publicly discoverable, without an access token. + * + * Each one comes with the GET endpoints its OpenAPI document says anyone may call, so the + * page shows it working without logging in. + */ +export const load: PageServerLoad = async ({ fetch }) => { + const base = env.PUBLIC_OBP_BASE_URL?.replace(/\/$/, '') ?? ''; + const path = `/obp/v7.0.0/banks/${encodeURIComponent(ENTITY_ROLE_BANK_ID)}/domain-apis`; + + let domainApis: (DomainApi & { publicGets: PublicGet[]; publicGetsError: string | null })[] = []; + let error: string | null = null; + try { + const response = await fetch(`${base}${path}`); + if (response.ok) { + const body = (await response.json()) as { domain_apis?: DomainApi[] }; + domainApis = await Promise.all((body.domain_apis ?? []).map(withPublicGets)); + } else { + error = `OBP returned HTTP ${response.status} for ${path}`; + } + } catch (e) { + error = e instanceof Error ? e.message : 'Could not reach OBP'; + } + + return { space: ENTITY_ROLE_BANK_ID, domainApis, error }; + + /** The Domain API with its public GETs, or why they could not be read. */ + async function withPublicGets(api: DomainApi) { + const url = `${api.url.replace(/\/$/, '')}/openapi.json`; + try { + const response = await fetch(url); + if (!response.ok) throw new Error(`OBP returned HTTP ${response.status} for ${url}`); + return { ...api, publicGets: publicGets(await response.json()), publicGetsError: null }; + } catch (e) { + return { + ...api, + publicGets: [], + publicGetsError: e instanceof Error ? e.message : 'Could not reach OBP' + }; + } + } +}; diff --git a/src/routes/domain-api/+page.svelte b/src/routes/domain-api/+page.svelte new file mode 100644 index 0000000..e463100 --- /dev/null +++ b/src/routes/domain-api/+page.svelte @@ -0,0 +1,145 @@ + + + + Domain API — OGCR DCR + + +
+

Domain API

+

+ The endpoints of space {data.space} published under a base path of their own, with an + OpenAPI document carrying the Domain API's own title and description. +

+ + {#if data.error} + + {:else if data.domainApis.length === 0} +

+ Space {data.space} has no publicly discoverable Domain API. A Domain API is listed + here once its is_publicly_discoverable is set to true. +

+ {:else} +
+ {#each data.domainApis as api (api.base_path)} + + {#snippet trailing()} + v{api.version} + {/snippet} + {#if api.description} +

{api.description}

+ {/if} +
+
Base URL
+
{api.url}
+
OpenAPI
+
+ openapi.json + openapi.yaml +
+
+ +
+

Public GET endpoints

+ {#if api.publicGetsError} + + {:else if api.publicGets.length === 0} +

None: every GET needs a login.

+ {:else} +

+ {api.publicGets.length} that need no login. Open one to see it answer. +

+
    + {#each api.publicGets as endpoint (endpoint.path)} +
  • + GET + {endpoint.path} + {#if endpoint.summary} + {endpoint.summary} + {/if} +
  • + {/each} +
+ {/if} +
+
+ {/each} +
+ {/if} +
+ + diff --git a/src/routes/help/+page.svelte b/src/routes/help/+page.svelte index 7abb4df..8e23d5c 100644 --- a/src/routes/help/+page.svelte +++ b/src/routes/help/+page.svelte @@ -16,7 +16,7 @@ Go to Chain -

+

How OGCR's tokens work, from the OBP glossary. The glossary is generated from the smart contracts' own documentation, so it is always the authority on how the contracts behave.

@@ -27,9 +27,7 @@
{@html item.html}
{:else} -

- The glossary could not be loaded from OBP. Try again later. -

+

The glossary could not be loaded from OBP. Try again later.

{/each} @@ -149,6 +147,10 @@ diff --git a/src/routes/registry/activities/[id]/+page.svelte b/src/routes/registry/activities/[id]/+page.svelte index af9b9ad..bed5004 100644 --- a/src/routes/registry/activities/[id]/+page.svelte +++ b/src/routes/registry/activities/[id]/+page.svelte @@ -1,4 +1,5 @@ + + + {name} — OGCR Registry + + +
+
+ +

{name}

+
+ + {#if data.error} + + {:else if !body} +

No certification body found with ID {data.bodyId}.

+ {:else} + + +

Certificates issued by this body

+
+ {#snippet cell(row, column)} + {@const value = row[column.key] as string | null | undefined} + {#if column.key === 'certificate_of_compliance_id' && value} + {value} + {:else if column.key === 'certification_scheme_id' && value} + {value} + {:else} + {value || '—'} + {/if} + {/snippet} +
+ {/if} +
+ + diff --git a/src/routes/registry/certification-schemes/[id]/+page.server.ts b/src/routes/registry/certification-schemes/[id]/+page.server.ts new file mode 100644 index 0000000..805d711 --- /dev/null +++ b/src/routes/registry/certification-schemes/[id]/+page.server.ts @@ -0,0 +1,7 @@ +import type { PageServerLoad } from './$types'; +import { getCertificationScheme } from '$lib/registry/certification'; + +/** Public: a certification scheme is a public dynamic entity, read anonymously. */ +export const load: PageServerLoad = async ({ params, fetch }) => { + return { schemeId: params.id, ...(await getCertificationScheme(fetch, params.id)) }; +}; diff --git a/src/routes/registry/certification-schemes/[id]/+page.svelte b/src/routes/registry/certification-schemes/[id]/+page.svelte new file mode 100644 index 0000000..10bedc0 --- /dev/null +++ b/src/routes/registry/certification-schemes/[id]/+page.svelte @@ -0,0 +1,128 @@ + + + + {name} — OGCR Registry + + +
+
+ +

{name}

+
+ + {#if data.error} + + {:else if !scheme} +

No certification scheme found with ID {data.schemeId}.

+ {:else} + + +

Certificates issued under this scheme

+ + {#snippet cell(row, column)} + {@const value = row[column.key] as string | null | undefined} + {#if column.key === 'certificate_of_compliance_id' && value} + {value} + {:else if column.key === 'certification_body_id' && value} + {value} + {:else} + {value || '—'} + {/if} + {/snippet} +
+ +

Activities that chose this scheme

+ + {#snippet cell(row, column)} + {@const value = row[column.key] as string | null | undefined} + {#if column.key === 'name' && row.activity_id} + + {value || row.activity_id} + + {:else} + {value || '—'} + {/if} + {/snippet} +
+ {/if} +
+ +