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
10 changes: 6 additions & 4 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -1112,9 +1112,9 @@ Load-bearing:
fails on any diff. The plugin owns them; never edit the copies, and never
copy `board-page.mjs`, `board.mjs`, `panel-http.mjs`, or
`board-review-write.mjs`.
- **Page assets are text imports.** `src/page/assets.ts` imports the page and
two vendor modules `with { type: 'text' }`, so `bun build --target=node`
embeds them. `canvas-theme.mjs` is imported as a module (for `THEME_CSS`)
- **Page assets are text imports.** `src/page/assets.ts` imports the page files
(the icon among them) and two vendor modules `with { type: 'text' }`, so
`bun build --target=node` embeds them. `canvas-theme.mjs` is imported as a module (for `THEME_CSS`)
and cannot also be a text import in the same bundle, which is why it is not
written into the output.

Expand Down Expand Up @@ -1220,7 +1220,9 @@ plus a local Podman smoke; the APIM path is unmeasured. Load-bearing:
- **The portal is per corpus at `/portal/{corpus}/`.** `/` redirects
relatively when one corpus is configured and lists them otherwise; links stay
relative so a gateway base path survives. Portal files carry the page's CSP
plus `frame-ancestors 'none'`.
plus `frame-ancestors 'none'`. Its `portal-data.json` sets `pathLookup` to
this server's own `governing` route (#315); the page prints it as text and
never requests it, so do not add a fetch or loosen `connect-src` for it.
- **Configuration is `ADRKIT_SERVER_CORPORA`** (`key[=dir],...` or JSON),
confined under `ADRKIT_SERVER_ROOT` by `realpath`, at start and before every
load. As PID 1 the binary handles SIGTERM itself.
Expand Down
20 changes: 20 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,16 @@ Until `1.0.0`, minor releases may include breaking changes
`governing` matches `affects` only: it never reads the file at the path, so
it has no `@adr` marker evidence.

