Allow three OAuth consent levels and cache agent permission checks
This commit is contained in:
1 parent
794274d31b
commit
daf9b458af
10 files changed
+245
-22
No files matched your search
@@ -150,3 +150,5 @@ MCP 同一连接、同一账目快照下的多个新增账号草稿可依次审
|
||||
独立资产支持现金分类和快速估值。登录页可记住账号并交由浏览器密码管理器保存密码。草稿弹窗支持全量分页、批量同意与取消,提交失败整批回滚。详见 [现金、草稿审阅与登录更新](docs/update-cash-drafts-login-2026-10-05.md)。
|
||||
|
||||
已授权的 OAuth 与个人令牌可在“我的连接”修改只读、草稿或直接写入权限和隐藏账户权限,验证当前密码后生效;不延长授权期限,实际变更会取消该连接未确认草稿。MCP `movement_create(operation=repay)` 支持信用卡等负债账户,并保留真正还款类型及优惠、编辑和撤销语义。详见 [连接权限、还款与账号删除更新](docs/update-connections-repayment-users-2026-10-05.md)。
|
||||
|
||||
OAuth 授权页始终提供只读、草稿和直接写入三档,普通用户可明确选择最终权限。Agent 教程和 Codex 配置提示词要求连接时核对一次当前权限并缓存,发生重连、权限变更通知、授权错误或缓存不足时才再次检查。详见 [OAuth 三档选择与权限缓存](docs/update-oauth-permissions-2026-10-05.md)。
|
||||
@@ -82,6 +82,7 @@ async function main() {
|
||||
'test/admin-integration.test.ts',
|
||||
'test/mcp.test.ts',
|
||||
'test/oauth-duration.test.ts',
|
||||
'test/oauth-permissions.test.ts',
|
||||
'test/database-migrations.test.ts',
|
||||
'test/connection-repayment-integration.test.ts',
|
||||
];
|
||||
|
||||
@@ -155,8 +155,10 @@ export class AgentManagementController {
|
||||
});
|
||||
return { ok: true };
|
||||
}
|
||||
@Get('authorizations/:id') pending(@Param('id') id: string) {
|
||||
return this.oauth.pending(z.string().uuid().parse(id));
|
||||
@Get('authorizations/:id') async pending(@Req() r: UserRequest, @Param('id') id: string) {
|
||||
const result = await this.oauth.pending(z.string().uuid().parse(id));
|
||||
const user = await this.db.user.findUniqueOrThrow({ where: { id: r.userId } });
|
||||
return { ...result, canWrite: user.role !== 'readonly' };
|
||||
}
|
||||
@Post('authorizations/:id') async consent(
|
||||
@Req() r: UserRequest,
|
||||
|
||||
@@ -162,6 +162,12 @@ export class AgentOAuth implements OAuthServerProvider {
|
||||
name: client?.client_name,
|
||||
redirectUri: p.redirectUri,
|
||||
scopes: p.scopes,
|
||||
availableScopes: [
|
||||
'read',
|
||||
'draft',
|
||||
'write',
|
||||
...p.scopes.filter((s: string) => s.startsWith('hidden_')),
|
||||
],
|
||||
resource: p.resource,
|
||||
};
|
||||
}
|
||||
@@ -188,8 +194,14 @@ export class AgentOAuth implements OAuthServerProvider {
|
||||
allowed.some((s: string) => ['draft', 'write', 'hidden_write'].includes(s))
|
||||
)
|
||||
throw new ForbiddenException('只读账号只能授予查询权限');
|
||||
if (allowed.some((scope: string) => !parameters.scopes.includes(scope)))
|
||||
throw new BadRequestException('不能授予客户端未请求的权限');
|
||||
// The owner explicitly chooses the ordinary permission level on the consent page.
|
||||
// Hidden access still requires a corresponding client request and separate consent.
|
||||
if (
|
||||
allowed.some(
|
||||
(scope: string) => scope.startsWith('hidden_') && !parameters.scopes.includes(scope),
|
||||
)
|
||||
)
|
||||
throw new BadRequestException('不能授予客户端未请求的隐藏权限');
|
||||
const code = secret();
|
||||
const changed = await this.db.agentAuthorization.updateMany({
|
||||
where: { id, status: 'pending', expiresAt: { gt: new Date() } },
|
||||
|
||||
@@ -0,0 +1,157 @@
|
||||
import 'dotenv/config';
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { randomBytes, randomUUID, createHash } from 'node:crypto';
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
|
||||
test(
|
||||
'OAuth read-only requests offer all three ordinary levels; explicit owner consent determines token scopes',
|
||||
{ skip: process.env.TEST_ISOLATED !== 'true' },
|
||||
async () => {
|
||||
const db = new PrismaClient(),
|
||||
ids: string[] = [];
|
||||
const base = process.env.TEST_API_URL!,
|
||||
root = base.replace(/\/api$/, '');
|
||||
const resource = process.env.MCP_PUBLIC_URL!,
|
||||
redirect = 'http://127.0.0.1:47891/callback';
|
||||
const clientId = randomUUID();
|
||||
async function web(cookie: string, id: string, body?: unknown) {
|
||||
const r = await fetch(base + '/agent/authorizations/' + id, {
|
||||
method: body ? 'POST' : 'GET',
|
||||
headers: {
|
||||
Cookie: cookie,
|
||||
Origin: process.env.WEB_ORIGIN!,
|
||||
...(body ? { 'Content-Type': 'application/json' } : {}),
|
||||
},
|
||||
body: body ? JSON.stringify(body) : undefined,
|
||||
});
|
||||
return { status: r.status, data: await r.json() };
|
||||
}
|
||||
async function request(scopes = ['read']) {
|
||||
const id = randomUUID(),
|
||||
verifier = randomBytes(32).toString('base64url');
|
||||
await db.agentAuthorization.create({
|
||||
data: {
|
||||
id,
|
||||
clientId,
|
||||
expiresAt: new Date(Date.now() + 600000),
|
||||
parameters: {
|
||||
redirectUri: redirect,
|
||||
resource,
|
||||
scopes,
|
||||
codeChallenge: createHash('sha256').update(verifier).digest('base64url'),
|
||||
},
|
||||
},
|
||||
});
|
||||
return { id, verifier };
|
||||
}
|
||||
try {
|
||||
await db.agentClient.create({
|
||||
data: {
|
||||
id: clientId,
|
||||
metadata: {
|
||||
client_id: clientId,
|
||||
client_name: 'Three permissions regression',
|
||||
redirect_uris: [redirect],
|
||||
token_endpoint_auth_method: 'none',
|
||||
grant_types: ['authorization_code', 'refresh_token'],
|
||||
},
|
||||
},
|
||||
});
|
||||
for (const role of ['user', 'readonly'] as const) {
|
||||
const u = await db.user.create({
|
||||
data: { username: 'oauth_levels_' + randomUUID(), passwordHash: 'unused', role },
|
||||
});
|
||||
ids.push(u.id);
|
||||
const session = randomBytes(32).toString('hex');
|
||||
await db.session.create({
|
||||
data: {
|
||||
id: createHash('sha256').update(session).digest('hex'),
|
||||
userId: u.id,
|
||||
expiresAt: new Date(Date.now() + 3600000),
|
||||
},
|
||||
});
|
||||
const cookie = 'wp_session=' + session;
|
||||
for (const level of ['read', 'draft', 'write']) {
|
||||
const a = await request();
|
||||
const preview = await web(cookie, a.id);
|
||||
assert.equal(preview.status, 200);
|
||||
assert.deepEqual(preview.data.scopes, ['read']);
|
||||
assert.deepEqual(preview.data.availableScopes, ['read', 'draft', 'write']);
|
||||
assert.equal(preview.data.canWrite, role !== 'readonly');
|
||||
const selected = level === 'read' ? ['read'] : ['read', level];
|
||||
const approved = await web(cookie, a.id, { approve: true, scopes: selected, days: 7 });
|
||||
if (role === 'readonly' && level !== 'read') {
|
||||
assert.equal(approved.status, 403);
|
||||
assert.equal(
|
||||
(await db.agentAuthorization.findUniqueOrThrow({ where: { id: a.id } })).status,
|
||||
'pending',
|
||||
);
|
||||
continue;
|
||||
}
|
||||
assert.equal(approved.status, 201, JSON.stringify(approved.data));
|
||||
const code = new URL(approved.data.redirect).searchParams.get('code')!;
|
||||
const response = await fetch(root + '/token', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: new URLSearchParams({
|
||||
client_id: clientId,
|
||||
grant_type: 'authorization_code',
|
||||
code,
|
||||
code_verifier: a.verifier,
|
||||
redirect_uri: redirect,
|
||||
resource,
|
||||
}),
|
||||
});
|
||||
const token = await response.json();
|
||||
assert.equal(response.status, 200, JSON.stringify(token));
|
||||
assert.equal(token.scope, selected.join(' '));
|
||||
const row = await db.agentGrant.findFirstOrThrow({
|
||||
where: {
|
||||
userId: u.id,
|
||||
accessDigest: createHash('sha256').update(token.access_token).digest('hex'),
|
||||
},
|
||||
});
|
||||
assert.deepEqual(row.scopes, selected);
|
||||
}
|
||||
if (role === 'user') {
|
||||
const noHidden = await request();
|
||||
assert.equal(
|
||||
(
|
||||
await web(cookie, noHidden.id, {
|
||||
approve: true,
|
||||
scopes: ['read', 'write', 'hidden_read'],
|
||||
})
|
||||
).status,
|
||||
400,
|
||||
);
|
||||
const requestedHidden = await request(['read', 'hidden_read', 'hidden_write']);
|
||||
assert.equal(
|
||||
(
|
||||
await web(cookie, requestedHidden.id, {
|
||||
approve: true,
|
||||
scopes: ['read', 'write', 'hidden_read', 'hidden_write'],
|
||||
})
|
||||
).status,
|
||||
201,
|
||||
);
|
||||
const defaults = await request();
|
||||
assert.equal((await web(cookie, defaults.id, { approve: true })).status, 201);
|
||||
assert.deepEqual(
|
||||
(await db.agentAuthorization.findUniqueOrThrow({ where: { id: defaults.id } }))
|
||||
.parameters &&
|
||||
(
|
||||
(await db.agentAuthorization.findUniqueOrThrow({ where: { id: defaults.id } }))
|
||||
.parameters as any
|
||||
).scopes,
|
||||
['read'],
|
||||
);
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
await db.user.deleteMany({ where: { id: { in: ids } } });
|
||||
await db.agentClient.deleteMany({ where: { id: clientId } });
|
||||
await db.$disconnect();
|
||||
}
|
||||
},
|
||||
);
|
||||
@@ -57,7 +57,7 @@ function instructions(url: string) {
|
||||
'以下是配置 MCP 连接后的操作说明,不会自动添加连接或授予工具。若本会话没有 WorthPath 工具,请先告知我,并指导我在客户端配置连接;不要仅凭网址或聊天中的令牌声称已连接。',
|
||||
'请使用已连接的 WorthPath MCP 服务处理我的资产与负债,服务地址:' + url,
|
||||
'接入使用 Streamable HTTP。支持 OAuth 的客户端使用该地址发现授权服务,并由我在 WorthPath 网页登录授权;个人令牌仅用于明确支持 Bearer 认证的客户端,按其文档配置环境变量或凭据存储。不要假设客户端有自定义请求头界面,不要让我把密码或令牌粘贴到对话中。localhost 仅适用于 MCP 请求在 WorthPath 所在电脑执行的客户端;云端客户端需要可访问的 HTTPS 地址。',
|
||||
'先 tools/list 发现工具,调用 connection_info 核对权限:read 只读,draft 创建待网页确认的草稿,write 直接普通写入。隐藏账户读写以连接附加授权为准。',
|
||||
'连接时先 tools/list 发现真实工具,再调用一次 connection_info 核对当前 resource、permission、scopes 和隐藏账户读写权限,并在本次连接中缓存结果。权限可能被我在网页修改,以服务器返回的当前权限为准,不把以前申请或聊天中的权限当作永久授权。read 只读,draft 创建待网页确认的草稿,write 直接普通写入。无需每次查询或写入前重复检查;仅在重新连接或授权、我告知权限已变更、遇到权限/授权错误,或当前缓存不足以判断能否执行任务时,再检查一次并更新缓存。不得自动扩大权限或绕过确认;不匹配预期时报告实际差异。',
|
||||
'查询:用 positions_list 搜索对象;需要选定对象且遇到同名时先让我选择稳定 UUID;按 limit/offset 或 cursor 完整翻页。金额和克数使用十进制字符串,业务时间是 UTC+8 的 YYYY-MM-DD 或 YYYY-MM-DDTHH:mm。不同币种不能直接相加;明确折算币种、汇率日期、统计纳入范围及缺失汇率或估值。不擅自排除测试项目或其他项目;若提供剔除后的参考汇总,同时说明系统实际汇总与剔除项,不修改纳入设置。',
|
||||
'写入:先 state_get 取得 state,使用 expectedState 与唯一 idempotencyKey 调用所需工具。网络重试保持键和全部参数一致;状态冲突则重新查询并换键。不要把估值更新、转账、还款或负债变化互相替代。',
|
||||
'若返回 pending,把 confirmationUrl 给我,由我在网页审阅并确认;用 operation_get 轮询最终结果,不能用 confirmed=true 代替人类确认,也不能把草稿称为已完成。',
|
||||
@@ -97,7 +97,7 @@ function codexSetupPrompt(url: string, level: string, hiddenRead = false, hidden
|
||||
selected +
|
||||
' --oauth-client-registration dcr 发起网页登录。我会自行登录并确认权限。等待回调时保留登录进程;需要等待我完成网页步骤时,明确告诉我当前状态。',
|
||||
'若授权链接包含 read,draft,write,hidden_read,hidden_write 等较宽范围,这是客户端候选范围,不代表最终授权。WorthPath 网页支持收窄,服务端按网页选择发行令牌。打开正确 WorthPath 资源的授权页供我审阅;由我选择本次指定的权限等级、隐藏账户读写选项和授权期限并确认。不要仅因候选范围较宽就终止登录;最终以 connection_info 返回的实际权限为准。',
|
||||
'分别验证连接配置正确且启用、OAuth 登录成功、真实 MCP 工具调用成功,三者不能互相替代。先通过 tools/list 或客户端暴露的工具目录发现真实工具,再调用 connection_info。resource 必须为 ' +
|
||||
'分别验证连接配置正确且启用、OAuth 登录成功、真实 MCP 工具调用成功,三者不能互相替代。首次连接通过 tools/list 或客户端暴露的工具目录发现真实工具,再调用一次 connection_info 核对并缓存当前授权。resource 必须为 ' +
|
||||
url +
|
||||
',permission 必须为 ' +
|
||||
level +
|
||||
@@ -107,9 +107,9 @@ function codexSetupPrompt(url: string, level: string, hiddenRead = false, hidden
|
||||
hiddenRead +
|
||||
'、writeHidden=' +
|
||||
requestedHiddenWrite +
|
||||
'。若结果不符,停止使用该连接并报告差异,不修改账目。若当前会话不会重新加载工具,请告诉我重启客户端或新建本机会话后继续验证。只有配置、OAuth 成功和工具调用成功都验证后才能说已可用,不把 codex mcp list 或网页登录成功当作工具调用成功。',
|
||||
'。若结果不符,停止使用该连接并报告差异,不修改账目。若当前会话不会重新加载工具,请告诉我重启客户端或新建本机会话后继续验证。只有配置、OAuth 成功和工具调用成功都验证后才能说已可用,不把 codex mcp list 或网页登录成功当作工具调用成功。此处申请范围用于首次配置验收;之后权限可能由我在网页更改,按当前服务器授权判断可执行范围。无需每次操作前重复检查,仅在重新连接/授权、我告知变更、权限错误或缓存不足时再检查一次。',
|
||||
'保存跨对话使用规则:在实际 CODEX_HOME(默认 ~/.codex)的全局指令中,先检查 AGENTS.override.md 与 AGENTS.md 的加载优先级,在会被加载的文件中新增或更新 <!-- worthpath-mcp-preference:start --> 与 <!-- worthpath-mcp-preference:end --> 标记块。保留已有内容,只更新此块;不要创建会遮蔽其他全局指令的 override 文件,不重复添加规则。',
|
||||
'全局规则应保存本次服务地址、申请的权限等级、scopes 和隐藏账户读写选项,以及下面的查询和写入操作规则。每个新会话先发现真实工具,再通过 connection_info 核对资源及本次授权,若不符停止使用并报告差异。用户提出个人余额、账户、资产、负债、净资产、历史、转账、借贷、还款或其他 WorthPath 任务时主动优先调用 worthpath MCP,不要求重复指定,不把旧聊天余额当作当前数据。这项偏好仅决定工具选择;只有用户提出具体写入任务后才执行,不自动授权账目修改。',
|
||||
'全局规则应保存本次服务地址、申请的权限等级、scopes 和隐藏账户读写选项,以及下面的查询和写入操作规则。每个新会话连接时先发现真实工具,再调用一次 connection_info 核对并缓存当前资源、权限、scopes 与隐藏读写;与本次明确预期不符时停止使用并报告差异。权限可能在网页被更改,旧申请和旧聊天不代表当前授权。无需每次操作前重复检查;只在重新连接/授权、用户告知权限变更、权限错误或缓存不足时再检查一次并更新缓存。用户提出个人余额、账户、资产、负债、净资产、历史、转账、借贷、还款或其他 WorthPath 任务时主动优先调用 worthpath MCP,不要求重复指定,不把旧聊天余额当作当前数据。这项偏好仅决定工具选择;只有用户提出具体写入任务后才执行,不自动授权账目修改。',
|
||||
'工具未加载、服务不可达或授权失效时如实说明,必要时提示重新授权、重启客户端或新建本机会话,不擅自改用 UI/API 写入。不记录密码、令牌、账户余额或其他财务明细到全局指令中。保存后验证文件内容与加载条件,说明规则适用于这台电脑使用同一 Codex 配置的本机会话,不声称自动适用于其他电脑或云端会话。',
|
||||
...instructions(url).split('\n\n').slice(3),
|
||||
'完成后简要报告连接配置、OAuth、真实工具验证和全局规则保存的实际结果及剩余步骤。在我提出具体账目任务前,不修改任何数据。',
|
||||
|
||||
@@ -13,6 +13,8 @@ type Consent = {
|
||||
id: string;
|
||||
name: string;
|
||||
scopes: string[];
|
||||
availableScopes: string[];
|
||||
canWrite: boolean;
|
||||
redirectUri: string;
|
||||
resource: string;
|
||||
};
|
||||
@@ -75,14 +77,20 @@ export function AgentAuthorization({ id }: { id: string | null }) {
|
||||
active = false;
|
||||
};
|
||||
}, [id, reload]);
|
||||
const selected =
|
||||
consent?.scopes.filter(
|
||||
(s) =>
|
||||
s === 'read' ||
|
||||
s === level ||
|
||||
(s === 'hidden_read' && hiddenRead) ||
|
||||
(s === 'hidden_write' && hiddenWrite && hiddenRead && level !== 'read'),
|
||||
) || [];
|
||||
const selected = consent
|
||||
? [
|
||||
'read',
|
||||
...(level !== 'read' && consent.canWrite ? [level] : []),
|
||||
...(hiddenRead && consent.scopes.includes('hidden_read') ? ['hidden_read'] : []),
|
||||
...(hiddenWrite &&
|
||||
hiddenRead &&
|
||||
level !== 'read' &&
|
||||
consent.canWrite &&
|
||||
consent.scopes.includes('hidden_write')
|
||||
? ['hidden_write']
|
||||
: []),
|
||||
]
|
||||
: [];
|
||||
async function decide(approve: boolean) {
|
||||
if (!consent || busy) return;
|
||||
setBusy(true);
|
||||
@@ -116,12 +124,12 @@ export function AgentAuthorization({ id }: { id: string | null }) {
|
||||
['draft', '草稿修改', '提出修改,由你逐项确认后执行。'],
|
||||
['write', '直接写入', '普通账目修改立即执行,无需逐次确认。'],
|
||||
]
|
||||
.filter(([key]) => consent.scopes.includes(key))
|
||||
.filter(([key]) => consent.availableScopes.includes(key))
|
||||
.map(([key, title, detail]) => (
|
||||
<button
|
||||
key={key}
|
||||
className={level === key ? 'permission-choice selected' : 'permission-choice'}
|
||||
disabled={busy}
|
||||
disabled={busy || (key !== 'read' && !consent.canWrite)}
|
||||
aria-pressed={level === key}
|
||||
onClick={() => {
|
||||
setLevel(key);
|
||||
@@ -133,6 +141,12 @@ export function AgentAuthorization({ id }: { id: string | null }) {
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{level === 'write' && (
|
||||
<p className="agent-warning">
|
||||
直接写入会立即修改账目,无需逐次网页确认。请仅授予你信任的助手。
|
||||
</p>
|
||||
)}
|
||||
{!consent.canWrite && <p className="muted">你的账号为只读用户,只能授予查询权限。</p>}
|
||||
<label className="agent-duration">
|
||||
授权有效期
|
||||
<select value={days} disabled={busy} onChange={(e) => setDays(e.target.value)}>
|
||||
@@ -170,7 +184,7 @@ export function AgentAuthorization({ id }: { id: string | null }) {
|
||||
<label className="check-line">
|
||||
<input
|
||||
type="checkbox"
|
||||
disabled={busy || !hiddenRead || level === 'read'}
|
||||
disabled={busy || !consent.canWrite || !hiddenRead || level === 'read'}
|
||||
checked={hiddenWrite}
|
||||
onChange={(e) => setHiddenWrite(e.target.checked)}
|
||||
/>
|
||||
@@ -200,12 +214,14 @@ export function AgentAuthorization({ id }: { id: string | null }) {
|
||||
<dd>{consent.resource}</dd>
|
||||
<dt>回调地址</dt>
|
||||
<dd>{consent.redirectUri}</dd>
|
||||
<dt>候选权限</dt>
|
||||
<dt>客户端申请权限</dt>
|
||||
<dd>{consent.scopes.join(', ')}</dd>
|
||||
<dt>最终权限代码</dt>
|
||||
<dd>{selected.join(', ')}</dd>
|
||||
</dl>
|
||||
<p>客户端请求的是候选范围;最终仅授予上方选中的权限。只批准你正在连接的客户端。</p>
|
||||
<p>
|
||||
普通权限等级由你在此选择,可以高于客户端申请范围;隐藏访问仅限客户端已申请的范围。最终仅授予上方明确选中的权限。只批准你正在连接并信任的客户端。
|
||||
</p>
|
||||
</details>
|
||||
<div className="modal-actions">
|
||||
<button className="secondary" disabled={busy} onClick={() => void decide(false)}>
|
||||
|
||||
+5
-1
@@ -126,7 +126,7 @@ cd E:\WorthPath
|
||||
|
||||
### 客户端请求范围与最终授权
|
||||
|
||||
本机 Codex CLI 0.160.0 实测:显式 login --scopes read,draft 只请求两项;不指定 scopes 的默认登录请求全部五项。请求范围是候选权限,不是已经授予的权限。授权页可选择只读、草稿或直接写入;隐藏读写默认关闭。页面分别展示候选和最终范围,服务端验证选择是请求子集,再用所选 scopes 覆盖待处理参数并发行令牌。
|
||||
本机 Codex CLI 0.160.0 实测:显式 login --scopes read,draft 只请求两项;不指定 scopes 的默认登录请求全部五项。请求范围是候选权限,不是已经授予的权限。授权页可选择只读、草稿或直接写入;隐藏读写默认关闭。页面分别展示候选和最终范围,2026-10-05 起,普通权限等级由用户在网页明确选择,可高于客户端申请范围;隐藏权限仍限于客户端申请范围。服务端用最终选择的 scopes 覆盖待处理参数并发行令牌。
|
||||
|
||||
申请草稿模式时选择“草稿修改(网页确认)”,不勾选隐藏账户;最终将授予应显示 read,draft。授权后用 connection_info 核对实际 scopes 仅有两项、readHidden/writeHidden 均 false。不要仅因默认请求包含全部候选权限就停止登录,最终批准仍由用户完成。
|
||||
|
||||
@@ -153,3 +153,7 @@ NETWORK_ALLOW_HTTP=true 时支持配置的 HTTP MCP 服务和网页审核地址
|
||||
`movement_create` 的 repay 从资产账户付款,目标支持借入债务和信用卡等负债账户。账户超额还款记为溢缴存款;独立债务仍拒绝超额还款。原币本金用 amount/received,fee 为负表示优惠、为正表示手续费;保留 repay 类型,双边余额及历史在同一事务提交,记录可编辑和撤销。
|
||||
|
||||
本次通过 57 项单元测试、9 项隔离 MySQL/REST/官方 SDK 集成测试、类型检查及生产构建;网页验证权限编辑表单、还款草稿预览、默认 admin 删除入口与余额颜色。详见 [更新验收](update-connections-repayment-users-2026-10-05.md)。
|
||||
|
||||
### OAuth 三档选择与助手权限缓存
|
||||
|
||||
OAuth 即使只申请 read,网页也提供只读、草稿与直接写入,默认只读,由用户确认最终等级;只读用户不能授予写入,隐藏访问仍需申请并勾选。助手在连接时检查一次 connection_info 并缓存;权限可能在网页更改,重新连接/授权、用户通知变更、权限错误或缓存不足时再检查一次,无需逐次操作重复检查。服务端继续逐请求验证实际权限。教程、配置提示词及其保存规则均已同步,见 [本次更新](update-oauth-permissions-2026-10-05.md)。
|
||||
@@ -0,0 +1,27 @@
|
||||
# OAuth 三档权限选择与权限缓存提示词
|
||||
|
||||
完成时间:2026-10-05 21:34(UTC+8)。
|
||||
|
||||
OAuth 授权页始终提供只读查询、草稿修改和直接写入三个等级,默认只读。即使客户端只申请 read,普通用户也可以在网页主动选择 draft 或 write,服务端按明确确认的范围发行令牌,并在 token.scope 返回实际范围。选择直接写入后显示即时写入说明,不自动替用户选择或确认。
|
||||
|
||||
隐藏读写仍要求客户端申请对应范围并由用户勾选。只读系统用户能看到三档说明,但草稿和直接写入不可选;服务端也拒绝授予写入。刷新令牌仍不能自行扩大范围,授权有效期和撤销规则保留。
|
||||
|
||||
授权预览新增 availableScopes 和 canWrite,scopes 保留客户端原申请,页面区分“客户端申请权限”与“最终权限代码”。三档权限互斥,draft/write 不同时授予。
|
||||
|
||||
## 提示词
|
||||
|
||||
页面的“Agent 使用教程”和“Codex 配置提示词”现在明确:
|
||||
|
||||
- 连接时发现真实工具,调用一次 connection_info 核对当前资源、权限、scopes 与隐藏读写,并在本连接中缓存。
|
||||
- 权限可能在网页更改,旧申请或聊天中的权限不代表当前授权。
|
||||
- 不需要每次查询或写入前检查;重新连接/授权、用户告知变更、权限错误或缓存不足以判断可执行范围时,再检查一次并更新缓存。
|
||||
- 初次配置仍核对本次预期范围;不匹配时报告差异,不擅自申请更高权限或绕过真实 pending 的人工确认。
|
||||
- 缓存是助手的查询策略;服务端每次请求仍检查实际授权、有效期、撤销状态和用户权限。
|
||||
|
||||
## 验证
|
||||
|
||||
7 项隔离 MySQL/HTTP/官方 SDK 集成测试通过,包括客户端仅申请 read 时分别确认三档权限、令牌 scope 正确、默认只读、只读用户拒绝写入、隐藏权限约束、原有 OAuth/刷新与业务回归。前后端生产构建通过。
|
||||
|
||||
独立临时数据库网页验收显示三个可选按钮,选择直接写入后最终授权与说明同步变化。未点击真实用户 OAuth 确认,也未改变线上连接或账目。清理测试库、服务和一次性脚本;保留可重跑回归测试。截图保存在仓库外,docs 无图片。本次没有数据库结构变更。
|
||||
|
||||
本地提交,不推送、不部署。已有 compose.yaml 和 docker.md 改动保留。
|
||||
@@ -103,3 +103,5 @@
|
||||
- ~~通过MCP 对账号还款时 确定时出现请选择有效的资产账户和对应借入或借出债务。本批修改未执行,请刷新后重新审阅。~~ — 已完成并验证:2026-10-05 20:23(UTC+8),见 docs/update-connections-repayment-users-2026-10-05.md
|
||||
- ~~删除账号功能,只能删除admin默认的这个账号,对其他账号无法被删除~~ — 已完成并验证:2026-10-05 20:23(UTC+8);按用户澄清:仅默认 admin 可删除,其他账号禁止删除,见 docs/update-connections-repayment-users-2026-10-05.md
|
||||
- ~~余额显示绿色,欠款为红色~~ — 已完成并验证:2026-10-05 20:23(UTC+8),见 docs/update-connections-repayment-users-2026-10-05.md
|
||||
- ~~使用OAuth的进行连接时应该可以设置三种权限,但是现在授权页只有一种~~ — 已完成并验证:2026-10-05 21:34(UTC+8),见 docs/update-oauth-permissions-2026-10-05.md
|
||||
- ~~修改 Agent 提示词:连接时检查一次当前权限,权限可能变更,仅在需要时重新检查~~ — 已完成:2026-10-05 21:34(UTC+8),见 docs/update-oauth-permissions-2026-10-05.md
|
||||
Reference in new issue
Block a user