From 650299b7c8891704d25f28aaeb5a18a12d81d8ee Mon Sep 17 00:00:00 2001 From: simonredfern Date: Tue, 13 Oct 2026 15:44:01 +0200 Subject: [PATCH 1/3] help and domain-api page --- src/lib/obp/domainApi.test.ts | 33 ++++++ src/lib/obp/domainApi.ts | 34 ++++++ src/routes/+page.svelte | 9 +- src/routes/domain-api/+page.server.ts | 60 +++++++++++ src/routes/domain-api/+page.svelte | 145 ++++++++++++++++++++++++++ src/routes/help/+page.svelte | 13 ++- 6 files changed, 289 insertions(+), 5 deletions(-) create mode 100644 src/lib/obp/domainApi.test.ts create mode 100644 src/lib/obp/domainApi.ts create mode 100644 src/routes/domain-api/+page.server.ts create mode 100644 src/routes/domain-api/+page.svelte 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/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/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..8caabfe 100644 --- a/src/routes/registry/activities/[id]/+page.svelte +++ b/src/routes/registry/activities/[id]/+page.svelte @@ -432,7 +432,7 @@
{#if scheme} - {scheme.name}{scheme.version ? ` v${scheme.version}` : ''} + {scheme.name}{scheme.version ? ` v${scheme.version.replace(/^v/i, '')}` : ''} {:else} {DASH} {/if} From 1a1932b68568144161f09c05ea3bd84c3a7f1b40 Mon Sep 17 00:00:00 2001 From: simonredfern Date: Tue, 13 Oct 2026 18:11:32 +0200 Subject: [PATCH 3/3] chain page: compact summary, chain id in headings, parcel / scheme / body pages - sync status and last mirror run in one compact card - section headings say they are read from chain , no counts - new /parcels/[id] (login) for the original parcel record - new public /registry/certification-schemes/[id] and /registry/certification-bodies/[id] - link scheme and body from chain and activity pages - tokenization backlog reads v7.0.0 endpoints --- src/lib/chain/backlogRules.test.ts | 15 +- src/lib/chain/onChain.test.ts | 18 ++- src/lib/chain/onChain.ts | 71 +++++---- src/lib/registry/certification.ts | 75 ++++++++++ src/lib/registry/certificationLinks.ts | 7 + src/lib/server/roleChecker.ts | 4 + src/routes/chain/+page.svelte | 137 +++++++++++++---- src/routes/parcels/[id]/+page.server.ts | 62 ++++++++ src/routes/parcels/[id]/+page.svelte | 141 ++++++++++++++++++ .../registry/activities/[id]/+page.svelte | 16 +- .../certification-bodies/[id]/+page.server.ts | 7 + .../certification-bodies/[id]/+page.svelte | 112 ++++++++++++++ .../[id]/+page.server.ts | 7 + .../certification-schemes/[id]/+page.svelte | 128 ++++++++++++++++ 14 files changed, 720 insertions(+), 80 deletions(-) create mode 100644 src/lib/registry/certification.ts create mode 100644 src/lib/registry/certificationLinks.ts create mode 100644 src/routes/parcels/[id]/+page.server.ts create mode 100644 src/routes/parcels/[id]/+page.svelte create mode 100644 src/routes/registry/certification-bodies/[id]/+page.server.ts create mode 100644 src/routes/registry/certification-bodies/[id]/+page.svelte create mode 100644 src/routes/registry/certification-schemes/[id]/+page.server.ts create mode 100644 src/routes/registry/certification-schemes/[id]/+page.svelte 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 397e2fc..0c7b693 100644 --- a/src/lib/chain/onChain.test.ts +++ b/src/lib/chain/onChain.test.ts @@ -72,12 +72,12 @@ describe('getChainTokens', () => { text: 'Op One', href: '/registry/operators/op_1' }); - expect(byLabel('Certification scheme')[0].href).toMatch( - /\/public\/certification_scheme\/cs_1$/ - ); - // p1 has a token on the page; p2 does not, and parcels are not public. - expect(byLabel('Parcel token')[0]).toMatchObject({ text: 'p1', href: '#parcel-token-4' }); - expect(byLabel('Parcel')[0]).toMatchObject({ text: 'p2', href: null }); + 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 certification to its certificate page and activity token, audit report as text', async () => { @@ -98,8 +98,10 @@ describe('getChainTokens', () => { 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', external: true }); - expect(byLabel('Certification body').href).toMatch(/\/public\/certification_body\/cb_1$/); + expect(byLabel('Certification body')).toMatchObject({ + text: 'Body', + href: '/registry/certification-bodies/cb_1' + }); expect(byLabel('Audit report')).toMatchObject({ text: 'ar_1', href: null }); }); diff --git a/src/lib/chain/onChain.ts b/src/lib/chain/onChain.ts index 7a9a0fc..3f2d296 100644 --- a/src/lib/chain/onChain.ts +++ b/src/lib/chain/onChain.ts @@ -18,11 +18,12 @@ import { 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'; /** A link from a token to a record it refers to. `href` is null for a record that is @@ -65,14 +66,16 @@ export type ChainSectionKey = 'parcel' | 'activity' | 'certification' | 'batch' 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 @@ -100,11 +103,6 @@ function toNumber(v: unknown): number | null { return typeof n === 'number' && Number.isFinite(n) ? n : null; } -/** Off-chain entities a token can refer to that have public access, and so a public - * OBP record to link to when the app has no page for them. */ -const ENTITY_CERTIFICATION_SCHEME = 'certification_scheme'; -const ENTITY_CERTIFICATION_BODY = 'certification_body'; - /** An absolute OBP URL for one public record. */ function publicEntityUrl(entity: string, id: string): string { const base = env.PUBLIC_OBP_BASE_URL?.replace(/\/$/, '') ?? ''; @@ -153,12 +151,6 @@ function pageLink(label: string, text: unknown, href: string | null): ChainLink return { label, text: String(text), href, external: false }; } -/** A public OBP record link, named by `text` and falling back to the id. */ -function publicLink(label: string, entity: string, id: string | null, text?: unknown): ChainLink | null { - if (!id) return null; - return { label, text: String(text || id), href: publicEntityUrl(entity, id), external: true }; -} - /** 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; @@ -178,6 +170,8 @@ 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; @@ -194,8 +188,9 @@ function details(pairs: [string, unknown][]): { label: string; value: string }[] /** * 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 registry page where there is one, else the - * public OBP record, else (for a record that is not public) its id as plain text. + * 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. * * A mirror that cannot be read leaves its section empty rather than failing the page. */ @@ -235,7 +230,7 @@ export async function getChainTokens(): Promise { ...base('parcel', ENTITY_PARCEL_ON_CHAIN, r), title: r.parcel_id ?? '', details: [], - links: compact([textLink('Parcel', r.parcel_id ?? null)]) + links: compact([pageLink('Parcel', r.parcel_id, parcelPage(r.parcel_id))]) })); const activityTokens: ChainToken[] = activities.map((r) => { @@ -253,12 +248,17 @@ export async function getChainTokens(): Promise { links: compact([ pageLink('Activity', r.activity_id, activityPage(r.activity_id)), pageLink('Operator', r.operator_name || r.operator_id, operatorPage(r.operator_id)), - publicLink('Certification scheme', ENTITY_CERTIFICATION_SCHEME, r.certification_scheme_id ?? null), - ...parcelIds.map((id) => { + pageLink( + 'Certification scheme', + r.certification_scheme_id, + certificationSchemeHref(r.certification_scheme_id) + ), + ...parcelIds.flatMap((id) => { const anchor = parcelAnchors.get(id); - return anchor - ? { label: 'Parcel token', text: id, href: `#${anchor}`, external: false } - : textLink('Parcel', id); + return compact([ + pageLink('Parcel', id, parcelPage(id)), + anchor ? { label: 'Parcel token', text: id, href: `#${anchor}`, external: false } : null + ]); }) ]) }; @@ -285,17 +285,15 @@ export async function getChainTokens(): Promise { `#${r.activity_nft_id}` ), pageLink('Activity', r.activity_name || r.activity_id, activityPage(r.activity_id)), - publicLink( + pageLink( 'Certification scheme', - ENTITY_CERTIFICATION_SCHEME, - idFromUri(r.certification_scheme_uri), - r.certification_scheme_name + r.certification_scheme_name || idFromUri(r.certification_scheme_uri), + certificationSchemeHref(idFromUri(r.certification_scheme_uri)) ), - publicLink( + pageLink( 'Certification body', - ENTITY_CERTIFICATION_BODY, - idFromUri(r.certification_body_uri), - r.certification_body_name + r.certification_body_name || idFromUri(r.certification_body_uri), + certificationBodyHref(idFromUri(r.certification_body_uri)) ), textLink('Audit report', idFromUri(r.audit_report_uri)) ]) @@ -326,8 +324,16 @@ export async function getChainTokens(): Promise { pageLink('Activity', r.activity_name || r.activity_id, activityPage(r.activity_id)), pageLink('Operator', operatorId, operatorPage(operatorId)), pageLink('Certificate', certificateId, certificatePage(certificateId)), - publicLink('Certification scheme', ENTITY_CERTIFICATION_SCHEME, idFromUri(r.certification_scheme_url)), - publicLink('Certification body', ENTITY_CERTIFICATION_BODY, idFromUri(r.certification_body_url)) + 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)) + ) ]) }; }); @@ -357,6 +363,7 @@ export async function getChainTokens(): Promise { ): 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)) }); 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/routes/chain/+page.svelte b/src/routes/chain/+page.svelte index f519070..22b7ae1 100644 --- a/src/routes/chain/+page.svelte +++ b/src/routes/chain/+page.svelte @@ -3,14 +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 { ChainSectionKey, ChainToken } from '$lib/chain/onChain'; + import type { ChainSection, ChainSectionKey, ChainToken } from '$lib/chain/onChain'; let { data }: { data: PageData } = $props(); @@ -52,6 +52,18 @@ ]; } + // 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); @@ -158,40 +170,46 @@ {/if} - - -
-
-

Last mirror run

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

{data.heartbeat.message}

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

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

- {/if} -
-

- Records written by OGCR-chain-cache on its most recent pass. - {#if status?.mirrored_types} - Mirrors that ran: {status.mirrored_types.split(',').join(', ')}. - {/if} -

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

+
+ {#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(', ')}. +

+ {/if} +
-

{totalMirrored(status)} records in total.

-
+ + public shows its id as plain text (audit report), except parcels, which link to + the login-only parcel page. --> {#if !explorer.available}

Transactions, blocks and addresses are shown as plain values because no block explorer @@ -203,8 +221,8 @@ {#each data.sections as section (section.key)} @@ -256,6 +274,51 @@

diff --git a/src/routes/registry/activities/[id]/+page.svelte b/src/routes/registry/activities/[id]/+page.svelte index 8caabfe..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} +
+ +