@opengeni/api-router 2.3.2-canary.2 → 2.5.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-attempt-context.d.ts +4 -0
- package/dist/auth/managed-auth-session-adapter.d.ts +4 -0
- package/dist/{chunk-IBV7Z6F4.js → chunk-QESX7HDK.js} +3645 -470
- package/dist/chunk-QESX7HDK.js.map +1 -0
- package/dist/fatal-process-boundary.d.ts +25 -0
- package/dist/http/sse.d.ts +2 -0
- package/dist/index.d.ts +5 -1
- package/dist/index.js +168 -6
- package/dist/index.js.map +1 -1
- package/dist/integrations/slack-interactions.d.ts +1 -1
- package/dist/mcp/receipts.d.ts +9 -0
- package/dist/mcp/server.d.ts +33 -0
- package/dist/organization-recovery-notifications.d.ts +41 -0
- package/dist/routes/managed-auth-session-sets.d.ts +19 -0
- package/dist/routes/organization-recovery.d.ts +19 -0
- package/dist/routes/sessions.d.ts +17 -5
- package/dist/routes/workspaces.d.ts +1 -0
- package/dist/work-discovery-observability.d.ts +33 -0
- package/package.json +18 -18
- package/src/app.ts +133 -1
- package/src/auth/managed-auth-attempt-context.ts +24 -0
- package/src/auth/managed-auth-session-adapter.ts +205 -0
- package/src/auth/managed-auth.ts +52 -2
- package/src/fatal-process-boundary.ts +231 -0
- package/src/http/sse.ts +7 -0
- package/src/index.ts +25 -5
- package/src/integrations/slack-interactions.ts +30 -17
- package/src/mcp/receipts.ts +34 -0
- package/src/mcp/server.ts +349 -68
- package/src/organization-recovery-notifications.ts +103 -0
- package/src/routes/canonical-human-identities.ts +29 -14
- package/src/routes/codex.ts +5 -1
- package/src/routes/environments.ts +23 -0
- package/src/routes/interaction-resources.ts +3 -0
- package/src/routes/managed-auth-session-sets.ts +994 -0
- package/src/routes/managed-onboarding.ts +2 -0
- package/src/routes/organization-memberships.ts +2 -0
- package/src/routes/organization-recovery.ts +325 -0
- package/src/routes/sessions.ts +401 -120
- package/src/routes/supergrok.ts +5 -1
- package/src/routes/workspaces.ts +23 -1
- package/src/work-discovery-observability.ts +121 -0
- package/dist/chunk-IBV7Z6F4.js.map +0 -1
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
ManagedAuthResolvedSession,
|
|
3
|
+
ManagedAuthSessionAdapter,
|
|
4
|
+
} from "@opengeni/core/managed-auth-session-sets";
|
|
5
|
+
import { sql } from "drizzle-orm";
|
|
6
|
+
import type { Database } from "@opengeni/db";
|
|
7
|
+
import type { ManagedAuth } from "@opengeni/core";
|
|
8
|
+
import { createHmac, timingSafeEqual } from "node:crypto";
|
|
9
|
+
import { runManagedAuthAttempt } from "./managed-auth-attempt-context";
|
|
10
|
+
|
|
11
|
+
export function createBetterAuthSessionAdapter(
|
|
12
|
+
auth: ManagedAuth,
|
|
13
|
+
db: Database,
|
|
14
|
+
): ManagedAuthSessionAdapter {
|
|
15
|
+
return {
|
|
16
|
+
async authenticate(input) {
|
|
17
|
+
if (input.provider !== "email_password") {
|
|
18
|
+
throw new Error("unsupported managed authentication provider");
|
|
19
|
+
}
|
|
20
|
+
const result = await runManagedAuthAttempt(
|
|
21
|
+
input.transactionId,
|
|
22
|
+
async () =>
|
|
23
|
+
await auth.api.signInEmail({
|
|
24
|
+
body: {
|
|
25
|
+
email: input.credentials.email,
|
|
26
|
+
password: input.credentials.password,
|
|
27
|
+
rememberMe: true,
|
|
28
|
+
},
|
|
29
|
+
headers: input.headers,
|
|
30
|
+
returnHeaders: true,
|
|
31
|
+
}),
|
|
32
|
+
);
|
|
33
|
+
const token = result.response?.token;
|
|
34
|
+
if (typeof token !== "string") {
|
|
35
|
+
throw new Error("managed authentication did not create an isolated session");
|
|
36
|
+
}
|
|
37
|
+
const resolved = await (await auth.$context).internalAdapter.findSession(token);
|
|
38
|
+
if (!resolved?.session?.id) {
|
|
39
|
+
throw new Error("managed authentication session could not be resolved");
|
|
40
|
+
}
|
|
41
|
+
return { authSessionId: resolved.session.id };
|
|
42
|
+
},
|
|
43
|
+
|
|
44
|
+
async resolveSelectedSession(input): Promise<ManagedAuthResolvedSession | null> {
|
|
45
|
+
const resolved = await (await auth.$context).internalAdapter.findSession(input.token);
|
|
46
|
+
return liveResolvedSession(resolved);
|
|
47
|
+
},
|
|
48
|
+
|
|
49
|
+
async resolveAmbientSession(headers): Promise<ManagedAuthResolvedSession | null> {
|
|
50
|
+
const context = await auth.$context;
|
|
51
|
+
const signed = cookieValue(headers.get("cookie"), context.authCookies.sessionToken.name);
|
|
52
|
+
const token = signed ? verifiedSignedCookieValue(signed, context.secret) : null;
|
|
53
|
+
if (!token) return null;
|
|
54
|
+
const resolved = await context.internalAdapter.findSession(token);
|
|
55
|
+
return liveResolvedSession(resolved);
|
|
56
|
+
},
|
|
57
|
+
|
|
58
|
+
async refreshSelectedSession(input): Promise<ManagedAuthResolvedSession | null> {
|
|
59
|
+
const context = await auth.$context;
|
|
60
|
+
const cookie = context.authCookies.sessionToken;
|
|
61
|
+
const headers = new Headers({
|
|
62
|
+
cookie: `${cookie.name}=${signedCookieValue(input.token, context.secret)}`,
|
|
63
|
+
});
|
|
64
|
+
const resolved = await auth.api.getSession({ headers, returnHeaders: true });
|
|
65
|
+
if (!resolved.response) return null;
|
|
66
|
+
// The provider may renew its durable expiry and emit token/cache cookies;
|
|
67
|
+
// this server-side selected-slot resolution intentionally discards them.
|
|
68
|
+
return resolved.response as ManagedAuthResolvedSession;
|
|
69
|
+
},
|
|
70
|
+
|
|
71
|
+
async revokeSession(input) {
|
|
72
|
+
await db.execute(sql`delete from auth_sessions where id = ${input.authSessionId}`);
|
|
73
|
+
},
|
|
74
|
+
|
|
75
|
+
async createLegacySelectedSessionCookies(input, currentCookieHeader) {
|
|
76
|
+
const context = await auth.$context;
|
|
77
|
+
const cookie = context.authCookies.sessionToken;
|
|
78
|
+
const value = input ? signedCookieValue(input.token, context.secret) : "";
|
|
79
|
+
const headers = [
|
|
80
|
+
serializeCookieHeader(cookie.name, value, {
|
|
81
|
+
...cookie.attributes,
|
|
82
|
+
...(input ? {} : { maxAge: 0, expires: new Date(0) }),
|
|
83
|
+
}),
|
|
84
|
+
];
|
|
85
|
+
for (const cacheCookie of [
|
|
86
|
+
context.authCookies.sessionData,
|
|
87
|
+
context.authCookies.accountData,
|
|
88
|
+
context.authCookies.dontRememberToken,
|
|
89
|
+
]) {
|
|
90
|
+
for (const name of cacheCookieNames(cacheCookie.name, currentCookieHeader)) {
|
|
91
|
+
headers.push(
|
|
92
|
+
serializeCookieHeader(name, "", {
|
|
93
|
+
...cacheCookie.attributes,
|
|
94
|
+
maxAge: 0,
|
|
95
|
+
expires: new Date(0),
|
|
96
|
+
}),
|
|
97
|
+
);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
return headers;
|
|
101
|
+
},
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function liveResolvedSession(value: unknown): ManagedAuthResolvedSession | null {
|
|
106
|
+
if (!value || typeof value !== "object") return null;
|
|
107
|
+
const session = (value as { session?: { expiresAt?: unknown } }).session;
|
|
108
|
+
const expiresAt = session?.expiresAt;
|
|
109
|
+
const expiryMillis =
|
|
110
|
+
expiresAt instanceof Date
|
|
111
|
+
? expiresAt.getTime()
|
|
112
|
+
: typeof expiresAt === "string" || typeof expiresAt === "number"
|
|
113
|
+
? new Date(expiresAt).getTime()
|
|
114
|
+
: Number.NaN;
|
|
115
|
+
if (!Number.isFinite(expiryMillis) || expiryMillis <= Date.now()) return null;
|
|
116
|
+
return value as ManagedAuthResolvedSession;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function cookieValue(header: string | null, name: string): string | null {
|
|
120
|
+
if (!header) return null;
|
|
121
|
+
for (const part of header.split(";")) {
|
|
122
|
+
const separator = part.indexOf("=");
|
|
123
|
+
if (separator < 0 || part.slice(0, separator).trim() !== name) continue;
|
|
124
|
+
return part.slice(separator + 1).trim() || null;
|
|
125
|
+
}
|
|
126
|
+
return null;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function verifiedSignedCookieValue(value: string, secret: string): string | null {
|
|
130
|
+
let decoded: string;
|
|
131
|
+
try {
|
|
132
|
+
decoded = decodeURIComponent(value);
|
|
133
|
+
} catch {
|
|
134
|
+
return null;
|
|
135
|
+
}
|
|
136
|
+
const separator = decoded.lastIndexOf(".");
|
|
137
|
+
if (separator <= 0) return null;
|
|
138
|
+
const token = decoded.slice(0, separator);
|
|
139
|
+
const signature = decoded.slice(separator + 1);
|
|
140
|
+
const expected = createHmac("sha256", secret).update(token, "utf8").digest("base64");
|
|
141
|
+
const actualBytes = Buffer.from(signature, "utf8");
|
|
142
|
+
const expectedBytes = Buffer.from(expected, "utf8");
|
|
143
|
+
return actualBytes.length === expectedBytes.length && timingSafeEqual(actualBytes, expectedBytes)
|
|
144
|
+
? token
|
|
145
|
+
: null;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function signedCookieValue(value: string, secret: string): string {
|
|
149
|
+
return encodeURIComponent(
|
|
150
|
+
`${value}.${createHmac("sha256", secret).update(value, "utf8").digest("base64")}`,
|
|
151
|
+
);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function serializeCookieHeader(
|
|
155
|
+
name: string,
|
|
156
|
+
value: string,
|
|
157
|
+
attributes: {
|
|
158
|
+
domain?: string;
|
|
159
|
+
expires?: Date;
|
|
160
|
+
httpOnly?: boolean;
|
|
161
|
+
maxAge?: number;
|
|
162
|
+
path?: string;
|
|
163
|
+
partitioned?: boolean;
|
|
164
|
+
prefix?: "secure" | "host";
|
|
165
|
+
sameSite?: string;
|
|
166
|
+
secure?: boolean;
|
|
167
|
+
},
|
|
168
|
+
): string {
|
|
169
|
+
let cookieName = name;
|
|
170
|
+
if (attributes.prefix === "secure" && !cookieName.startsWith("__Secure-")) {
|
|
171
|
+
cookieName = `__Secure-${cookieName}`;
|
|
172
|
+
} else if (attributes.prefix === "host" && !cookieName.startsWith("__Host-")) {
|
|
173
|
+
cookieName = `__Host-${cookieName}`;
|
|
174
|
+
}
|
|
175
|
+
if (cookieName.startsWith("__Secure-")) attributes.secure = true;
|
|
176
|
+
if (cookieName.startsWith("__Host-")) {
|
|
177
|
+
attributes.secure = true;
|
|
178
|
+
attributes.path = "/";
|
|
179
|
+
delete attributes.domain;
|
|
180
|
+
}
|
|
181
|
+
let header = `${cookieName}=${value}`;
|
|
182
|
+
if (attributes.maxAge !== undefined)
|
|
183
|
+
header += `; Max-Age=${Math.max(0, Math.floor(attributes.maxAge))}`;
|
|
184
|
+
if (attributes.domain) header += `; Domain=${attributes.domain}`;
|
|
185
|
+
if (attributes.path) header += `; Path=${attributes.path}`;
|
|
186
|
+
if (attributes.expires) header += `; Expires=${attributes.expires.toUTCString()}`;
|
|
187
|
+
if (attributes.httpOnly) header += "; HttpOnly";
|
|
188
|
+
if (attributes.secure) header += "; Secure";
|
|
189
|
+
if (attributes.sameSite) {
|
|
190
|
+
header += `; SameSite=${attributes.sameSite[0]?.toUpperCase()}${attributes.sameSite.slice(1)}`;
|
|
191
|
+
}
|
|
192
|
+
if (attributes.partitioned) header += "; Partitioned";
|
|
193
|
+
return header;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function cacheCookieNames(baseName: string, cookieHeader?: string | null): string[] {
|
|
197
|
+
const names = new Set([baseName]);
|
|
198
|
+
for (const part of cookieHeader?.split(";") ?? []) {
|
|
199
|
+
const separator = part.indexOf("=");
|
|
200
|
+
if (separator < 0) continue;
|
|
201
|
+
const name = part.slice(0, separator).trim();
|
|
202
|
+
if (name === baseName || name.startsWith(`${baseName}.`)) names.add(name);
|
|
203
|
+
}
|
|
204
|
+
return [...names].sort();
|
|
205
|
+
}
|
package/src/auth/managed-auth.ts
CHANGED
|
@@ -9,14 +9,20 @@ import { ensureManagedAccessForUser } from "@opengeni/db";
|
|
|
9
9
|
import {
|
|
10
10
|
ensureCanonicalHumanIdentityForAuthUser,
|
|
11
11
|
getCanonicalHumanIdentityProjection,
|
|
12
|
+
getCanonicalHumanExactLoginBindingForAuthUser,
|
|
12
13
|
synchronizeCanonicalHumanLoginBindings,
|
|
13
14
|
} from "@opengeni/db/canonical-human-identities";
|
|
14
15
|
import { betterAuth } from "better-auth";
|
|
15
16
|
import { createEmailVerificationToken } from "better-auth/api";
|
|
16
17
|
import { hashPassword } from "better-auth/crypto";
|
|
18
|
+
import { sql } from "drizzle-orm";
|
|
17
19
|
import { Pool } from "pg";
|
|
18
20
|
|
|
19
21
|
import { decideCanonicalHumanSessionAdmission } from "./canonical-human-session-admission";
|
|
22
|
+
import {
|
|
23
|
+
currentManagedAuthAttemptId,
|
|
24
|
+
shouldDiscardCurrentManagedAuthProviderSession,
|
|
25
|
+
} from "./managed-auth-attempt-context";
|
|
20
26
|
|
|
21
27
|
// `ManagedAuth` (the Better Auth `Auth<any>` alias) is owned by @opengeni/core
|
|
22
28
|
// (`managed-auth-type.ts`) — `dependencies.ts`/`access` reference it as a
|
|
@@ -121,6 +127,25 @@ export function createManagedAuth(
|
|
|
121
127
|
returned: false,
|
|
122
128
|
bigint: true,
|
|
123
129
|
},
|
|
130
|
+
loginBindingId: {
|
|
131
|
+
type: "string",
|
|
132
|
+
fieldName: "login_binding_id",
|
|
133
|
+
input: false,
|
|
134
|
+
returned: false,
|
|
135
|
+
},
|
|
136
|
+
loginBindingRevision: {
|
|
137
|
+
type: "number",
|
|
138
|
+
fieldName: "login_binding_revision",
|
|
139
|
+
input: false,
|
|
140
|
+
returned: false,
|
|
141
|
+
bigint: true,
|
|
142
|
+
},
|
|
143
|
+
managedAuthLoginTransactionId: {
|
|
144
|
+
type: "string",
|
|
145
|
+
fieldName: "managed_auth_login_transaction_id",
|
|
146
|
+
input: false,
|
|
147
|
+
returned: false,
|
|
148
|
+
},
|
|
124
149
|
},
|
|
125
150
|
},
|
|
126
151
|
account: {
|
|
@@ -215,8 +240,13 @@ export function createManagedAuth(
|
|
|
215
240
|
binding: null,
|
|
216
241
|
});
|
|
217
242
|
if (!preflight.allowed) {
|
|
243
|
+
const exactRecoveryBinding = await getCanonicalHumanExactLoginBindingForAuthUser(db, {
|
|
244
|
+
authUserId: session.userId,
|
|
245
|
+
providerId: "credential",
|
|
246
|
+
});
|
|
218
247
|
const recoveryBinding = preflightProjection.loginBindings.find(
|
|
219
|
-
(binding) =>
|
|
248
|
+
(binding) =>
|
|
249
|
+
binding.id === exactRecoveryBinding.id && binding.status === "recovery_pending",
|
|
220
250
|
);
|
|
221
251
|
const recoveryAdmission = decideCanonicalHumanSessionAdmission({
|
|
222
252
|
intent: "recovery_completion",
|
|
@@ -235,17 +265,27 @@ export function createManagedAuth(
|
|
|
235
265
|
return {
|
|
236
266
|
data: {
|
|
237
267
|
...session,
|
|
268
|
+
...(shouldDiscardCurrentManagedAuthProviderSession()
|
|
269
|
+
? { expiresAt: new Date(0) }
|
|
270
|
+
: {}),
|
|
238
271
|
identityId: preflightProjection.activeIdentity.id,
|
|
239
272
|
identityRevision: preflightProjection.activeIdentity.identityRevision,
|
|
240
273
|
authRevision: preflightProjection.activeIdentity.authRevision,
|
|
274
|
+
loginBindingId: recoveryBinding!.id,
|
|
275
|
+
loginBindingRevision: recoveryBinding!.revision,
|
|
276
|
+
managedAuthLoginTransactionId: currentManagedAuthAttemptId(),
|
|
241
277
|
},
|
|
242
278
|
};
|
|
243
279
|
}
|
|
244
280
|
|
|
245
281
|
await synchronizeCanonicalHumanLoginBindings(db, session.userId);
|
|
246
282
|
const projection = await getCanonicalHumanIdentityProjection(db, session.userId);
|
|
283
|
+
const exactBinding = await getCanonicalHumanExactLoginBindingForAuthUser(db, {
|
|
284
|
+
authUserId: session.userId,
|
|
285
|
+
providerId: "credential",
|
|
286
|
+
});
|
|
247
287
|
const activeBinding = projection.loginBindings.find(
|
|
248
|
-
(binding) => binding.id ===
|
|
288
|
+
(binding) => binding.id === exactBinding.id,
|
|
249
289
|
);
|
|
250
290
|
const admission = decideCanonicalHumanSessionAdmission({
|
|
251
291
|
intent: "ordinary_session",
|
|
@@ -265,12 +305,22 @@ export function createManagedAuth(
|
|
|
265
305
|
return {
|
|
266
306
|
data: {
|
|
267
307
|
...session,
|
|
308
|
+
...(shouldDiscardCurrentManagedAuthProviderSession()
|
|
309
|
+
? { expiresAt: new Date(0) }
|
|
310
|
+
: {}),
|
|
268
311
|
identityId: projection.activeIdentity.id,
|
|
269
312
|
identityRevision: projection.activeIdentity.identityRevision,
|
|
270
313
|
authRevision: projection.activeIdentity.authRevision,
|
|
314
|
+
loginBindingId: exactBinding.id,
|
|
315
|
+
loginBindingRevision: exactBinding.revision,
|
|
316
|
+
managedAuthLoginTransactionId: currentManagedAuthAttemptId(),
|
|
271
317
|
},
|
|
272
318
|
};
|
|
273
319
|
},
|
|
320
|
+
after: async (session) => {
|
|
321
|
+
if (!shouldDiscardCurrentManagedAuthProviderSession()) return;
|
|
322
|
+
await db.execute(sql`delete from auth_sessions where id = ${session.id}`);
|
|
323
|
+
},
|
|
274
324
|
},
|
|
275
325
|
},
|
|
276
326
|
user: {
|
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import type { Attributes, Observability, Span } from "@opengeni/observability";
|
|
3
|
+
|
|
4
|
+
export type ApiFatalEvent = "startup_failure" | "unhandled_rejection" | "uncaught_exception";
|
|
5
|
+
export type ApiFatalPhase = "startup" | "running";
|
|
6
|
+
export type ApiFatalReasonKind =
|
|
7
|
+
| "bigint"
|
|
8
|
+
| "boolean"
|
|
9
|
+
| "error"
|
|
10
|
+
| "function"
|
|
11
|
+
| "null"
|
|
12
|
+
| "number"
|
|
13
|
+
| "object"
|
|
14
|
+
| "string"
|
|
15
|
+
| "symbol"
|
|
16
|
+
| "undefined";
|
|
17
|
+
|
|
18
|
+
type ApiFatalObservability = Pick<Observability, "error" | "flush" | "startSpan">;
|
|
19
|
+
|
|
20
|
+
type ApiFatalProcess = {
|
|
21
|
+
on: (
|
|
22
|
+
event: "unhandledRejection" | "uncaughtException",
|
|
23
|
+
listener: (reason: unknown) => void,
|
|
24
|
+
) => void;
|
|
25
|
+
off: (
|
|
26
|
+
event: "unhandledRejection" | "uncaughtException",
|
|
27
|
+
listener: (reason: unknown) => void,
|
|
28
|
+
) => void;
|
|
29
|
+
exit: (code: number) => void;
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
export type ApiFatalProcessBoundaryOptions = {
|
|
33
|
+
process?: ApiFatalProcess;
|
|
34
|
+
observability?: ApiFatalObservability;
|
|
35
|
+
flushTimeoutMs?: number;
|
|
36
|
+
correlationId?: () => string;
|
|
37
|
+
fallbackLog?: (message: string) => void;
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
export type ApiFatalProcessBoundary = {
|
|
41
|
+
attachObservability: (observability: ApiFatalObservability) => void;
|
|
42
|
+
markRunning: () => void;
|
|
43
|
+
reportStartupFailure: (reason: unknown) => Promise<void>;
|
|
44
|
+
dispose: () => void;
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
const API_FATAL_FLUSH_TIMEOUT_MS = 1_000;
|
|
48
|
+
const API_FATAL_CORRELATION_ID_PATTERN = /^[A-Za-z0-9._:-]{1,128}$/;
|
|
49
|
+
|
|
50
|
+
const FATAL_ERROR_CODES = {
|
|
51
|
+
startup_failure: "api_startup_failed",
|
|
52
|
+
unhandled_rejection: "api_unhandled_rejection",
|
|
53
|
+
uncaught_exception: "api_uncaught_exception",
|
|
54
|
+
} as const;
|
|
55
|
+
|
|
56
|
+
export function installApiFatalProcessBoundary(
|
|
57
|
+
options: ApiFatalProcessBoundaryOptions = {},
|
|
58
|
+
): ApiFatalProcessBoundary {
|
|
59
|
+
const runtimeProcess = options.process ?? defaultProcessBoundary();
|
|
60
|
+
const flushTimeoutMs = options.flushTimeoutMs ?? API_FATAL_FLUSH_TIMEOUT_MS;
|
|
61
|
+
const correlationId = options.correlationId ?? (() => `api-fatal.${randomUUID()}`);
|
|
62
|
+
const fallbackLog = options.fallbackLog ?? ((message: string) => console.error(message));
|
|
63
|
+
let observability = options.observability;
|
|
64
|
+
let phase: ApiFatalPhase = "startup";
|
|
65
|
+
let reporting = false;
|
|
66
|
+
|
|
67
|
+
const report = async (event: ApiFatalEvent, reason: unknown): Promise<void> => {
|
|
68
|
+
if (reporting) return;
|
|
69
|
+
reporting = true;
|
|
70
|
+
|
|
71
|
+
const diagnostic = apiFatalDiagnostic(event, phase, reason, correlationId);
|
|
72
|
+
const message = apiFatalMessage(diagnostic);
|
|
73
|
+
const activeObservability = observability;
|
|
74
|
+
try {
|
|
75
|
+
if (activeObservability) {
|
|
76
|
+
let logged = false;
|
|
77
|
+
try {
|
|
78
|
+
activeObservability.error(message, diagnostic);
|
|
79
|
+
logged = true;
|
|
80
|
+
} catch {
|
|
81
|
+
// The fatal boundary must still report and terminate if logging is unhealthy.
|
|
82
|
+
}
|
|
83
|
+
if (!logged) {
|
|
84
|
+
safeFallbackLog(fallbackLog, message);
|
|
85
|
+
}
|
|
86
|
+
try {
|
|
87
|
+
const span: Span = activeObservability.startSpan("api.process.fatal", diagnostic);
|
|
88
|
+
span.end({ error: true });
|
|
89
|
+
} catch {
|
|
90
|
+
// The synchronous log remains authoritative when span creation fails.
|
|
91
|
+
}
|
|
92
|
+
await flushWithin(activeObservability, flushTimeoutMs);
|
|
93
|
+
} else {
|
|
94
|
+
safeFallbackLog(fallbackLog, message);
|
|
95
|
+
}
|
|
96
|
+
} finally {
|
|
97
|
+
runtimeProcess.exit(1);
|
|
98
|
+
}
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
const onUnhandledRejection = (reason: unknown): void => {
|
|
102
|
+
void report("unhandled_rejection", reason);
|
|
103
|
+
};
|
|
104
|
+
const onUncaughtException = (reason: unknown): void => {
|
|
105
|
+
void report("uncaught_exception", reason);
|
|
106
|
+
};
|
|
107
|
+
runtimeProcess.on("unhandledRejection", onUnhandledRejection);
|
|
108
|
+
runtimeProcess.on("uncaughtException", onUncaughtException);
|
|
109
|
+
|
|
110
|
+
return {
|
|
111
|
+
attachObservability: (value) => {
|
|
112
|
+
observability = value;
|
|
113
|
+
},
|
|
114
|
+
markRunning: () => {
|
|
115
|
+
phase = "running";
|
|
116
|
+
},
|
|
117
|
+
reportStartupFailure: async (reason) => {
|
|
118
|
+
await report("startup_failure", reason);
|
|
119
|
+
},
|
|
120
|
+
dispose: () => {
|
|
121
|
+
runtimeProcess.off("unhandledRejection", onUnhandledRejection);
|
|
122
|
+
runtimeProcess.off("uncaughtException", onUncaughtException);
|
|
123
|
+
},
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function apiFatalDiagnostic(
|
|
128
|
+
event: ApiFatalEvent,
|
|
129
|
+
phase: ApiFatalPhase,
|
|
130
|
+
reason: unknown,
|
|
131
|
+
correlationId: () => string,
|
|
132
|
+
): Attributes & {
|
|
133
|
+
errorClass: "ApiFatalOperationError";
|
|
134
|
+
errorCode: (typeof FATAL_ERROR_CODES)[ApiFatalEvent];
|
|
135
|
+
origin: "api";
|
|
136
|
+
phase: ApiFatalPhase;
|
|
137
|
+
reasonKind: ApiFatalReasonKind;
|
|
138
|
+
correlationId: string;
|
|
139
|
+
} {
|
|
140
|
+
let safeCorrelationId = "api-fatal.fallback";
|
|
141
|
+
try {
|
|
142
|
+
const candidate = correlationId();
|
|
143
|
+
if (API_FATAL_CORRELATION_ID_PATTERN.test(candidate)) {
|
|
144
|
+
safeCorrelationId = candidate;
|
|
145
|
+
}
|
|
146
|
+
} catch {
|
|
147
|
+
// A fixed valid fallback preserves the fatal report and nonzero exit.
|
|
148
|
+
}
|
|
149
|
+
return {
|
|
150
|
+
errorClass: "ApiFatalOperationError",
|
|
151
|
+
errorCode: FATAL_ERROR_CODES[event],
|
|
152
|
+
origin: "api",
|
|
153
|
+
phase,
|
|
154
|
+
reasonKind: apiFatalReasonKind(reason),
|
|
155
|
+
correlationId: safeCorrelationId,
|
|
156
|
+
};
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function apiFatalReasonKind(reason: unknown): ApiFatalReasonKind {
|
|
160
|
+
if (reason === null) return "null";
|
|
161
|
+
const kind = typeof reason;
|
|
162
|
+
if (kind !== "object") return kind;
|
|
163
|
+
try {
|
|
164
|
+
return reason instanceof Error ? "error" : "object";
|
|
165
|
+
} catch {
|
|
166
|
+
return "object";
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function apiFatalMessage(diagnostic: ReturnType<typeof apiFatalDiagnostic>): string {
|
|
171
|
+
return (
|
|
172
|
+
`OpenGeni API fatal process failure (${diagnostic.errorCode}; ` +
|
|
173
|
+
`phase=${diagnostic.phase}; reason_kind=${diagnostic.reasonKind}; ` +
|
|
174
|
+
`correlation_id=${diagnostic.correlationId})`
|
|
175
|
+
);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
async function flushWithin(
|
|
179
|
+
observability: Pick<Observability, "flush">,
|
|
180
|
+
timeoutMs: number,
|
|
181
|
+
): Promise<void> {
|
|
182
|
+
let flush: Promise<void>;
|
|
183
|
+
try {
|
|
184
|
+
flush = Promise.resolve(observability.flush()).catch(() => undefined);
|
|
185
|
+
} catch {
|
|
186
|
+
return;
|
|
187
|
+
}
|
|
188
|
+
if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) return;
|
|
189
|
+
|
|
190
|
+
let timeout: ReturnType<typeof setTimeout> | undefined;
|
|
191
|
+
try {
|
|
192
|
+
await Promise.race([
|
|
193
|
+
flush,
|
|
194
|
+
new Promise<void>((resolve) => {
|
|
195
|
+
timeout = setTimeout(resolve, timeoutMs);
|
|
196
|
+
}),
|
|
197
|
+
]);
|
|
198
|
+
} finally {
|
|
199
|
+
if (timeout !== undefined) clearTimeout(timeout);
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
function safeFallbackLog(fallbackLog: (message: string) => void, message: string): void {
|
|
204
|
+
try {
|
|
205
|
+
fallbackLog(message);
|
|
206
|
+
} catch {
|
|
207
|
+
// Process termination remains mandatory even when every diagnostic sink fails.
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
function defaultProcessBoundary(): ApiFatalProcess {
|
|
212
|
+
return {
|
|
213
|
+
on: (event, listener) => {
|
|
214
|
+
if (event === "unhandledRejection") {
|
|
215
|
+
process.on("unhandledRejection", listener);
|
|
216
|
+
} else {
|
|
217
|
+
process.on("uncaughtException", listener);
|
|
218
|
+
}
|
|
219
|
+
},
|
|
220
|
+
off: (event, listener) => {
|
|
221
|
+
if (event === "unhandledRejection") {
|
|
222
|
+
process.off("unhandledRejection", listener);
|
|
223
|
+
} else {
|
|
224
|
+
process.off("uncaughtException", listener);
|
|
225
|
+
}
|
|
226
|
+
},
|
|
227
|
+
exit: (code) => {
|
|
228
|
+
process.exit(code);
|
|
229
|
+
},
|
|
230
|
+
};
|
|
231
|
+
}
|
package/src/http/sse.ts
CHANGED
|
@@ -19,6 +19,7 @@ import {
|
|
|
19
19
|
type EventBus,
|
|
20
20
|
} from "@opengeni/events";
|
|
21
21
|
import type { Observability } from "@opengeni/observability";
|
|
22
|
+
import { MANAGED_AUTH_ACTOR_EPOCH_HEADER } from "@opengeni/core/managed-auth-session-sets";
|
|
22
23
|
|
|
23
24
|
const SESSION_REPLAY_PAGE_SIZE = 100;
|
|
24
25
|
const WORKSPACE_CONTROL_REPLAY_PAGE_SIZE = 100;
|
|
@@ -463,6 +464,7 @@ export async function sseSessionStream(
|
|
|
463
464
|
"Content-Type": "text/event-stream; charset=utf-8",
|
|
464
465
|
"Cache-Control": "no-cache, no-transform",
|
|
465
466
|
Connection: "keep-alive",
|
|
467
|
+
...(options.actorEpoch ? { [MANAGED_AUTH_ACTOR_EPOCH_HEADER]: options.actorEpoch } : {}),
|
|
466
468
|
},
|
|
467
469
|
});
|
|
468
470
|
}
|
|
@@ -636,6 +638,7 @@ export async function sseWorkspaceControlStream(
|
|
|
636
638
|
"Content-Type": "text/event-stream; charset=utf-8",
|
|
637
639
|
"Cache-Control": "no-cache, no-transform",
|
|
638
640
|
Connection: "keep-alive",
|
|
641
|
+
...(options.actorEpoch ? { [MANAGED_AUTH_ACTOR_EPOCH_HEADER]: options.actorEpoch } : {}),
|
|
639
642
|
},
|
|
640
643
|
});
|
|
641
644
|
}
|
|
@@ -763,6 +766,7 @@ export async function sseWorkspaceLiveStream(
|
|
|
763
766
|
"Content-Type": "text/event-stream; charset=utf-8",
|
|
764
767
|
"Cache-Control": "no-cache, no-transform",
|
|
765
768
|
Connection: "keep-alive",
|
|
769
|
+
...(options.actorEpoch ? { [MANAGED_AUTH_ACTOR_EPOCH_HEADER]: options.actorEpoch } : {}),
|
|
766
770
|
},
|
|
767
771
|
});
|
|
768
772
|
}
|
|
@@ -851,6 +855,7 @@ export async function sseWorkspaceInteractionRevisionStream(
|
|
|
851
855
|
"Content-Type": "text/event-stream; charset=utf-8",
|
|
852
856
|
"Cache-Control": "no-cache, no-transform",
|
|
853
857
|
Connection: "keep-alive",
|
|
858
|
+
...(options.actorEpoch ? { [MANAGED_AUTH_ACTOR_EPOCH_HEADER]: options.actorEpoch } : {}),
|
|
854
859
|
},
|
|
855
860
|
});
|
|
856
861
|
}
|
|
@@ -922,6 +927,8 @@ export type SseDeliveryOptions = {
|
|
|
922
927
|
/** Current ACL re-check, run even while the event stream is idle. */
|
|
923
928
|
reauthorize?: (() => Promise<void>) | undefined;
|
|
924
929
|
reauthorizeAfterMs?: number | undefined;
|
|
930
|
+
/** Exact selected actor emitted on the stream response for cross-tab fencing. */
|
|
931
|
+
actorEpoch?: string | undefined;
|
|
925
932
|
};
|
|
926
933
|
|
|
927
934
|
export type SessionSseDeliveryOptions = SseDeliveryOptions;
|
package/src/index.ts
CHANGED
|
@@ -21,7 +21,11 @@ import {
|
|
|
21
21
|
type Database,
|
|
22
22
|
} from "@opengeni/db";
|
|
23
23
|
import { createNatsEventBus, type ResponderConnection } from "@opengeni/events";
|
|
24
|
-
import {
|
|
24
|
+
import {
|
|
25
|
+
createObservability,
|
|
26
|
+
logStartupDependencyRetry,
|
|
27
|
+
type Observability,
|
|
28
|
+
} from "@opengeni/observability";
|
|
25
29
|
import { createObjectStorage } from "@opengeni/storage";
|
|
26
30
|
import { isArtifactRuntimeConfigured } from "@opengeni/artifact-tool/runtime/development";
|
|
27
31
|
import { SESSION_WORKFLOW_WAKE_DISPATCHER_SCHEDULE_ID } from "@opengeni/core";
|
|
@@ -51,6 +55,7 @@ import {
|
|
|
51
55
|
createStandaloneEditableArtifactApplication,
|
|
52
56
|
type StandaloneEditableArtifactApplication,
|
|
53
57
|
} from "./editable-artifact-production";
|
|
58
|
+
import { installApiFatalProcessBoundary } from "./fatal-process-boundary";
|
|
54
59
|
|
|
55
60
|
/**
|
|
56
61
|
* A REJECT_DUPLICATE start collides on the deterministic workflowId when the
|
|
@@ -299,9 +304,15 @@ export async function createTemporalWorkflowClient(
|
|
|
299
304
|
};
|
|
300
305
|
}
|
|
301
306
|
|
|
302
|
-
export async function startApi(
|
|
303
|
-
|
|
304
|
-
|
|
307
|
+
export async function startApi(
|
|
308
|
+
options: {
|
|
309
|
+
settings?: ReturnType<typeof getSettings>;
|
|
310
|
+
observability?: Observability;
|
|
311
|
+
} = {},
|
|
312
|
+
) {
|
|
313
|
+
const settings = options.settings ?? getSettings();
|
|
314
|
+
const observability =
|
|
315
|
+
options.observability ?? createObservability(settings, { component: "api" });
|
|
305
316
|
// Step I: standalone → dbSchema unset → searchPath undefined → today's plain
|
|
306
317
|
// handle (public). Embedded → scoped to the dedicated schema + the host's RLS
|
|
307
318
|
// strategy.
|
|
@@ -531,7 +542,16 @@ export async function startApi() {
|
|
|
531
542
|
}
|
|
532
543
|
|
|
533
544
|
if (import.meta.main) {
|
|
534
|
-
|
|
545
|
+
const fatalBoundary = installApiFatalProcessBoundary();
|
|
546
|
+
try {
|
|
547
|
+
const settings = getSettings();
|
|
548
|
+
const observability = createObservability(settings, { component: "api" });
|
|
549
|
+
fatalBoundary.attachObservability(observability);
|
|
550
|
+
await startApi({ settings, observability });
|
|
551
|
+
fatalBoundary.markRunning();
|
|
552
|
+
} catch (error) {
|
|
553
|
+
await fatalBoundary.reportStartupFailure(error);
|
|
554
|
+
}
|
|
535
555
|
}
|
|
536
556
|
|
|
537
557
|
export function temporalOverlapPolicy(policy: ScheduledTaskOverlapPolicy): ScheduleOverlapPolicy {
|