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
1 change: 1 addition & 0 deletions apps/web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
"@ant-design/icons-vue": "catalog:",
"@gocell/core": "workspace:*",
"@gocell/access": "workspace:*",
"@gocell/audit": "workspace:*",
"@gocell/request": "workspace:*",
"vue-i18n": "catalog:"
},
Expand Down
10 changes: 10 additions & 0 deletions apps/web/src/router/index.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,4 +40,14 @@ describe('router layout fork', () => {
expect(m.meta.requiredAction).toBe('read')
expect(m.meta.requiredResource).toBe('identity')
})

it('renders /audit nested under the shell, behind the auth + PDP gates (Batch 4)', () => {
const m = router.resolve('/audit')
expect(m.name).toBe('audit')
expect(m.matched.length).toBeGreaterThanOrEqual(2)
expect(m.meta.requiresAuth).toBe(true)
// PDP fail-closed gate (read on audit) — guards.ts denies until the PDP backend allows.
expect(m.meta.requiredAction).toBe('read')
expect(m.meta.requiredResource).toBe('audit')
})
})
9 changes: 9 additions & 0 deletions apps/web/src/router/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,15 @@ const routes: RouteRecordRaw[] = [
props: { titleKey: 'nav.reviews' },
meta: { requiresAuth: true },
},
{
// Operate · Audit log (Batch 4). PDP-gated read on `audit` (fail-closed
// until the /access/decide backend ships, BR-004); own async chunk via
// the @gocell/audit/views/audit subpath export.
path: 'audit',
name: 'audit',
component: () => import('@gocell/audit/views/audit'),
meta: { requiresAuth: true, requiredAction: 'read', requiredResource: 'audit' },
},
],
},
]
Expand Down
55 changes: 49 additions & 6 deletions packages/audit/README.md
Original file line number Diff line number Diff line change
@@ -1,11 +1,54 @@
# @gocell/audit

> 镜像后端 cells/auditcore:auditquery 审计页
> 对应后端 cell:`cells/auditcore/` (`auditquery` slice — cursor-paginated audit log list)

- **对应后端 cell**:cells/auditcore
- **对外 exports**:`.` → `src/index.ts`(唯一收口;未列出路径外部不可访问)
- **依赖 contract**:(待 Batch 实施时补全)
## 对外 exports

## 边界
- `.` → 主入口:`useAuditStore` store + `AuditEntry` 类型
- `./stores` → `useAuditStore`(Pinia setup-store)
- `./views/audit` → `AuditView.vue`(路由级页面组件)

依赖规则见仓库根 `CLAUDE.md` §依赖规则。跨包仅经 `@gocell/contracts` 类型 + `@gocell/request` client,禁深路径 import。
## 依赖的 contract

- `HttpAuditListV1Response` (v1) — `packages/contracts/src/http/audit/list/v1/response.ts`
- 行类型:`AuditEntry = HttpAuditListV1Response['data'][number]`

## 已知后端缺口(BR-006)

契约当前缺少以下字段(设计稿有,后端未暴露):

| 字段 | 降级处理 |
|------|---------|
| `hash` / `prevHash` | chain integrity card 显示 "verification unavailable" |
| `result` (ok/denied/failed) | 不在 UI 展示 |
| `reason` | 不在 UI 展示 |
| `actor.ip` / `actor.mfa` | 不在 UI 展示 |
| `actorType` | classifyActor 启发式前缀推断(见 `src/lib/auditClassify.ts`) |

后端补字段 + `pnpm codegen` 后,hash chain 校验将自动激活(verifyChain 已完整实现)。

## 提供给其他包的能力

- `useAuditStore` — audit log 查询 store(列表 / 分页 / 过滤 / 分组 / chain status)
- `AuditEntry` — 单条审计条目类型(codegen-derived)

## 内部模块(不在 exports,不可跨包直接引用)

- `src/api/audit.ts` — HTTP 层(listAudit)
- `src/lib/hashChain.ts` — 纯函数 verifyChain
- `src/lib/auditClassify.ts` — classifyActor + groupByDay
- `src/components/ActorPill.vue` — 行内角色装饰组件

## 测试

```bash
pnpm -F @gocell/audit test --run
pnpm -F @gocell/audit test:coverage --run
pnpm -F @gocell/audit typecheck
```

## 边界约束

