refactor(web): restructure into api/domain/services/worker; capabilities, file domains, video covers
- dissolve core-bridge into api (wire contract), domain (models), services (what the UI calls) and worker (everything that runs in the Web Worker, grouped by account, crypto, space, files, shares, runtime); regroup features, lib and store by responsibility and delete unused components - the UI only reads backend capabilities (domain/capabilities); one tokenRole picks the token, hasCode/isRetryable handle errors; pre-checks that decided permissions on the client are gone; tests fail if those rules are reimplemented - files go straight to the file domain (create, upload, download, delete, commit, cancel); the host sees one registration per file; creating a channel allocates a file domain and works even when the file service is down - file panel shows which channel the files belong to and its space usage; unlimited files preview in chat; videos get a stored cover; the file service status is shown - fix: console flooded by Retry-After reads on every upload frame
This commit is contained in:
1 parent
17638b6e20
commit
5489407241
260 files changed
+1503
-1246
No files matched your search
@@ -0,0 +1,20 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { supportsApiContract } from './contract';
|
||||
|
||||
describe('API contract compatibility', () => {
|
||||
it('accepts compatible SemVer releases in the supported major', () => {
|
||||
expect(supportsApiContract('8.0.0')).toBe(true);
|
||||
expect(supportsApiContract('8.8.2')).toBe(true);
|
||||
expect(supportsApiContract('8.2.0-rc.1')).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects a breaking major and malformed versions', () => {
|
||||
expect(supportsApiContract('3.0.0')).toBe(false);
|
||||
expect(supportsApiContract('4.0.0')).toBe(false);
|
||||
expect(supportsApiContract('5.0.0')).toBe(false);
|
||||
expect(supportsApiContract('6.1.0')).toBe(false);
|
||||
expect(supportsApiContract('7.0.0')).toBe(false);
|
||||
expect(supportsApiContract('01.0.0')).toBe(false);
|
||||
expect(supportsApiContract(undefined)).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,9 @@
|
||||
export const SUPPORTED_API_CONTRACT_MAJOR = 8;
|
||||
|
||||
const semverPattern = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$/;
|
||||
|
||||
export function supportsApiContract(version: unknown): boolean {
|
||||
if (typeof version !== 'string') return false;
|
||||
const match = semverPattern.exec(version);
|
||||
return match !== null && Number(match[1]) === SUPPORTED_API_CONTRACT_MAJOR;
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
const messages: Record<string, string> = {
|
||||
not_found: '内容不存在或已过期。', gone: '内容已删除、下载次数已用完或续传已失效。',
|
||||
members_full: '空间成员人数已满,请联系所有者。', not_joined: '你已被移出此空间。',
|
||||
visitors_full: '分享的访客名额已满。', unauthorized: '链接权限无效,请使用完整链接重新打开。',
|
||||
forbidden: '此链接没有执行此操作的权限。', sealed: '分享已封存,无法再修改。',
|
||||
too_large: '内容超过服务端容量或条目数量限制。', rate_limited: '请求过于频繁,请稍后重试。',
|
||||
captcha_required: '验证码未通过或已失效,请重新创建。',
|
||||
captcha_unavailable: '验证码服务暂时不可用,请稍后重试。',
|
||||
api_contract_mismatch: '服务端 API 有破坏性更新,请刷新或更新前端后再试。',
|
||||
mode_mismatch: '空间加密模式与链接不符,已拒绝访问。', conflict: '上传内容发生冲突,已停止发送。',
|
||||
username_unavailable: '这个账号 ID 已被使用。', vault_conflict: '账号资源在其他设备上刚刚更新,请再次同步。',
|
||||
account_invalid: '账号 ID 或密码错误,或登录已过期。', account_required: '请先登录账号。',
|
||||
invitation_required: '该空间仅允许受邀账号加入,请登录受邀账号或联系空间主。',
|
||||
channel_password_required: '请输入空间加入密码。', channel_password_invalid: '空间加入密码不正确。',
|
||||
space_name_required: '请填写空间名称。',
|
||||
};
|
||||
|
||||
export function errorMessage(error: unknown): string {
|
||||
if (!(error instanceof Error)) return '操作失败,请重试。';
|
||||
const mapped = messages[error.message];
|
||||
if (mapped) return mapped;
|
||||
// 不回显来自服务端或解析器的原始错误,它们可能包含密钥或明文。
|
||||
return /[\u4e00-\u9fff]/.test(error.message) && error.message.length < 100
|
||||
? error.message : '操作失败,请重试。';
|
||||
}
|
||||
@@ -0,0 +1,503 @@
|
||||
// Generated from backend DTOs; do not edit.
|
||||
// backend-contract-sha256: 340876061d7b324e4fd16972e276ffb0f238eeac66b214a043b3b4b98d560fcf
|
||||
|
||||
export interface AccountChannelBody {
|
||||
channel_id: string;
|
||||
created_at: number;
|
||||
payload: string;
|
||||
role: string;
|
||||
}
|
||||
export interface AccountDeviceBody {
|
||||
created_at: number;
|
||||
current: boolean;
|
||||
id: string;
|
||||
last_seen: number;
|
||||
name: string;
|
||||
}
|
||||
export interface AccountDeviceNameRequest {
|
||||
name: string;
|
||||
}
|
||||
export interface AccountDeviceSigningRequest {
|
||||
device_id: string;
|
||||
public_key: string;
|
||||
signature: string;
|
||||
}
|
||||
export interface AccountProfile {
|
||||
avatar?: string | null;
|
||||
displayName: string;
|
||||
hasAvatar: boolean;
|
||||
modifiedAt: number;
|
||||
username: string;
|
||||
}
|
||||
export interface AccountProfileRequest {
|
||||
avatar?: string | null;
|
||||
avatarThumb?: string | null;
|
||||
displayName: string;
|
||||
keepAvatar: boolean;
|
||||
}
|
||||
export interface AccountRegisterRequest {
|
||||
public_key: string;
|
||||
salt: string;
|
||||
signing_public_key: string;
|
||||
username: string;
|
||||
vault: string;
|
||||
verifier: string;
|
||||
}
|
||||
export interface AccountSaltBody {
|
||||
salt: string;
|
||||
}
|
||||
export interface AccountSaltRequest {
|
||||
username: string;
|
||||
}
|
||||
export interface AccountSessionBody {
|
||||
token: string;
|
||||
username: string;
|
||||
}
|
||||
export interface AccountSessionRequest {
|
||||
device_id?: string | null;
|
||||
device_name?: string | null;
|
||||
username: string;
|
||||
verifier: string;
|
||||
}
|
||||
export interface AccountVaultBody {
|
||||
revision: number;
|
||||
vault: string;
|
||||
}
|
||||
export interface AccountVaultRequest {
|
||||
revision: number;
|
||||
vault: string;
|
||||
}
|
||||
export interface AppendResult {
|
||||
seq: number;
|
||||
}
|
||||
export interface ChannelHistoryRequest {
|
||||
chatHistory: boolean;
|
||||
filesHistory: boolean;
|
||||
}
|
||||
export interface ChannelInfo {
|
||||
avatar?: string | null;
|
||||
can_create_channel: boolean;
|
||||
can_delete_any: boolean;
|
||||
can_delete_own: boolean;
|
||||
can_edit_settings: boolean;
|
||||
can_invite: boolean;
|
||||
can_manage_folders: boolean;
|
||||
can_manage_roles: boolean;
|
||||
can_manage_structure: boolean;
|
||||
can_remove_members: boolean;
|
||||
can_upload: boolean;
|
||||
created_at: number;
|
||||
creator_account?: string | null;
|
||||
expires_at: number;
|
||||
id: string;
|
||||
is_owner: boolean;
|
||||
last_seq: number;
|
||||
members_used: number;
|
||||
mode: ChannelMode;
|
||||
oldest_seq: number;
|
||||
password_required: boolean;
|
||||
settings: ChannelSettings;
|
||||
}
|
||||
export type ChannelMode = 'enc' | 'raw';
|
||||
export const ChannelMode = { Enc: 'enc', Raw: 'raw' } as const;
|
||||
|
||||
export interface ChannelSettings {
|
||||
delete_policy: DeletePolicy;
|
||||
file_max_downloads: number;
|
||||
join_policy: JoinPolicy;
|
||||
max_items: number;
|
||||
max_members: number;
|
||||
members_can_upload: boolean;
|
||||
name?: string | null;
|
||||
ttl_seconds: number;
|
||||
ui_restrict_copy_download: boolean;
|
||||
}
|
||||
export interface ChannelSettingsPatch {
|
||||
avatar?: string | null;
|
||||
avatar_thumb?: string | null;
|
||||
delete_policy?: DeletePolicy | null;
|
||||
file_max_downloads?: number | null;
|
||||
join_password?: string | null;
|
||||
join_policy?: JoinPolicy | null;
|
||||
max_items?: number | null;
|
||||
max_members?: number | null;
|
||||
members_can_upload?: boolean | null;
|
||||
name?: string | null;
|
||||
ttl_seconds?: number | null;
|
||||
ui_restrict_copy_download?: boolean | null;
|
||||
}
|
||||
export interface CreateChannelRequest {
|
||||
id?: string | null;
|
||||
join_password?: string | null;
|
||||
key_package?: string | null;
|
||||
mode?: ChannelMode | null;
|
||||
owner_hash?: string | null;
|
||||
read_hash?: string | null;
|
||||
settings?: ChannelSettings | null;
|
||||
write_hash?: string | null;
|
||||
}
|
||||
export interface CreateShareRequest {
|
||||
id?: string | null;
|
||||
owner_hash?: string | null;
|
||||
read_hash?: string | null;
|
||||
settings?: ShareSettings | null;
|
||||
}
|
||||
export interface CryptoState {
|
||||
access: ChannelAccess[];
|
||||
envelopes: DomainEnvelope[];
|
||||
objects: ObjectEnvelope[];
|
||||
revision: number;
|
||||
}
|
||||
export interface ChannelAccess {
|
||||
channelId: string;
|
||||
chatFromRevision: number;
|
||||
chatRead: boolean;
|
||||
chatWrite: boolean;
|
||||
filesFromRevision: number;
|
||||
filesRead: boolean;
|
||||
filesUpload: boolean;
|
||||
username: string;
|
||||
}
|
||||
export type DeletePolicy = 'owner_only' | 'own' | 'anyone';
|
||||
export const DeletePolicy = { OwnerOnly: 'owner_only', Own: 'own', Anyone: 'anyone' } as const;
|
||||
|
||||
export interface DomainEnvelope {
|
||||
channelId: string;
|
||||
domain: CryptoDomain;
|
||||
epoch: number;
|
||||
payload: string;
|
||||
username: string;
|
||||
}
|
||||
export type CryptoDomain = 'chat' | 'files';
|
||||
export const CryptoDomain = { Chat: 'chat', Files: 'files' } as const;
|
||||
|
||||
export interface DomainPublishRequest {
|
||||
channelId: string;
|
||||
domain: CryptoDomain;
|
||||
envelopes: DomainEnvelope[];
|
||||
epoch: number;
|
||||
}
|
||||
export interface ErrorBody {
|
||||
error: string;
|
||||
}
|
||||
export interface FileGrantBody {
|
||||
domain: string;
|
||||
expires_at: number;
|
||||
ticket: string;
|
||||
url: string;
|
||||
}
|
||||
export interface FileGrantRequest {
|
||||
channel?: string | null;
|
||||
from: number;
|
||||
item?: string | null;
|
||||
op: string;
|
||||
}
|
||||
export interface FinishShareRequest {
|
||||
manifest_item_id?: string | null;
|
||||
}
|
||||
export interface GoneLogBody {
|
||||
error: string;
|
||||
oldest_seq: number;
|
||||
}
|
||||
export interface InvitationBody {
|
||||
can_revoke: boolean;
|
||||
channel_id: string;
|
||||
created_at: number;
|
||||
payload: string;
|
||||
username: string;
|
||||
}
|
||||
export interface InvitationCryptoPlan {
|
||||
batches: CryptoGrantBatch[];
|
||||
channelId: string;
|
||||
username: string;
|
||||
}
|
||||
export interface CryptoGrantBatch {
|
||||
domain: CryptoDomain;
|
||||
epoch: number;
|
||||
recipients: string[];
|
||||
rotate: boolean;
|
||||
}
|
||||
export interface InvitationRequest {
|
||||
domain_envelopes?: DomainEnvelope[] | null;
|
||||
payload: string;
|
||||
username: string;
|
||||
}
|
||||
export type JoinPolicy = 'open' | 'invite';
|
||||
export const JoinPolicy = { Open: 'open', Invite: 'invite' } as const;
|
||||
|
||||
export interface JoinRequest {
|
||||
password?: string | null;
|
||||
}
|
||||
export interface JoinResult {
|
||||
joined_at: number;
|
||||
members_max: number;
|
||||
members_used: number;
|
||||
}
|
||||
export interface KeyPackageRequest {
|
||||
payload: string;
|
||||
}
|
||||
export interface MemberBody {
|
||||
can_remove: boolean;
|
||||
devices: number;
|
||||
joined_at: number;
|
||||
last_seen: number;
|
||||
online: boolean;
|
||||
online_devices: number;
|
||||
role: string;
|
||||
username: string;
|
||||
}
|
||||
export interface ObjectEnvelope {
|
||||
authorAccount?: string | null;
|
||||
channelId: string;
|
||||
createdRevision: number;
|
||||
deleteEvent?: SignedContentEventRecord | null;
|
||||
domain: CryptoDomain;
|
||||
epoch: number;
|
||||
event?: SignedContentEventRecord | null;
|
||||
itemId: string;
|
||||
keyEnvelope: string;
|
||||
}
|
||||
export interface PasskeyBeginRequest {
|
||||
username: string;
|
||||
verifier?: string | null;
|
||||
}
|
||||
export interface PasskeyEnvelopeRequest {
|
||||
credential: string;
|
||||
id: string;
|
||||
key_envelope: string;
|
||||
username: string;
|
||||
}
|
||||
export interface PasskeyFinishRequest {
|
||||
credential: string;
|
||||
device_id?: string | null;
|
||||
device_name?: string | null;
|
||||
id: string;
|
||||
key_envelope?: string | null;
|
||||
name?: string | null;
|
||||
username: string;
|
||||
}
|
||||
export interface PasskeyInfoBody {
|
||||
can_recover: boolean;
|
||||
created_at: number;
|
||||
id: string;
|
||||
name: string;
|
||||
prf_salt: string;
|
||||
}
|
||||
export interface PasskeyOptionsBody {
|
||||
credentials: PasskeyInfoBody[];
|
||||
id: string;
|
||||
options: string;
|
||||
prf_salt?: string | null;
|
||||
}
|
||||
export interface PasskeySessionBody {
|
||||
credential_id: string;
|
||||
key_envelope?: string | null;
|
||||
prf_salt: string;
|
||||
token: string;
|
||||
username: string;
|
||||
}
|
||||
export interface PublicKeyBody {
|
||||
public_key: string;
|
||||
signing_public_key?: string | null;
|
||||
}
|
||||
export interface ServerInfoBody {
|
||||
captcha: CaptchaBody;
|
||||
choices: SettingChoicesBody;
|
||||
contract_version: string;
|
||||
defaults: DefaultsBody;
|
||||
files: FilesInfoBody;
|
||||
limits: LimitsBody;
|
||||
protocol: number;
|
||||
transports: TransportsBody;
|
||||
}
|
||||
export interface CaptchaBody {
|
||||
api_endpoint?: string | null;
|
||||
enabled: boolean;
|
||||
provider: string;
|
||||
}
|
||||
export interface DefaultsBody {
|
||||
channel: ChannelSettings;
|
||||
channel_mode: ChannelMode;
|
||||
share: ShareSettings;
|
||||
}
|
||||
export interface FilesInfoBody {
|
||||
enabled: boolean;
|
||||
url?: string | null;
|
||||
}
|
||||
export interface LimitsBody {
|
||||
max_channel_bytes: number;
|
||||
max_frames_per_item: number;
|
||||
max_items: number;
|
||||
max_members: number;
|
||||
max_payload_bytes: number;
|
||||
max_ttl_seconds: number;
|
||||
max_visitors: number;
|
||||
max_workspace_bytes: number;
|
||||
min_ttl_seconds: number;
|
||||
}
|
||||
export interface SettingChoicesBody {
|
||||
file_max_downloads: number[];
|
||||
max_members: number[];
|
||||
max_visitors: number[];
|
||||
ttl_seconds: number[];
|
||||
}
|
||||
export interface ShareInfo {
|
||||
created_at: number;
|
||||
expires_at: number;
|
||||
id: string;
|
||||
items: ShareItemInfo[];
|
||||
manifest_item_id?: string | null;
|
||||
sealed_at?: number | null;
|
||||
settings: ShareSettings;
|
||||
state: string;
|
||||
}
|
||||
export interface ShareItemInfo {
|
||||
bytes: number;
|
||||
complete: boolean;
|
||||
downloads_left: number | null;
|
||||
frames: number;
|
||||
item_id: string;
|
||||
max_downloads: number;
|
||||
}
|
||||
export interface ShareSettings {
|
||||
file_max_downloads: number;
|
||||
max_visitors: number;
|
||||
ttl_seconds: number;
|
||||
}
|
||||
export interface ShareSettingsPatch {
|
||||
file_max_downloads?: number | null;
|
||||
max_visitors?: number | null;
|
||||
ttl_seconds?: number | null;
|
||||
}
|
||||
export interface SignedContentEventRecord {
|
||||
deviceCertificateSignature: string;
|
||||
eventId: string;
|
||||
frameCount: number;
|
||||
kind: string;
|
||||
manifestHash: string;
|
||||
signature: string;
|
||||
signerAccount?: string | null;
|
||||
signerDeviceId: string;
|
||||
signerDevicePublicKey: string;
|
||||
signerRootPublicKey: string;
|
||||
targetItemId?: string | null;
|
||||
}
|
||||
export interface TransportsBody {
|
||||
http3: boolean;
|
||||
sse: boolean;
|
||||
websocket: boolean;
|
||||
}
|
||||
export interface WorkspaceBody {
|
||||
people: AccountProfile[];
|
||||
structure: WorkspaceState;
|
||||
}
|
||||
export interface WorkspaceChannelCreatedBody {
|
||||
channel: WorkspaceChannel;
|
||||
workspace: WorkspaceBody;
|
||||
}
|
||||
export interface WorkspaceChannel {
|
||||
chatEpoch: number;
|
||||
chatHistory: boolean;
|
||||
createdAt: number;
|
||||
createdBy: string;
|
||||
fileDomain?: string | null;
|
||||
filesEpoch: number;
|
||||
filesHistory: boolean;
|
||||
groupId?: string | null;
|
||||
id: string;
|
||||
name: string;
|
||||
}
|
||||
export interface WorkspaceChannelRequest {
|
||||
fileDomain?: string | null;
|
||||
groupId?: string | null;
|
||||
name: string;
|
||||
}
|
||||
export interface WorkspaceFileDomainRequest {
|
||||
fileDomain: string;
|
||||
}
|
||||
export interface WorkspaceFolderRequest {
|
||||
name: string;
|
||||
parentId?: string | null;
|
||||
threadId: string;
|
||||
}
|
||||
export interface WorkspaceGroupCreatedBody {
|
||||
group: WorkspaceGroup;
|
||||
workspace: WorkspaceBody;
|
||||
}
|
||||
export interface WorkspaceGroup {
|
||||
createdAt: number;
|
||||
id: string;
|
||||
name: string;
|
||||
}
|
||||
export interface WorkspaceGroupRequest {
|
||||
groupId?: string | null;
|
||||
}
|
||||
export interface WorkspaceMoveRequest {
|
||||
folderId?: string | null;
|
||||
itemIds: string[];
|
||||
threadId: string;
|
||||
}
|
||||
export interface WorkspaceNameRequest {
|
||||
name: string;
|
||||
}
|
||||
export interface WorkspaceRoleCreatedBody {
|
||||
role: WorkspaceRole;
|
||||
workspace: WorkspaceBody;
|
||||
}
|
||||
export interface WorkspaceRole {
|
||||
color?: string | null;
|
||||
createdAt: number;
|
||||
id: string;
|
||||
members: string[];
|
||||
name: string;
|
||||
permissions: string[];
|
||||
prefix?: string | null;
|
||||
suffix?: string | null;
|
||||
}
|
||||
export interface WorkspaceRoleMembersRequest {
|
||||
members: string[];
|
||||
}
|
||||
export interface WorkspaceRoleRequest {
|
||||
color?: string | null;
|
||||
name: string;
|
||||
permissions: string[];
|
||||
prefix?: string | null;
|
||||
suffix?: string | null;
|
||||
}
|
||||
export interface WorkspaceState {
|
||||
channels: WorkspaceChannel[];
|
||||
defaultChannelId?: string | null;
|
||||
folders: WorkspaceFolder[];
|
||||
groups: WorkspaceGroup[];
|
||||
locations: WorkspaceLocation[];
|
||||
revision: number;
|
||||
roles: WorkspaceRole[];
|
||||
rolesSeeded: boolean;
|
||||
}
|
||||
export interface WorkspaceFolder {
|
||||
id: string;
|
||||
name: string;
|
||||
parentId?: string | null;
|
||||
threadId: string;
|
||||
}
|
||||
export interface WorkspaceLocation {
|
||||
authorAccount?: string | null;
|
||||
folderId?: string | null;
|
||||
itemId: string;
|
||||
threadId: string;
|
||||
}
|
||||
/** 错误响应里的 error 取值 */
|
||||
export const ErrorCode = { BadRequest: 'bad_request', Unauthorized: 'unauthorized', Forbidden: 'forbidden', NotJoined: 'not_joined', MembersFull: 'members_full', ModeMismatch: 'mode_mismatch', NotFound: 'not_found', Conflict: 'conflict', Gone: 'gone', TooLarge: 'too_large', CaptchaRequired: 'captcha_required', CaptchaUnavailable: 'captcha_unavailable', RateLimited: 'rate_limited', VisitorsFull: 'visitors_full', Sealed: 'sealed', AccountRequired: 'account_required', InvitationRequired: 'invitation_required', ChannelPasswordRequired: 'channel_password_required', ChannelPasswordInvalid: 'channel_password_invalid', SpaceNameRequired: 'space_name_required', UseFileApi: 'use_file_api', FilesUnavailable: 'files_unavailable' } as const;
|
||||
export type ErrorCode = (typeof ErrorCode)[keyof typeof ErrorCode];
|
||||
|
||||
/** X-WD-Role 请求头:这次请求出示哪张令牌 */
|
||||
export const TokenRole = { Read: 'read', Write: 'write', Owner: 'owner' } as const;
|
||||
export type TokenRole = (typeof TokenRole)[keyof typeof TokenRole];
|
||||
|
||||
/** 空间权限(身份组授予) */
|
||||
export const WorkspacePermission = { RemoveMembers: 'members.remove', ManageStructure: 'structure.manage' } as const;
|
||||
export type WorkspacePermission = (typeof WorkspacePermission)[keyof typeof WorkspacePermission];
|
||||
|
||||
/** 便捷分享的状态 */
|
||||
export const ShareState = { Draft: 'draft', Sealed: 'sealed' } as const;
|
||||
export type ShareState = (typeof ShareState)[keyof typeof ShareState];
|
||||
@@ -0,0 +1,37 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
/**
|
||||
* 前端不做权限和状态判断,只读后端给的能力、按后端的错误码处理。规则只在后端一处,这里守住前端不要重新实现:
|
||||
* - 角色/权限:界面只通过 domain/capabilities 读后端给的 can_* 字段,不比较角色、不写 isOwner 判断;
|
||||
* - 选哪张令牌:只有 tokenRole 一处;
|
||||
* - 服务端为什么拒绝:只通过 hasCode(错误码),不比较 HTTP 状态码数字。
|
||||
* 允许的例外写在 allowed 里,并说明理由。
|
||||
*/
|
||||
const modules = import.meta.glob<string>('/src/**/*.{ts,tsx}', { query: '?raw', import: 'default', eager: true });
|
||||
const files = Object.entries(modules)
|
||||
.map(([path, text]) => ({ path: path.replace(/^\/src\//, ''), text }))
|
||||
.filter(({ path }) => !/\.test\.(ts|tsx)$/.test(path) && !path.startsWith('api/generated'));
|
||||
|
||||
interface Rule { name: string; pattern: RegExp; allowed: string[] }
|
||||
const RULES: Rule[] = [
|
||||
{ name: '不用 session.can(...) 自己判断权限(选令牌的 tokenRole、必须持有所有者密钥才能做的密码学操作、分享的所有权释放除外)', pattern: /session\.can\('(owner|write)'\)/,
|
||||
allowed: ['worker/runtime/sessions.ts', 'worker/crypto/channelDataKeys.ts', 'worker/shares/shareState.ts', 'worker/shares/shareSeal.ts', 'worker/space/channelResources.ts', 'worker/space/channelTransport.ts'] },
|
||||
{ name: '不比较 HTTP 状态码数字(请用 hasCode / isRetryable)', pattern: /\.status\s*(===|!==|>=|<=|<|>)\s*\d{3}\b/, allowed: ['worker/runtime/http.ts'] },
|
||||
{ name: '不比较角色字符串(成员行的展示、授权包的密码学校验除外;分享的 mine 待后端提供)', pattern: /\.role\s*(===|!==)\s*'(owner|member|write|read)'/,
|
||||
allowed: ['features/channel/members/MembersPane.tsx', 'features/channel/members/RolesEditor.tsx', 'worker/account/accountChannels.ts', 'worker/crypto/channelGrant.ts', 'worker/runtime/wire.ts'] },
|
||||
];
|
||||
|
||||
describe('前端不重新实现后端的规则', () => {
|
||||
it('扫描到了源码', () => { expect(files.length).toBeGreaterThan(100); });
|
||||
for (const rule of RULES) {
|
||||
it(rule.name, () => {
|
||||
const offenders = files
|
||||
.filter(({ path }) => !rule.allowed.includes(path))
|
||||
.flatMap(({ path, text }) => text.split('\n')
|
||||
.map((line, index) => ({ path, line: index + 1, text: line.trim() }))
|
||||
.filter(({ text: line }) => !line.startsWith('//') && !line.startsWith('*') && rule.pattern.test(line))
|
||||
.map(({ path: p, line, text: source }) => `${p}:${line} ${source}`));
|
||||
expect(offenders, `这些地方重新实现了后端的规则:\n${offenders.join('\n')}`).toEqual([]);
|
||||
});
|
||||
}
|
||||
});
|
||||
Reference in new issue
Block a user