Skip to content

Commit d6a756f

Browse files
authored
feat(auth): abuse-control primitives for app-owned org admission policy (#889)
* feat(auth): abuse-control primitives for org admission policy (#862) Levers in, policy out (per #857 — admission policy stays app-owned): - RateLimiter service: in-memory fixed-window, per-caller keyed, enforce(operation, subject) -> Effect<void, RateLimitExceeded>; no-op when no rule is configured - abuseControl.rateLimits per operation (createOrganization, createOnboardingCode, redeemOnboardingCode) in auth variables; off by default — no default policy ships - RATE_LIMITED declared on the three routes (every-plugin errors shape: retryAfterSeconds + limitType), failures flow through the existing mapDomainError seam - gate signals documented for app-side admission: user.isAnonymous, emailVerified, account age, NEAR linkage Multi-instance hosts share no counters — documented limitation; the enforce-shaped interface admits a shared-store adapter later. Issue: #862 * review(auth): 862 fixes — rotate guard, RATE_LIMITED declarations, limiter hygiene - rotateStandingOnboardingCode enforces the same limit as create; both it and createOnboardingCode now declare RATE_LIMITED (previously the declaration lived only on rotate while the guard lived only on create) - rate-limit.ts: prune expired buckets once the map exceeds 256 entries (per-caller keys never went away), export RateLimiterService instead of indexed RateLimiter["Service"] access, drop the dead RateDecision shape - every-plugin runtime: drop the inline comment duplicating the method doc; trim the doc to two lines - tests: the no-config no-op case asserts enforce resolves undefined instead of expect(true).toBe(true) Self-review of the series; behavior change: rotate is now rate-limited.
1 parent a26c120 commit d6a756f

15 files changed

Lines changed: 238 additions & 13 deletions

File tree

‎packages/every-plugin/src/runtime/index.ts‎

Lines changed: 3 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -187,12 +187,6 @@ export class PluginRuntime<R = RegisteredPlugins> {
187187
}
188188
}
189189

190-
// Create client factory that accepts request context. The plugin's
191-
// Effect context is injected so `.effect()` handlers resolve services
192-
// on in-process (server-side / SSR) calls. In development, a context
193-
// missing keys the plugin declares as required warns at construction —
194-
// the footgun this guards against is failing deep inside the first route
195-
// call, far from the factory call that dropped the credentials.
196190
const createClient = (context?: any) => {
197191
if (process.env.NODE_ENV !== "production") {
198192
this.warnOnMissingRequiredContext(initialized, context);
@@ -214,10 +208,9 @@ export class PluginRuntime<R = RegisteredPlugins> {
214208
}
215209

216210
/**
217-
* Warns once per (plugin, missing-keys) combination when a client is
218-
* created without context keys the plugin's declared context schema marks
219-
* required. Development-only: the runtime stays auth-agnostic — this reads
220-
* the schema, never the semantics of any particular key.
211+
* Warns once per (plugin, missing-keys) when a client is created without
212+
* context keys the plugin's declared context schema marks required.
213+
* Development-only and schema-driven — the runtime stays auth-agnostic.
221214
*/
222215
private warnOnMissingRequiredContext(
223216
initialized: InitializedPlugin<AnyPlugin>,

‎plugins/auth/src/auth-config.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import type {
44
SessionGasKeyDualNetworkConfig,
55
SubAccountConfig,
66
} from "better-near-auth";
7+
import type { RateRules } from "./services/rate-limit";
78

89
export type AuthNetwork = "mainnet" | "testnet";
910

@@ -83,6 +84,7 @@ export interface AuthConfig {
8384
baseUrl: string;
8485
network?: AuthNetwork;
8586
organizationMembershipLimit?: number;
87+
abuseControl?: { rateLimits?: RateRules };
8688
trustedOrigins?: string[];
8789
isProduction?: boolean;
8890
rateLimit?: AuthRateLimitConfig;

‎plugins/auth/src/config-schemas.ts‎

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,23 @@ export const authVariablesSchema = z.object({
9595
trustedOrigins: z.array(z.string()).optional(),
9696
apiKeyHeaders: z.array(z.string()).default(["x-api-key"]),
9797
organizationMembershipLimit: z.number().int().positive().optional(),
98+
abuseControl: z
99+
.object({
100+
rateLimits: z
101+
.object({
102+
createOrganization: z
103+
.object({ window: z.number().int().positive(), max: z.number().int().positive() })
104+
.optional(),
105+
createOnboardingCode: z
106+
.object({ window: z.number().int().positive(), max: z.number().int().positive() })
107+
.optional(),
108+
redeemOnboardingCode: z
109+
.object({ window: z.number().int().positive(), max: z.number().int().positive() })
110+
.optional(),
111+
})
112+
.optional(),
113+
})
114+
.optional(),
98115
socialProviders: z
99116
.object({
100117
github: z

‎plugins/auth/src/contract/errors.ts‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import {
33
BAD_REQUEST,
44
FORBIDDEN,
55
NOT_FOUND,
6+
RATE_LIMITED,
67
SERVICE_UNAVAILABLE,
78
UNAUTHORIZED,
89
} from "every-plugin/errors";
@@ -73,3 +74,8 @@ export const InvitationErrors = {
7374
...Errors,
7475
ALREADY_MEMBER,
7576
} as const;
77+
78+
export const OnboardingRateLimitedErrors = {
79+
...OnboardingErrors,
80+
RATE_LIMITED,
81+
} as const;

‎plugins/auth/src/contract/onboarding.contract.ts‎

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
import "@orpc/openapi/extensions/route";
22
import { oc } from "@orpc/contract";
33
import { z } from "zod";
4-
import { OnboardingErrors } from "./errors";
4+
import { OnboardingErrors, OnboardingRateLimitedErrors } from "./errors";
5+
56
import { onboardingCodeSummarySchema, onboardingStatsSchema } from "./schemas";
67

78
export const onboardingRoutes = {
@@ -34,7 +35,7 @@ export const onboardingRoutes = {
3435
.route({ method: "POST", path: "/v1/auth/onboarding/codes/standing/rotate" })
3536
.input(z.object({ organizationId: z.string().optional(), eventId: z.string().optional() }))
3637
.output(onboardingCodeSummarySchema.extend({ code: z.string() }))
37-
.errors(OnboardingErrors),
38+
.errors(OnboardingRateLimitedErrors),
3839

3940
getStandingOnboardingCode: oc
4041
.route({ method: "GET", path: "/v1/auth/onboarding/codes/standing" })
@@ -130,5 +131,5 @@ export const onboardingRoutes = {
130131
eventName: z.string().nullable(),
131132
}),
132133
)
133-
.errors(OnboardingErrors),
134+
.errors(OnboardingRateLimitedErrors),
134135
};

‎plugins/auth/src/handlers/onboarding.ts‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import type { Implementer } from "@orpc/server";
22
import type { ContractType } from "../contract";
33
import type { AuthHandlerContext, RequireAuthMiddleware } from "../middleware";
4+
import { RateLimiterTag } from "../service-types";
45
import { mapDomainError } from "../services/domain-errors";
56
import { OnboardingService } from "../services/onboarding";
67

@@ -13,6 +14,8 @@ export function createOnboardingHandlers(
1314
input,
1415
context,
1516
}) {
17+
const limiter = yield* RateLimiterTag;
18+
yield* mapDomainError(limiter.enforce("createOnboardingCode", context.userId));
1619
const onboarding = yield* OnboardingService;
1720
return yield* mapDomainError(onboarding.createOnboardingCode(context, input));
1821
}),
@@ -75,6 +78,8 @@ export function createOnboardingHandlers(
7578
rotateStandingOnboardingCode: builder.rotateStandingOnboardingCode
7679
.use(requireAuth)
7780
.effect(function* ({ input, context }) {
81+
const limiter = yield* RateLimiterTag;
82+
yield* mapDomainError(limiter.enforce("createOnboardingCode", context.userId));
7883
const onboarding = yield* OnboardingService;
7984
return yield* mapDomainError(onboarding.rotateStandingOnboardingCode(context, input));
8085
}),
@@ -90,6 +95,8 @@ export function createOnboardingHandlers(
9095
input,
9196
context,
9297
}) {
98+
const limiter = yield* RateLimiterTag;
99+
yield* mapDomainError(limiter.enforce("redeemOnboardingCode", context.userId));
93100
const onboarding = yield* OnboardingService;
94101
return yield* mapDomainError(onboarding.redeemOnboardingCode(context, input.code));
95102
}),

‎plugins/auth/src/handlers/organizations.ts‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import type {
66
AuthHandlerContext,
77
RequireAuthMiddleware,
88
} from "../middleware";
9+
import { RateLimiterTag } from "../service-types";
910
import { mapDomainError } from "../services/domain-errors";
1011
import { OrganizationsService } from "../services/organizations";
1112
import { createHeaders } from "../utils";
@@ -65,6 +66,8 @@ export function createOrganizationHandlers(
6566
input,
6667
context,
6768
}) {
69+
const limiter = yield* RateLimiterTag;
70+
yield* mapDomainError(limiter.enforce("createOrganization", context.userId));
6871
const organizations = yield* OrganizationsService;
6972
return yield* mapDomainError(
7073
organizations.createOrganization(createHeaders(context.reqHeaders), input),

‎plugins/auth/src/index.ts‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ import { createOrganizationMembershipPolicy } from "./organization-membership-po
2828
import { OrphanSweepLive } from "./orphan-sweep";
2929
import { AuthServicesTag } from "./service-types";
3030
import { buildAuthServicesLayer } from "./service-wiring";
31+
import { createRateLimiter } from "./services/rate-limit";
3132
import { toError } from "./utils";
3233
import { cookieDomainWarnings, socialProviderWarnings } from "./warnings";
3334

@@ -60,6 +61,7 @@ export default createPlugin.withPlugins<PluginsClient>()({
6061
authConfig.organizationMembershipLimit,
6162
);
6263
const onboardingCodeCipher = createOnboardingCodeCipher(authConfig.secret);
64+
const rateLimiter = createRateLimiter(config.variables.abuseControl?.rateLimits);
6365
const permissionRegistry = {
6466
statements: authConfig.siwn.permissions.statements,
6567
roleGrants: authConfig.siwn.permissions.roles,
@@ -86,6 +88,7 @@ export default createPlugin.withPlugins<PluginsClient>()({
8688
membershipPolicy,
8789
onboardingCodeCipher,
8890
permissionRegistry,
91+
rateLimiter,
8992
}),
9093
OrphanSweepLive(db),
9194
);

‎plugins/auth/src/middleware.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import {
1010
type MembershipPolicyTag,
1111
type OnboardingCodeCipherTag,
1212
type PluginServices,
13+
type RateLimiterTag,
1314
} from "./service-types";
1415
import type { AdminOrganizationsService } from "./services/admin-organizations";
1516
import type { AdminUsersService } from "./services/admin-users";
@@ -42,6 +43,7 @@ export type AuthEffectServices =
4243
| OnboardingService
4344
| PasskeysService
4445
| PermissionsService
46+
| RateLimiterTag
4547
| NearService
4648
| SessionService;
4749

‎plugins/auth/src/service-types.ts‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,11 @@ import type { Auth as ConfiguredAuth } from "./auth-instance";
33
import type { Database } from "./db/layer";
44
import type { OnboardingCodeCipher } from "./onboarding-code-cipher";
55
import type { OrganizationMembershipPolicy } from "./organization-membership-policy";
6+
import type { RateLimiterService } from "./services/rate-limit";
7+
8+
export class RateLimiterTag extends Context.Service<RateLimiterTag, RateLimiterService>()(
9+
"auth/RateLimiter",
10+
) {}
611

712
export type PermissionRegistry = {
813
statements: Record<string, string[]>;
@@ -17,6 +22,7 @@ export type PluginServices = {
1722
membershipPolicy: OrganizationMembershipPolicy;
1823
onboardingCodeCipher: OnboardingCodeCipher;
1924
permissionRegistry: PermissionRegistry;
25+
rateLimiter: RateLimiterService;
2026
};
2127

2228
export class AuthApiTag extends Context.Service<

0 commit comments

Comments
 (0)