fix(runtime,spec): 用 lint 规则守住槽类型化 —— 它第一次运行就查出项目成员资格门形同虚设 (#4127) - #4214
Merged
os-zhuang merged 2 commits intoJul 30, 2026
Merged
Conversation
… which found the project-membership gate not gating (#4127) Batch 4 of the #4127 gate. #4168/#4176/#4202 made a slot lookup return the slot's contract. Nothing protected that: an `any` annotation on the RESULT switches the checking back off for that call site, silently, with no test failing and no visual difference from code that has it. Three such sites already existed and were found by grep — the same unrepeatable sweep this work replaced. The rule bans `: any` / `as any` on a `resolveService` / `getService` / `getRequestKernelService` result. Slots with no written contract (`protocol`, `mcp`, `kernel-resolver`, `scope-manager`) are exempted BY NAME, CENTRALLY, in eslint.config.mjs — not by inline disables, because `pnpm lint` runs `--no-inline-config` and ignores those on purpose. The effect is the one worth having: a deliberate gap is a reviewed line in one file, a careless one is a build failure, and they stop looking identical in the code. ITS FIRST RUN FOUND A LIVE FAIL-OPEN. `enforceProjectMembership` read the session as `authService?.api?.getSession?.(...)` with no `getApi()` fallback — the only one of the codebase's three `.api` readers without it. `plugin-auth` registers `AuthManager`, which has NO `.api` member at all. So the read yielded `undefined`, `userId` stayed unset, and the function returned at its "anonymous — upstream auth will decide" line BEFORE ever querying `sys_environment_member`. A signed-in non-member passed the gate, on every deployment with project scoping on — which is where the flag defaults to true. Anonymous callers were still denied elsewhere (#2567/#3963), so this was specifically the signed-in non-member case. The existing test for that gate mocked auth as `{ api: { getSession } }` — the legacy shape the shipped provider does not have — so it was green throughout. That is the FOURTH test in this work line found encoding a contract nobody implements, after batch 1's three `auth.handler` mocks and batch 3's `status: 'open'`. The new test uses the `getApi()` shape and fails against the pre-fix code. Also found by the rule, all the same #4127 shape (implemented, called, undeclared) and all now declared: IAuthService gains `api`, `getApi`, `isAuthGateActive` and `verifyMcpAccessToken`; IMetadataService gains `load` and `loadDiagnosed`. `getApi`'s return type is the EVIDENCED SUBSET — `getSession({headers})` and the three fields callers read — not a re-declaration of better-auth's handle, which belongs to that library. And the pattern's real root: the lookup facade returning `any` was re-declared in THREE places. Batches 1-3 typed `DomainHandlerDeps` and left `ActionExecutionDeps` and resolve-execution-context's `ResolveOptions` still saying `any` — so the copy that stayed untyped was the way around all the others, and it is where the auth reads lived. All three are typed now. Completing the interface: `getRequestKernelService` gets the same overload split (its one caller resolves the same `objectql` slot the `resolveService` fallback beside it does, so the two arms of one expression had different types), and share-links' `getEngine` loses a `Promise<any>` return annotation — a THIRD erasure syntax after `: any` and `as any`, and one this AST rule cannot see. That residual is documented in the config. `getObjectQL` STAYS `any`, deliberately, with the reason recorded: it exists to reach ObjectQL's surface beyond IDataEngine (`registry`, `executeAction`), which has no contract. Typing it IDataEngine would be the comfortable-looking lie. Refs #4127 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015T5CeitwV1jXLvsL1A84tw
|
The latest updates on your projects. Learn more about Vercel for GitHub. 1 Skipped Deployment
|
Contributor
📓 Docs Drift CheckThis PR changes 2 package(s): 113 hand-written doc(s) reference the affected code and may need an implementation-accuracy re-verification:
|
…he contracts actually have (#4127) Both pages reproduce their interface source, and both had fallen behind. The auth page stopped at `getCurrentUser` and still called the interface "intentionally minimal" — it was already missing the two MCP members batch 2 added, which I did not catch when that shipped, and this batch added four more. The metadata page was missing `load`/`loadDiagnosed`. Documented with the part a caller can get wrong, not just the signatures: `api` vs `getApi()` (the shipped provider has no `api` member, so reading only that one yields undefined everywhere — the fail-open this batch fixed), and `load` vs `loadDiagnosed` (a null from `load` means either absent or every loader down, and treating the second as the first turns an outage into an authorization answer). Refs #4127 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015T5CeitwV1jXLvsL1A84tw
os-zhuang
marked this pull request as ready for review
July 30, 2026 16:27
os-zhuang
deleted the
claude/dispatcher-storage-upload-typeerror-0exbcb-b4
branch
July 30, 2026 16:28
os-zhuang
added a commit
that referenced
this pull request
Jul 31, 2026
…4321) * fix(spec,runtime): catch the getService<any> type-argument form and widen the lookup guard to all packages (#4251) The #4127/#4214 rule matched ': any' and 'as any' on a lookup result but not the type-argument form getService<any>(...) — the shape the codebase actually used — and its files glob stopped at packages/runtime while the composition roots held the majority of lookups. Both gaps close here: - eslint.config.mjs: third selector for the type-argument form (typeArguments.params.0 = TSAnyKeyword), scope widened to packages/**, http.server added to UNCONTRACTED_SLOTS (three providers, no contract). The 40 not-yet-swept files are grandfathered in SLOT_LOOKUP_UNSWEPT — a visible ratchet enumerated at 180 sites by running the rule with the list emptied; batches remove entries, never add. - The three in-scope runtime src sites are typed. Doing so surfaced the batch's first yield: both addDatasource branches (DefaultDatasourcePlugin parity branch, DriverPlugin.start) probed a method no metadata service implements, so they had never run on any boot — deleted rather than typed against a phantom shape. The inert DriverPluginOptions are tracked in #4320; registerInMemory('datasource', ...) is the actual visibility path. - Contracts declare the evidenced members those sites read, both optional: IDataEngine.getDefaultDriverName/getDriverByName (ObjectQL's driver registry, re-registered as driver.<name> services for os migrate and serve storage detection) and IMetadataService.registerInMemory (MetadataManager's boot-time seeding primitive, #3827). - Runtime test lookups typed as IDataEngine; the five http.server test lookups ride the slot exemption. Verified: pnpm lint clean; spec check:generated all 8 up to date; tests green — spec 7137/277, objectql 1345/85, runtime 954/67, metadata 281/13, plugin-webhooks 25/3. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AWW5xEALZNU5VBXfGyWPGz * docs(kernel): document the evidenced contract members added for #4251 The IDataEngine and IMetadataService contract references enumerate the interface surface member by member, so the two additions (driver registry lookups; registerInMemory) were stale-by-omission — flagged by the docs drift check on #4321. Both interface listings and the per-member sections now match packages/spec/src/contracts. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AWW5xEALZNU5VBXfGyWPGz * feat(runtime,cli)!: retire the inert DriverPluginOptions (#4320) new DriverPlugin(driver, { datasourceName, registerAsDefault }) never did what it promised: both options configured the start() datasource block that probed metadata.addDatasource — a method no metadata service implements — so they were dead weight on every boot since inception. Routing to a named auxiliary driver never came from the option: it keys off the DRIVER name (init registers driver.<name>, ObjectQL's discovery loop adopts it, the engine's lifecycle resolution looks the name up — engine.ts LIFECYCLE_DATASOURCE), which is why serve's telemetry split worked all along despite the option doing nothing. - DriverPlugin constructor narrowed to (driver, driverName?); the options interface (module-local, never exported from the package root) is gone. - serve.ts drops the options argument; the telemetry wiring comment now states the driver-name mechanism explicitly. - Major changeset for @objectstack/runtime carries the FROM -> TO migration; behavior is unchanged by construction (the code the options configured never ran). Verified: full turbo build green (71 packages); runtime 954/67 and cli 628/64 tests pass; pnpm lint clean. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AWW5xEALZNU5VBXfGyWPGz --------- Co-authored-by: Claude <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Refs #4127。#4127 gate 的第四批(前三批 #4168 / #4176 / #4202)。
1. 规则本身
前三批让槽查找返回契约,但没有任何东西守着这个成果。在结果上标一个
any就能把该调用点的检查静默关掉 —— 没有测试失败,和有检查的代码长得一模一样。这样的点已经存在过三处,是我 grep 找出来的,而 grep 正是这条工作线要取代的那种不可重复的扫描。规则禁止在
resolveService/getService/getRequestKernelService的结果上写: any或as any。没有写过契约的槽(
protocol、mcp、kernel-resolver、scope-manager)按名字在eslint.config.mjs里集中豁免,而不是靠就地 disable 注释 —— 因为pnpm lint跑的是eslint . --no-inline-config,它会刻意忽略所有 disable 注释。(我最初就是用 disable 注释写的,验证时才发现 14 处在 CI 下全会失败。仓库这个 flag 等于在告诉我正确答案:例外必须集中声明。)于是效果正是想要的那个:刻意的缺口是一个文件里被评审过的一行,随手的缺口是构建失败,两者在代码里不再长得一样。
2.⚠️ 规则查出的 fail-open
enforceProjectMembership读 session 的方式是:这是代码库里三处
.api读取中唯一没有兜底的(resolve-execution-context和dispatcher-plugin都有,它们的注释还写明了原因:.api是 legacy 直挂,getApi()是 lazy-plugin 访问器)。而
plugin-auth注册的是AuthManager(auth-plugin.ts:288),AuthManager根本没有.api成员 ——getApi()是唯一入口。后果链:读取得
undefined→userId始终未赋值 → 函数在 "anonymous — upstream auth will decide" 那行返回 →sys_environment_member查询从未执行。成员资格门整体不生效。
enforceProjectMembership默认值 =enableProjectScoping,也就是多环境/云端部署默认开启。匿名调用者仍被别处拦下(#2567/#3963),所以受影响的精确范围是已登录的非成员。而现有测试一直是绿的
那个门的现有测试把 auth 服务 mock 成
{ api: { getSession } }—— 线上提供方从不具备的 legacy 形状。这是本工作线发现的第四个「测试为不存在的契约背书」(batch 1 三个
auth.handlermock、batch 3 的status: 'open')。新增测试用真实的getApi()形状,在修复前会失败(已实测验证:还原修复后该测试红,恢复后绿)。3. 规则同时查出的其他缺口
全是同一形状(实现存在、被调用、没声明),现已全部声明:
IAuthServiceapi、getApi、isAuthGateActive、verifyMcpAccessTokenauth-manager.ts2694 / 3543 / 2837IMetadataServiceload、loadDiagnosedmetadata-manager.ts1402 / 1426getApi的返回类型只声明有证据的子集 ——getSession({headers})及调用点实际读的三个字段 —— 而不是重新声明 better-auth 的完整 handle,那属于那个库。4. 这个模式的真正根源
返回
any的查找门面被重复声明了三份。 前三批只类型化了DomainHandlerDeps,留下:ActionExecutionDeps.resolveService(action-execution.ts:62)ResolveOptions.getService(resolve-execution-context.ts:33)仍然说
any的那一份,就是绕过其余所有份的通道 —— 而上面那些 auth 读取正好都住在那里。三份现已全部类型化。5. 接口收尾
getRequestKernelService用同样的重载切分。它唯一的调用点和旁边的resolveServicefallback 解析同一个objectql槽,此前一个表达式的两条分支类型不同。getEngine去掉Promise<any>返回注解 —— 这是继: any、as any之后的第三种抹除句法,而且这条 AST 规则看不见它(注解在外层函数上)。该残留已写进配置注释,需要类型信息的 lint 才能覆盖。getObjectQL刻意保持any,并记录了理由:它存在的目的就是够到 ObjectQL 超出IDataEngine的表面(registry、executeAction),那部分没有契约。标成IDataEngine会是那个「看起来更舒服的谎」。验证
@objectstack/runtime@objectstack/specpnpm lint--no-inline-config)tsc --noEmitcheck:*门禁🤖 Generated with Claude Code
https://claude.ai/code/session_015T5CeitwV1jXLvsL1A84tw
Generated by Claude Code