- 禁 import `@gocell/access` / `@gocell/config` / `@gocell/observability` / `@gocell/devboard`
- 跨包只走 `@gocell/contracts` 类型 + `@gocell/request` client + `@gocell/core` UI 壳
- 禁深路径 `@gocell/x/src/...`
26 changes: 24 additions & 2 deletions packages/audit/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,34 @@
"private": true,
"type": "module",
"exports": {
".": "./src/index.ts"
".": "./src/index.ts",
"./stores": "./src/stores/index.ts",
"./views/audit": "./src/views/AuditView.vue"
},
"files": [
"src"
],
"scripts": {
"typecheck": "vue-tsc --noEmit"
"typecheck": "vue-tsc --noEmit",
"test": "vitest run",
"test:coverage": "vitest run --coverage"
},
"dependencies": {
"@gocell/core": "workspace:*",
"@gocell/request": "workspace:*",
"@gocell/contracts": "workspace:*",
"pinia": "catalog:",
"vue": "catalog:",
"vue-i18n": "catalog:",
"vue-router": "catalog:"
},
"devDependencies": {
"vitest": "catalog:",
"@pinia/testing": "catalog:",
"@vitejs/plugin-vue": "catalog:",
"@vue/test-utils": "catalog:",
"axios-mock-adapter": "catalog:",
"jsdom": "catalog:",
"@vitest/coverage-v8": "catalog:"
}
}
65 changes: 65 additions & 0 deletions packages/audit/src/api/audit.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
import MockAdapter from 'axios-mock-adapter'
import { http } from '@gocell/request'
import { listAudit, AUDIT_URL } from './audit'

const page = {
data: [
{
id: 'evt-001',
eventId: 'eid-001',
eventType: 'slice.merge',
actorId: 'dario@gocell.dev',
subjectId: 'auditcore/persist@a7f3',
correlationId: 'corr-abc',
occurredAt: '2026-06-01T12:48:55Z',
timestamp: '2026-06-01T12:48:56Z',
payload: { pr: 2241, sha: 'a7f37c1' },
},
],
nextCursor: 'cur-2',
hasMore: true,
}

describe('audit api · listAudit', () => {
let mock: MockAdapter

beforeEach(() => {
mock = new MockAdapter(http)
})

afterEach(() => {
mock.restore()
})

it('GETs the audit collection URL and returns the list envelope', async () => {
mock.onGet(AUDIT_URL).reply(200, page)
const res = await listAudit()
expect(res).toEqual(page)
})

it('forwards cursor + limit as query params', async () => {
let seenParams: Record<string, unknown> | undefined
mock.onGet(AUDIT_URL).reply((config) => {
seenParams = config.params as Record<string, unknown>
return [200, page]
})
await listAudit({ cursor: 'cur-1', limit: 25 })
expect(seenParams).toEqual({ cursor: 'cur-1', limit: 25 })
})

it('sends no query params when called without args', async () => {
let seenParams: Record<string, unknown> | undefined
mock.onGet(AUDIT_URL).reply((config) => {
seenParams = config.params as Record<string, unknown>
return [200, page]
})
await listAudit()
expect(seenParams).toEqual({})
})

it('rejects on network failure (interceptor maps the error upstream)', async () => {
mock.onGet(AUDIT_URL).networkError()
await expect(listAudit()).rejects.toThrow()
})
})
34 changes: 34 additions & 0 deletions packages/audit/src/api/audit.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
/**
* audit.ts — audit-query API (auditcore `auditquery` slice).
*
* MVP scope: cursor-paginated audit log list (HttpAuditListV1Response).
* The request schema is not yet codegen-derived (no backend request schema);
* ListAuditParams is defined locally and annotated for future cutover.
*/
import { http } from '@gocell/request'
import type { HttpAuditListV1Response } from '@gocell/contracts'

/** Collection endpoint for audit entries. */
export const AUDIT_URL = '/api/v1/audit/'

/**
* A single audit log entry. Derives directly from the codegen-produced
* response contract so the row type stays schema-bound.
*/
export type AuditEntry = HttpAuditListV1Response['data'][number]

/**
* Local request params type.
* Note: the backend has no audit list request schema yet; once codegen
* derives HttpAuditListV1Request, delete this interface and import it instead.
*/
export interface ListAuditParams {
cursor?: string
limit?: number
}

