Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
48 commits
Select commit Hold shift + click to select a range
f291c1c
feat(domain): add immutable intake and dataset governance contracts
BeforeLights Aug 2, 2026
2a128ef
feat(iae): add idempotent intake admission service
BeforeLights Aug 2, 2026
bddb2a8
feat(dsm): add governed dataset version service
BeforeLights Aug 2, 2026
087b628
feat(storage): persist IAE and DSM governance records
BeforeLights Aug 2, 2026
08b39be
feat(api): expose scoped IAE intake and DSM catalog endpoints
BeforeLights Aug 2, 2026
2bf0614
feat(engine): add deterministic dataset profiling primitive
BeforeLights Aug 2, 2026
66d34b7
feat(iae): validate evidence coordinates against source geometry
BeforeLights Aug 2, 2026
83b480e
feat(iae): add lineage and retention decision contracts
BeforeLights Aug 2, 2026
11a4023
feat(iae): persist scoped artifact lineage and retention decisions
BeforeLights Aug 2, 2026
5d8acac
feat(iae): enforce derived artifact data-mode policy
BeforeLights Aug 2, 2026
7f8a197
feat(dsm): add deterministic mappings and quality rule sets
BeforeLights Aug 2, 2026
b8b5ab7
feat(dsm): add scoped mapping definition service
BeforeLights Aug 2, 2026
e25aac3
feat(dsm): add scoped quality rule-set service
BeforeLights Aug 2, 2026
1f4377d
feat(dsm): add immutable reference entity resolutions
BeforeLights Aug 2, 2026
6d99417
feat(storage): add DSM mapping and rule-set tables
BeforeLights Aug 2, 2026
19be9c8
feat(api): expose DSM mapping rules and reference entities
BeforeLights Aug 2, 2026
a0ef706
fix(api): clear strict lint violations
BeforeLights Aug 2, 2026
dcbf3a3
fix(api): remove stale intake test import
BeforeLights Aug 2, 2026
ece4d2c
Merge fix/iae-dsm-lint
BeforeLights Aug 2, 2026
cb0d554
feat(iae): add expiring evidence access grants
BeforeLights Aug 2, 2026
6d4ac0c
feat(iae): issue and revoke scoped evidence grants
BeforeLights Aug 2, 2026
4ce9d96
fix(iae): bind evidence grants to authenticated epochs
BeforeLights Aug 2, 2026
2766ad1
feat(api): expose exact evidence grant lifecycle
BeforeLights Aug 2, 2026
75d1060
feat(storage): persist evidence grant lifecycle records
BeforeLights Aug 2, 2026
b25312b
feat(api): list scoped artifact intake items
BeforeLights Aug 2, 2026
3b2c4d6
feat(web): add governed artifact inbox
BeforeLights Aug 2, 2026
c1f6182
feat(api): add durable artifact intake repository port
BeforeLights Aug 2, 2026
e7d62c9
fix(storage): enforce tenant scope uniqueness
BeforeLights Aug 2, 2026
6f4801f
feat(api): wire Prisma intake composition
BeforeLights Aug 2, 2026
ec508a3
feat(api): add durable dataset definition repository
BeforeLights Aug 2, 2026
5decd26
feat(api): wire Prisma dataset composition
BeforeLights Aug 2, 2026
876d77f
feat(api): add durable mapping repository
BeforeLights Aug 2, 2026
662ba38
feat(api): wire Prisma mapping composition
BeforeLights Aug 2, 2026
8363052
feat(api): add durable rule-set repository
BeforeLights Aug 2, 2026
0514ee9
feat(api): wire Prisma rule-set composition
BeforeLights Aug 2, 2026
5f93e2c
feat(api): add durable reference entity repository
BeforeLights Aug 2, 2026
02ef82c
feat(api): wire Prisma reference entity composition
BeforeLights Aug 2, 2026
1233bb6
feat(api): wire Prisma artifact composition
BeforeLights Aug 2, 2026
0c7f32e
feat(api): add durable artifact repository
BeforeLights Aug 2, 2026
50315ab
test(api): exercise scoped inbox endpoint
BeforeLights Aug 2, 2026
bcaaa31
fix(style): format foundation implementation
BeforeLights Aug 2, 2026
9b46085
merge: format foundation implementation
BeforeLights Aug 2, 2026
abcd3fc
fix(lint): clear repository check violations
BeforeLights Aug 2, 2026
11c4785
merge: clear repository check violations
BeforeLights Aug 2, 2026
3e03de1
fix(api): refresh generated openapi artifact
BeforeLights Aug 2, 2026
7046106
fix(api): align openapi generation with repository formatting
BeforeLights Aug 2, 2026
3021c7d
fix(engine): satisfy ci formatting checks
BeforeLights Aug 2, 2026
151fbae
merge: satisfy engine ci checks
BeforeLights Aug 2, 2026
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
34 changes: 34 additions & 0 deletions apps/web/src/app/messages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,23 @@ const vietnameseMessages = {
'home.status.approval': 'Chờ phê duyệt',
'home.status.device': 'Cần xác nhận',
'home.status.review': 'Cần xem xét',
'inbox.heading': 'Hộp thư đến dữ liệu',
'inbox.caption': 'Các mục tiếp nhận được máy chủ quản trị trong không gian hiện tại.',
'inbox.loading': 'Đang tải danh sách tiếp nhận…',
'inbox.error': 'Không thể tải Hộp thư đến. Không có thay đổi nào được gửi.',
'inbox.retry': 'Tải lại an toàn',
'inbox.empty': 'Chưa có mục tiếp nhận nào.',
'inbox.column.item': 'Mục tiếp nhận',
'inbox.column.state': 'Trạng thái',
'inbox.column.created': 'Đã tạo',
'inbox.column.version': 'Phiên bản hiện vật',
'inbox.state.new': 'Mới',
'inbox.state.routed': 'Đã định tuyến',
'inbox.state.needsReview': 'Cần xem xét',
'inbox.state.processing': 'Đang xử lý',
'inbox.state.resolved': 'Đã xử lý',
'inbox.state.quarantined': 'Cách ly',
'inbox.state.archived': 'Đã lưu trữ',
'locale.english': 'English',
'locale.vietnamese': 'Tiếng Việt',
'nav.administration': 'Quản trị',
Expand Down Expand Up @@ -81,6 +98,23 @@ const englishMessages: Readonly<Record<AppMessageKey, string>> = {
'home.status.approval': 'Awaiting approval',
'home.status.device': 'Confirmation needed',
'home.status.review': 'Needs review',
'inbox.heading': 'Data Inbox',
'inbox.caption': 'Governed intake items visible in the current workspace.',
'inbox.loading': 'Loading governed intake…',
'inbox.error': 'The Inbox could not load. No changes were sent.',
'inbox.retry': 'Retry safely',
'inbox.empty': 'No intake items yet.',
'inbox.column.item': 'Intake item',
'inbox.column.state': 'State',
'inbox.column.created': 'Created',
'inbox.column.version': 'Artifact version',
'inbox.state.new': 'New',
'inbox.state.routed': 'Routed',
'inbox.state.needsReview': 'Needs review',
'inbox.state.processing': 'Processing',
'inbox.state.resolved': 'Resolved',
'inbox.state.quarantined': 'Quarantined',
'inbox.state.archived': 'Archived',
'locale.english': 'English',
'locale.vietnamese': 'Tiếng Việt',
'nav.administration': 'Administration',
Expand Down
8 changes: 7 additions & 1 deletion apps/web/src/app/router.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import {
UnavailableFeature,
} from '../pages/shell-states.tsx';
import { WorkspaceHome } from '../pages/workspace-home.tsx';
import { InboxPage } from '../features/inbox/inbox-page.tsx';
import { WEB_FEATURE_REGISTRY } from './feature-registry.ts';
import { DEFAULT_ACCESS_CONTEXT, type WebAccessContext } from './navigation.ts';

Expand Down Expand Up @@ -55,7 +56,12 @@ function createRoutes(accessContext: WebAccessContext): RouteObject[] {
{ path: 'workspace', element: <WorkspaceHome accessContext={accessContext} /> },
...WEB_FEATURE_REGISTRY.filter((feature) => feature.key !== 'workspace').map((feature) => ({
path: feature.path,
element: <UnavailableFeature featureKey={feature.key} />,
element:
feature.key === 'inbox' ? (
<InboxPage />
) : (
<UnavailableFeature featureKey={feature.key} />
),
})),
{ path: 'debug/route-error', element: <RouteFailure /> },
{ path: '*', element: <NotFoundPage /> },
Expand Down
68 changes: 68 additions & 0 deletions apps/web/src/features/inbox/inbox-api.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
import {
parseStableIdentifierV1,
parseStrictUtcTimestampV1,
} from '@databreeze/domain/tenant-scope/v1';

const inboxStates = [
'NEW',
'ROUTED',
'NEEDS_REVIEW',
'PROCESSING',
'RESOLVED',
'QUARANTINED',
'ARCHIVED',
] as const;

export type InboxState = (typeof inboxStates)[number];

export interface InboxListItem {
readonly inboxItemId: string;
readonly artifactVersionId: string;
readonly state: InboxState;
readonly createdAt: string;
readonly revision: number;
}

function apiBaseUrl(): string {
const configured: unknown = import.meta.env['VITE_DATABREEZE_API_BASE_URL'];
if (typeof configured !== 'string' || configured.trim() === '') return '';
return configured.replace(/\/$/u, '');
}

function parseInboxItem(input: unknown): InboxListItem | undefined {
if (typeof input !== 'object' || input === null || Array.isArray(input)) return undefined;
const item = input as Record<string, unknown>;
const inboxItemId = parseStableIdentifierV1(item['inboxItemId']);
const artifactVersionId = parseStableIdentifierV1(item['artifactVersionId']);
const createdAt = parseStrictUtcTimestampV1(item['createdAt']);
if (!inboxItemId.accepted || !artifactVersionId.accepted || !createdAt.accepted) return undefined;
if (!inboxStates.includes(item['state'] as InboxState)) return undefined;
if (
typeof item['revision'] !== 'number' ||
!Number.isSafeInteger(item['revision']) ||
item['revision'] < 1
)
return undefined;
return Object.freeze({
inboxItemId: inboxItemId.value,
artifactVersionId: artifactVersionId.value,
state: item['state'] as InboxState,
createdAt: createdAt.value,
revision: item['revision'],
});
}

export async function listInbox(signal?: AbortSignal): Promise<readonly InboxListItem[]> {
const requestInit: RequestInit = {
headers: { Accept: 'application/json' },
credentials: 'include',
};
if (signal !== undefined) requestInit.signal = signal;
const response = await fetch(`${apiBaseUrl()}/v1/artifacts/inbox`, requestInit);
if (!response.ok) throw new Error('INBOX_REQUEST_FAILED');
const payload: unknown = await response.json();
if (!Array.isArray(payload)) throw new Error('INBOX_RESPONSE_INVALID');
const parsed = payload.map(parseInboxItem);
if (parsed.some((item) => item === undefined)) throw new Error('INBOX_RESPONSE_INVALID');
return Object.freeze(parsed as InboxListItem[]);
}
131 changes: 131 additions & 0 deletions apps/web/src/features/inbox/inbox-page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
import { Button, Status } from '@databreeze/ui/v1';
import { useQuery } from '@tanstack/react-query';
import { appMessage } from '../../app/messages.ts';
import { useLocale } from '../../app/locale-context.tsx';
import { listInbox } from './inbox-api.ts';

function stateMessageKey(
state: string,
):
| 'inbox.state.new'
| 'inbox.state.routed'
| 'inbox.state.needsReview'
| 'inbox.state.processing'
| 'inbox.state.resolved'
| 'inbox.state.quarantined'
| 'inbox.state.archived' {
const keys = {
NEW: 'inbox.state.new',
ROUTED: 'inbox.state.routed',
NEEDS_REVIEW: 'inbox.state.needsReview',
PROCESSING: 'inbox.state.processing',
RESOLVED: 'inbox.state.resolved',
QUARANTINED: 'inbox.state.quarantined',
ARCHIVED: 'inbox.state.archived',
} as const;
return keys[state as keyof typeof keys] ?? keys.NEW;
}

function stateKind(state: string): 'danger' | 'info' | 'success' | 'warning' {
if (state === 'RESOLVED' || state === 'ARCHIVED') return 'success';
if (state === 'QUARANTINED') return 'danger';
if (state === 'PROCESSING') return 'info';
return 'warning';
}

export function InboxPage() {
const locale = useLocale();
const query = useQuery({
queryKey: ['artifacts', 'inbox'],
queryFn: ({ signal }) => listInbox(signal),
retry: false,
});

if (query.isPending) {
return (
<section aria-labelledby="inbox-heading" className="feature-surface">
<div className="work-surface__heading">
<div>
<h1 id="inbox-heading">{appMessage(locale, 'inbox.heading')}</h1>
<p>{appMessage(locale, 'inbox.caption')}</p>
</div>
</div>
<Status kind="info">{appMessage(locale, 'inbox.loading')}</Status>
</section>
);
}

if (query.isError) {
return (
<section aria-labelledby="inbox-heading" className="feature-surface">
<div className="work-surface__heading">
<div>
<h1 id="inbox-heading">{appMessage(locale, 'inbox.heading')}</h1>
<p>{appMessage(locale, 'inbox.caption')}</p>
</div>
</div>
<Status kind="danger">{appMessage(locale, 'inbox.error')}</Status>
<Button className="inbox-retry" onClick={() => void query.refetch()} variant="secondary">
{appMessage(locale, 'inbox.retry')}
</Button>
</section>
);
}

const items = query.data;
return (
<section aria-labelledby="inbox-heading" className="feature-surface">
<div className="work-surface__heading">
<div>
<h1 id="inbox-heading">{appMessage(locale, 'inbox.heading')}</h1>
<p>{appMessage(locale, 'inbox.caption')}</p>
</div>
<Status kind="info">
{items.length === 0 ? appMessage(locale, 'inbox.empty') : `${items.length}`}
</Status>
</div>
{items.length === 0 ? (
<p className="inbox-empty">{appMessage(locale, 'inbox.empty')}</p>
) : (
<div className="table-scroll" tabIndex={0}>
<table aria-label={appMessage(locale, 'inbox.heading')}>
<thead>
<tr>
<th scope="col">{appMessage(locale, 'inbox.column.item')}</th>
<th scope="col">{appMessage(locale, 'inbox.column.state')}</th>
<th scope="col">{appMessage(locale, 'inbox.column.created')}</th>
<th scope="col">{appMessage(locale, 'inbox.column.version')}</th>
</tr>
</thead>
<tbody>
{items.map((item) => (
<tr key={item.inboxItemId}>
<td>
<code>{item.inboxItemId}</code>
</td>
<td>
<Status kind={stateKind(item.state)}>
{appMessage(locale, stateMessageKey(item.state))}
</Status>
</td>
<td>
<time dateTime={item.createdAt}>
{new Intl.DateTimeFormat(locale, {
dateStyle: 'medium',
timeStyle: 'short',
}).format(new Date(item.createdAt))}
</time>
</td>
<td>
<code>{item.artifactVersionId}</code>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
<p className="authority-note">{appMessage(locale, 'access.clientHint')}</p>
</section>
);
}
15 changes: 14 additions & 1 deletion apps/web/src/styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -217,10 +217,23 @@ a {
outline-offset: calc(-1 * var(--db-focus-ring-width));
}
.work-surface,
.feature-placeholder {
.feature-placeholder,
.feature-surface {
max-width: 1120px;
margin-inline: auto;
}
.feature-surface code {
color: var(--db-color-text-muted);
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
font-size: var(--db-typography-font-size-caption);
}
.inbox-retry {
margin-block-start: var(--db-spacing-4);
}
.inbox-empty {
padding-block: var(--db-spacing-8);
color: var(--db-color-text-muted);
}
.work-surface__heading {
display: flex;
justify-content: space-between;
Expand Down
47 changes: 47 additions & 0 deletions apps/web/test/inbox-page.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import { render, screen, waitFor } from '@testing-library/react';
import { describe, expect, it, vi } from 'vitest';
import { ApplicationBoundary, createAppRouter } from '../src/app/app.tsx';

const inboxItem = {
schemaVersion: 1,
inboxItemId: '00000000-0000-4000-8000-000000000001',
artifactVersionId: '00000000-0000-4000-8000-000000000002',
tenantScope: {
scopeType: 'workspace',
organizationId: '00000000-0000-4000-8000-000000000003',
workspaceId: '00000000-0000-4000-8000-000000000004',
},
idempotencyKey: 'safe-fixture',
state: 'NEEDS_REVIEW',
createdAt: '2026-01-02T00:00:00.000Z',
revision: 1,
};

describe('governed artifact inbox', () => {
it('renders server-owned intake state without exposing scope or idempotency data', async () => {
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue(new Response(JSON.stringify([inboxItem]), { status: 200 })),
);
const router = createAppRouter({ initialEntries: ['/en/inbox'] });
render(<ApplicationBoundary router={router} />);

expect(await screen.findByRole('heading', { name: 'Data Inbox' })).toBeTruthy();
expect(await screen.findByText('Needs review')).toBeTruthy();
expect(screen.getByText(inboxItem.inboxItemId)).toBeTruthy();
expect(screen.queryByText('safe-fixture')).toBeNull();
expect(screen.queryByText(inboxItem.tenantScope.organizationId)).toBeNull();
});

it('shows a safe retry state when the API is unavailable', async () => {
const fetchMock = vi.fn().mockRejectedValue(new Error('private provider detail'));
vi.stubGlobal('fetch', fetchMock);
const router = createAppRouter({ initialEntries: ['/en/inbox'] });
render(<ApplicationBoundary router={router} />);

expect(await screen.findByText('The Inbox could not load. No changes were sent.')).toBeTruthy();
expect(screen.getByRole('button', { name: 'Retry safely' })).toBeTruthy();
expect(screen.queryByText(/private provider detail/u)).toBeNull();
await waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(1));
});
});
28 changes: 28 additions & 0 deletions packages/domain/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -48,10 +48,22 @@
"types": "./src/artifact/v1.ts",
"import": "./dist/artifact/v1.js"
},
"./artifact-intake/v1": {
"types": "./src/artifact-intake/v1.ts",
"import": "./dist/artifact-intake/v1.js"
},
"./artifact-governance/v1": {
"types": "./src/artifact-governance/v1.ts",
"import": "./dist/artifact-governance/v1.js"
},
"./dataset/v1": {
"types": "./src/dataset/v1.ts",
"import": "./dist/dataset/v1.js"
},
"./dataset-governance/v1": {
"types": "./src/dataset-governance/v1.ts",
"import": "./dist/dataset-governance/v1.js"
},
"./jobs/v1": {
"types": "./src/jobs/v1.ts",
"import": "./dist/jobs/v1.js"
Expand Down Expand Up @@ -79,6 +91,22 @@
"./finding/v1": {
"types": "./src/finding/v1.ts",
"import": "./dist/finding/v1.js"
},
"./reference-entity/v1": {
"types": "./src/reference-entity/v1.ts",
"import": "./dist/reference-entity/v1.js"
},
"./mapping/v1": {
"types": "./src/mapping/v1.ts",
"import": "./dist/mapping/v1.js"
},
"./rule-set/v1": {
"types": "./src/rule-set/v1.ts",
"import": "./dist/rule-set/v1.js"
},
"./evidence-grant/v1": {
"types": "./src/evidence-grant/v1.ts",
"import": "./dist/evidence-grant/v1.js"
}
},
"scripts": {
Expand Down
Loading
Loading