@opengeni/api-router 2.2.0-canary.0 → 2.3.2-canary.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/app.js +1 -1
- package/dist/auth/managed-auth.d.ts +5 -2
- package/dist/auth/managed-email.d.ts +29 -0
- package/dist/auth/organization-user-setup.d.ts +57 -0
- package/dist/{chunk-3TP54PPX.js → chunk-IBV7Z6F4.js} +3545 -2009
- package/dist/chunk-IBV7Z6F4.js.map +1 -0
- package/dist/index.js +1 -1
- package/dist/integrations/slack-app-home.d.ts +1 -1
- package/dist/mcp/server.d.ts +1 -1
- package/dist/mcp/session-view.d.ts +1 -0
- package/dist/routes/automations.d.ts +13 -0
- package/dist/routes/insights.d.ts +2 -1
- package/dist/routes/managed-onboarding.d.ts +29 -0
- package/dist/routes/pr-review-github.d.ts +3 -0
- package/package.json +18 -18
- package/src/app.ts +56 -5
- package/src/auth/managed-auth.ts +29 -34
- package/src/auth/managed-email.ts +174 -0
- package/src/auth/organization-user-setup.ts +217 -0
- package/src/http/auth.ts +15 -0
- package/src/http/sse.ts +62 -13
- package/src/integrations/slack-app-home.ts +2 -2
- package/src/mcp/company-brain-governed-writes.ts +4 -4
- package/src/mcp/company-profile-agent-admin.ts +11 -18
- package/src/mcp/remember.ts +4 -4
- package/src/mcp/server.ts +50 -4
- package/src/mcp/session-view.ts +8 -2
- package/src/routes/automations.ts +3 -3
- package/src/routes/documents.ts +2 -0
- package/src/routes/insights.ts +61 -19
- package/src/routes/managed-onboarding.ts +317 -0
- package/src/routes/organization-memberships.ts +212 -155
- package/src/routes/pr-review-github.ts +844 -0
- package/src/routes/pr-review.ts +20 -0
- package/src/routes/rigs.ts +37 -4
- package/src/routes/sessions.ts +101 -0
- package/src/sandbox/channel-a.ts +34 -7
- package/src/sandbox/viewer.ts +47 -11
- package/dist/chunk-3TP54PPX.js.map +0 -1
package/src/mcp/session-view.ts
CHANGED
|
@@ -534,6 +534,11 @@ export function boundSessionDetailMcp(
|
|
|
534
534
|
effectiveControl: unknown = session.effectiveControl,
|
|
535
535
|
maxBytes = SESSION_DETAIL_MCP_MAX_BYTES,
|
|
536
536
|
) {
|
|
537
|
+
const variableSetIds = session.variableSetIds;
|
|
538
|
+
// The plural selection is authoritative. Keep the singular/environment
|
|
539
|
+
// fields only as final-entry compatibility aliases so they cannot be read as
|
|
540
|
+
// complete attachment membership by newer consumers.
|
|
541
|
+
const variableSetId = variableSetIds.at(-1) ?? null;
|
|
537
542
|
const title = session.title === null ? null : modelStringProjection(session.title, 512);
|
|
538
543
|
const initialMessage = modelStringProjection(session.initialMessage, 4_000);
|
|
539
544
|
const instructions =
|
|
@@ -592,8 +597,9 @@ export function boundSessionDetailMcp(
|
|
|
592
597
|
sandboxGroupId: session.sandboxGroupId,
|
|
593
598
|
activeSandboxId: session.activeSandboxId,
|
|
594
599
|
activeEpoch: session.activeEpoch,
|
|
595
|
-
|
|
596
|
-
|
|
600
|
+
variableSetIds,
|
|
601
|
+
variableSetId,
|
|
602
|
+
environmentId: variableSetId,
|
|
597
603
|
rigId: session.rigId,
|
|
598
604
|
rigVersionId: session.rigVersionId,
|
|
599
605
|
firstPartyMcpPermissions: permissions.value,
|
|
@@ -278,7 +278,7 @@ export function registerAutomationRoutes(app: Hono, deps: ApiRouteDeps): void {
|
|
|
278
278
|
});
|
|
279
279
|
const bytes = new TextEncoder().encode(stableJson(request));
|
|
280
280
|
return c.json(
|
|
281
|
-
await
|
|
281
|
+
await acceptAutomationEvent(deps, source, {
|
|
282
282
|
deliveryKey:
|
|
283
283
|
request.deliveryId ?? `manual:${automationRequestDigest(source.adapterId, bytes)}`,
|
|
284
284
|
requestDigest: automationRequestDigest(source.adapterId, bytes),
|
|
@@ -335,7 +335,7 @@ export function registerAutomationRoutes(app: Hono, deps: ApiRouteDeps): void {
|
|
|
335
335
|
}
|
|
336
336
|
const requestDigest = automationRequestDigest(source.adapterId, rawBody);
|
|
337
337
|
try {
|
|
338
|
-
const result = await
|
|
338
|
+
const result = await acceptAutomationEvent(deps, source, {
|
|
339
339
|
deliveryKey: adapter.deliveryKey({
|
|
340
340
|
headers: c.req.raw.headers,
|
|
341
341
|
requestDigest,
|
|
@@ -357,7 +357,7 @@ export function registerAutomationRoutes(app: Hono, deps: ApiRouteDeps): void {
|
|
|
357
357
|
});
|
|
358
358
|
}
|
|
359
359
|
|
|
360
|
-
async function
|
|
360
|
+
export async function acceptAutomationEvent(
|
|
361
361
|
deps: ApiRouteDeps,
|
|
362
362
|
source: AutomationSourceSecret,
|
|
363
363
|
input: {
|
package/src/routes/documents.ts
CHANGED
|
@@ -594,6 +594,7 @@ export function registerDocumentRoutes(app: Hono, deps: ApiRouteDeps): void {
|
|
|
594
594
|
limit: payload.limit,
|
|
595
595
|
mode: payload.mode,
|
|
596
596
|
sourceKinds: payload.sourceKinds,
|
|
597
|
+
authorityKinds: payload.authorityKinds,
|
|
597
598
|
aclTags: payload.aclTags,
|
|
598
599
|
initiatingSubjectId: grant.subjectId,
|
|
599
600
|
surface: "human",
|
|
@@ -620,6 +621,7 @@ export function registerDocumentRoutes(app: Hono, deps: ApiRouteDeps): void {
|
|
|
620
621
|
limit: payload.limit,
|
|
621
622
|
mode: payload.mode,
|
|
622
623
|
sourceKinds: payload.sourceKinds,
|
|
624
|
+
authorityKinds: payload.authorityKinds,
|
|
623
625
|
aclTags: payload.aclTags,
|
|
624
626
|
initiatingSubjectId: grant.subjectId,
|
|
625
627
|
surface: "human",
|
package/src/routes/insights.ts
CHANGED
|
@@ -1,30 +1,72 @@
|
|
|
1
1
|
import { InsightsRange, WorkspaceInsightsResponse } from "@opengeni/contracts";
|
|
2
|
-
import {
|
|
2
|
+
import {
|
|
3
|
+
getWorkspaceInsights,
|
|
4
|
+
normalizeWorkspaceInsightsFilter,
|
|
5
|
+
requireAccessGrant,
|
|
6
|
+
WorkspaceInsightsFilterValidationError,
|
|
7
|
+
type ApiRouteDeps,
|
|
8
|
+
type WorkspaceInsightsFilterField,
|
|
9
|
+
} from "@opengeni/core";
|
|
10
|
+
import { workspaceInsightsMetricObserver } from "@opengeni/observability";
|
|
3
11
|
import type { Hono } from "hono";
|
|
4
12
|
import { HTTPException } from "hono/http-exception";
|
|
5
13
|
|
|
14
|
+
export function normalizeWorkspaceInsightsQueryFilter(
|
|
15
|
+
value: string | null | undefined,
|
|
16
|
+
field: WorkspaceInsightsFilterField,
|
|
17
|
+
): string | null {
|
|
18
|
+
try {
|
|
19
|
+
return normalizeWorkspaceInsightsFilter(value, field);
|
|
20
|
+
} catch (error) {
|
|
21
|
+
if (error instanceof WorkspaceInsightsFilterValidationError) {
|
|
22
|
+
throw new HTTPException(400, { message: error.message });
|
|
23
|
+
}
|
|
24
|
+
throw error;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
6
28
|
export function registerInsightsRoutes(app: Hono, deps: ApiRouteDeps): void {
|
|
29
|
+
const observeRequest = workspaceInsightsMetricObserver(deps.observability);
|
|
7
30
|
app.get("/v1/workspaces/:workspaceId/insights", async (c) => {
|
|
8
|
-
const
|
|
9
|
-
await requireAccessGrant(c, deps, workspaceId, "workspace:admin");
|
|
10
|
-
|
|
31
|
+
const startedAtMs = performance.now();
|
|
11
32
|
const rangeRaw = c.req.query("range") ?? "week";
|
|
12
|
-
const
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
33
|
+
const providerRaw = c.req.query("provider");
|
|
34
|
+
const modelRaw = c.req.query("model");
|
|
35
|
+
let provider: string | null = null;
|
|
36
|
+
let model: string | null = null;
|
|
37
|
+
let outcome = "failed";
|
|
38
|
+
|
|
39
|
+
try {
|
|
40
|
+
const workspaceId = c.req.param("workspaceId");
|
|
41
|
+
await requireAccessGrant(c, deps, workspaceId, "workspace:admin");
|
|
42
|
+
|
|
43
|
+
const rangeParsed = InsightsRange.safeParse(rangeRaw);
|
|
44
|
+
if (!rangeParsed.success) {
|
|
45
|
+
throw new HTTPException(400, {
|
|
46
|
+
message: "range must be one of today|week|month|ytd",
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
provider = normalizeWorkspaceInsightsQueryFilter(providerRaw, "provider");
|
|
50
|
+
model = normalizeWorkspaceInsightsQueryFilter(modelRaw, "model");
|
|
51
|
+
|
|
52
|
+
const response = await getWorkspaceInsights(deps.db, deps.settings, {
|
|
53
|
+
workspaceId,
|
|
54
|
+
range: rangeParsed.data,
|
|
55
|
+
provider,
|
|
56
|
+
model,
|
|
57
|
+
});
|
|
58
|
+
c.header("cache-control", "private, no-store");
|
|
59
|
+
const result = c.json(WorkspaceInsightsResponse.parse(response));
|
|
60
|
+
outcome = "completed";
|
|
61
|
+
return result;
|
|
62
|
+
} finally {
|
|
63
|
+
observeRequest({
|
|
64
|
+
range: rangeRaw,
|
|
65
|
+
providerFiltered: provider !== null,
|
|
66
|
+
modelFiltered: model !== null,
|
|
67
|
+
outcome,
|
|
68
|
+
durationMs: performance.now() - startedAtMs,
|
|
16
69
|
});
|
|
17
70
|
}
|
|
18
|
-
const provider = c.req.query("provider");
|
|
19
|
-
const model = c.req.query("model");
|
|
20
|
-
|
|
21
|
-
const response = await getWorkspaceInsights(deps.db, deps.settings, {
|
|
22
|
-
workspaceId,
|
|
23
|
-
range: rangeParsed.data,
|
|
24
|
-
provider: provider && provider !== "all" ? provider : null,
|
|
25
|
-
model: model && model !== "all" ? model : null,
|
|
26
|
-
});
|
|
27
|
-
c.header("cache-control", "private, no-store");
|
|
28
|
-
return c.json(WorkspaceInsightsResponse.parse(response));
|
|
29
71
|
});
|
|
30
72
|
}
|
|
@@ -0,0 +1,317 @@
|
|
|
1
|
+
import {
|
|
2
|
+
CompleteSelfServiceOrganizationSetupRequest,
|
|
3
|
+
CompleteSelfServiceOrganizationSetupResponse,
|
|
4
|
+
CompleteOrganizationUserSetupRequest,
|
|
5
|
+
CompleteOrganizationUserSetupResponse,
|
|
6
|
+
OrganizationUserSetupPreview,
|
|
7
|
+
PreviewOrganizationUserSetupRequest,
|
|
8
|
+
SelfServiceOrganizationOnboardingStatus,
|
|
9
|
+
} from "@opengeni/contracts";
|
|
10
|
+
import { getManagedSession, type ApiRouteDeps } from "@opengeni/core";
|
|
11
|
+
import {
|
|
12
|
+
completeSelfServiceOrganizationSetup,
|
|
13
|
+
completeOrganizationUserSetup,
|
|
14
|
+
getSelfServiceOrganizationOnboardingState,
|
|
15
|
+
nestedPostgresSqlState,
|
|
16
|
+
preflightOrganizationUserSetup,
|
|
17
|
+
previewOrganizationUserSetup,
|
|
18
|
+
} from "@opengeni/db";
|
|
19
|
+
import type { Context, Hono } from "hono";
|
|
20
|
+
import { HTTPException } from "hono/http-exception";
|
|
21
|
+
|
|
22
|
+
import {
|
|
23
|
+
organizationUserSetupRequestFingerprint,
|
|
24
|
+
organizationUserSetupTokenDigest,
|
|
25
|
+
selfServiceOrganizationSetupRequestFingerprint,
|
|
26
|
+
} from "../auth/organization-user-setup";
|
|
27
|
+
import { hashManagedAuthPassword } from "../auth/managed-auth";
|
|
28
|
+
|
|
29
|
+
export type ManagedOnboardingRouteOptions = {
|
|
30
|
+
accountSetupLimiter?: { take(key: string): boolean };
|
|
31
|
+
hashPassword?: (password: string) => Promise<string>;
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
const completedSetupReplayPasswordHash = `completed-replay:${"0".repeat(64)}`;
|
|
35
|
+
|
|
36
|
+
export function registerManagedOnboardingRoutes(
|
|
37
|
+
app: Hono,
|
|
38
|
+
deps: ApiRouteDeps,
|
|
39
|
+
options: ManagedOnboardingRouteOptions = {},
|
|
40
|
+
): void {
|
|
41
|
+
const accountSetupLimiter = options.accountSetupLimiter ?? new PublicSetupRateLimiter();
|
|
42
|
+
const hashPassword = options.hashPassword ?? hashManagedAuthPassword;
|
|
43
|
+
app.get("/v1/auth/organization-onboarding", async (context) => {
|
|
44
|
+
const session = await requireManagedHuman(context, deps);
|
|
45
|
+
return context.json(
|
|
46
|
+
SelfServiceOrganizationOnboardingStatus.parse({
|
|
47
|
+
state: await getSelfServiceOrganizationOnboardingState(deps.db, {
|
|
48
|
+
authUserId: session.user.id,
|
|
49
|
+
email: session.user.email,
|
|
50
|
+
emailVerified: session.user.emailVerified,
|
|
51
|
+
}),
|
|
52
|
+
}),
|
|
53
|
+
);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
app.post("/v1/auth/organization-onboarding", async (context) => {
|
|
57
|
+
const session = await requireManagedHuman(context, deps);
|
|
58
|
+
const parsed = CompleteSelfServiceOrganizationSetupRequest.safeParse(
|
|
59
|
+
await context.req.json().catch(() => null),
|
|
60
|
+
);
|
|
61
|
+
if (!parsed.success) {
|
|
62
|
+
throw new HTTPException(422, {
|
|
63
|
+
message: "invalid organization setup request",
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
const organizationName = parsed.data.organizationName.trim();
|
|
67
|
+
try {
|
|
68
|
+
const requestFingerprint = await selfServiceOrganizationSetupRequestFingerprint({
|
|
69
|
+
authUserId: session.user.id,
|
|
70
|
+
organizationName,
|
|
71
|
+
});
|
|
72
|
+
return context.json(
|
|
73
|
+
CompleteSelfServiceOrganizationSetupResponse.parse(
|
|
74
|
+
await completeSelfServiceOrganizationSetup(deps.db, {
|
|
75
|
+
authUserId: session.user.id,
|
|
76
|
+
actorSubjectId: `user:${session.user.id}`,
|
|
77
|
+
organizationName,
|
|
78
|
+
operationId: parsed.data.operationId,
|
|
79
|
+
requestFingerprint,
|
|
80
|
+
}),
|
|
81
|
+
),
|
|
82
|
+
);
|
|
83
|
+
} catch (error) {
|
|
84
|
+
const sqlState = nestedPostgresSqlState(error);
|
|
85
|
+
if (sqlState === "22023") {
|
|
86
|
+
throw new HTTPException(422, {
|
|
87
|
+
message: "invalid organization setup request",
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
if (sqlState === "42501") {
|
|
91
|
+
throw new HTTPException(403, {
|
|
92
|
+
message: "verified managed user required",
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
if (sqlState === "23505" || sqlState === "55000") {
|
|
96
|
+
throw new HTTPException(409, {
|
|
97
|
+
message: "organization setup is no longer available; refresh to continue",
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
throw error;
|
|
101
|
+
}
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
app.post("/v1/auth/organization-setup/preview", async (context) => {
|
|
105
|
+
if (deps.settings.productAccessMode !== "managed" || !deps.managedAuth) {
|
|
106
|
+
throw new HTTPException(404, { message: "account setup is unavailable" });
|
|
107
|
+
}
|
|
108
|
+
enforceAccountSetupRateLimit(context, accountSetupLimiter);
|
|
109
|
+
const parsed = PreviewOrganizationUserSetupRequest.safeParse(
|
|
110
|
+
await context.req.json().catch(() => null),
|
|
111
|
+
);
|
|
112
|
+
if (!parsed.success) {
|
|
113
|
+
throw new HTTPException(422, { message: "invalid account setup preview request" });
|
|
114
|
+
}
|
|
115
|
+
return context.json(
|
|
116
|
+
OrganizationUserSetupPreview.parse(
|
|
117
|
+
await previewOrganizationUserSetup(
|
|
118
|
+
deps.db,
|
|
119
|
+
await organizationUserSetupTokenDigest(parsed.data.token),
|
|
120
|
+
),
|
|
121
|
+
),
|
|
122
|
+
);
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
app.post("/v1/auth/organization-setup", async (context) => {
|
|
126
|
+
if (deps.settings.productAccessMode !== "managed" || !deps.managedAuth) {
|
|
127
|
+
throw new HTTPException(404, { message: "account setup is unavailable" });
|
|
128
|
+
}
|
|
129
|
+
enforceAccountSetupRateLimit(context, accountSetupLimiter);
|
|
130
|
+
const parsed = CompleteOrganizationUserSetupRequest.safeParse(
|
|
131
|
+
await context.req.json().catch(() => null),
|
|
132
|
+
);
|
|
133
|
+
if (!parsed.success) {
|
|
134
|
+
throw new HTTPException(422, {
|
|
135
|
+
message: "invalid account setup request",
|
|
136
|
+
});
|
|
137
|
+
}
|
|
138
|
+
const name = parsed.data.name.trim();
|
|
139
|
+
const tokenDigest = await organizationUserSetupTokenDigest(parsed.data.token);
|
|
140
|
+
const preflight = await preflightOrganizationUserSetup(deps.db, tokenDigest);
|
|
141
|
+
if (preflight === "unavailable") {
|
|
142
|
+
throw new HTTPException(404, {
|
|
143
|
+
message: "account setup link is invalid, expired, or already belongs to an account",
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
const requestFingerprint = await organizationUserSetupRequestFingerprint(deps.settings, {
|
|
147
|
+
tokenDigest,
|
|
148
|
+
name,
|
|
149
|
+
password: parsed.data.password,
|
|
150
|
+
});
|
|
151
|
+
const passwordHash =
|
|
152
|
+
preflight === "completed"
|
|
153
|
+
? completedSetupReplayPasswordHash
|
|
154
|
+
: await hashPassword(parsed.data.password);
|
|
155
|
+
try {
|
|
156
|
+
return context.json(
|
|
157
|
+
CompleteOrganizationUserSetupResponse.parse(
|
|
158
|
+
await completeOrganizationUserSetup(deps.db, {
|
|
159
|
+
tokenDigest,
|
|
160
|
+
operationId: parsed.data.operationId,
|
|
161
|
+
requestFingerprint,
|
|
162
|
+
authUserId: crypto.randomUUID(),
|
|
163
|
+
name,
|
|
164
|
+
passwordHash,
|
|
165
|
+
}),
|
|
166
|
+
),
|
|
167
|
+
);
|
|
168
|
+
} catch (error) {
|
|
169
|
+
const sqlState = nestedPostgresSqlState(error);
|
|
170
|
+
if (sqlState === "22023") {
|
|
171
|
+
throw new HTTPException(422, {
|
|
172
|
+
message: "invalid account setup request",
|
|
173
|
+
});
|
|
174
|
+
}
|
|
175
|
+
if (sqlState === "23505") {
|
|
176
|
+
throw new HTTPException(409, {
|
|
177
|
+
message: "account setup request changed; reopen the original link and try again",
|
|
178
|
+
});
|
|
179
|
+
}
|
|
180
|
+
if (sqlState === "P0002" || sqlState === "42501" || sqlState === "55000") {
|
|
181
|
+
throw new HTTPException(404, {
|
|
182
|
+
message: "account setup link is invalid, expired, or already belongs to an account",
|
|
183
|
+
});
|
|
184
|
+
}
|
|
185
|
+
throw error;
|
|
186
|
+
}
|
|
187
|
+
});
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function enforceAccountSetupRateLimit(
|
|
191
|
+
context: Context,
|
|
192
|
+
limiter: { take(key: string): boolean },
|
|
193
|
+
): void {
|
|
194
|
+
let allowed = false;
|
|
195
|
+
try {
|
|
196
|
+
allowed = limiter.take(accountSetupClientKey(context));
|
|
197
|
+
} catch {
|
|
198
|
+
// A public credential-setting endpoint must fail closed if its abuse gate
|
|
199
|
+
// cannot make a decision.
|
|
200
|
+
}
|
|
201
|
+
if (!allowed) {
|
|
202
|
+
throw new HTTPException(429, { message: "too many account setup requests; slow down" });
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
function accountSetupClientKey(context: Context): string {
|
|
207
|
+
const forwarded = context.req.header("x-forwarded-for")?.split(",")[0]?.trim();
|
|
208
|
+
const address = forwarded || context.req.header("x-real-ip")?.trim() || "unknown";
|
|
209
|
+
return address.slice(0, 128);
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* Bounded application-tier protection for the public setup bearer endpoint.
|
|
214
|
+
* The global bucket prevents spoofed/high-cardinality client keys from
|
|
215
|
+
* multiplying password-hash work, while the per-client bucket limits bursts.
|
|
216
|
+
* A full key map rejects new clients until an idle bucket can be pruned.
|
|
217
|
+
*/
|
|
218
|
+
export class PublicSetupRateLimiter {
|
|
219
|
+
private readonly buckets = new Map<string, { tokens: number; updatedAt: number }>();
|
|
220
|
+
private global: { tokens: number; updatedAt: number };
|
|
221
|
+
|
|
222
|
+
constructor(
|
|
223
|
+
private readonly options: {
|
|
224
|
+
globalCapacity?: number;
|
|
225
|
+
globalRefillPerSecond?: number;
|
|
226
|
+
clientCapacity?: number;
|
|
227
|
+
clientRefillPerSecond?: number;
|
|
228
|
+
maxClientKeys?: number;
|
|
229
|
+
now?: () => number;
|
|
230
|
+
} = {},
|
|
231
|
+
) {
|
|
232
|
+
this.global = {
|
|
233
|
+
tokens: options.globalCapacity ?? 50,
|
|
234
|
+
updatedAt: options.now?.() ?? Date.now(),
|
|
235
|
+
};
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
take(key: string): boolean {
|
|
239
|
+
const now = this.options.now?.() ?? Date.now();
|
|
240
|
+
const globalCapacity = this.options.globalCapacity ?? 50;
|
|
241
|
+
if (
|
|
242
|
+
!takeSetupRateLimitToken(
|
|
243
|
+
this.global,
|
|
244
|
+
globalCapacity,
|
|
245
|
+
this.options.globalRefillPerSecond ?? 5,
|
|
246
|
+
now,
|
|
247
|
+
)
|
|
248
|
+
) {
|
|
249
|
+
return false;
|
|
250
|
+
}
|
|
251
|
+
const clientCapacity = this.options.clientCapacity ?? 5;
|
|
252
|
+
const maxClientKeys = this.options.maxClientKeys ?? 2_048;
|
|
253
|
+
let bucket = this.buckets.get(key);
|
|
254
|
+
if (!bucket && this.buckets.size >= maxClientKeys) {
|
|
255
|
+
for (const [candidateKey, candidate] of this.buckets) {
|
|
256
|
+
refillSetupRateLimitBucket(
|
|
257
|
+
candidate,
|
|
258
|
+
clientCapacity,
|
|
259
|
+
this.options.clientRefillPerSecond ?? 0.1,
|
|
260
|
+
now,
|
|
261
|
+
);
|
|
262
|
+
if (candidate.tokens >= clientCapacity) this.buckets.delete(candidateKey);
|
|
263
|
+
}
|
|
264
|
+
if (this.buckets.size >= maxClientKeys) return false;
|
|
265
|
+
}
|
|
266
|
+
bucket ??= { tokens: clientCapacity, updatedAt: now };
|
|
267
|
+
const allowed = takeSetupRateLimitToken(
|
|
268
|
+
bucket,
|
|
269
|
+
clientCapacity,
|
|
270
|
+
this.options.clientRefillPerSecond ?? 0.1,
|
|
271
|
+
now,
|
|
272
|
+
);
|
|
273
|
+
this.buckets.set(key, bucket);
|
|
274
|
+
return allowed;
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
function takeSetupRateLimitToken(
|
|
279
|
+
bucket: { tokens: number; updatedAt: number },
|
|
280
|
+
capacity: number,
|
|
281
|
+
refillPerSecond: number,
|
|
282
|
+
now: number,
|
|
283
|
+
): boolean {
|
|
284
|
+
refillSetupRateLimitBucket(bucket, capacity, refillPerSecond, now);
|
|
285
|
+
if (bucket.tokens < 1) return false;
|
|
286
|
+
bucket.tokens -= 1;
|
|
287
|
+
return true;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
function refillSetupRateLimitBucket(
|
|
291
|
+
bucket: { tokens: number; updatedAt: number },
|
|
292
|
+
capacity: number,
|
|
293
|
+
refillPerSecond: number,
|
|
294
|
+
now: number,
|
|
295
|
+
): void {
|
|
296
|
+
const elapsedSeconds = Math.max(0, (now - bucket.updatedAt) / 1_000);
|
|
297
|
+
bucket.tokens = Math.min(capacity, bucket.tokens + elapsedSeconds * refillPerSecond);
|
|
298
|
+
bucket.updatedAt = now;
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
async function requireManagedHuman(context: Context, deps: ApiRouteDeps) {
|
|
302
|
+
if (
|
|
303
|
+
deps.settings.productAccessMode !== "managed" ||
|
|
304
|
+
!deps.managedAuth ||
|
|
305
|
+
!context.req.header("cookie") ||
|
|
306
|
+
context.req.header("authorization")
|
|
307
|
+
) {
|
|
308
|
+
throw new HTTPException(401, { message: "managed human session required" });
|
|
309
|
+
}
|
|
310
|
+
const session = await getManagedSession(context, deps.managedAuth, {
|
|
311
|
+
db: deps.db,
|
|
312
|
+
});
|
|
313
|
+
if (!session?.user) {
|
|
314
|
+
throw new HTTPException(401, { message: "managed human session required" });
|
|
315
|
+
}
|
|
316
|
+
return session;
|
|
317
|
+
}
|