/** GET /audit/ — cursor-paginated audit entry list. */
export async function listAudit(params: ListAuditParams = {}): Promise<HttpAuditListV1Response> {
const res = await http.get<HttpAuditListV1Response>(AUDIT_URL, { params })
return res.data
}
43 changes: 43 additions & 0 deletions packages/audit/src/components/ActorPill.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import { describe, it, expect, vi } from 'vitest'
import { mount } from '@vue/test-utils'
import ActorPill from './ActorPill.vue'

vi.mock('vue-i18n', () => ({ useI18n: () => ({ t: (k: string) => k }) }))

describe('ActorPill', () => {
it('renders the actorId as the visible text', () => {
const wrapper = mount(ActorPill, { props: { actorId: 'dario@gocell.dev' } })
expect(wrapper.find('.pill__label').text()).toBe('dario@gocell.dev')
})

it('decorative dot is aria-hidden', () => {
const wrapper = mount(ActorPill, { props: { actorId: 'dario@gocell.dev' } })
const dot = wrapper.find('.pill__dot')
expect(dot.attributes('aria-hidden')).toBe('true')
})

it('human actor gets human variant', () => {
const wrapper = mount(ActorPill, { props: { actorId: 'dario@gocell.dev' } })
expect(wrapper.find('.pill').attributes('data-variant')).toBe('human')
})

it('sandbox actor (sb- prefix) gets sandbox variant', () => {
const wrapper = mount(ActorPill, { props: { actorId: 'sb-790' } })
expect(wrapper.find('.pill').attributes('data-variant')).toBe('sandbox')
})

it('service actor (sa: prefix) gets service variant', () => {
const wrapper = mount(ActorPill, { props: { actorId: 'sa:platform-bot' } })
expect(wrapper.find('.pill').attributes('data-variant')).toBe('service')
})

it('cell actor (core suffix) gets cell variant', () => {
const wrapper = mount(ActorPill, { props: { actorId: 'observecore' } })
expect(wrapper.find('.pill').attributes('data-variant')).toBe('cell')
})

it('unknown actor gets unknown variant', () => {
const wrapper = mount(ActorPill, { props: { actorId: 'some-random-id' } })
expect(wrapper.find('.pill').attributes('data-variant')).toBe('unknown')
})
})
75 changes: 75 additions & 0 deletions packages/audit/src/components/ActorPill.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
<script setup lang="ts">
/**
* ActorPill — decorative actor-kind badge for an audit log entry.
*
* a11y (WCAG 1.4.1 + 1.4.3): actor kind is conveyed by the text label
* (the full actorId), never by colour alone. The variant colour lives only on
* the decorative dot (`aria-hidden`). The label text is the raw actorId —
* actors that don't match a known prefix fall back to the 'unknown' variant
* but still show the full opaque id so the UI never hides information.
*
* Note: actor classification is a heuristic (see auditClassify.ts). Awaiting
* backend actorType field (BR-006).
*/
import { computed } from 'vue'
import { classifyActor, type ActorKind } from '../lib/auditClassify'

const props = defineProps<{ actorId: string }>()

const kind = computed<ActorKind>(() => classifyActor(props.actorId))
</script>

<template>
<span class="pill" :class="`pill--${kind}`" :data-variant="kind" :title="actorId">
<span class="pill__dot" aria-hidden="true" />
<span class="pill__label">{{ actorId }}</span>
</span>
</template>

<style scoped>
.pill {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 1px 9px;
font-size: 12px;
line-height: 18px;
color: var(--fg);
background: var(--bg-sunken);
border: 1px solid var(--line);
border-radius: var(--r-pill);
font-family: var(--font-mono);
max-width: 180px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}

.pill__dot {
width: 6px;
height: 6px;
flex-shrink: 0;
background: var(--fg-faint);
border-radius: var(--r-pill);
}

.pill--human .pill__dot {
background: var(--accent);
}

.pill--service .pill__dot {
background: var(--ok);
}

.pill--cell .pill__dot {
background: var(--fg-muted);
}

.pill--sandbox .pill__dot {
background: var(--warn);
}

.pill--unknown .pill__dot {
background: var(--fg-faint);
}
</style>
3 changes: 2 additions & 1 deletion packages/audit/src/index.ts
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
export {}
export { useAuditStore } from './stores'
export type { AuditEntry } from './api/audit'
Loading
Loading