@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.
Files changed (39) hide show
  1. package/dist/app.js +1 -1
  2. package/dist/auth/managed-auth.d.ts +5 -2
  3. package/dist/auth/managed-email.d.ts +29 -0
  4. package/dist/auth/organization-user-setup.d.ts +57 -0
  5. package/dist/{chunk-3TP54PPX.js → chunk-IBV7Z6F4.js} +3545 -2009
  6. package/dist/chunk-IBV7Z6F4.js.map +1 -0
  7. package/dist/index.js +1 -1
  8. package/dist/integrations/slack-app-home.d.ts +1 -1
  9. package/dist/mcp/server.d.ts +1 -1
  10. package/dist/mcp/session-view.d.ts +1 -0
  11. package/dist/routes/automations.d.ts +13 -0
  12. package/dist/routes/insights.d.ts +2 -1
  13. package/dist/routes/managed-onboarding.d.ts +29 -0
  14. package/dist/routes/pr-review-github.d.ts +3 -0
  15. package/package.json +18 -18
  16. package/src/app.ts +56 -5
  17. package/src/auth/managed-auth.ts +29 -34
  18. package/src/auth/managed-email.ts +174 -0
  19. package/src/auth/organization-user-setup.ts +217 -0
  20. package/src/http/auth.ts +15 -0
  21. package/src/http/sse.ts +62 -13
  22. package/src/integrations/slack-app-home.ts +2 -2
  23. package/src/mcp/company-brain-governed-writes.ts +4 -4
  24. package/src/mcp/company-profile-agent-admin.ts +11 -18
  25. package/src/mcp/remember.ts +4 -4
  26. package/src/mcp/server.ts +50 -4
  27. package/src/mcp/session-view.ts +8 -2
  28. package/src/routes/automations.ts +3 -3
  29. package/src/routes/documents.ts +2 -0
  30. package/src/routes/insights.ts +61 -19
  31. package/src/routes/managed-onboarding.ts +317 -0
  32. package/src/routes/organization-memberships.ts +212 -155
  33. package/src/routes/pr-review-github.ts +844 -0
  34. package/src/routes/pr-review.ts +20 -0
  35. package/src/routes/rigs.ts +37 -4
  36. package/src/routes/sessions.ts +101 -0
  37. package/src/sandbox/channel-a.ts +34 -7
  38. package/src/sandbox/viewer.ts +47 -11
  39. package/dist/chunk-3TP54PPX.js.map +0 -1
@@ -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
- variableSetId: session.variableSetId,
596
- environmentId: session.environmentId,
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 acceptEvent(deps, source, {
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 acceptEvent(deps, source, {
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 acceptEvent(
360
+ export async function acceptAutomationEvent(
361
361
  deps: ApiRouteDeps,
362
362
  source: AutomationSourceSecret,
363
363
  input: {
@@ -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",
@@ -1,30 +1,72 @@
1
1
  import { InsightsRange, WorkspaceInsightsResponse } from "@opengeni/contracts";
2
- import { getWorkspaceInsights, requireAccessGrant, type ApiRouteDeps } from "@opengeni/core";
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 workspaceId = c.req.param("workspaceId");
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 rangeParsed = InsightsRange.safeParse(rangeRaw);
13
- if (!rangeParsed.success) {
14
- throw new HTTPException(400, {
15
- message: "range must be one of today|week|month|ytd",
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
+ }