diff --git a/README.md b/README.md index 8dadee8..3945af1 100644 --- a/README.md +++ b/README.md @@ -22,7 +22,9 @@ npm run dev # http://localhost:5200 ``` `.env.example` documents every variable, which are required, and which are optional. -`SESSION_SECRET` must be set (`openssl rand -hex 32`). +`SESSION_SECRET` signs the session cookie; generate one with `openssl rand -hex 32`. In +production the app refuses to start without it (at least 16 characters); in development +it warns and falls back to an insecure default. See [release_notes.md](./release_notes.md). Alternatively, `docker compose up` builds the app and starts Redis alongside it on port 3000; it reads the same variables from the environment. @@ -69,4 +71,5 @@ pinned to which upstream commit, and which components have been ported to Svelte | [design_system_integration.md](./design_system_integration.md) | How this app consumes the OGCR Design System | | [chain_integration.md](./chain_integration.md) | How on-chain state reaches the app, and the heartbeat | | [open_questions.md](./open_questions.md) | Unresolved design and product questions | +| [release_notes.md](./release_notes.md) | Notable and breaking changes, with upgrade steps | | [CLAUDE.md](./CLAUDE.md) | Notes on discovering OBP dynamic entity endpoints | diff --git a/release_notes.md b/release_notes.md new file mode 100644 index 0000000..11d97ec --- /dev/null +++ b/release_notes.md @@ -0,0 +1,56 @@ +# Release Notes + +## 2026-10-02 + +### Breaking: the registry now reads the `registry/activities-query` Dynamic Query + +The public registry (`/registry`, `/registry/activities`, `/registry/certificates/…`) +now calls `GET /obp/dynamic-endpoint/dynamic-resource-doc/registry/activities-query` +instead of the Scala Dynamic Resource Doc at `…/registry/activities`. The host still +comes from `PUBLIC_OBP_BASE_URL`; the response shape is unchanged. + +**Action required** on each OBP instance the app points at, before deploying: + +1. OBP-API must include the "Dynamic Query" commit (`fd8f70691`). Older versions reject + the doc with `OBP-40049`. +2. Create the doc from OGCR-DynamicEntities, with `OBP_ENTITY_SPACE_ID` set to the + entity space (it is created at that bank): + + ```sh + python3 dynamic_resource_docs.py create registry_activities_query + ``` + +3. The `activity`, `country`, `operator`, `activity_verification`, + `certificate_of_compliance` and `activity_on_chain` Dynamic Entities must have public + access. Otherwise + signed-out visitors get a 403 and the registry pages show an error with no data. + +Check it with an anonymous call; it should return `{"activities": [...], "count": N}`: + +```sh +curl "$PUBLIC_OBP_BASE_URL/obp/dynamic-endpoint/dynamic-resource-doc/registry/activities-query" +``` + +## 2026-09-08 + +### Breaking: `SESSION_SECRET` is now required in production + +Session cookies were previously signed with the hardcoded string `'secret'`, so +anyone could forge them. They are now signed with the value of the +`SESSION_SECRET` environment variable (commit `1f5f715`). + +- **Production** (`NODE_ENV=production`): the app refuses to start unless + `SESSION_SECRET` is set to at least 16 characters. +- **Development**: if unset or too short, the app logs a warning and falls back + to an insecure built-in default so `npm run dev` keeps working. +- **Docker Compose**: `docker compose up` fails unless `SESSION_SECRET` is set. + +**Action required:** generate a secret and add it to your `.env` (see +`.env.example`): + +```sh +openssl rand -hex 32 +``` + +Changing the secret invalidates existing sessions, so users will need to log in +again after upgrading. diff --git a/src/lib/components/FieldList.svelte b/src/lib/components/FieldList.svelte new file mode 100644 index 0000000..dc6d4d4 --- /dev/null +++ b/src/lib/components/FieldList.svelte @@ -0,0 +1,113 @@ + + + + +
+ {#each fields as field (field.label)} +
+
{field.label}
+
+ {#if !hasValue(field.value)} + {DASH} + {:else if field.pill && field.href} + {field.value} + {:else if field.pill} + {field.value} + {:else if field.href} + {field.value} + {:else} + {field.value} + {/if} +
+
+ {/each} +
+ + diff --git a/src/lib/components/Table.svelte b/src/lib/components/Table.svelte index 75127dd..15f8663 100644 --- a/src/lib/components/Table.svelte +++ b/src/lib/components/Table.svelte @@ -33,6 +33,8 @@ caption, emptyMessage = 'No records', regionLabel, + maxHeight, + stickyFirstColumn = false, cell }: { columns: TableColumn[]; @@ -41,6 +43,12 @@ emptyMessage?: string; /** Accessible name for the scroll region. */ regionLabel?: string; + /** Caps the table's height (any CSS length) so it scrolls inside its own box, with + * the header row pinned. Without it a long, wide table's horizontal scrollbar sits + * below the last row, out of sight. */ + maxHeight?: string; + /** Pins the first column while scrolling sideways, so each row stays identifiable. */ + stickyFirstColumn?: boolean; /** Renders one cell. Falls back to the raw value when not supplied. */ cell?: Snippet<[Record, TableColumn]>; } = $props(); @@ -91,12 +99,22 @@ } -
+
-
+
{#if caption} @@ -300,6 +318,40 @@ font-weight: 500; } + /* Sticky cells need an opaque background, or rows show through them. The header + rule moves to a shadow because a collapsed border does not stick with its cell. */ + [data-sticky-header] .ogcr-table__scroll { + overflow: auto; + } + [data-sticky-header] .ogcr-table__th { + position: sticky; + top: 0; + z-index: 2; + background: var(--surface-neutral); + border-bottom: none; + box-shadow: inset 0 -1px 0 var(--border-medium); + } + [data-sticky-first-column] .ogcr-table__th:first-child, + [data-sticky-first-column] .ogcr-table__td:first-child { + position: sticky; + left: 0; + z-index: 1; + background: var(--surface-light); + box-shadow: inset -1px 0 0 var(--border-light); + } + [data-sticky-first-column] .ogcr-table__th:first-child { + z-index: 3; + background: var(--surface-neutral); + } + [data-sticky-header][data-sticky-first-column] .ogcr-table__th:first-child { + box-shadow: + inset 0 -1px 0 var(--border-medium), + inset -1px 0 0 var(--border-light); + } + [data-sticky-first-column] .ogcr-table__tr:hover .ogcr-table__td:first-child { + background: var(--surface-neutral); + } + .ogcr-table__empty { padding: var(--space-xl) var(--space-m); text-align: center; @@ -345,6 +397,14 @@ :global([data-mode='dark']) .ogcr-table__sort:hover { color: var(--color-surface-50); } + :global([data-mode='dark']) [data-sticky-header] .ogcr-table__th, + :global([data-mode='dark']) [data-sticky-first-column] .ogcr-table__th:first-child, + :global([data-mode='dark']) [data-sticky-first-column] .ogcr-table__tr:hover .ogcr-table__td:first-child { + background: var(--color-surface-800); + } + :global([data-mode='dark']) [data-sticky-first-column] .ogcr-table__td:first-child { + background: var(--color-surface-900); + } :global([data-mode='dark']) .ogcr-table__empty { color: var(--color-surface-400); } diff --git a/src/lib/registry/activities.test.ts b/src/lib/registry/activities.test.ts new file mode 100644 index 0000000..366d69c --- /dev/null +++ b/src/lib/registry/activities.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, it } from 'vitest'; +import { inlineTokenMetadata, mintedAtIso } from './activities'; + +describe('mintedAtIso', () => { + it('reads activity_on_chain.minted_at as Unix seconds', () => { + expect(mintedAtIso(1779280448)).toBe('2026-05-20T12:34:08.000Z'); + }); + + it('is null until the activity is minted', () => { + expect(mintedAtIso(null)).toBeNull(); + expect(mintedAtIso(undefined)).toBeNull(); + }); +}); + +describe('inlineTokenMetadata', () => { + it('decodes a base64 data URI', () => { + expect(inlineTokenMetadata('data:application/json;base64,eyJuYW1lIjoiQ2FyYm9uIn0=')).toBe( + '{"name":"Carbon"}' + ); + }); + + it('decodes UTF-8 in a base64 data URI', () => { + const payload = Buffer.from('{"name":"Évora"}').toString('base64'); + expect(inlineTokenMetadata(`data:application/json;base64,${payload}`)).toBe('{"name":"Évora"}'); + }); + + it('decodes a percent-encoded data URI', () => { + expect(inlineTokenMetadata('data:application/json,%7B%22a%22%3A1%7D')).toBe('{"a":1}'); + }); + + it('is null for a URI that links to the metadata', () => { + expect(inlineTokenMetadata('ipfs://bafy/1.json')).toBeNull(); + expect(inlineTokenMetadata('https://example.org/1.json')).toBeNull(); + expect(inlineTokenMetadata(null)).toBeNull(); + }); +}); diff --git a/src/lib/registry/activities.ts b/src/lib/registry/activities.ts index 6a843c3..9c91dbe 100644 --- a/src/lib/registry/activities.ts +++ b/src/lib/registry/activities.ts @@ -4,25 +4,26 @@ import { env } from '$env/dynamic/public'; * The public registry read model. * * Unlike the rest of the app, the registry does NOT read dynamic entities directly. - * It calls one OBP Dynamic Resource Doc that joins activity, operator, country, + * It calls one OBP Dynamic Resource Doc, a Dynamic Query (a JSON declaration OBP runs + * without compiling anything), that joins activity, operator, country, * certificate_of_compliance and activity_verification server-side and projects only - * the columns below. That matters for two reasons: + * the columns below. * - * - It is public. The endpoint is created with `roles: ""`, so no access token is - * involved and a signed-out visitor sees the same registry a buyer would. - * - Because it is public, the projection IS the access control. Opening the - * underlying entities for public read would expose every field on them; this - * exposes the registry columns and nothing else. + * It is called without an access token, so a signed-out visitor sees the same registry + * a buyer would. What that caller may see is decided by OBP from the access settings + * on the entity definitions, not here: a caller who can't read one of the joined + * entities gets a 403 (rendered as `error`), and a field hidden from the caller comes + * back null — which every field below already allows. * - * Source of the endpoint: OGCR-DynamicEntities/registry_activities_endpoint.scala, - * deployed with dynamic_resource_docs.py. + * Source of the endpoint: OGCR-DynamicEntities/registry_activities_query.json, + * deployed with `dynamic_resource_docs.py create registry_activities_query`. */ /** Served under an extra `dynamic-resource-doc` segment — see the note in * OGCR-DynamicEntities/dynamic_resource_docs.py; the Dynamic Endpoint glossary * documents a shorter path that 404s. */ export const REGISTRY_ACTIVITIES_PATH = - '/obp/dynamic-endpoint/dynamic-resource-doc/registry/activities'; + '/obp/dynamic-endpoint/dynamic-resource-doc/registry/activities-query'; /** One row of the registry. Every field can be null: the endpoint left-joins, and an * activity with no certificate or no resolvable operator is normal, not an error. */ @@ -45,6 +46,10 @@ export interface RegistryActivity { certification_status: string | null; certificate_issue_date: string | null; certificate_expiry_date: string | null; + /** From activity_on_chain, the activity's latest mint: Unix seconds, null until minted. */ + minted_at: number | null; + /** From activity_on_chain: the token's metadata URI, often an inline `data:` URI. */ + token_uri: string | null; } export interface RegistryActivitiesResult { @@ -87,6 +92,36 @@ export async function getRegistryActivities( } } +/** The mint time as an ISO string, or null when the activity has not been minted. */ +export function mintedAtIso(mintedAt: number | null | undefined): string | null { + if (typeof mintedAt !== 'number' || !Number.isFinite(mintedAt)) return null; + return new Date(mintedAt * 1000).toISOString(); +} + +/** The metadata a `data:` token URI carries inline, decoded; null for any other URI, + * which is a link to the metadata rather than the metadata itself. */ +export function inlineTokenMetadata(tokenUri: string | null | undefined): string | null { + const match = tokenUri?.match(/^data:([^,]*?)(;base64)?,(.*)$/s); + if (!match) return null; + const [, , base64, payload] = match; + try { + if (!base64) return decodeURIComponent(payload); + const bytes = Uint8Array.from(atob(payload), (c) => c.charCodeAt(0)); + return new TextDecoder().decode(bytes); + } catch { + return null; + } +} + +/** Public registry detail pages. All three read the same registry endpoint, so a + * signed-out visitor can follow every link in the registry. */ +export const registryActivityHref = (activityId: string) => + `/registry/activities/${encodeURIComponent(activityId)}`; +export const registryOperatorHref = (operatorId: string) => + `/registry/operators/${encodeURIComponent(operatorId)}`; +export const registryCertificateHref = (certificateId: string) => + `/registry/certificates/${encodeURIComponent(certificateId)}`; + /** Distinct non-empty values of a field, sorted — used to populate the filter dropdowns * from the data actually present rather than a hardcoded list. */ export function distinctValues( diff --git a/src/routes/registry/activities/+page.svelte b/src/routes/registry/activities/+page.svelte index 0f8d514..2ca43c6 100644 --- a/src/routes/registry/activities/+page.svelte +++ b/src/routes/registry/activities/+page.svelte @@ -3,7 +3,14 @@ import Table, { type TableColumn } from '$lib/components/Table.svelte'; import Pill from '$lib/components/Pill.svelte'; import ObpErrorDisplay from '$lib/components/ObpErrorDisplay.svelte'; - import { distinctValues, type RegistryActivity } from '$lib/registry/activities'; + import { + distinctValues, + mintedAtIso, + registryActivityHref, + registryCertificateHref, + registryOperatorHref, + type RegistryActivity + } from '$lib/registry/activities'; import { Search } from '@lucide/svelte'; let { data }: { data: PageData } = $props(); @@ -45,17 +52,20 @@ /** * Ordered by what a buyer scans for, not the order §4.3 of the brief lists them in. - * Ten nowrap columns overflow any realistic viewport, and the design system's table + * Eleven nowrap columns overflow any realistic viewport, and the design system's table * scrolls horizontally by design — so the question is which columns are visible - * before you scroll. Status and certificate decide whether an activity is worth - * looking at, so they sit next to the name; dates, operator and the free-text - * summary are detail and follow. No column was dropped. + * before you scroll. Status, certificate and whether it is minted on-chain decide + * whether an activity is worth looking at, so they sit next to the name; dates, + * operator and the free-text summary are detail and follow. No column was dropped. The table scrolls inside a + * window-high box with the header and the name pinned, so its sideways scrollbar is + * always in view and every row stays identifiable while scrolling. */ const columns: TableColumn[] = [ { key: 'name', header: 'Activity name', sortable: true }, { key: 'activity_type', header: 'Type', sortable: true }, { key: 'verification_status', header: 'Activity status', sortable: true }, { key: 'certificate', header: 'Certificate' }, + { key: 'minted', header: 'Minted', sortable: true }, { key: 'location', header: 'Location', sortable: true }, { key: 'operator_legal_name', header: 'Operator', sortable: true }, { key: 'start_date', header: 'Start', sortable: true }, @@ -73,7 +83,9 @@ a.monitoring_period_start_date && a.monitoring_period_end_date ? `${a.monitoring_period_start_date} – ${a.monitoring_period_end_date}` : '', - certificate: a.certificate_of_compliance_id ?? '' + certificate: a.certificate_of_compliance_id ?? '', + // The day only, like the other date columns; the activity page has the time. + minted: mintedAtIso(a.minted_at)?.slice(0, 10) ?? '' })) ); @@ -155,35 +167,96 @@ {columns} {rows} regionLabel="Registry activities" + maxHeight="calc(100dvh - 8rem)" + stickyFirstColumn emptyMessage={filtersApplied ? 'No activities match these filters' : 'No activities'} > {#snippet cell(row, column)} + + {@const activityHref = + typeof row.activity_id === 'string' ? registryActivityHref(row.activity_id) : null} {#if column.key === 'name'} - - {show(row.name)} - {:else if column.key === 'summary'} - - {show(row.summary)} - + {#if activityHref} + {show(row.name)} + {:else} + {show(row.name)} + {/if} {:else if column.key === 'activity_type'} - {#if row.activity_type} - {row.activity_type} + {#if typeof row.activity_type === 'string' && row.activity_type} + {:else} {DASH} {/if} {:else if column.key === 'verification_status'} - - {row.verification_status === 'verified' ? 'Verified' : 'Unverified'} - + {@const pill = row.verification_status === 'verified' ? 'Verified' : 'Unverified'} + {#if activityHref} + + + {pill} + + + {:else} + + {pill} + + {/if} {:else if column.key === 'certificate'} - {#if row.certificate_of_compliance_id} - + {#if typeof row.certificate_of_compliance_id === 'string' && row.certificate_of_compliance_id} + View certificate {:else} Not yet certified {/if} + {:else if column.key === 'minted'} + {#if row.minted && activityHref} + {row.minted} + {:else if row.minted} + {row.minted} + {:else} + Not minted + {/if} + {:else if column.key === 'location'} + {#if typeof row.country_name === 'string' && row.country_name} + + {:else} + {show(row.location)} + {/if} + {:else if column.key === 'operator_legal_name'} + {#if typeof row.operator_id === 'string' && row.operator_id} + + {show(row.operator_legal_name ?? row.operator_id)} + + {:else} + {show(row.operator_legal_name)} + {/if} + {:else if activityHref} + + + {show(row[column.key])} + {:else} {show(row[column.key])} {/if} @@ -267,6 +340,27 @@ color: var(--text-secondary); } + /* Links on plain facts (dates, summary, location) keep the table's text colour so + the grid stays calm; the underline on hover/focus says they go somewhere. */ + .ogcr-cell-link { + color: inherit; + text-decoration: none; + } + .ogcr-cell-link:hover, + .ogcr-cell-link:focus-visible { + text-decoration: underline; + text-underline-offset: 2px; + } + + /* Type and location narrow the list rather than navigate, so they are buttons. */ + .ogcr-cell-filter { + padding: 0; + background: none; + border: none; + font: inherit; + cursor: pointer; + } + @media (prefers-reduced-motion: reduce) { .ogcr-input { transition: none; diff --git a/src/routes/registry/activities/[id]/+page.server.ts b/src/routes/registry/activities/[id]/+page.server.ts new file mode 100644 index 0000000..fc5d1fd --- /dev/null +++ b/src/routes/registry/activities/[id]/+page.server.ts @@ -0,0 +1,14 @@ +import type { PageServerLoad } from './$types'; +import { getRegistryActivities, type RegistryActivity } from '$lib/registry/activities'; + +/** + * Public activity view: the registry row for one activity. Like the certificate + * page, there is no public endpoint for a single activity, so this reads the + * registry activities and picks the matching row, so it shows exactly the columns + * the list does, with the same nulls for anything OBP hides from the caller. + */ +export const load: PageServerLoad = async ({ params, fetch }) => { + const { activities, error } = await getRegistryActivities(fetch); + const activity = activities.find((a: RegistryActivity) => a.activity_id === params.id) ?? null; + return { activityId: params.id, activity, error }; +}; diff --git a/src/routes/registry/activities/[id]/+page.svelte b/src/routes/registry/activities/[id]/+page.svelte new file mode 100644 index 0000000..03d8096 --- /dev/null +++ b/src/routes/registry/activities/[id]/+page.svelte @@ -0,0 +1,99 @@ + + + + {activity?.name ?? 'Activity'} — OGCR Registry + + +
+
+ +

{activity?.name ?? 'Activity'}

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

+ No activity found with ID {data.activityId}. It may have been withdrawn from the + registry. +

+ {:else} + + {/if} + +

Back to activities

+
diff --git a/src/routes/registry/certificates/[id]/+page.svelte b/src/routes/registry/certificates/[id]/+page.svelte index 7122d26..f82ed52 100644 --- a/src/routes/registry/certificates/[id]/+page.svelte +++ b/src/routes/registry/certificates/[id]/+page.svelte @@ -1,31 +1,39 @@ + + + {data.legalName ?? 'Operator'} — OGCR Registry + + +
+
+ +

{data.legalName ?? 'Operator'}

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

+ No operator found with ID {data.operatorId}. The registry lists an operator only + through the activities it operates, and none name this one. +

+ {:else} + + +

Activities operated

+
{caption}
+ {#snippet cell(row, column)} + {#if column.key === 'name' && typeof row.activity_id === 'string'} + {show(row.name)} + {:else if column.key === 'verification_status'} + {row.verification_status === 'verified' ? 'Verified' : 'Unverified'} + {:else} + {show(row[column.key])} + {/if} + {/snippet} +
+ {/if} + +

Back to activities

+