You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
fix(runtime,spec): guard the service-lookup typing with a lint rule — which found the project-membership gate not gating (#4127) (#4214)
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.
The auth and metadata contract doc pages are brought back in step with the
interfaces — the auth page still called itself "intentionally minimal" while
missing six members, two of them from batch 2.
Refs #4127
/** RFC 9728 protected-resource metadata URL, or `null` when OAuth is off. Optional. */
49
+
getMcpResourceMetadataUrl?():string|null;
32
50
}
33
51
```
34
52
35
-
The interface is intentionally minimal: it follows the Dependency Inversion Principle so that plugins depend on the contract rather than a concrete provider. Concrete implementations (better-auth, custom, LDAP, etc.) supply the actual logic. `handleRequest` works directly with the standard `Request`/`Response` types; `logout` and `getCurrentUser` are optional.
53
+
The two required members are the core: it follows the Dependency Inversion Principle so that plugins depend on the contract rather than a concrete provider. Concrete implementations (better-auth, custom, LDAP, etc.) supply the actual logic, and `handleRequest` works directly with the standard `Request`/`Response` types.
54
+
55
+
Everything after `verify` is optional, and each optional member describes a capability a provider may or may not have — a caller must probe before use. Two pairs are worth reading together:
56
+
57
+
-**`api` and `getApi()`** are the same session handle by two routes. `api` is the legacy direct mount; `getApi()` is the lazy accessor, and it is the one to prefer — the shipped `plugin-auth` registers an `AuthManager`, which has **no `api` member at all**, so a caller that reads only `api` gets `undefined` on every current deployment. Read `api ?? await getApi?.()`.
58
+
-**`getMcpResourceUrl()` and `getMcpResourceMetadataUrl()`** both describe the MCP OAuth surface; the second returns `null` (rather than being absent) when the OAuth track is off for the deployment.
0 commit comments