@opengeni/api-router 2.1.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 (44) 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-QKDFBBUE.js → chunk-IBV7Z6F4.js} +3872 -1845
  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/integrations/slack-bot.d.ts +8 -0
  10. package/dist/integrations/slack-interactions.d.ts +35 -2
  11. package/dist/mcp/server.d.ts +1 -1
  12. package/dist/mcp/session-view.d.ts +1 -0
  13. package/dist/routes/automations.d.ts +13 -0
  14. package/dist/routes/insights.d.ts +2 -1
  15. package/dist/routes/managed-onboarding.d.ts +29 -0
  16. package/dist/routes/pr-review-github.d.ts +3 -0
  17. package/package.json +18 -18
  18. package/src/app.ts +64 -5
  19. package/src/auth/managed-auth.ts +29 -34
  20. package/src/auth/managed-email.ts +174 -0
  21. package/src/auth/organization-user-setup.ts +217 -0
  22. package/src/http/auth.ts +15 -0
  23. package/src/http/sse.ts +62 -13
  24. package/src/integrations/slack-app-home.ts +2 -2
  25. package/src/integrations/slack-bot.ts +5 -0
  26. package/src/integrations/slack-interactions.ts +653 -71
  27. package/src/integrations/slack-routing.ts +25 -12
  28. package/src/mcp/company-brain-governed-writes.ts +4 -4
  29. package/src/mcp/company-profile-agent-admin.ts +11 -18
  30. package/src/mcp/remember.ts +4 -4
  31. package/src/mcp/server.ts +50 -4
  32. package/src/mcp/session-view.ts +8 -2
  33. package/src/routes/automations.ts +3 -3
  34. package/src/routes/documents.ts +136 -3
  35. package/src/routes/insights.ts +61 -19
  36. package/src/routes/managed-onboarding.ts +317 -0
  37. package/src/routes/organization-memberships.ts +212 -155
  38. package/src/routes/pr-review-github.ts +844 -0
  39. package/src/routes/pr-review.ts +20 -0
  40. package/src/routes/rigs.ts +37 -4
  41. package/src/routes/sessions.ts +101 -0
  42. package/src/sandbox/channel-a.ts +34 -7
  43. package/src/sandbox/viewer.ts +47 -11
  44. package/dist/chunk-QKDFBBUE.js.map +0 -1
@@ -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
+ }