@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.
- 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-QKDFBBUE.js → chunk-IBV7Z6F4.js} +3872 -1845
- 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/integrations/slack-bot.d.ts +8 -0
- package/dist/integrations/slack-interactions.d.ts +35 -2
- 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 +64 -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/integrations/slack-bot.ts +5 -0
- package/src/integrations/slack-interactions.ts +653 -71
- package/src/integrations/slack-routing.ts +25 -12
- 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 +136 -3
- 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-QKDFBBUE.js.map +0 -1
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
import type { Settings } from "@opengeni/config";
|
|
2
|
+
import type {
|
|
3
|
+
ManagedEmailDeliveryResult,
|
|
4
|
+
ManagedEmailMessage,
|
|
5
|
+
ManagedEmailTransport,
|
|
6
|
+
} from "@opengeni/core";
|
|
7
|
+
import { createHmac } from "node:crypto";
|
|
8
|
+
import { Resend } from "resend";
|
|
9
|
+
|
|
10
|
+
const CAPTURE_MAX_MESSAGES = 250;
|
|
11
|
+
const CAPTURE_TTL_MS = 15 * 60 * 1000;
|
|
12
|
+
|
|
13
|
+
export type CapturedManagedEmail = ManagedEmailMessage & {
|
|
14
|
+
capturedAt: string;
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Process-local test/development transport. Captures are count/TTL bounded and
|
|
19
|
+
* one-time readable; there is deliberately no route, disk, database, or log
|
|
20
|
+
* projection for message bodies or setup bearers.
|
|
21
|
+
*/
|
|
22
|
+
export class InMemoryManagedEmailTransport implements ManagedEmailTransport {
|
|
23
|
+
private readonly messages: CapturedManagedEmail[] = [];
|
|
24
|
+
readonly sender: string;
|
|
25
|
+
readonly idempotency: ManagedEmailTransport["idempotency"];
|
|
26
|
+
|
|
27
|
+
constructor(
|
|
28
|
+
private readonly options: {
|
|
29
|
+
maxMessages?: number;
|
|
30
|
+
ttlMs?: number;
|
|
31
|
+
now?: () => number;
|
|
32
|
+
sender?: string;
|
|
33
|
+
idempotency?: ManagedEmailTransport["idempotency"];
|
|
34
|
+
} = {},
|
|
35
|
+
) {
|
|
36
|
+
this.sender = options.sender ?? "OpenGeni <auth@mail.opengeni.ai>";
|
|
37
|
+
this.idempotency = options.idempotency ?? {
|
|
38
|
+
scope: "opengeni-in-memory-v1",
|
|
39
|
+
retentionSeconds: 86_400,
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
async send(message: ManagedEmailMessage): Promise<ManagedEmailDeliveryResult> {
|
|
44
|
+
this.prune();
|
|
45
|
+
const maxMessages = this.options.maxMessages ?? CAPTURE_MAX_MESSAGES;
|
|
46
|
+
if (this.messages.length >= maxMessages) this.messages.splice(0, 1);
|
|
47
|
+
this.messages.push({
|
|
48
|
+
...message,
|
|
49
|
+
capturedAt: new Date(this.now()).toISOString(),
|
|
50
|
+
});
|
|
51
|
+
return { status: "sent", providerMessageId: null };
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
take(predicate: (message: CapturedManagedEmail) => boolean): CapturedManagedEmail | null {
|
|
55
|
+
this.prune();
|
|
56
|
+
const index = this.messages.findIndex(predicate);
|
|
57
|
+
if (index < 0) return null;
|
|
58
|
+
return this.messages.splice(index, 1)[0] ?? null;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
size(): number {
|
|
62
|
+
this.prune();
|
|
63
|
+
return this.messages.length;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
private prune(): void {
|
|
67
|
+
const cutoff = this.now() - (this.options.ttlMs ?? CAPTURE_TTL_MS);
|
|
68
|
+
while (this.messages[0] && Date.parse(this.messages[0].capturedAt) <= cutoff) {
|
|
69
|
+
this.messages.shift();
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
private now(): number {
|
|
74
|
+
return this.options.now?.() ?? Date.now();
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
class UnconfiguredManagedEmailTransport implements ManagedEmailTransport {
|
|
79
|
+
readonly idempotency = {
|
|
80
|
+
scope: "opengeni-unconfigured-v1",
|
|
81
|
+
retentionSeconds: 0,
|
|
82
|
+
} as const;
|
|
83
|
+
|
|
84
|
+
constructor(readonly sender: string) {}
|
|
85
|
+
|
|
86
|
+
async send(): Promise<ManagedEmailDeliveryResult> {
|
|
87
|
+
return { status: "failed", errorClass: "provider_not_configured" };
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
class ResendManagedEmailTransport implements ManagedEmailTransport {
|
|
92
|
+
private readonly client: Resend;
|
|
93
|
+
readonly idempotency: ManagedEmailTransport["idempotency"];
|
|
94
|
+
|
|
95
|
+
constructor(
|
|
96
|
+
apiKey: string,
|
|
97
|
+
readonly sender: string,
|
|
98
|
+
scopeSecret: string,
|
|
99
|
+
) {
|
|
100
|
+
this.client = new Resend(apiKey);
|
|
101
|
+
this.idempotency = {
|
|
102
|
+
// The keyed digest binds this delivery to the Resend account without
|
|
103
|
+
// persisting an API-key derivative that can be tested offline.
|
|
104
|
+
scope: `resend-v1-24h:${createHmac("sha256", scopeSecret).update(apiKey).digest("hex")}`,
|
|
105
|
+
retentionSeconds: 86_400,
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
async send(message: ManagedEmailMessage): Promise<ManagedEmailDeliveryResult> {
|
|
110
|
+
if (message.from !== this.sender) {
|
|
111
|
+
return { status: "failed", errorClass: "sender_changed" };
|
|
112
|
+
}
|
|
113
|
+
try {
|
|
114
|
+
const result = await this.client.emails.send(
|
|
115
|
+
{
|
|
116
|
+
from: message.from,
|
|
117
|
+
to: message.to,
|
|
118
|
+
subject: message.subject,
|
|
119
|
+
text: message.text,
|
|
120
|
+
html: message.html,
|
|
121
|
+
},
|
|
122
|
+
message.idempotencyKey ? { idempotencyKey: message.idempotencyKey } : undefined,
|
|
123
|
+
);
|
|
124
|
+
if (!result.error) {
|
|
125
|
+
return { status: "sent", providerMessageId: result.data?.id ?? null };
|
|
126
|
+
}
|
|
127
|
+
const statusCode = "statusCode" in result.error ? Number(result.error.statusCode) : NaN;
|
|
128
|
+
return clearProviderRefusal(statusCode)
|
|
129
|
+
? { status: "failed", errorClass: boundedErrorClass(result.error.name, "provider_refused") }
|
|
130
|
+
: {
|
|
131
|
+
status: "outcome_unknown",
|
|
132
|
+
errorClass: boundedErrorClass(result.error.name, "provider_ambiguous"),
|
|
133
|
+
};
|
|
134
|
+
} catch (error) {
|
|
135
|
+
return {
|
|
136
|
+
status: "outcome_unknown",
|
|
137
|
+
errorClass: boundedErrorClass(
|
|
138
|
+
error instanceof Error ? error.name : "transport_error",
|
|
139
|
+
"transport_error",
|
|
140
|
+
),
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
export function createManagedEmailTransport(settings: Settings): ManagedEmailTransport {
|
|
147
|
+
if (settings.resendApiKey) {
|
|
148
|
+
if (!settings.betterAuthSecret) {
|
|
149
|
+
throw new Error("OPENGENI_BETTER_AUTH_SECRET is required for managed email delivery");
|
|
150
|
+
}
|
|
151
|
+
return new ResendManagedEmailTransport(
|
|
152
|
+
settings.resendApiKey,
|
|
153
|
+
settings.emailFrom,
|
|
154
|
+
settings.betterAuthSecret,
|
|
155
|
+
);
|
|
156
|
+
}
|
|
157
|
+
if (settings.environment === "local" || settings.environment === "test") {
|
|
158
|
+
return new InMemoryManagedEmailTransport({ sender: settings.emailFrom });
|
|
159
|
+
}
|
|
160
|
+
return new UnconfiguredManagedEmailTransport(settings.emailFrom);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function clearProviderRefusal(statusCode: number): boolean {
|
|
164
|
+
return statusCode >= 400 && statusCode < 500 && ![408, 409, 425, 429].includes(statusCode);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function boundedErrorClass(value: string, fallback: string): string {
|
|
168
|
+
const normalized = value
|
|
169
|
+
.trim()
|
|
170
|
+
.toLowerCase()
|
|
171
|
+
.replaceAll(/[^a-z0-9_-]+/g, "_")
|
|
172
|
+
.slice(0, 64);
|
|
173
|
+
return normalized || fallback;
|
|
174
|
+
}
|
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
import type { Settings } from "@opengeni/config";
|
|
2
|
+
import type { ManagedEmailTransport } from "@opengeni/core";
|
|
3
|
+
|
|
4
|
+
const encoder = new TextEncoder();
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Prove that the stable invited-user setup bearer can be constructed before an
|
|
8
|
+
* invitation commits. Provider availability is intentionally outside this
|
|
9
|
+
* precondition: the durable delivery journal records a failed or ambiguous
|
|
10
|
+
* transport outcome after the invitation exists.
|
|
11
|
+
*/
|
|
12
|
+
export function assertOrganizationUserSetupDeliveryConfigured(
|
|
13
|
+
settings: Settings,
|
|
14
|
+
transport: ManagedEmailTransport,
|
|
15
|
+
): void {
|
|
16
|
+
requiredSetupSecret(settings);
|
|
17
|
+
requiredPublicBaseUrl(settings);
|
|
18
|
+
assertManagedEmailTransportMetadata(transport);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** Reject an invalid embedded-provider contract before any durable boundary. */
|
|
22
|
+
export function assertManagedEmailTransportMetadata(transport: ManagedEmailTransport): void {
|
|
23
|
+
if (
|
|
24
|
+
transport.sender.trim() !== transport.sender ||
|
|
25
|
+
encoder.encode(transport.sender).byteLength < 3 ||
|
|
26
|
+
encoder.encode(transport.sender).byteLength > 320
|
|
27
|
+
) {
|
|
28
|
+
throw new Error("Managed email sender is invalid");
|
|
29
|
+
}
|
|
30
|
+
const { scope, retentionSeconds } = transport.idempotency;
|
|
31
|
+
if (
|
|
32
|
+
scope.trim() !== scope ||
|
|
33
|
+
!/^[a-z0-9][a-z0-9:._-]*$/.test(scope) ||
|
|
34
|
+
encoder.encode(scope).byteLength > 200 ||
|
|
35
|
+
!Number.isInteger(retentionSeconds) ||
|
|
36
|
+
retentionSeconds < 0 ||
|
|
37
|
+
retentionSeconds > 31_536_000
|
|
38
|
+
) {
|
|
39
|
+
throw new Error("Managed email idempotency contract is invalid");
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export async function deriveOrganizationUserSetupToken(
|
|
44
|
+
settings: Settings,
|
|
45
|
+
input: { invitationId: string; deliveryId: string },
|
|
46
|
+
): Promise<{ token: string; digest: string; url: string }> {
|
|
47
|
+
const secret = requiredSetupSecret(settings);
|
|
48
|
+
const key = await crypto.subtle.importKey(
|
|
49
|
+
"raw",
|
|
50
|
+
encoder.encode(secret),
|
|
51
|
+
{ name: "HMAC", hash: "SHA-256" },
|
|
52
|
+
false,
|
|
53
|
+
["sign"],
|
|
54
|
+
);
|
|
55
|
+
const signature = await crypto.subtle.sign(
|
|
56
|
+
"HMAC",
|
|
57
|
+
key,
|
|
58
|
+
encoder.encode(
|
|
59
|
+
`opengeni:organization-user-setup-delivery:v1:${input.deliveryId}:${input.invitationId}`,
|
|
60
|
+
),
|
|
61
|
+
);
|
|
62
|
+
const token = base64Url(new Uint8Array(signature));
|
|
63
|
+
const digest = await sha256Hex(token);
|
|
64
|
+
const url = new URL("/setup-account", requiredPublicBaseUrl(settings));
|
|
65
|
+
url.hash = new URLSearchParams({ token }).toString();
|
|
66
|
+
return { token, digest, url: url.toString() };
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export type OrganizationUserSetupEmailSnapshot = {
|
|
70
|
+
senderEmail: string;
|
|
71
|
+
recipientEmail: string;
|
|
72
|
+
recipientName: string | null;
|
|
73
|
+
organizationName: string;
|
|
74
|
+
organizationRole: "owner" | "admin" | "member";
|
|
75
|
+
sharedWorkspaceAccess: Array<{
|
|
76
|
+
workspaceId: string;
|
|
77
|
+
workspaceName: string;
|
|
78
|
+
role: "viewer" | "member" | "admin";
|
|
79
|
+
}>;
|
|
80
|
+
setupUrl: string;
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
export function renderOrganizationUserSetupEmail(input: OrganizationUserSetupEmailSnapshot): {
|
|
84
|
+
from: string;
|
|
85
|
+
to: string;
|
|
86
|
+
subject: string;
|
|
87
|
+
text: string;
|
|
88
|
+
html: string;
|
|
89
|
+
} {
|
|
90
|
+
const greeting = input.recipientName ? `Hi ${input.recipientName},` : "Hello,";
|
|
91
|
+
const role = titleCase(input.organizationRole);
|
|
92
|
+
const workspaceSummary =
|
|
93
|
+
input.sharedWorkspaceAccess.length === 0
|
|
94
|
+
? "No shared workspaces are assigned yet."
|
|
95
|
+
: `Shared workspace access:\n${input.sharedWorkspaceAccess
|
|
96
|
+
.map((workspace) => `- ${workspace.workspaceName}: ${titleCase(workspace.role)}`)
|
|
97
|
+
.join("\n")}`;
|
|
98
|
+
const workspaceHtml =
|
|
99
|
+
input.sharedWorkspaceAccess.length === 0
|
|
100
|
+
? "<p>No shared workspaces are assigned yet.</p>"
|
|
101
|
+
: `<p>Shared workspace access:</p><ul>${input.sharedWorkspaceAccess
|
|
102
|
+
.map(
|
|
103
|
+
(workspace) =>
|
|
104
|
+
`<li>${escapeHtml(workspace.workspaceName)}: ${escapeHtml(titleCase(workspace.role))}</li>`,
|
|
105
|
+
)
|
|
106
|
+
.join("")}</ul>`;
|
|
107
|
+
return {
|
|
108
|
+
from: input.senderEmail,
|
|
109
|
+
to: input.recipientEmail,
|
|
110
|
+
subject: `Join ${input.organizationName} on OpenGeni`,
|
|
111
|
+
text: `${greeting}\n\nYou have been invited to ${input.organizationName} as ${role}.\n\n${workspaceSummary}\n\nThis invitation grants only the organization role and shared workspace access listed above. It never shares anyone's Personal workspace.\n\nSet up your account: ${input.setupUrl}\n\nIf you already have an OpenGeni account, sign in and accept the invitation instead.`,
|
|
112
|
+
html: `<p>${escapeHtml(greeting)}</p><p>You have been invited to <strong>${escapeHtml(input.organizationName)}</strong> as ${escapeHtml(role)}.</p>${workspaceHtml}<p>This invitation grants only the organization role and shared workspace access listed above. It never shares anyone's Personal workspace.</p><p><a href="${escapeHtml(input.setupUrl)}">Set up your account</a></p><p>If you already have an OpenGeni account, sign in and accept the invitation instead.</p>`,
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export async function organizationUserSetupPayloadDigest(input: {
|
|
117
|
+
from: string;
|
|
118
|
+
to: string;
|
|
119
|
+
subject: string;
|
|
120
|
+
text: string;
|
|
121
|
+
html: string;
|
|
122
|
+
providerIdempotencyScope: string;
|
|
123
|
+
}): Promise<string> {
|
|
124
|
+
return await sha256Hex(
|
|
125
|
+
JSON.stringify({
|
|
126
|
+
version: 2,
|
|
127
|
+
providerIdempotencyScope: input.providerIdempotencyScope,
|
|
128
|
+
from: input.from,
|
|
129
|
+
to: input.to,
|
|
130
|
+
subject: input.subject,
|
|
131
|
+
text: input.text,
|
|
132
|
+
html: input.html,
|
|
133
|
+
}),
|
|
134
|
+
);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
export async function organizationUserSetupRequestFingerprint(
|
|
138
|
+
settings: Settings,
|
|
139
|
+
input: { tokenDigest: string; name: string; password: string },
|
|
140
|
+
): Promise<string> {
|
|
141
|
+
const key = await crypto.subtle.importKey(
|
|
142
|
+
"raw",
|
|
143
|
+
encoder.encode(requiredSetupSecret(settings)),
|
|
144
|
+
{ name: "HMAC", hash: "SHA-256" },
|
|
145
|
+
false,
|
|
146
|
+
["sign"],
|
|
147
|
+
);
|
|
148
|
+
const signature = await crypto.subtle.sign(
|
|
149
|
+
"HMAC",
|
|
150
|
+
key,
|
|
151
|
+
encoder.encode(
|
|
152
|
+
JSON.stringify({
|
|
153
|
+
tokenDigest: input.tokenDigest,
|
|
154
|
+
name: input.name,
|
|
155
|
+
password: input.password,
|
|
156
|
+
}),
|
|
157
|
+
),
|
|
158
|
+
);
|
|
159
|
+
return hex(new Uint8Array(signature));
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
export async function selfServiceOrganizationSetupRequestFingerprint(input: {
|
|
163
|
+
authUserId: string;
|
|
164
|
+
organizationName: string;
|
|
165
|
+
}): Promise<string> {
|
|
166
|
+
const actorSubjectId = `user:${input.authUserId}`;
|
|
167
|
+
const organizationNameBytes = encoder.encode(input.organizationName);
|
|
168
|
+
return await sha256Hex(
|
|
169
|
+
`opengeni:self-service-organization:v1:${actorSubjectId}:${organizationNameBytes.byteLength}:${input.organizationName}`,
|
|
170
|
+
);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
export async function organizationUserSetupTokenDigest(token: string): Promise<string> {
|
|
174
|
+
return await sha256Hex(token);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function requiredSetupSecret(settings: Settings): string {
|
|
178
|
+
if (!settings.betterAuthSecret) {
|
|
179
|
+
throw new Error("OPENGENI_BETTER_AUTH_SECRET is required for organization user setup");
|
|
180
|
+
}
|
|
181
|
+
return settings.betterAuthSecret;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function requiredPublicBaseUrl(settings: Settings): string {
|
|
185
|
+
if (!settings.publicBaseUrl) {
|
|
186
|
+
throw new Error("OPENGENI_PUBLIC_BASE_URL is required for organization user setup");
|
|
187
|
+
}
|
|
188
|
+
return settings.publicBaseUrl;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
async function sha256Hex(value: string): Promise<string> {
|
|
192
|
+
const digest = await crypto.subtle.digest("SHA-256", encoder.encode(value));
|
|
193
|
+
return hex(new Uint8Array(digest));
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function hex(bytes: Uint8Array): string {
|
|
197
|
+
return [...bytes].map((byte) => byte.toString(16).padStart(2, "0")).join("");
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function base64Url(bytes: Uint8Array): string {
|
|
201
|
+
let binary = "";
|
|
202
|
+
for (const byte of bytes) binary += String.fromCharCode(byte);
|
|
203
|
+
return btoa(binary).replaceAll("+", "-").replaceAll("/", "_").replace(/=+$/, "");
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
function titleCase(value: string): string {
|
|
207
|
+
return `${value.slice(0, 1).toUpperCase()}${value.slice(1)}`;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
function escapeHtml(value: string): string {
|
|
211
|
+
return value
|
|
212
|
+
.replaceAll("&", "&")
|
|
213
|
+
.replaceAll("<", "<")
|
|
214
|
+
.replaceAll(">", ">")
|
|
215
|
+
.replaceAll('"', """)
|
|
216
|
+
.replaceAll("'", "'");
|
|
217
|
+
}
|
package/src/http/auth.ts
CHANGED
|
@@ -5,6 +5,8 @@ import { installExactPaths, isInstallRedirectPath } from "../routes/install";
|
|
|
5
5
|
|
|
6
6
|
const githubConnectPathPattern = /^\/v1\/workspaces\/[^/]+\/github\/connect$/;
|
|
7
7
|
const githubInstallationLinkPathPattern = /^\/v1\/workspaces\/[^/]+\/github\/installations$/;
|
|
8
|
+
const prReviewGithubBrowserPathPattern =
|
|
9
|
+
/^\/v1\/workspaces\/[^/]+\/pr-review\/github\/(?:connect|installations\/select|installations\/[^/]+\/configure)$/;
|
|
8
10
|
|
|
9
11
|
export function requireAccessKey(settings: Settings): MiddlewareHandler {
|
|
10
12
|
return async (c, next) => {
|
|
@@ -43,6 +45,9 @@ function isAuthExempt(c: Context, settings: Settings): boolean {
|
|
|
43
45
|
if (c.req.method === "POST" && path.startsWith("/v1/webhooks/automations/")) {
|
|
44
46
|
return true;
|
|
45
47
|
}
|
|
48
|
+
if (c.req.method === "POST" && path === "/v1/webhooks/pr-review/github") {
|
|
49
|
+
return true;
|
|
50
|
+
}
|
|
46
51
|
if (
|
|
47
52
|
path === "/v1/github/setup" ||
|
|
48
53
|
path === "/v1/github/install/callback" ||
|
|
@@ -51,6 +56,13 @@ function isAuthExempt(c: Context, settings: Settings): boolean {
|
|
|
51
56
|
) {
|
|
52
57
|
return true;
|
|
53
58
|
}
|
|
59
|
+
if (
|
|
60
|
+
path === "/v1/pr-review/github/setup" ||
|
|
61
|
+
path === "/v1/pr-review/github/install/callback" ||
|
|
62
|
+
path === "/v1/pr-review/github/oauth/callback"
|
|
63
|
+
) {
|
|
64
|
+
return true;
|
|
65
|
+
}
|
|
54
66
|
if (
|
|
55
67
|
path === "/v1/integrations/oauth/callback" ||
|
|
56
68
|
path === "/v1/integrations/provider-oauth/callback" ||
|
|
@@ -83,6 +95,9 @@ function isAuthExempt(c: Context, settings: Settings): boolean {
|
|
|
83
95
|
if (githubConnectPathPattern.test(path)) {
|
|
84
96
|
return true;
|
|
85
97
|
}
|
|
98
|
+
if (prReviewGithubBrowserPathPattern.test(path)) {
|
|
99
|
+
return true;
|
|
100
|
+
}
|
|
86
101
|
// Compatibility endpoint for stale chooser submissions. It remains public
|
|
87
102
|
// only so already-rendered forms can authenticate their signed account and
|
|
88
103
|
// workspace state locally before terminating with 410; it does not parse a
|
package/src/http/sse.ts
CHANGED
|
@@ -13,6 +13,7 @@ import {
|
|
|
13
13
|
coalesceSessionEventDeltas,
|
|
14
14
|
formatSessionEventSse,
|
|
15
15
|
formatWorkspaceControlEventSse,
|
|
16
|
+
requireSessionEventDurableFanoutCapability,
|
|
16
17
|
SESSION_EVENT_SSE_FRAME_MAX_BYTES,
|
|
17
18
|
sessionEventResumeSequence,
|
|
18
19
|
type EventBus,
|
|
@@ -269,12 +270,14 @@ export async function sseSessionStream(
|
|
|
269
270
|
signal: AbortSignal,
|
|
270
271
|
options: SessionSseDeliveryOptions = {},
|
|
271
272
|
): Promise<Response> {
|
|
273
|
+
const durableFanout = requireSessionEventDurableFanoutCapability(bus);
|
|
272
274
|
const heartbeatIntervalMs = resolveHeartbeatInterval(options.heartbeatIntervalMs);
|
|
273
275
|
let lastSent = after;
|
|
274
276
|
let bootstrapping = true;
|
|
275
277
|
let newestBuffered: SessionEvent | null = null;
|
|
276
278
|
let unsubscribe: (() => void) | null = null;
|
|
277
279
|
let delivery: LatestWinsDelivery<SessionEvent> | null = null;
|
|
280
|
+
let stopReconnectObservation = () => {};
|
|
278
281
|
let stopReauthorization = () => {};
|
|
279
282
|
let heartbeatTimer: ReturnType<typeof setTimeout> | null = null;
|
|
280
283
|
let detachAbortListener = () => {};
|
|
@@ -282,6 +285,8 @@ export async function sseSessionStream(
|
|
|
282
285
|
const stopUpstream = () => {
|
|
283
286
|
closeMetrics();
|
|
284
287
|
detachAbortListener();
|
|
288
|
+
stopReconnectObservation();
|
|
289
|
+
stopReconnectObservation = () => {};
|
|
285
290
|
stopReauthorization();
|
|
286
291
|
if (heartbeatTimer) {
|
|
287
292
|
clearTimeout(heartbeatTimer);
|
|
@@ -316,17 +321,6 @@ export async function sseSessionStream(
|
|
|
316
321
|
writeTail = write.catch(() => {});
|
|
317
322
|
return write;
|
|
318
323
|
};
|
|
319
|
-
const scheduleHeartbeat = () => {
|
|
320
|
-
if (channel.stopped()) return;
|
|
321
|
-
heartbeatTimer = setTimeout(() => {
|
|
322
|
-
heartbeatTimer = null;
|
|
323
|
-
void writeFrame(": heartbeat\n\n")
|
|
324
|
-
.then(scheduleHeartbeat)
|
|
325
|
-
.catch((error) => {
|
|
326
|
-
if (!(error instanceof SseStreamStoppedError)) fail(error);
|
|
327
|
-
});
|
|
328
|
-
}, heartbeatIntervalMs);
|
|
329
|
-
};
|
|
330
324
|
const deliverDurableThrough = async (targetSequence?: number) => {
|
|
331
325
|
while (true) {
|
|
332
326
|
if (targetSequence !== undefined && lastSent >= targetSequence) return;
|
|
@@ -364,12 +358,66 @@ export async function sseSessionStream(
|
|
|
364
358
|
if (targetSequence === undefined && page.length < limit) return;
|
|
365
359
|
}
|
|
366
360
|
};
|
|
361
|
+
let durableDeliveryTail = Promise.resolve();
|
|
362
|
+
const reconcileDurableThrough = (targetSequence?: number): Promise<void> => {
|
|
363
|
+
const deliveryRun = durableDeliveryTail.then(() => deliverDurableThrough(targetSequence));
|
|
364
|
+
durableDeliveryTail = deliveryRun.catch(() => {});
|
|
365
|
+
return deliveryRun;
|
|
366
|
+
};
|
|
367
|
+
let newestReconnectGeneration = 0;
|
|
368
|
+
let reconnectReconcilePending = false;
|
|
369
|
+
let reconnectReconcileRunning = false;
|
|
370
|
+
const drainReconnectReconciliation = () => {
|
|
371
|
+
if (
|
|
372
|
+
bootstrapping ||
|
|
373
|
+
reconnectReconcileRunning ||
|
|
374
|
+
!reconnectReconcilePending ||
|
|
375
|
+
channel.stopped()
|
|
376
|
+
) {
|
|
377
|
+
return;
|
|
378
|
+
}
|
|
379
|
+
reconnectReconcileRunning = true;
|
|
380
|
+
void (async () => {
|
|
381
|
+
while (reconnectReconcilePending && !channel.stopped()) {
|
|
382
|
+
// Multiple reconnects during one durable read collapse into one newest
|
|
383
|
+
// catch-up. Postgres is authoritative, so that later read covers every
|
|
384
|
+
// disconnect window without one query per heartbeat or buffered event.
|
|
385
|
+
reconnectReconcilePending = false;
|
|
386
|
+
await reconcileDurableThrough();
|
|
387
|
+
}
|
|
388
|
+
})()
|
|
389
|
+
.catch((error) => {
|
|
390
|
+
if (!(error instanceof SseStreamStoppedError)) fail(error);
|
|
391
|
+
})
|
|
392
|
+
.finally(() => {
|
|
393
|
+
reconnectReconcileRunning = false;
|
|
394
|
+
drainReconnectReconciliation();
|
|
395
|
+
});
|
|
396
|
+
};
|
|
397
|
+
const scheduleReconnectReconciliation = (generation: number) => {
|
|
398
|
+
if (generation <= newestReconnectGeneration || channel.stopped()) return;
|
|
399
|
+
newestReconnectGeneration = generation;
|
|
400
|
+
reconnectReconcilePending = true;
|
|
401
|
+
drainReconnectReconciliation();
|
|
402
|
+
};
|
|
367
403
|
const send = async (event: SessionEvent) => {
|
|
368
404
|
const targetSequence = sessionEventResumeSequence(event);
|
|
369
405
|
if (targetSequence <= lastSent) return;
|
|
370
|
-
await
|
|
406
|
+
await reconcileDurableThrough(targetSequence);
|
|
407
|
+
};
|
|
408
|
+
const scheduleHeartbeat = () => {
|
|
409
|
+
if (channel.stopped()) return;
|
|
410
|
+
heartbeatTimer = setTimeout(() => {
|
|
411
|
+
heartbeatTimer = null;
|
|
412
|
+
void writeFrame(": heartbeat\n\n")
|
|
413
|
+
.then(scheduleHeartbeat)
|
|
414
|
+
.catch((error) => {
|
|
415
|
+
if (!(error instanceof SseStreamStoppedError)) fail(error);
|
|
416
|
+
});
|
|
417
|
+
}, heartbeatIntervalMs);
|
|
371
418
|
};
|
|
372
419
|
delivery = createLatestWinsDelivery(send, fail);
|
|
420
|
+
stopReconnectObservation = durableFanout.subscribeRecovery(scheduleReconnectReconciliation);
|
|
373
421
|
|
|
374
422
|
void (async () => {
|
|
375
423
|
const release = await bus.subscribe(workspaceId, sessionId, (events) => {
|
|
@@ -389,10 +437,11 @@ export async function sseSessionStream(
|
|
|
389
437
|
}
|
|
390
438
|
unsubscribe = release;
|
|
391
439
|
|
|
392
|
-
await
|
|
440
|
+
await reconcileDurableThrough();
|
|
393
441
|
await writeFrame(": connected\n\n");
|
|
394
442
|
scheduleHeartbeat();
|
|
395
443
|
bootstrapping = false;
|
|
444
|
+
drainReconnectReconciliation();
|
|
396
445
|
const buffered = newestBuffered;
|
|
397
446
|
newestBuffered = null;
|
|
398
447
|
if (buffered) delivery.publish([buffered]);
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type
|
|
1
|
+
import { AUTOMATIC_SESSION_TITLE_FALLBACK, type Session } from "@opengeni/contracts";
|
|
2
2
|
import type { SlackHomeBlock } from "./slack-bot";
|
|
3
3
|
|
|
4
4
|
const ATTENTION_LIMIT = 5;
|
|
@@ -186,7 +186,7 @@ function appendSessionGroup(
|
|
|
186
186
|
);
|
|
187
187
|
for (const session of sessions) {
|
|
188
188
|
const url = sessionUrl(session.id);
|
|
189
|
-
const title = (session.title ||
|
|
189
|
+
const title = (session.title?.trim() || AUTOMATIC_SESSION_TITLE_FALLBACK).slice(0, 180);
|
|
190
190
|
blocks.push({
|
|
191
191
|
type: "section",
|
|
192
192
|
block_id: `opengeni_home_session_${session.id}`,
|
|
@@ -207,6 +207,11 @@ export type SlackMessageBlock =
|
|
|
207
207
|
style?: "primary" | "danger";
|
|
208
208
|
}>;
|
|
209
209
|
}
|
|
210
|
+
| {
|
|
211
|
+
type: "context";
|
|
212
|
+
block_id?: string;
|
|
213
|
+
elements: Array<{ type: "mrkdwn" | "plain_text"; text: string; emoji?: boolean }>;
|
|
214
|
+
}
|
|
210
215
|
| { type: "divider" };
|
|
211
216
|
|
|
212
217
|
export type SlackHomeBlock =
|