- **The server-hosted decision portal points at the server's own
`governing?path=` route** ([#315](https://github.com/mbeacom/adrkit/issues/315),
ADR-0055 clause 9). The governance-by-path view still says this host has no
path table, and now names the route, relative to the page, with an example
such as `../../api/v1/corpora/demo/governing?path=src/index.ts`. The page
shows it as text and never requests it, so the renderer, its CSP, and the
GitHub Pages host are unchanged. `portal-data.json` grows one additive
top-level field, `pathLookup` (`{ route }` or `null`); `version` stays `"1"`,
and a Pages bundle carries `"pathLookup": null`.

### Fixed

- **A `repo`-qualified `affects` matcher no longer vanishes silently
Expand All @@ -100,6 +110,16 @@ Until `1.0.0`, minor releases may include breaking changes
`matcherAppliesToMember` applies a qualified matcher when it equals either
name. No surface passes a member yet; that is ADR-0059's federation work.

- **The decision portal ships its own tab icon**
([#309](https://github.com/mbeacom/adrkit/issues/309)). Without one, every
load also requested `/favicon.ico` at the host root and logged a 404 on a
Pages project path, at adrkit.dev's `/decisions/`, or behind the REST
server. The bundle now writes `page/icon.svg` and `index.html` names it
relatively, so it works at any path; the CSP is unchanged (`img-src 'self'`
already covers it). The REST server serves it at
`/portal/{corpus}/page/icon.svg` as `image/svg+xml` (operation
`getPortalIcon`).

### Added

- **`@adrkit/core` exports `todayUtcDate()`, the `--as-of` default.** When
Expand Down
36 changes: 34 additions & 2 deletions packages/ci/dist/portal-action.js

Large diffs are not rendered by default.

33 changes: 31 additions & 2 deletions packages/portal/src/data/build.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import {
type PortalIssue,
type PortalPathDecision,
type PortalPathEntry,
type PortalPathLookup,
type PortalPathTable,
type PortalPathTableAvailable,
type PortalPathTableCaps,
Expand All @@ -50,25 +51,39 @@ export interface BuildPortalDataOptions {
readonly asOf: string;
/** The resolved path table (see `buildPathTable`), `{ state: 'unavailable', … }`, or `{ state: 'absent' }`. */
readonly pathTable: PortalPathTable;
/**
* Where this host answers one path per request, or `null` (the default). Only ADR-0055's
* server passes one, and only beside an `absent` path table. Written as `pathLookup`.
*/
readonly pathLookup?: PortalPathLookup | null;
}

const ALL_EDGE_KINDS = ['supersedes', 'relatesTo', 'conflictsWith'] as const;
const COMMIT = /^(?:[0-9a-f]{40}|[0-9a-f]{64})$/;
const CALENDAR_DATE = /^\d{4}-\d{2}-\d{2}$/;
/**
* A page-relative route with no query, fragment, scheme, or root: `./` or `../` steps, then
* plain segments. The page shows it as text, but a host still cannot publish something that
* reads as an absolute URL or carries markup-looking characters.
*/
const LOOKUP_ROUTE = /^(?:\.\.?\/)*(?:[A-Za-z0-9_-][A-Za-z0-9._-]*\/)*[A-Za-z0-9_-][A-Za-z0-9._-]*$/;
const LOOKUP_ROUTE_MAX = 512;

/**
* Build `portal-data.json` v1 from an SDK handle. Same inputs, byte-identical
* {@link serializePortalData} output.
*
* Throws `TypeError` for malformed options, and for one inconsistent combination: an
* `available` path table over a corpus with an error-severity issue. With corpus errors the
* Throws `TypeError` for malformed options (a `pathLookup` beside a table that is not
* `absent` among them), and for one inconsistent combination: an `available` path table
* over a corpus with an error-severity issue. With corpus errors the
* SDK's `governing` answers every path with empty groups, so such a table would present a
* confident "nothing governs this" for every file (clause 6). The caller must pass
* `unavailable` (or `absent`) instead. Any `RangeError` from `handle.queue` (a malformed
* `asOf`) propagates.
*/
export function buildPortalData(handle: PortalDataSource, options: BuildPortalDataOptions): PortalData {
const { dir, commit, asOf, pathTable } = options;
const pathLookup = pathLookupOf(options.pathLookup ?? null);
if (typeof dir !== 'string' || dir.length === 0) throw new TypeError('dir must be a non-empty string');
if (commit !== null && (typeof commit !== 'string' || !COMMIT.test(commit))) {
throw new TypeError('commit must be a 40- or 64-character lowercase hex string, or null');
Expand All @@ -81,6 +96,9 @@ export function buildPortalData(handle: PortalDataSource, options: BuildPortalDa
if (hasCorpusError && table.state === 'available') {
throw new TypeError("an available path table is inconsistent with a corpus that has errors; pass state 'unavailable'");
}
if (pathLookup !== null && table.state !== 'absent') {
throw new TypeError("a pathLookup hint is only for a host with no path table; pass pathTable state 'absent'");
}

const recordPaths = new Map<string, string>();
for (const record of handle.records) recordPaths.set(record.path, record.id);
Expand All @@ -100,9 +118,20 @@ export function buildPortalData(handle: PortalDataSource, options: BuildPortalDa
},
queue: queue.entries.map(queueEntryOf),
pathTable: table,
pathLookup,
};
}

/** Rebuild the lookup hint from its known field only, after checking the route's shape. */
function pathLookupOf(lookup: PortalPathLookup | null): PortalPathLookup | null {
if (lookup === null) return null;
const route = (lookup as { route?: unknown } | undefined)?.route;
if (typeof route !== 'string' || route.length > LOOKUP_ROUTE_MAX || !LOOKUP_ROUTE.test(route)) {
throw new TypeError('pathLookup.route must be a page-relative path with no query, fragment, or scheme');
}
return { route };
}

function recordOf(record: DecisionRecord, recordPaths: ReadonlyMap<string, string>): PortalRecord {
return {
id: record.id,
Expand Down
21 changes: 21 additions & 0 deletions packages/portal/src/data/contract.ts
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,27 @@ export interface PortalData {
readonly queue: readonly PortalQueueEntry[];
/** Governance by path (clause 6). */
readonly pathTable: PortalPathTable;
/**
* Where this host answers governance for one path at a time, or `null` (the Pages host).
* Added within version 1. Only ADR-0055's server sets it, alongside an `absent` path table
* (clause 12; ADR-0055 clause 9); see {@link PortalPathLookup}.
*/
readonly pathLookup: PortalPathLookup | null;
}

/**
* A hint, set by a host that has no path table but can answer one path per request
* (ADR-0055's `governing?path=` route). The page shows it as **text** and never fetches it:
* ADR-0054 keeps the renderer free of network calls beyond its own data file, under the
* same CSP on both hosts. Never set beside an `available` or `unavailable` table.
*/
export interface PortalPathLookup {
/**
* The route, relative to the page (e.g. `../../api/v1/corpora/demo/governing`), without a
* query: a caller appends `?path=<repository-relative path>`. Built by the host from its
* own configuration, not from repository text. Relative so a gateway base path survives.
*/
readonly route: string;
}

/* ------------------------------------------------------------------ *
Expand Down
35 changes: 34 additions & 1 deletion packages/portal/src/page/app.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -70,8 +70,25 @@ export const PATH_TEXT = {
'Resolution was withheld because the corpus has errors. The errors are listed above; fix them and rebuild the portal to look paths up.',
absent:
'This host has no path table, so it cannot say which decisions reach a path. Build the portal from a git work tree to get one, or run adr explain on the path.',
/** The absent state on a host that sets `pathLookup` (ADR-0055's server), in place of `absent`. */
absentServed:
'This host has no path table: it holds the decision records, not the source tree, so this page cannot say which decisions reach a path.',
/** The lines naming that host's own route. Text only: the page never requests it. */
lookupRoute: (route) => [
'This server answers for one path at a time, matching affects only and reading no file, at',
route + '?path=<repository-relative path>',
'relative to this page. For example:',
route + '?path=src/index.ts',
],
};

/** The host's `pathLookup` route, when the data file carries a usable one; else `null`. */
export function lookupRouteOf(data) {
const lookup = data && data.pathLookup;
const route = lookup && typeof lookup === 'object' ? lookup.route : null;
return typeof route === 'string' && route.length > 0 && route.length <= 512 ? route : null;
}

/* ------------------------------------------------------------------ *
* Small pure helpers (exported for the tests)
* ------------------------------------------------------------------ */
Expand Down Expand Up @@ -1015,7 +1032,23 @@ export function startPortal(env) {
return;
}
if (table.state !== 'available') {
main.appendChild(callout('neutral', 'info', 'No path table', [PATH_TEXT.absent]));
const route = lookupRouteOf(data);
if (route === null) {
main.appendChild(callout('neutral', 'info', 'No path table', [PATH_TEXT.absent]));
return;
}
// ADR-0055 clause 9: point at the server's own route, as text. No link and no request,
// so the renderer keeps one network path (its data file) and one CSP on both hosts.
const [lead, template, between, example] = PATH_TEXT.lookupRoute(route);
main.appendChild(
callout('neutral', 'info', 'No path table', [
PATH_TEXT.absentServed,
el('p', null, lead),
el('p', 'mono path-route', template),
el('p', null, between),
el('p', 'mono path-route', example),
]),
);
return;
}
const entries = list(table.entries);
Expand Down
3 changes: 3 additions & 0 deletions packages/portal/src/page/assets.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
* index.html src/page/index.html, with the meta CSP of clause 7
* app.css THEME_CSS, then src/page/page.css
* page/app.mjs src/page/app.mjs
* page/icon.svg src/page/icon.svg, the tab icon (no script, no external reference)
* vendor/board-layout.mjs src/vendor/board-layout.mjs, the generated copy
* vendor/board-view.mjs src/vendor/board-view.mjs, the generated copy
* portal-data.json written by generate.ts
Expand All @@ -32,6 +33,7 @@ import * as layoutText from '../vendor/board-layout.mjs' with { type: 'text' };
import * as viewText from '../vendor/board-view.mjs' with { type: 'text' };
import * as appText from './app.mjs' with { type: 'text' };
import * as htmlText from './index.html' with { type: 'text' };
import * as iconText from './icon.svg' with { type: 'text' };
import * as cssText from './page.css' with { type: 'text' };

/** One file of the page bundle. `path` is relative to the output directory, POSIX separators. */
Expand All @@ -55,6 +57,7 @@ export const PORTAL_ASSETS: readonly PortalAsset[] = Object.freeze([
{ path: 'index.html', contents: textOf(htmlText, 'index.html') },
{ path: 'app.css', contents: `${THEME_CSS}\n${PAGE_CSS}` },
{ path: 'page/app.mjs', contents: textOf(appText, 'app.mjs') },
{ path: 'page/icon.svg', contents: textOf(iconText, 'icon.svg') },
{ path: 'vendor/board-layout.mjs', contents: textOf(layoutText, 'board-layout.mjs') },
{ path: 'vendor/board-view.mjs', contents: textOf(viewText, 'board-view.mjs') },
]);
6 changes: 6 additions & 0 deletions packages/portal/src/page/icon.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
1 change: 1 addition & 0 deletions packages/portal/src/page/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="referrer" content="no-referrer">
<title>Decision portal</title>
<link rel="icon" href="./page/icon.svg" type="image/svg+xml">
<link rel="stylesheet" href="./app.css">
<script type="module" src="./page/app.mjs"></script>
</head>
Expand Down
1 change: 1 addition & 0 deletions packages/portal/src/page/page.css
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,7 @@ input[type="search"]:focus-visible { outline: 2px solid var(--c-focus); outline-
/* Queue and paths */
.queue-row .facts { margin: var(--sp-2) 0 0; }
.path-result { margin: var(--sp-3) 0; }
.path-route { overflow-wrap: anywhere; }
.path-entry .path { font-weight: 600; overflow-wrap: anywhere; margin: 0 0 var(--sp-2); }
.path-group { margin-top: var(--sp-3); }
.path-group h3 { margin-bottom: var(--sp-1); }
Expand Down
11 changes: 8 additions & 3 deletions packages/portal/src/page/text-imports.d.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,14 @@
/**
* `src/page/assets.ts` imports the page stylesheet with `with { type: 'text' }`. Bun's own
* declarations cover `*.txt` and `*.html` but not `*.css`; this one says what the text loader
* gives. No other module in the repository imports a stylesheet.
* `src/page/assets.ts` imports the page stylesheet and icon with `with { type: 'text' }`.
* Bun's own declarations cover `*.txt` and `*.html` but not `*.css` or `*.svg`; these say
* what the text loader gives. No other module in the repository imports either.
*/
declare module '*.css' {
const text: string;
export default text;
}

declare module '*.svg' {
const text: string;
export default text;
}
2 changes: 1 addition & 1 deletion packages/portal/test/assets.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,7 @@ describe('the page bundle', () => {
...[...app.matchAll(/^\s*import\b[^;]*?from\s+['"]([^'"]+)['"]/gm)].map((match) => new URL(match[1]!, 'https://x.invalid/page/app.mjs').pathname.slice(1)),
...[...html.matchAll(/\b(?:href|src)="(\.\/[^"]+)"/g)].map((match) => new URL(match[1]!, 'https://x.invalid/index.html').pathname.slice(1)),
];
expect(wanted.length).toBe(4);
expect(wanted.length).toBe(5);
for (const path of wanted) expect({ path, written: written.has(path) }).toEqual({ path, written: true });
});

Expand Down
46 changes: 46 additions & 0 deletions packages/portal/test/build.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,10 @@
* fit" failed.
* - the graph called with no `kinds`: "maps a real SDK handle" (missing `relatesTo` and
* `conflictsWith` edges) and "asks the handle for all three edge kinds" failed.
* - `pathLookup` written as `options.pathLookup` (not rebuilt from `route`): "the pathLookup
* hint is rebuilt from its route" failed on the leaked key; the `absent`-only guard
* removed: "a pathLookup hint is refused beside a table" failed; the route check removed:
* "a pathLookup route must be page-relative" failed.
*/

import { afterAll, beforeAll, describe, expect, test } from 'bun:test';
Expand Down Expand Up @@ -179,6 +183,48 @@ describe('buildPortalData', () => {
});
});

describe('the pathLookup hint (ADR-0055 clause 9)', () => {
const LOOKUP = { route: '../../api/v1/corpora/demo/governing' };

test('is null unless a host passes one', () => {
expect(buildPortalData(fakeHandle(), OPTIONS).pathLookup).toBeNull();
expect(buildPortalData(fakeHandle(), { ...OPTIONS, pathLookup: null }).pathLookup).toBeNull();
expect(serializePortalData(buildPortalData(fakeHandle(), OPTIONS))).toContain('"pathLookup":null,"pathTable":');
});

test('the pathLookup hint is rebuilt from its route', () => {
const data = buildPortalData(fakeHandle(), { ...OPTIONS, pathLookup: { ...LOOKUP, extra: 'x' } as never });
expect(data.pathLookup).toEqual(LOOKUP);
expect(serializePortalData(data)).not.toContain('"extra"');
});

test('a pathLookup hint is refused beside a table', () => {
const table = buildPathTable([governance('src/a.ts', { governing: ['0001'] })]);
expect(() => buildPortalData(healthy, { ...OPTIONS, pathTable: table, pathLookup: LOOKUP })).toThrow(TypeError);
expect(() => buildPortalData(broken, { ...OPTIONS, pathTable: { state: 'unavailable', reason: 'corpus-errors' }, pathLookup: LOOKUP })).toThrow(TypeError);
// The server always passes `absent`, so a corpus with errors still carries the hint.
expect(buildPortalData(broken, { ...OPTIONS, pathLookup: LOOKUP }).pathLookup).toEqual(LOOKUP);
});

test('a pathLookup route must be page-relative', () => {
for (const route of ['./governing', 'governing', '../../api/v1/corpora/a.b_c-d/governing']) {
expect({ route, kept: buildPortalData(fakeHandle(), { ...OPTIONS, pathLookup: { route } }).pathLookup?.route }).toEqual({ route, kept: route });
}
for (const route of ['', '/api/v1/governing', '//evil.invalid/x', 'https://evil.invalid/x', 'javascript:alert(1)', 'governing?path=a', 'governing#x', '../a/../b', 'a b', 'a/<b>', `a/${'x'.repeat(512)}`, 42]) {
expect({ route, threw: (() => { try { buildPortalData(fakeHandle(), { ...OPTIONS, pathLookup: { route } as never }); return false; } catch (error) { return error instanceof TypeError; } })() }).toEqual({ route, threw: true });
}
expect(() => buildPortalData(fakeHandle(), { ...OPTIONS, pathLookup: 'governing' as never })).toThrow(TypeError);
});

test('is byte-identical across calls, with and without the hint', () => {
const without = serializePortalData(buildPortalData(healthy, OPTIONS));
expect(serializePortalData(buildPortalData(healthy, { ...OPTIONS, pathLookup: null }))).toBe(without);
const withHint = serializePortalData(buildPortalData(healthy, { ...OPTIONS, pathLookup: LOOKUP }));
expect(serializePortalData(buildPortalData(healthy, { ...OPTIONS, pathLookup: { ...LOOKUP } }))).toBe(withHint);
expect(withHint).toBe(without.replace('"pathLookup":null', `"pathLookup":{"route":"${LOOKUP.route}"}`));
});
});

describe('determinism', () => {
test('is byte-identical across calls and across separately opened handles', async () => {
const first = serializePortalData(buildPortalData(healthy, OPTIONS));
Expand Down
3 changes: 2 additions & 1 deletion packages/portal/test/contract.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,8 @@ describe('portal-data.json v1', () => {
});

test('top-level keys', () => {
expect(keys(json)).toEqual(['asOf', 'commit', 'dir', 'graph', 'issues', 'pathTable', 'queue', 'records', 'version']);
expect(keys(json)).toEqual(['asOf', 'commit', 'dir', 'graph', 'issues', 'pathLookup', 'pathTable', 'queue', 'records', 'version']);
expect(json.pathLookup).toBeNull();
expect(json.commit).toBeNull();
expect('generatedAt' in json).toBe(false);
});
Expand Down
Loading
Loading