Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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 |
56 changes: 56 additions & 0 deletions release_notes.md
Original file line number Diff line number Diff line change
@@ -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.
113 changes: 113 additions & 0 deletions src/lib/components/FieldList.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
<script lang="ts" module>
import type { PillTone } from './Pill.svelte';

export interface Field {
label: string;
/** Already formatted for display; an empty value shows as an em dash. */
value: string | null | undefined;
/** Links the value to the page with more detail about it. */
href?: string;
/** Shows the value as a status pill instead of plain text. */
pill?: { tone: PillTone; dot?: boolean };
}
</script>

<script lang="ts">
/**
* A label / value list for the registry's detail pages, with mono-caps labels and
* dashed row rules to match the Table. Values can link onward to their own detail.
*/
import Pill from './Pill.svelte';

let { fields }: { fields: Field[] } = $props();

/** An em dash reads as "no value" where an empty cell reads as a rendering bug. */
const DASH = '—';
function hasValue(value: string | null | undefined): value is string {
return typeof value === 'string' && value.trim() !== '';
}
</script>

<dl class="ogcr-fields">
{#each fields as field (field.label)}
<div class="ogcr-fields__row">
<dt class="ogcr-fields__label">{field.label}</dt>
<dd class="ogcr-fields__value">
{#if !hasValue(field.value)}
{DASH}
{:else if field.pill && field.href}
<a href={field.href}><Pill tone={field.pill.tone} dot={field.pill.dot}>{field.value}</Pill></a>
{:else if field.pill}
<Pill tone={field.pill.tone} dot={field.pill.dot}>{field.value}</Pill>
{:else if field.href}
<a href={field.href} class="anchor">{field.value}</a>
{:else}
{field.value}
{/if}
</dd>
</div>
{/each}
</dl>

<style>
.ogcr-fields {
margin: 0;
background: var(--surface-light);
border: 1px solid var(--border-light);
border-radius: var(--radius-l);
overflow: hidden;
}

.ogcr-fields__row {
display: grid;
grid-template-columns: minmax(10rem, 14rem) 1fr;
gap: var(--space-m);
padding: var(--space-s) var(--space-m);
border-bottom: 1px dashed var(--border-light);
}
.ogcr-fields__row:last-child {
border-bottom: none;
}

.ogcr-fields__label {
margin: 0;
font-family: var(--font-family-mono);
font-size: 11px;
font-weight: 500;
letter-spacing: 0.14em;
text-transform: uppercase;
color: var(--text-secondary);
align-self: center;
}

.ogcr-fields__value {
margin: 0;
font-size: var(--font-size-s);
color: var(--text-primary);
min-width: 0;
overflow-wrap: anywhere;
align-self: center;
}

@media (max-width: 640px) {
.ogcr-fields__row {
grid-template-columns: 1fr;
gap: var(--space-2xs);
}
}

/* See Card.svelte — no dark palette upstream. */
:global([data-mode='dark']) .ogcr-fields {
background: var(--color-surface-900);
border-color: var(--color-surface-700);
}
:global([data-mode='dark']) .ogcr-fields__row {
border-bottom-color: var(--color-surface-700);
}
:global([data-mode='dark']) .ogcr-fields__label {
color: var(--color-surface-400);
}
:global([data-mode='dark']) .ogcr-fields__value {
color: var(--color-surface-100);
}
</style>
64 changes: 62 additions & 2 deletions src/lib/components/Table.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,8 @@
caption,
emptyMessage = 'No records',
regionLabel,
maxHeight,
stickyFirstColumn = false,
cell
}: {
columns: TableColumn[];
Expand All @@ -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<string, unknown>, TableColumn]>;
} = $props();
Expand Down Expand Up @@ -91,12 +99,22 @@
}
</script>

<div class="ogcr-table">
<div
class="ogcr-table"
data-sticky-header={maxHeight ? '' : undefined}
data-sticky-first-column={stickyFirstColumn ? '' : undefined}
>
<!-- A horizontally scrolling region must be reachable by keyboard, or a keyboard user
cannot scroll to the columns that overflow. WCAG 2.1.1; the linter's rule does not
know about the scroll container case. -->
<!-- svelte-ignore a11y_no_noninteractive_tabindex -->
<div class="ogcr-table__scroll" role="region" aria-label={regionLabel ?? caption} tabindex="0">
<div
class="ogcr-table__scroll"
role="region"
aria-label={regionLabel ?? caption}
tabindex="0"
style:max-height={maxHeight}
>
<table class="ogcr-table__table">
{#if caption}
<caption class="ogcr-table__caption">{caption}</caption>
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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);
}
Expand Down
36 changes: 36 additions & 0 deletions src/lib/registry/activities.test.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});
Loading
Loading