diff --git a/CLAUDE.md b/CLAUDE.md index 627eb79..305c61e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,5 +1,28 @@ # OGCR App - Claude Code Notes +## Always use the OGCR Design System + +Every piece of UI in this app must use the OGCR Design System. This is a requirement, +not a preference. How the app consumes it (token mirror, ported components, porting +conventions) is in `design_system_integration.md`; the live reference is `/design`. + +- **Colour, type, spacing, radius, elevation:** use the design system's named tokens + from `src/ogcr-theme.css` (`var(--text-primary)`, `var(--text-secondary)`, + `var(--surface-light)`, `var(--space-m)`, `var(--radius-l)`, the `text-h1` / `text-body` + classes, ...). Do not pick a colour from the Skeleton ramps (`text-surface-600-400`, + `bg-primary-300`, ...): most ramp steps are interpolated and match no design system + token. Never hard-code hex values or pixel sizes that a token already covers. +- **Components:** use the ported components in `src/lib/components/` (`Card`, `Pill`, + `Kpi`, `Table`) rather than hand-rolling the same thing. If the design system has a + component that is not ported yet, port it (see "Porting conventions" in + `design_system_integration.md`) rather than inventing a look. +- **No design system answer?** Ask before inventing one. Dark mode is the documented + exception: the design system has no dark palette, so components add a + `:global([data-mode='dark'])` block using the Skeleton dark surfaces. +- **Existing code:** much of the app predates this rule (for example, around 180 uses of + `text-surface-600-400`). Bring the lines you are already changing onto design system + tokens, but do not sweep files the task does not touch. + ## OBP API Dynamic Endpoints Discovery Dynamic entity structures change over time. Always fetch the current documentation before working with endpoints. diff --git a/design_system_integration.md b/design_system_integration.md index ce14bc0..96cf6f9 100644 --- a/design_system_integration.md +++ b/design_system_integration.md @@ -20,6 +20,11 @@ Everything in this document exists so that mirroring is a _checkable_ operation than a copy that silently rots — which is exactly what happened to the previous copy (see the [drift log](#drift-log)). +**Using the design system is mandatory in this app.** All UI takes its colours, type, +spacing, radii and elevation from the named DS tokens and uses the ported components +below. Skeleton ramp steps that are not DS anchors are not a substitute. The working rules +are in `CLAUDE.md` under _Always use the OGCR Design System_. + ## Upstream provenance (what we are pinned to) | | | @@ -182,6 +187,7 @@ the two in step. | Upstream component | Spec | Status here | | ------------------ | ------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| Button | §4.1 | **ported** — `src/lib/components/Button.svelte`. Adds `href` (renders an anchor) and drops `select-none`; icon-only not yet ported | | Card | §4.3 | **ported** — `src/lib/components/Card.svelte` | | Pill | §4.6 | **ported** — `src/lib/components/Pill.svelte` | | Message | §4.9 | not needed — the app's `ObpErrorDisplay` already handles OBP errors (it detects missing-entitlement messages and renders `MissingRoleAlert`), which DS Message would not | @@ -217,8 +223,9 @@ the header trail. page. Add a `:global([data-mode='dark'])` block falling back to the Skeleton dark surfaces the rest of the app uses. When upstream ships a dark palette, those blocks should be replaced by `--ds-*` overrides rather than extended. -- **Document any API extension** in the component header. `Card` has two: `href` (renders - as an anchor so the whole card is one click target) and a `leading` snippet for an icon. +- **Document any API extension** in the component header. `Card` has two: `href` (makes + the title a link; only the title, so the rest of the card stays selectable text) and a + `leading` snippet for an icon. ### A trap in the spec diff --git a/src/lib/components/Button.svelte b/src/lib/components/Button.svelte new file mode 100644 index 0000000..1a4fd97 --- /dev/null +++ b/src/lib/components/Button.svelte @@ -0,0 +1,200 @@ + + +{#snippet inner()} + {#if iconLeft} + + {/if} + {#if children} + {@render children()} + {/if} + {#if iconRight} + + {/if} +{/snippet} + +{#if href} + {@render inner()} +{:else} + +{/if} + + diff --git a/src/lib/obp/appDirectory.ts b/src/lib/obp/appDirectory.ts index 8a74d06..6713e91 100644 --- a/src/lib/obp/appDirectory.ts +++ b/src/lib/obp/appDirectory.ts @@ -10,8 +10,13 @@ import { env } from '$env/dynamic/public'; export const APP_DIRECTORY_PATH = '/obp/v6.0.0/app-directory'; /** The app-directory names this app links to. */ +export const API_EXPLORER_URL = 'public_obp_api_explorer_url'; export const API_MANAGER_URL = 'public_obp_api_manager_url'; export const MCP_URL = 'public_obp_mcp_url'; +export const PORTAL_URL = 'public_obp_portal_url'; + +/** The Portal's guide to authenticating against OBP (OAuth2 / OpenID Connect). */ +export const PORTAL_AUTHENTICATION_PATH = '/developers/oauth2-oidc'; /** name -> URL, for the entries that hold an http(s) URL. */ export type AppDirectory = Record; diff --git a/src/lib/obp/groupEntitlements.test.ts b/src/lib/obp/groupEntitlements.test.ts new file mode 100644 index 0000000..b53f8b3 --- /dev/null +++ b/src/lib/obp/groupEntitlements.test.ts @@ -0,0 +1,83 @@ +import { describe, expect, it } from 'vitest'; +import { + additionalEntitlements, + groupsOfSpace, + operationsOn, + otherRoles, + type ObpGroup +} from './groupEntitlements'; + +const group = (overrides: Partial): ObpGroup => ({ + group_id: 'g', + group_name: 'G', + list_of_roles: [], + ...overrides +}); + +describe('operationsOn', () => { + it('lists the granted operations in CRUD order', () => { + const roles = [ + 'CanDeleteDynamicEntityRecord_activity', + 'CanGetDynamicEntityRecord_activity', + 'CanCreateDynamicEntityRecord_activity', + 'CanUpdateDynamicEntityRecord_activity' + ]; + expect(operationsOn('activity', roles)).toEqual(['C', 'R', 'U', 'D']); + }); + + it('gives read only access as R', () => { + expect(operationsOn('activity', ['CanGetDynamicEntityRecord_activity'])).toEqual(['R']); + }); + + it('does not match an entity whose name only starts the same', () => { + expect(operationsOn('activity', ['CanGetDynamicEntityRecord_activity_on_chain'])).toEqual([]); + }); +}); + +describe('otherRoles', () => { + it('keeps roles that are not record roles of a listed entity', () => { + const g = group({ + list_of_roles: [ + 'CanGetDynamicEntityRecord_activity', + 'CanGetDynamicEntityRecord_retired_entity', + 'CanGetBank' + ] + }); + expect(otherRoles(g, ['activity'])).toEqual([ + 'CanGetDynamicEntityRecord_retired_entity', + 'CanGetBank' + ]); + }); +}); + +describe('additionalEntitlements', () => { + it('lists each additional role once, with the groups that grant it', () => { + const admins = group({ group_id: 'a', list_of_roles: ['CanGetBank', 'CanGetUser'] }); + const readers = group({ + group_id: 'r', + list_of_roles: ['CanGetDynamicEntityRecord_activity', 'CanGetBank'] + }); + const result = additionalEntitlements([admins, readers], ['activity']); + expect(result.map(({ role, groups }) => [role, groups.map((g) => g.group_id)])).toEqual([ + ['CanGetBank', ['a', 'r']], + ['CanGetUser', ['a']] + ]); + }); +}); + +describe('groupsOfSpace', () => { + const groups = [ + group({ group_id: '1', group_name: 'Verifiers', bank_id: 'ogcr' }), + group({ group_id: '2', group_name: 'Admins', bank_id: 'ogcr' }), + group({ group_id: '3', group_name: 'Other bank', bank_id: 'gh.29.uk' }), + group({ group_id: '4', group_name: 'System', bank_id: null }) + ]; + + it('keeps the bank’s groups, sorted by name', () => { + expect(groupsOfSpace(groups, 'ogcr').map((g) => g.group_id)).toEqual(['2', '1']); + }); + + it('keeps system level groups for SYS', () => { + expect(groupsOfSpace(groups, 'SYS').map((g) => g.group_id)).toEqual(['4']); + }); +}); diff --git a/src/lib/obp/groupEntitlements.ts b/src/lib/obp/groupEntitlements.ts new file mode 100644 index 0000000..a26a41c --- /dev/null +++ b/src/lib/obp/groupEntitlements.ts @@ -0,0 +1,81 @@ +/** + * Which record operations each OBP Group grants on each Dynamic Entity of a space. + * + * A Group (GET /obp/v6.0.0/management/groups?bank_id=BANK_ID) carries a `list_of_roles` + * that its members are granted at the group's bank. The record Roles of a Dynamic Entity + * are `CanDynamicEntityRecord_` for Op in Create, Get, Update, Delete + * (see OBP-API DynamicEntityHelper), so a group's access to an entity is read off those + * four names. + */ + +export interface ObpGroup { + group_id: string; + bank_id?: string | null; + group_name: string; + group_description?: string; + list_of_roles?: string[]; + is_enabled?: boolean; +} + +/** The record operations in CRUD order, with the verb OBP uses in the Role name. */ +export const RECORD_OPERATIONS = [ + { letter: 'C', verb: 'Create' }, + { letter: 'R', verb: 'Get' }, + { letter: 'U', verb: 'Update' }, + { letter: 'D', verb: 'Delete' } +] as const; + +export type OperationLetter = (typeof RECORD_OPERATIONS)[number]['letter']; + +export function recordRole(verb: string, entity: string): string { + return `Can${verb}DynamicEntityRecord_${entity}`; +} + +/** The operations a set of roles grants on one entity, in CRUD order, e.g. `['R']` or `['C','R','U','D']`. */ +export function operationsOn(entity: string, roles: Iterable): OperationLetter[] { + const held = new Set(roles); + return RECORD_OPERATIONS.filter(({ verb }) => held.has(recordRole(verb, entity))).map( + ({ letter }) => letter + ); +} + +const RECORD_ROLE = /^Can(?:Create|Get|Update|Delete)DynamicEntityRecord_(.+)$/; + +/** A group's roles that are not record roles of any of the given entities, e.g. CanGetBank. */ +export function otherRoles(group: ObpGroup, entities: Iterable): string[] { + const known = new Set(entities); + return (group.list_of_roles ?? []).filter((role) => { + const match = role.match(RECORD_ROLE); + return !match || !known.has(match[1]); + }); +} + +/** + * The roles the groups grant beyond the record roles of the given entities, each with the + * groups that grant it, sorted by role name. + */ +export function additionalEntitlements( + groups: ObpGroup[], + entities: Iterable +): { role: string; groups: ObpGroup[] }[] { + const entityNames = [...entities]; + const byRole = new Map(); + for (const group of groups) { + for (const role of new Set(otherRoles(group, entityNames))) { + byRole.set(role, [...(byRole.get(role) ?? []), group]); + } + } + return [...byRole] + .map(([role, granting]) => ({ role, groups: granting })) + .sort((a, b) => a.role.localeCompare(b.role)); +} + +/** + * The groups of the space, by name. `SYS` is OBP's bank id for system level grants; system + * level groups have no bank id, so for `SYS` those are the ones kept. + */ +export function groupsOfSpace(groups: ObpGroup[], bankId: string): ObpGroup[] { + return groups + .filter((group) => (bankId === 'SYS' ? !group.bank_id : group.bank_id === bankId)) + .sort((a, b) => a.group_name.localeCompare(b.group_name)); +} diff --git a/src/lib/registry/activityDetail.test.ts b/src/lib/registry/activityDetail.test.ts new file mode 100644 index 0000000..12a4f90 --- /dev/null +++ b/src/lib/registry/activityDetail.test.ts @@ -0,0 +1,80 @@ +import { describe, expect, it } from 'vitest'; +import { + countryLabel, + humanise, + mockPricePerUnit, + parseCobenefits, + verificationStatus +} from './activityDetail'; + +describe('verificationStatus', () => { + it('has no status without a verification record', () => { + expect(verificationStatus([])).toBeNull(); + expect(verificationStatus(null)).toBeNull(); + }); + + it('lets the strongest status win, since records carry no date', () => { + expect(verificationStatus([{ status_code: 'failed' }, { status_code: 'verified' }])).toBe( + 'verified' + ); + expect(verificationStatus([{ status_code: 'failed' }, { status_code: 'in_progress' }])).toBe( + 'in_progress' + ); + expect(verificationStatus([{ status_code: 'failed' }])).toBe('failed'); + }); + + it('ignores codes it does not know rather than guessing', () => { + expect(verificationStatus([{ status_code: 'unverified' }])).toBeNull(); + }); +}); + +describe('parseCobenefits', () => { + it('reads the JSON array the field holds', () => { + expect(parseCobenefits('["biodiversity","water_quality","soil_health"]')).toEqual([ + 'Biodiversity', + 'Water quality', + 'Soil health' + ]); + }); + + it('falls back to a comma-separated list', () => { + expect(parseCobenefits('biodiversity, soil_health')).toEqual(['Biodiversity', 'Soil health']); + }); + + it('is empty when there is nothing declared', () => { + expect(parseCobenefits(null)).toEqual([]); + expect(parseCobenefits(' ')).toEqual([]); + expect(parseCobenefits('[]')).toEqual([]); + }); +}); + +describe('humanise', () => { + it('turns codes into sentence case', () => { + expect(humanise('CARBON_FARMING')).toBe('Carbon farming'); + }); +}); + +describe('mockPricePerUnit', () => { + it('maps each unit type to its test price', () => { + expect(mockPricePerUnit('Carbon Farming Sequestration')).toBe(10); + expect(mockPricePerUnit('Soil Emission Reduction')).toBe(18); + expect(mockPricePerUnit('Permanent Removal')).toBe(37); + expect(mockPricePerUnit('Carbon Storage in Product')).toBe(28); + }); + + it('has no price for an unknown or missing unit type', () => { + expect(mockPricePerUnit('Something else')).toBeNull(); + expect(mockPricePerUnit(null)).toBeNull(); + }); +}); + +describe('countryLabel', () => { + it('names an ISO code and keeps the code', () => { + expect(countryLabel('IT')).toBe('Italy (IT)'); + expect(countryLabel('DE', 'Germany')).toBe('Germany (DE)'); + }); + + it('is null without a country', () => { + expect(countryLabel(null)).toBeNull(); + }); +}); diff --git a/src/lib/registry/activityDetail.ts b/src/lib/registry/activityDetail.ts new file mode 100644 index 0000000..3f1ff47 --- /dev/null +++ b/src/lib/registry/activityDetail.ts @@ -0,0 +1,250 @@ +import { env } from '$env/dynamic/public'; + +/** + * The public registry's activity detail page (/registry/activities/). + * + * Like the registry list (see ./activities.ts), this reads OBP Dynamic Queries, called + * without an access token, never the dynamic entities directly. A Query cannot nest one + * join inside another, so the page reads four of them, each filtered to the activity + * with `obp_filter[activity_id]=eq:`: + * + * - the activity itself, with its country, operator, chosen certification scheme, + * verifications, media and latest on-chain mint; + * - its practices (activity -> link table -> practice name); + * - its Sustainable Development Goals (likewise); + * - its certificates of compliance, with the scheme certified under and the body. + * + * Sources: OGCR-DynamicEntities/registry_activity*_query.json, deployed with + * `dynamic_resource_docs.py create `. Each read degrades on its own: a Query that + * is missing or forbidden leaves its section empty rather than failing the page. + */ + +const QUERY_PREFIX = '/obp/dynamic-endpoint/dynamic-resource-doc/registry'; +export const REGISTRY_ACTIVITY_PATH = `${QUERY_PREFIX}/activity-query`; +export const REGISTRY_ACTIVITY_PRACTICES_PATH = `${QUERY_PREFIX}/activity-practices-query`; +export const REGISTRY_ACTIVITY_SDGS_PATH = `${QUERY_PREFIX}/activity-sdgs-query`; +export const REGISTRY_ACTIVITY_CERTIFICATES_PATH = `${QUERY_PREFIX}/activity-certificates-query`; + +export interface ActivityMedia { + activity_media_id: string | null; + title: string | null; + link: string | null; + activity_media_type: string | null; + author: string | null; + published_date: string | null; + image: string | null; +} + +/** One activity with what the detail page shows. Every field can be null: the Query + * left-joins, and OBP nulls any field hidden from the caller. */ +export interface ActivityDetail { + activity_id: string | null; + name: string | null; + summary: string | null; + description: string | null; + website: string | null; + hero_image: string | null; + image: string | null; + activity_type: string | null; + unit_types: string | null; + city: string | null; + country_id: string | null; + country_name: string | null; + multipolygon_coordinates: unknown; + start_date: string | null; + end_date: string | null; + term_commitment: number | null; + /** Declared as a string; in practice a JSON array such as `["biodiversity","soil_health"]`. */ + cobenefits: string | null; + methodologies: string | null; + monitoring_period_start_date: string | null; + monitoring_period_end_date: string | null; + /** The scheme the operator chose, before any certification. */ + certification_scheme_id: string | null; + selected_certification_scheme_name: string | null; + selected_certification_scheme_version: string | null; + operator_id: string | null; + operator_legal_name: string | null; + operator_country_id: string | null; + verifications: { status_code: string | null }[] | null; + media: ActivityMedia[] | null; + minted_at: number | null; + token_id: string | number | null; + contract_address: string | null; + chain_id: number | null; + tx_hash: string | null; +} + +export interface ActivityPractice { + technologies_practices_processes_id: string | null; + practice_name: string | null; +} + +export interface ActivitySdg { + sustainable_development_goal_id: string | null; + sustainable_development_goal_name: string | null; + sustainable_development_goal_link: string | null; +} + +export interface ActivityCertificate { + certificate_of_compliance_id: string | null; + certification_status: string | null; + issue_date: string | null; + expiry_date: string | null; + certification_scheme_id: string | null; + certification_scheme_name: string | null; + certification_scheme_version: string | null; + certification_body_id: string | null; + certification_body_legal_name: string | null; +} + +export interface QueryResult { + rows: T[]; + error: string | null; +} + +/** Read one registry Query's rows for one activity. Never throws. */ +async function readForActivity( + fetchFn: typeof fetch, + path: string, + rowsKey: string, + activityId: string, + extraParams: Record = {} +): Promise> { + const base = env.PUBLIC_OBP_BASE_URL?.replace(/\/$/, '') ?? ''; + const params = new URLSearchParams({ + 'obp_filter[activity_id]': `eq:${activityId}`, + ...extraParams + }); + try { + const response = await fetchFn(`${base}${path}?${params}`); + if (!response.ok) { + return { rows: [], error: `Registry returned HTTP ${response.status} from ${path}` }; + } + const body = (await response.json()) as Record; + const rows = body[rowsKey]; + return { rows: Array.isArray(rows) ? (rows as T[]) : [], error: null }; + } catch (error) { + return { + rows: [], + error: error instanceof Error ? error.message : 'Could not reach the registry' + }; + } +} + +export async function getActivityDetail(fetchFn: typeof fetch, activityId: string) { + const [activity, practices, sdgs, certificates] = await Promise.all([ + readForActivity(fetchFn, REGISTRY_ACTIVITY_PATH, 'activities', activityId), + readForActivity( + fetchFn, + REGISTRY_ACTIVITY_PRACTICES_PATH, + 'practices', + activityId + ), + readForActivity(fetchFn, REGISTRY_ACTIVITY_SDGS_PATH, 'sdgs', activityId), + // The certificate shown is the latest by issue date. + readForActivity( + fetchFn, + REGISTRY_ACTIVITY_CERTIFICATES_PATH, + 'certificates', + activityId, + { obp_sort_by: 'issue_date', obp_sort_direction: 'desc', obp_limit: '1' } + ) + ]); + return { + activity: activity.rows[0] ?? null, + activityError: activity.error, + practices: practices.rows, + practicesError: practices.error, + sdgs: sdgs.rows, + sdgsError: sdgs.error, + certificate: certificates.rows[0] ?? null, + certificateError: certificates.error + }; +} + +export type VerificationStatus = 'verified' | 'in_progress' | 'failed'; + +/** + * The activity's verification status, from its activity_verification records. An + * activity with no record has no status (null, shown as "Not yet verified"); it is + * never defaulted to one of the three codes. + * + * activity_verification has no date, so "the most recent record" cannot be told apart. + * Instead the strongest status wins: one verified record makes the activity verified, + * else any in-progress record makes it in progress, else it failed. + */ +export function verificationStatus( + verifications: { status_code: string | null }[] | null | undefined +): VerificationStatus | null { + const codes = new Set((verifications ?? []).map((v) => v.status_code)); + if (codes.has('verified')) return 'verified'; + if (codes.has('in_progress')) return 'in_progress'; + if (codes.has('failed')) return 'failed'; + return null; +} + +/** `["biodiversity","water_quality"]` -> ['Biodiversity', 'Water quality']. Accepts a + * JSON array or, failing that, a comma-separated list. */ +export function parseCobenefits(value: string | null | undefined): string[] { + if (!value?.trim()) return []; + let items: unknown; + try { + items = JSON.parse(value); + } catch { + items = value.split(','); + } + if (!Array.isArray(items)) items = [items]; + return (items as unknown[]) + .filter((item): item is string => typeof item === 'string' && item.trim() !== '') + .map((item) => humanise(item.trim())); +} + +/** 'water_quality' / 'CARBON_FARMING' -> 'Water quality' / 'Carbon farming'. */ +export function humanise(code: string): string { + const words = code.replace(/[_-]+/g, ' ').trim().toLowerCase(); + return words.charAt(0).toUpperCase() + words.slice(1); +} + +/** + * Price per unit, by unit type. NOT in the schema: price is set by the marketplace or + * the operator, outside the registry. These are the static mock values from the + * min_field_matrix sheet's "Test data" tab, until a real source exists. + */ +const MOCK_PRICE_PER_UNIT_EUR: Record = { + 'carbon farming sequestration': 10, + 'soil emission reduction': 18, + 'permanent removal': 37, + 'carbon storage in product': 28 +}; + +export function mockPricePerUnit(unitType: string | null | undefined): number | null { + return unitType ? (MOCK_PRICE_PER_UNIT_EUR[unitType.trim().toLowerCase()] ?? null) : null; +} + +/** + * Available units. NOT in the schema: a static mock value from the sheet's "Test data" + * tab, the same for every activity. Placeholder pending a future on-chain credits + * entity, parallel to parcel_on_chain; once an activity- or credit-level on-chain entity + * holds issued units, replace this with a live read. + */ +export const MOCK_AVAILABLE_UNITS = 300; + +/** 'IT' -> 'Italy (IT)'. activity and operator both store country_id, an ISO 3166-1 + * alpha-2 code; the operator's is a second hop the Query cannot join, so its name comes + * from the browser's region names rather than the country entity. */ +export function countryLabel( + countryId: string | null | undefined, + name?: string | null +): string | null { + if (!countryId) return name ?? null; + let resolved = name ?? null; + if (!resolved) { + try { + resolved = new Intl.DisplayNames(['en'], { type: 'region' }).of(countryId) ?? null; + } catch { + resolved = null; + } + } + return resolved && resolved !== countryId ? `${resolved} (${countryId})` : countryId; +} diff --git a/src/lib/server/roleChecker.ts b/src/lib/server/roleChecker.ts index 4a931d9..d7c7b17 100644 --- a/src/lib/server/roleChecker.ts +++ b/src/lib/server/roleChecker.ts @@ -18,6 +18,13 @@ import type { PageRoleConfig } from '$lib/utils/roleCheck'; // space the entities live in (OBP_ENTITY_SPACE_ID), so a grant at some other bank does not pass a // page whose data calls would then 403, and a role request asks for the grant at the right bank. export const SITE_MAP: Record = { + // Either one lists the space's groups; an all-banks grant has no bank id to match. + '/group-entitlements': { + required: [ + { role: 'CanGetGroupsAtOneBank', bankId: ENTITY_ROLE_BANK_ID }, + { role: 'CanGetGroupsAtAllBanks' } + ] + }, '/activities': { required: [{ role: `CanGetDynamicEntityRecord_${ENTITY_ACTIVITY}`, bankId: ENTITY_ROLE_BANK_ID }] }, diff --git a/src/routes/+layout.svelte b/src/routes/+layout.svelte index 147fae9..c90b113 100644 --- a/src/routes/+layout.svelte +++ b/src/routes/+layout.svelte @@ -26,12 +26,14 @@ my: 'My', operators: 'Operators', schema: 'Schema', + 'group-entitlements': 'Group entitlements', create: 'Create', user: 'My Account', login: 'Login', logout: 'Logout', design: 'Design System', registry: 'Registry', + simple: 'Simple view', certificates: 'Certificates', trading: 'Trading', banks: 'Bank', diff --git a/src/routes/+page.server.ts b/src/routes/+page.server.ts index b50abba..4f959b1 100644 --- a/src/routes/+page.server.ts +++ b/src/routes/+page.server.ts @@ -1,12 +1,25 @@ import type { PageServerLoad } from './$types'; -import { API_MANAGER_URL, MCP_URL, getAppDirectory } from '$lib/obp/appDirectory'; +import { + API_EXPLORER_URL, + API_MANAGER_URL, + MCP_URL, + PORTAL_URL, + PORTAL_AUTHENTICATION_PATH, + getAppDirectory +} from '$lib/obp/appDirectory'; /** The OBP tools linked below the fold, located through OBP's app directory. A tool * OBP has no URL for is null, and its card is left out. */ export const load: PageServerLoad = async ({ fetch }) => { const directory = await getAppDirectory(fetch); + const portalUrl = directory[PORTAL_URL]; return { + apiExplorerUrl: directory[API_EXPLORER_URL] ?? null, apiManagerUrl: directory[API_MANAGER_URL] ?? null, - mcpUrl: directory[MCP_URL] ?? null + mcpUrl: directory[MCP_URL] ?? null, + portalUrl: portalUrl ?? null, + authenticationUrl: portalUrl + ? `${portalUrl.replace(/\/$/, '')}${PORTAL_AUTHENTICATION_PATH}` + : null }; }; diff --git a/src/routes/+page.svelte b/src/routes/+page.svelte index 112ac07..b324f6c 100644 --- a/src/routes/+page.svelte +++ b/src/routes/+page.svelte @@ -10,45 +10,68 @@ Library, Wrench, Bot, - Database + Database, + Compass, + KeyRound, + Globe, + MessageSquare, + Users } from '@lucide/svelte'; let { data }: { data: PageData } = $props(); // Destinations are the app's existing routes — Operators lives under /my - // because the page is scoped to the operators linked to the signed-in user. - const destinations = [ - { - href: '/registry', - icon: Library, - title: 'Registry', - description: 'Browse the public carbon activity registry.' - }, - { - href: '/activities', - icon: Leaf, - title: 'Activities', - description: 'Create and manage activities for testing against the DCR.' - }, - { - href: '/my/operators', - icon: Building2, - title: 'Operators', - description: 'Manage operator records.' - }, - { - href: '/trading', - icon: Store, - title: 'Trading', - description: 'Open the trading interface.' - }, - { - href: '/chain', - icon: Link2, - title: 'Chain', - description: 'Inspect on-chain state and the sync status.' - } - ]; + // because the page is scoped to the operators linked to the signed-in user — + // then the OBP Portal, whose home page also hosts the Opey agent chat. + const destinations = $derived( + [ + { + href: '/registry', + icon: Library, + title: 'Registry', + description: 'Browse the public carbon activity registry.' + }, + { + href: '/activities', + icon: Leaf, + title: 'Activities', + description: 'Create and manage activities for testing against the DCR.' + }, + { + href: '/my/operators', + icon: Building2, + title: 'Operators', + description: 'Manage operator records.' + }, + { + href: '/trading', + icon: Store, + title: 'Trading', + description: 'Open the trading interface.' + }, + { + href: '/chain', + icon: Link2, + title: 'Chain', + description: 'Inspect on-chain state and the sync status.' + }, + { + href: data.portalUrl, + icon: Globe, + title: 'Portal', + description: 'Register, get API keys and read the guides.' + }, + { + href: data.portalUrl, + icon: MessageSquare, + title: 'Agent', + description: 'Ask the agent about the DCR and its schema.' + } + ].filter( + (destination): destination is typeof destination & { href: string } => + destination.href !== null + ) + ); // Below the fold: this deployment's OBP data model, then its other OBP apps, wherever // OBP's app directory says they are. Only the public MCP server is linked, never @@ -61,6 +84,18 @@ title: 'Schema', description: 'View schema and endpoints.' }, + { + href: '/group-entitlements', + icon: Users, + title: 'Group entitlements', + description: 'What each group can do with each entity.' + }, + { + href: data.apiExplorerUrl, + icon: Compass, + title: 'API Explorer', + description: 'Browse and try the API endpoints.' + }, { href: data.apiManagerUrl, icon: Wrench, @@ -72,6 +107,12 @@ icon: Bot, title: 'MCP Server', description: 'Connect AI tools to the API.' + }, + { + href: data.authenticationUrl, + icon: KeyRound, + title: 'Authentication', + description: 'How to authenticate against the API.' } ].filter((tool): tool is typeof tool & { href: string } => tool.href !== null) ); @@ -87,14 +128,12 @@
OGCR

OGCR DCR

-

- Explore and Operate -

+

Explore and Create