支持财务当前页批量读取店铺密码 (#18)

This commit is contained in:
WuYongJie 2026-09-08 17:19:01 +08:00
parent 811926e67b
commit caa960d70a
7 changed files with 232 additions and 21 deletions

View File

@ -1,4 +1,4 @@
# 财务系统店铺密码按需读取契约
# 财务系统店铺密码直接显示契约
状态:代码和本地测试交付;生产迁移、配置、发布及真实接口验收须单独批准。
@ -11,7 +11,7 @@
- 2026-09-08 对 `furniture3d_prod` 的只读核验:`accounts_v1` COUNT=958、DISTINCT id=958;957 条非空密文均可在生产主机内存中解密,空值 1、解密失败 0。过程只输出聚合,不导出密码或密文。
- 普通 `store_account_reader` 可读视图、不能读取基表或密码列。此次迁移不增加其基表权限。
- 本接口只信任专用服务身份 `finance-store-accounts`。财务系统必须在自身后端校验用户登录态、查看/复制权限及店铺范围,然后从可信会话中取 `requesterId`。不得把客户端传入的任意人员 ID 当成操作者。服务凭据仅保存在财务后端,禁止交给浏览器。
- 不提供列表、批量密码导出或自动预取。列表只读取 `passwordConfigured`;每次明确的查看/复制操作单独调用接口。上游审计记录的用户 ID 是专用财务服务的身份断言,未授权请求中的 ID 不可信,`serviceId=null`。
- 不提供密码列表或批量导出。财务页面可在已通过自身页面权限校验后,自动读取当前可见页最多 50 个账号;CSV 和共享镜像仍只读取 `passwordConfigured`。复制操作继续走单账号接口。上游审计记录的用户 ID 是专用财务服务的身份断言,未授权请求中的 ID 不可信,`serviceId=null`。
## HTTP 契约
@ -19,6 +19,8 @@
`POST https://cp.jiansun.vip/internal/store-accounts/{id}/password/reveal`
`POST https://cp.jiansun.vip/internal/store-accounts/visible-page/password/reveal`
`id` 为 `accounts_v1.id`,仅接受单个 1–128 位字母、数字、下划线或连字符。无查询参数;禁止在 URL、请求体、请求头中传待读取密码。
请求头:
@ -36,12 +38,30 @@ Content-Type: application/json
`purpose` 仅允许 `view` 或 `copy`。`requesterId` 是财务会话中的稳定人员标识,1–128 位 `[A-Za-z0-9_.:@-]`。不接受批量 ID、查询参数、普通登录 Cookie/JWT、普通数据库凭据或其他内部任务密钥。
当前页批量请求严格只接受以下字段,正文最大 8192 字节;`accountIds` 为 1 至 50 个互不重复且符合单账号 ID 规则的值:
```json
{ "requesterId": "finance-user-123", "purpose": "view", "accountIds": ["account-1", "account-2"] }
```
成功 `200`:
```json
{ "requestId": "<服务端生成的 UUID>", "password": "<仅本次响应体中的密码>" }
```
当前页批量成功的 HTTP 状态为 `200`,每个账号分别返回 `200/404/429/503`。每项都有独立 UUID;只有状态 `200` 的项包含密码,并按请求顺序完整返回:
```json
{
"requestId": "<批次 UUID>",
"items": [
{ "accountId": "account-1", "requestId": "<项目 UUID>", "status": 200, "password": "<密码>" },
{ "accountId": "account-2", "requestId": "<项目 UUID>", "status": 404 }
]
}
```
所有状态均返回 `Cache-Control: no-store`、`Pragma: no-cache`、`X-Content-Type-Options: nosniff`。`X-Request-Id` 与响应体、审计的追踪 ID 相同。不会在响应头中放密码。
错误体:
@ -61,7 +81,7 @@ Content-Type: application/json
## 限流和超时
- 数据库滚动窗口:服务全部账号 30 次/分钟、300 次/24 小时;同一人员 10 次/分钟;同一账号 5 次/分钟。查看和复制共享额度,轮换令牌不重置服务身份或额度。
- 单账号数据库滚动窗口:服务全部账号 30 次/分钟、300 次/24 小时;同一人员 10 次/分钟;同一账号 5 次/分钟。当前页批次另按批次标记限制为服务 10 次/分钟、500 次/24 小时、同一人员 5 次/分钟;批次中的每个账号仍受同一账号 5 次/分钟限制。查看和复制共享账号额度,轮换令牌不重置服务身份或额度。
- 已认证的成功、404、参数拒绝和解密失败均占额度;429 不延长窗口。额度检查与审计在同一数据库事务中用事务级锁串行化,跨进程/重启共享状态。
- 429 不携带剩余数量或精确窗口,避免推测其他用户行为。客户端不得自动重试密码读取;提示用户稍后再试,持续超限联系管理员。
- 财务客户端设置总超时 10 秒、禁用自动重定向和重试。后端池上限 4、连接获取 1 秒、SQL 执行 1.5 秒/客户端查询 2 秒、锁等待 1 秒,路由运行预算 10 秒。网关必须配置请求体 1 秒和总请求截止,防止慢请求占用。
@ -77,6 +97,7 @@ Content-Type: application/json
| STORE_ACCOUNT_REVEAL_EDGE_TOKEN | 上游与可信 TLS 网关之间的独立随机凭据,base64url 43–128 字符;与财务令牌完全分离,不提供给财务系统;缺失时拒绝全部调用 |
| DATABASE_URL | 上游现有数据库连接;仅专用不输出查询/异常的连接池读取密文、写审计 |
| STORE_ACCOUNT_REVEAL_URL | 财务后端配置完整 HTTPS 地址模板 |
| STORE_ACCOUNT_REVEAL_VISIBLE_PAGE_URL | 财务后端配置当前页批量 HTTPS 地址;必须使用精确 `visible-page` 路径 |
| STORE_ACCOUNT_REVEAL_TOKEN | 财务后端保存与上游专用令牌对应的值 |
| STORE_REVEAL_TEST_ADMIN_URL | 仅本地集成测试;必须指向回环地址、有建测试库/角色权限的 PostgreSQL |
| STORE_REVEAL_TEST_POSTGRES_CONTAINER | 仅本地日志验收,指定本地 PostgreSQL 容器以只读扫描测试期间日志 |
@ -88,7 +109,7 @@ Content-Type: application/json
3. 对精确路径 `/internal/store-accounts/*/password/reveal` 禁止网关缓存、响应落盘缓冲、CDN缓存、请求/响应体及 Authorization 采集;包括 APM、WAF、异常上报、链路追踪和调试抓包。网关日志只允许时间、方法、路径(不带参数)、状态、耗时、追踪 ID。不要采集响应对象。
4. 网关自己的 403/404/429/503 也使用脱敏 JSON、`Cache-Control: no-store` 和追踪 ID;不得返回上游诊断页。禁止将该路由接入通用 `withApi`/request logger;目前路由直接返回白名单响应,不记录异常对象。
5. 数据库不执行明文解密,SQL 参数不包含明文密码。现有密码写入或密钥轮换时,关闭 PostgreSQL/pgAudit 的绑定参数及错误参数记录,防止记录密文。不要启用该连接池的 SQL 结果或参数追踪。
6. 财务后端同样禁用正文采集、缓存及异常对象打印。浏览器仅在用户点击后获取,避免 localStorage/sessionStorage/Service Worker 持久化;显示或复制完清除页面内存状态,不把密码放入通知、埋点或消息队列。
6. 财务后端同样禁用正文采集、缓存及异常对象打印。浏览器在进入已授权页面后只读取当前可见页,避免 localStorage/sessionStorage/Service Worker 持久化;页面隐藏或离开时清除页面密码,不把密码放入通知、埋点或消息队列。
## 视图与审计迁移
@ -121,6 +142,7 @@ Content-Type: application/json
## 本次验证记录
- 当前页批量扩展的服务、HTTP 路由和代理边界共 3 个测试套件、41 项测试通过;`npm run type-check` 通过。测试覆盖 1–50 个账号、重复账号拒绝、部分 404/429/503、批次与每账号审计,以及失败时不返回明文。
- 单元/HTTP/代理路由测试:34 项通过,含伪造 HTTPS URL/转发头和错误网关凭据拒绝。
- 追加现有代理路由的 7 项回归,与 10 项集成测试合计 6 套件、51 项全部通过。
- 隔离本地 PostgreSQL 集成测试:10 项通过,覆盖完整加解密与 HTTP handler、已提交审计、403/404/429/no-store、并发/跨实例限流、分钟/24小时配额、审计失败拒绝泄露、原字段及筛选保留、958/958 与 boolean 957/1/0、普通只读角色拒绝基表/密码/审计读取。

View File

@ -1,11 +1,15 @@
/** @jest-environment node */
import { randomBytes } from 'node:crypto'
import { randomBytes, randomUUID } from 'node:crypto'
import { POST, GET } from './route'
jest.mock('server-only', () => ({}))
const mockReveal = jest.fn()
const mockRevealVisiblePage = jest.fn()
jest.mock('@/server/services/store-account-password-reveal.service', () => ({
StoreAccountPasswordRevealService: jest.fn().mockImplementation(() => ({ reveal: (...args: unknown[]) => mockReveal(...args) })),
StoreAccountPasswordRevealService: jest.fn().mockImplementation(() => ({
reveal: (...args: unknown[]) => mockReveal(...args),
revealVisiblePage: (...args: unknown[]) => mockRevealVisiblePage(...args),
})),
}))
const path = 'https://example.test/internal/store-accounts/test-id/password/reveal'
@ -15,7 +19,7 @@ function request(url = path, body = JSON.stringify({ requesterId: 'u', purpose:
}
describe('secret HTTP response boundary', () => {
beforeEach(() => { mockReveal.mockReset(); delete process.env.STORE_ACCOUNT_REVEAL_EDGE_TOKEN })
beforeEach(() => { mockReveal.mockReset(); mockRevealVisiblePage.mockReset(); delete process.env.STORE_ACCOUNT_REVEAL_EDGE_TOKEN })
afterAll(() => { delete process.env.STORE_ACCOUNT_REVEAL_EDGE_TOKEN })
it.each([200, 403, 404, 429, 503])('sets no-store for status %i and places no credential in headers', async status => {
@ -85,4 +89,20 @@ describe('secret HTTP response boundary', () => {
await POST(request(path, body), context)
expect(mockReveal.mock.calls[0][0].body).toBeNull()
})
it('routes the reserved visible-page id to the bounded batch service without exposing passwords in headers', async () => {
const password = randomBytes(24).toString('base64url')
mockRevealVisiblePage.mockResolvedValue({ status: 200, items: [
{ accountId: 'account-1', requestId: randomUUID(), status: 200, password },
] })
const visible = request(path.replace('test-id', 'visible-page'), JSON.stringify({
requesterId: 'finance-user-1', purpose: 'view', accountIds: ['account-1'],
}))
const response = await POST(visible, { params: Promise.resolve({ id: 'visible-page' }) })
expect(response.status).toBe(200)
expect(mockReveal).not.toHaveBeenCalled()
expect(mockRevealVisiblePage).toHaveBeenCalledWith(expect.objectContaining({ post: true }))
expect(JSON.stringify([...response.headers]).includes(password)).toBe(false)
expect((await response.json()).items[0].password === password).toBe(true)
})
})

View File

@ -1,6 +1,7 @@
import { randomUUID } from 'node:crypto'
import { StoreAccountPasswordRevealService } from '@/server/services/store-account-password-reveal.service'
import { hasTrustedRevealTransport } from '@/server/services/store-account-password-reveal-transport'
import { VISIBLE_PAGE_ACCOUNT_ID } from '@/server/services/store-account-password-reveal.contract'
export const runtime = 'nodejs'
export const dynamic = 'force-dynamic'
@ -13,7 +14,7 @@ const errors = {
503: ['UNAVAILABLE', '服务暂不可用'],
} as const
async function readBody(request: Request): Promise<unknown> {
async function readBody(request: Request, maxBytes = 1024): Promise<unknown> {
if (request.method !== 'POST' || !/^application\/json(?:\s*;|$)/i.test(request.headers.get('content-type') || '')) return null
const reader = request.body?.getReader()
if (!reader) return null
@ -29,7 +30,7 @@ async function readBody(request: Request): Promise<unknown> {
const { done, value } = await reader.read()
if (done) break
size += value.byteLength
if (size > 1024) { await reader.cancel(); return null }
if (size > maxBytes) { await reader.cancel(); return null }
chunks.push(value)
}
return timedOut ? null : JSON.parse(Buffer.concat(chunks).toString('utf8'))
@ -40,16 +41,25 @@ export async function POST(request: Request, context: { params: Promise<{ id: st
const requestId = randomUUID()
const url = new URL(request.url)
const secure = hasTrustedRevealTransport(request.headers)
const result = await service.reveal({
accountId: (await context.params).id, body: await readBody(request),
authorization: request.headers.get('authorization'),
const accountId = (await context.params).id
const body = await readBody(request, accountId === VISIBLE_PAGE_ACCOUNT_ID ? 8192 : 1024)
const common = {
body, authorization: request.headers.get('authorization'),
secure: secure && !url.search, post: request.method === 'POST', requestId,
})
const headers = {
'Cache-Control': 'no-store', 'Pragma': 'no-cache',
'X-Content-Type-Options': 'nosniff', 'X-Request-Id': requestId,
'Strict-Transport-Security': 'max-age=31536000',
}
if (accountId === VISIBLE_PAGE_ACCOUNT_ID) {
const result = await service.revealVisiblePage(common)
const headers = responseHeaders(requestId)
if (result.status === 200) {
return Response.json({ requestId, items: result.items }, { status: 200, headers })
}
const [code, message] = errors[result.status as keyof typeof errors]
return Response.json({ requestId, error: { code, message } }, { status: result.status, headers })
}
const result = await service.reveal({
accountId, ...common,
})
const headers = responseHeaders(requestId)
if (result.status === 200) {
return Response.json({ requestId, password: result.password }, { status: 200, headers })
}
@ -57,6 +67,14 @@ export async function POST(request: Request, context: { params: Promise<{ id: st
return Response.json({ requestId, error: { code, message } }, { status: result.status, headers })
}
function responseHeaders(requestId: string) {
return {
'Cache-Control': 'no-store', 'Pragma': 'no-cache',
'X-Content-Type-Options': 'nosniff', 'X-Request-Id': requestId,
'Strict-Transport-Security': 'max-age=31536000',
}
}
export const GET = POST
export const PUT = POST
export const PATCH = POST

View File

@ -1,7 +1,8 @@
import 'server-only'
import { Pool, type PoolClient } from 'pg'
import {
REVEAL_LIMITS,
FINANCE_SERVICE_ID, REVEAL_LIMITS,
VISIBLE_PAGE_LIMITS,
type RevealAudit, type RevealRepository, type RevealTransaction,
} from '@/server/services/store-account-password-reveal.contract'
@ -41,6 +42,33 @@ class Transaction implements RevealTransaction {
.some(key => counts[key] >= REVEAL_LIMITS[key])
}
async limitedVisiblePage(event: RevealAudit) {
await this.client.query('SELECT pg_advisory_xact_lock(1937010546, 1)')
const { rows: [counts] } = await this.client.query<{
serviceMinute: number; serviceDay: number; requesterMinute: number
}>(`SELECT
count(*) FILTER (WHERE "createdAt" > clock_timestamp() - interval '1 minute')::int AS "serviceMinute",
count(*)::int AS "serviceDay",
count(*) FILTER (WHERE "createdAt" > clock_timestamp() - interval '1 minute' AND "requesterId" = $2)::int AS "requesterMinute"
FROM public."StoreAccountPasswordAccessAudit"
WHERE "serviceId" = $1 AND "accountId" IS NULL AND status <> 429
AND "createdAt" > clock_timestamp() - interval '24 hours'`,
[event.serviceId, event.requesterId])
return (Object.keys(VISIBLE_PAGE_LIMITS) as Array<keyof typeof VISIBLE_PAGE_LIMITS>)
.some(key => counts[key] >= VISIBLE_PAGE_LIMITS[key])
}
async limitedAccounts(accountIds: string[]) {
const { rows } = await this.client.query<{ accountId: string }>(`SELECT "accountId"
FROM public."StoreAccountPasswordAccessAudit"
WHERE "serviceId" = $1 AND status <> 429
AND "createdAt" > clock_timestamp() - interval '1 minute'
AND "accountId" = ANY($2::text[])
GROUP BY "accountId" HAVING count(*) >= $3`,
[FINANCE_SERVICE_ID, accountIds, REVEAL_LIMITS.accountMinute])
return new Set(rows.map(row => row.accountId))
}
async findPassword(accountId: string) {
const { rows } = await this.client.query<{ passwordCiphertext: string }>(
'SELECT "passwordCiphertext" FROM public."StoreAccount" WHERE id = $1', [accountId],
@ -48,6 +76,13 @@ class Transaction implements RevealTransaction {
return rows[0]?.passwordCiphertext || null
}
async findPasswords(accountIds: string[]) {
const { rows } = await this.client.query<{ id: string; passwordCiphertext: string }>(
'SELECT id, "passwordCiphertext" FROM public."StoreAccount" WHERE id = ANY($1::text[])', [accountIds],
)
return new Map(rows.filter(row => row.passwordCiphertext).map(row => [row.id, row.passwordCiphertext]))
}
async audit(event: RevealAudit) {
// Explicit allowlist: never spread request, response, account record or exception.
await this.client.query(`INSERT INTO public."StoreAccountPasswordAccessAudit"
@ -55,6 +90,10 @@ class Transaction implements RevealTransaction {
VALUES ($1, $2, $3, $4, $5, $6)`,
[event.requestId, event.requesterId, event.serviceId, event.accountId, event.purpose, event.status])
}
async auditMany(events: RevealAudit[]) {
for (const event of events) await this.audit(event)
}
}
export class StoreAccountPasswordRevealRepository implements RevealRepository {

View File

@ -5,9 +5,17 @@ export const revealInputSchema = z.object({
purpose: z.enum(['view', 'copy']),
}).strict()
export const accountIdSchema = z.string().regex(/^[A-Za-z0-9_-]{1,128}$/)
export const revealVisiblePageInputSchema = z.object({
requesterId: z.string().regex(/^[A-Za-z0-9_.:@-]{1,128}$/),
purpose: z.literal('view'),
accountIds: z.array(accountIdSchema).min(1).max(50)
.refine(ids => new Set(ids).size === ids.length),
}).strict()
export const FINANCE_SERVICE_ID = 'finance-store-accounts'
export const VISIBLE_PAGE_ACCOUNT_ID = 'visible-page'
export const REVEAL_LIMITS = { serviceMinute: 30, serviceDay: 300, requesterMinute: 10, accountMinute: 5 } as const
export const VISIBLE_PAGE_LIMITS = { serviceMinute: 10, serviceDay: 500, requesterMinute: 5 } as const
export type RevealInput = z.infer<typeof revealInputSchema>
export type RevealStatus = 200 | 403 | 404 | 429 | 503
export type RevealAudit = {
@ -19,10 +27,16 @@ export type RevealAudit = {
status: RevealStatus
}
export type RevealResult = { status: RevealStatus; password?: string }
export type RevealVisiblePageItem = { accountId: string; requestId: string; status: RevealStatus; password?: string }
export type RevealVisiblePageResult = { status: RevealStatus; items?: RevealVisiblePageItem[] }
export interface RevealTransaction {
limited(audit: RevealAudit): Promise<boolean>
limitedVisiblePage(audit: RevealAudit): Promise<boolean>
limitedAccounts(accountIds: string[]): Promise<Set<string>>
findPassword(accountId: string): Promise<string | null>
findPasswords(accountIds: string[]): Promise<Map<string, string>>
audit(event: RevealAudit): Promise<void>
auditMany(events: RevealAudit[]): Promise<void>
}
export interface RevealRepository {
transaction<T>(run: (tx: RevealTransaction) => Promise<T>): Promise<T>

View File

@ -24,8 +24,14 @@ describe('finance password reveal security boundary', () => {
events = []
tx = {
limited: jest.fn().mockResolvedValue(false),
limitedVisiblePage: jest.fn().mockResolvedValue(false),
limitedAccounts: jest.fn().mockResolvedValue(new Set()),
findPassword: jest.fn().mockResolvedValue(encryptStoreAccountPassword(syntheticPassword)),
findPasswords: jest.fn().mockResolvedValue(new Map([
['synthetic-account', encryptStoreAccountPassword(syntheticPassword)],
])),
audit: jest.fn(async event => { events.push(event) }),
auditMany: jest.fn(async batch => { events.push(...batch) }),
}
service = new StoreAccountPasswordRevealService({ transaction: run => run(tx) })
})
@ -76,6 +82,46 @@ describe('finance password reveal security boundary', () => {
expect((await service.reveal(request())).status).toBe(403)
})
it('returns up to one visible page with a batch marker and one password-free audit per account', async () => {
const result = await service.revealVisiblePage({
body: { requesterId: 'finance-user-1', purpose: 'view', accountIds: ['synthetic-account'] },
authorization: `Bearer ${token}`, secure: true, post: true, requestId: randomUUID(),
})
expect(result.status).toBe(200)
expect(result.items).toHaveLength(1)
expect(result.items?.[0].password === syntheticPassword).toBe(true)
expect(tx.findPasswords).toHaveBeenCalledWith(['synthetic-account'])
expect(events).toHaveLength(2)
expect(events[0].accountId).toBeNull()
expect(events[1]).toMatchObject({ accountId: 'synthetic-account', status: 200 })
expect(JSON.stringify(events).includes(syntheticPassword)).toBe(false)
})
it('returns per-account not-found and rate-limit states without exposing another account password', async () => {
tx.limitedAccounts.mockResolvedValue(new Set(['limited-account']))
tx.findPasswords.mockResolvedValue(new Map())
const result = await service.revealVisiblePage({
body: { requesterId: 'finance-user-1', purpose: 'view', accountIds: ['limited-account', 'missing-account'] },
authorization: `Bearer ${token}`, secure: true, post: true, requestId: randomUUID(),
})
expect(result).toMatchObject({ status: 200, items: [
{ accountId: 'limited-account', status: 429 }, { accountId: 'missing-account', status: 404 },
] })
expect(tx.findPasswords).toHaveBeenCalledWith(['missing-account'])
})
it.each([
{ accountIds: [] }, { accountIds: ['a', 'a'] }, { accountIds: ['../a'] },
{ accountIds: Array.from({ length: 51 }, (_, index) => `a-${index}`) },
])('rejects invalid visible-page batches without selecting credentials (%#)', async fields => {
const result = await service.revealVisiblePage({
body: { requesterId: 'finance-user-1', purpose: 'view', ...fields },
authorization: `Bearer ${token}`, secure: true, post: true, requestId: randomUUID(),
})
expect(result.status).toBe(403)
expect(tx.findPasswords).not.toHaveBeenCalled()
})
it.each([null, ''])('returns the same 404 for missing or empty passwords', async value => {
tx.findPassword.mockResolvedValue(value)
expect((await service.reveal(request())).status).toBe(404)

View File

@ -1,10 +1,10 @@
import 'server-only'
import { createHash, timingSafeEqual } from 'node:crypto'
import { createHash, randomUUID, timingSafeEqual } from 'node:crypto'
import { decryptStoreAccountPassword } from '@/lib/store-account-crypto'
import { StoreAccountPasswordRevealRepository } from '@/server/repositories/store-account-password-reveal.repository'
import {
accountIdSchema, FINANCE_SERVICE_ID, revealInputSchema,
type RevealAudit, type RevealRepository, type RevealResult,
accountIdSchema, FINANCE_SERVICE_ID, revealInputSchema, revealVisiblePageInputSchema,
type RevealAudit, type RevealRepository, type RevealResult, type RevealVisiblePageResult,
} from './store-account-password-reveal.contract'
function authenticate(authorization: string | null) {
@ -68,4 +68,56 @@ export class StoreAccountPasswordRevealService {
return { status: 503 }
}
}
async revealVisiblePage(input: {
body: unknown; authorization: string | null
secure: boolean; post: boolean; requestId: string
}): Promise<RevealVisiblePageResult> {
const body = revealVisiblePageInputSchema.safeParse(input.body)
const authenticated = authenticate(input.authorization)
const batchEvent: RevealAudit = {
requestId: input.requestId,
requesterId: body.success ? body.data.requesterId : null,
serviceId: authenticated ? FINANCE_SERVICE_ID : null,
accountId: null,
purpose: body.success ? 'view' : 'unknown', status: 403,
}
try {
return await this.repository.transaction(async tx => {
let result: RevealVisiblePageResult = { status: 403 }
if (authenticated && await tx.limitedVisiblePage(batchEvent)) {
result = { status: 429 }
} else if (authenticated && input.secure && input.post && body.success) {
const limited = await tx.limitedAccounts(body.data.accountIds)
const allowed = body.data.accountIds.filter(id => !limited.has(id))
const encrypted = await tx.findPasswords(allowed)
const items = body.data.accountIds.map(accountId => {
const requestId = randomUUID()
if (limited.has(accountId)) return { accountId, requestId, status: 429 as const }
const ciphertext = encrypted.get(accountId)
if (!ciphertext) return { accountId, requestId, status: 404 as const }
try {
const password = this.decrypt(ciphertext)
return password
? { accountId, requestId, status: 200 as const, password }
: { accountId, requestId, status: 404 as const }
} catch { return { accountId, requestId, status: 503 as const } }
})
await tx.auditMany([
{ ...batchEvent, status: 200 },
...items.map(item => ({
requestId: item.requestId, requesterId: body.data.requesterId,
serviceId: FINANCE_SERVICE_ID, accountId: item.accountId,
purpose: 'view' as const, status: item.status,
})),
])
return { status: 200, items }
}
await tx.audit({ ...batchEvent, status: result.status })
return result
})
} catch {
return { status: 503 }
}
}
}