@opengeni/api-router 0.2.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.d.ts +16 -0
- package/dist/app.js +35 -0
- package/dist/app.js.map +1 -0
- package/dist/chunk-XSYUDIX3.js +6331 -0
- package/dist/chunk-XSYUDIX3.js.map +1 -0
- package/dist/index.d.ts +19 -0
- package/dist/index.js +567 -0
- package/dist/index.js.map +1 -0
- package/package.json +74 -0
- package/src/app.ts +351 -0
- package/src/auth/managed-auth.ts +237 -0
- package/src/http/auth.ts +92 -0
- package/src/http/common.ts +16 -0
- package/src/http/sse.ts +89 -0
- package/src/index.ts +362 -0
- package/src/mcp/documents.ts +57 -0
- package/src/mcp/server.ts +961 -0
- package/src/mcp/session-view.ts +281 -0
- package/src/routes/api-keys.ts +65 -0
- package/src/routes/billing.ts +495 -0
- package/src/routes/capabilities.ts +80 -0
- package/src/routes/codex.ts +393 -0
- package/src/routes/documents.ts +185 -0
- package/src/routes/enrollments.ts +357 -0
- package/src/routes/environments.ts +175 -0
- package/src/routes/files.ts +148 -0
- package/src/routes/github.ts +341 -0
- package/src/routes/install.ts +218 -0
- package/src/routes/machines.ts +107 -0
- package/src/routes/packs.ts +241 -0
- package/src/routes/scheduled-tasks.ts +126 -0
- package/src/routes/sessions.ts +1083 -0
- package/src/routes/social.ts +119 -0
- package/src/routes/workspaces.ts +206 -0
- package/src/sandbox/access.ts +89 -0
- package/src/sandbox/auth-callout.ts +178 -0
- package/src/sandbox/channel-a.ts +265 -0
- package/src/sandbox/enrollment.ts +498 -0
- package/src/sandbox/machines.ts +255 -0
- package/src/sandbox/metrics-ingestion.ts +289 -0
- package/src/sandbox/viewer.ts +993 -0
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
import type { Settings } from "@opengeni/config";
|
|
2
|
+
import { type ManagedAuth } from "@opengeni/core";
|
|
3
|
+
import type { Database } from "@opengeni/db";
|
|
4
|
+
import { ensureManagedAccessForUser } from "@opengeni/db";
|
|
5
|
+
import { betterAuth } from "better-auth";
|
|
6
|
+
import { createEmailVerificationToken } from "better-auth/api";
|
|
7
|
+
import { Pool } from "pg";
|
|
8
|
+
import { Resend } from "resend";
|
|
9
|
+
|
|
10
|
+
// `ManagedAuth` (the Better Auth `Auth<any>` alias) is owned by @opengeni/core
|
|
11
|
+
// (`managed-auth-type.ts`) — `dependencies.ts`/`access` reference it as a
|
|
12
|
+
// type-only slot. We re-export it from this construction site so existing
|
|
13
|
+
// importers (`app.ts`) keep the same import path.
|
|
14
|
+
export type { ManagedAuth };
|
|
15
|
+
|
|
16
|
+
export function createManagedAuth(settings: Settings, db: Database): ManagedAuth | null {
|
|
17
|
+
if (settings.productAccessMode !== "managed") {
|
|
18
|
+
return null;
|
|
19
|
+
}
|
|
20
|
+
const pool = new Pool({ connectionString: settings.databaseUrl });
|
|
21
|
+
return betterAuth({
|
|
22
|
+
appName: "OpenGeni",
|
|
23
|
+
baseURL: betterAuthBaseUrl(settings),
|
|
24
|
+
basePath: "/v1/auth",
|
|
25
|
+
secret: settings.betterAuthSecret,
|
|
26
|
+
database: pool,
|
|
27
|
+
trustedOrigins: betterAuthTrustedOrigins(settings),
|
|
28
|
+
advanced: {
|
|
29
|
+
useSecureCookies: settings.publicBaseUrl?.startsWith("https://") ?? false,
|
|
30
|
+
...(settings.betterAuthCookieDomain ? {
|
|
31
|
+
crossSubDomainCookies: {
|
|
32
|
+
enabled: true,
|
|
33
|
+
domain: settings.betterAuthCookieDomain,
|
|
34
|
+
},
|
|
35
|
+
} : {}),
|
|
36
|
+
database: {
|
|
37
|
+
generateId: () => crypto.randomUUID(),
|
|
38
|
+
},
|
|
39
|
+
},
|
|
40
|
+
rateLimit: {
|
|
41
|
+
enabled: true,
|
|
42
|
+
storage: "database",
|
|
43
|
+
modelName: "auth_rate_limits",
|
|
44
|
+
fields: {
|
|
45
|
+
lastRequest: "last_request",
|
|
46
|
+
},
|
|
47
|
+
},
|
|
48
|
+
user: {
|
|
49
|
+
modelName: "auth_users",
|
|
50
|
+
fields: {
|
|
51
|
+
emailVerified: "email_verified",
|
|
52
|
+
createdAt: "created_at",
|
|
53
|
+
updatedAt: "updated_at",
|
|
54
|
+
},
|
|
55
|
+
},
|
|
56
|
+
session: {
|
|
57
|
+
modelName: "auth_sessions",
|
|
58
|
+
fields: {
|
|
59
|
+
userId: "user_id",
|
|
60
|
+
expiresAt: "expires_at",
|
|
61
|
+
ipAddress: "ip_address",
|
|
62
|
+
userAgent: "user_agent",
|
|
63
|
+
createdAt: "created_at",
|
|
64
|
+
updatedAt: "updated_at",
|
|
65
|
+
},
|
|
66
|
+
},
|
|
67
|
+
account: {
|
|
68
|
+
modelName: "auth_identities",
|
|
69
|
+
fields: {
|
|
70
|
+
userId: "user_id",
|
|
71
|
+
accountId: "account_id",
|
|
72
|
+
providerId: "provider_id",
|
|
73
|
+
accessToken: "access_token",
|
|
74
|
+
refreshToken: "refresh_token",
|
|
75
|
+
idToken: "id_token",
|
|
76
|
+
accessTokenExpiresAt: "access_token_expires_at",
|
|
77
|
+
refreshTokenExpiresAt: "refresh_token_expires_at",
|
|
78
|
+
createdAt: "created_at",
|
|
79
|
+
updatedAt: "updated_at",
|
|
80
|
+
},
|
|
81
|
+
accountLinking: {
|
|
82
|
+
enabled: false,
|
|
83
|
+
},
|
|
84
|
+
},
|
|
85
|
+
verification: {
|
|
86
|
+
modelName: "auth_verifications",
|
|
87
|
+
fields: {
|
|
88
|
+
expiresAt: "expires_at",
|
|
89
|
+
createdAt: "created_at",
|
|
90
|
+
updatedAt: "updated_at",
|
|
91
|
+
},
|
|
92
|
+
storeIdentifier: "hashed",
|
|
93
|
+
},
|
|
94
|
+
emailAndPassword: {
|
|
95
|
+
enabled: true,
|
|
96
|
+
requireEmailVerification: true,
|
|
97
|
+
revokeSessionsOnPasswordReset: true,
|
|
98
|
+
onExistingUserSignUp: async ({ user }) => {
|
|
99
|
+
if (!user.emailVerified) {
|
|
100
|
+
const url = await verificationUrl(settings, user.email);
|
|
101
|
+
await sendEmail(settings, {
|
|
102
|
+
to: user.email,
|
|
103
|
+
subject: "Verify your OpenGeni email",
|
|
104
|
+
text: `Verify your OpenGeni email: ${url}`,
|
|
105
|
+
html: `<p>Verify your OpenGeni email:</p><p><a href="${escapeHtml(url)}">Verify email</a></p>`,
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
},
|
|
109
|
+
sendResetPassword: async ({ user, url }) => {
|
|
110
|
+
await sendEmail(settings, {
|
|
111
|
+
to: user.email,
|
|
112
|
+
subject: "Reset your OpenGeni password",
|
|
113
|
+
text: `Reset your OpenGeni password: ${url}`,
|
|
114
|
+
html: `<p>Reset your OpenGeni password:</p><p><a href="${escapeHtml(url)}">Reset password</a></p>`,
|
|
115
|
+
});
|
|
116
|
+
},
|
|
117
|
+
},
|
|
118
|
+
emailVerification: {
|
|
119
|
+
sendOnSignUp: true,
|
|
120
|
+
sendVerificationEmail: async ({ user, url }) => {
|
|
121
|
+
await sendEmail(settings, {
|
|
122
|
+
to: user.email,
|
|
123
|
+
subject: "Verify your OpenGeni email",
|
|
124
|
+
text: `Verify your OpenGeni email: ${url}`,
|
|
125
|
+
html: `<p>Verify your OpenGeni email:</p><p><a href="${escapeHtml(url)}">Verify email</a></p>`,
|
|
126
|
+
});
|
|
127
|
+
},
|
|
128
|
+
afterEmailVerification: async (user) => {
|
|
129
|
+
await ensureManagedAccessForUser(db, {
|
|
130
|
+
userId: user.id,
|
|
131
|
+
email: user.email,
|
|
132
|
+
name: user.name,
|
|
133
|
+
});
|
|
134
|
+
},
|
|
135
|
+
},
|
|
136
|
+
databaseHooks: {
|
|
137
|
+
user: {
|
|
138
|
+
create: {
|
|
139
|
+
after: async (user) => {
|
|
140
|
+
await ensureManagedAccessForUser(db, {
|
|
141
|
+
userId: user.id,
|
|
142
|
+
email: user.email,
|
|
143
|
+
name: user.name,
|
|
144
|
+
});
|
|
145
|
+
},
|
|
146
|
+
},
|
|
147
|
+
},
|
|
148
|
+
},
|
|
149
|
+
}) as ManagedAuth;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
export async function managedSessionAccessContext(auth: ManagedAuth, db: Database, headers: Headers) {
|
|
153
|
+
const session = await auth.api.getSession({ headers });
|
|
154
|
+
if (!session?.user) {
|
|
155
|
+
return null;
|
|
156
|
+
}
|
|
157
|
+
return await ensureManagedAccessForUser(db, {
|
|
158
|
+
userId: session.user.id,
|
|
159
|
+
email: session.user.email,
|
|
160
|
+
name: session.user.name,
|
|
161
|
+
});
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function betterAuthBaseUrl(settings: Settings) {
|
|
165
|
+
const allowedHosts = splitCsv(settings.betterAuthAllowedHosts);
|
|
166
|
+
if (allowedHosts.length === 0) {
|
|
167
|
+
return settings.publicBaseUrl;
|
|
168
|
+
}
|
|
169
|
+
return {
|
|
170
|
+
allowedHosts,
|
|
171
|
+
fallback: settings.publicBaseUrl,
|
|
172
|
+
protocol: "auto" as const,
|
|
173
|
+
};
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function betterAuthTrustedOrigins(settings: Settings): string[] {
|
|
177
|
+
const origins = new Set<string>();
|
|
178
|
+
if (settings.publicBaseUrl) {
|
|
179
|
+
origins.add(new URL(settings.publicBaseUrl).origin);
|
|
180
|
+
}
|
|
181
|
+
for (const origin of splitCsv(settings.betterAuthTrustedOrigins)) {
|
|
182
|
+
origins.add(origin);
|
|
183
|
+
}
|
|
184
|
+
return [...origins];
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
async function sendEmail(settings: Settings, input: {
|
|
188
|
+
to: string;
|
|
189
|
+
subject: string;
|
|
190
|
+
text: string;
|
|
191
|
+
html: string;
|
|
192
|
+
}): Promise<void> {
|
|
193
|
+
if (!settings.resendApiKey) {
|
|
194
|
+
if (settings.environment === "local" || settings.environment === "test") {
|
|
195
|
+
console.warn(`[opengeni] Skipping email to ${input.to}: OPENGENI_RESEND_API_KEY is not configured`);
|
|
196
|
+
return;
|
|
197
|
+
}
|
|
198
|
+
throw new Error("OPENGENI_RESEND_API_KEY is required to send managed auth email");
|
|
199
|
+
}
|
|
200
|
+
const resend = new Resend(settings.resendApiKey);
|
|
201
|
+
const result = await resend.emails.send({
|
|
202
|
+
from: settings.emailFrom,
|
|
203
|
+
to: input.to,
|
|
204
|
+
subject: input.subject,
|
|
205
|
+
text: input.text,
|
|
206
|
+
html: input.html,
|
|
207
|
+
});
|
|
208
|
+
if (result.error) {
|
|
209
|
+
throw new Error(result.error.message);
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
async function verificationUrl(settings: Settings, email: string): Promise<string> {
|
|
214
|
+
if (!settings.betterAuthSecret) {
|
|
215
|
+
throw new Error("OPENGENI_BETTER_AUTH_SECRET is required to send managed auth verification email");
|
|
216
|
+
}
|
|
217
|
+
if (!settings.publicBaseUrl) {
|
|
218
|
+
throw new Error("OPENGENI_PUBLIC_BASE_URL is required to send managed auth verification email");
|
|
219
|
+
}
|
|
220
|
+
const token = await createEmailVerificationToken(settings.betterAuthSecret, email);
|
|
221
|
+
const url = new URL("/v1/auth/verify-email", settings.publicBaseUrl);
|
|
222
|
+
url.searchParams.set("token", token);
|
|
223
|
+
url.searchParams.set("callbackURL", "/");
|
|
224
|
+
return url.toString();
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
function splitCsv(raw: string): string[] {
|
|
228
|
+
return raw.split(",").map((value) => value.trim()).filter(Boolean);
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
function escapeHtml(value: string): string {
|
|
232
|
+
return value
|
|
233
|
+
.replaceAll("&", "&")
|
|
234
|
+
.replaceAll("<", "<")
|
|
235
|
+
.replaceAll(">", ">")
|
|
236
|
+
.replaceAll('"', """);
|
|
237
|
+
}
|
package/src/http/auth.ts
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import type { Settings } from "@opengeni/config";
|
|
2
|
+
import type { Context, MiddlewareHandler } from "hono";
|
|
3
|
+
import { installExactPaths, isInstallRedirectPath } from "../routes/install";
|
|
4
|
+
|
|
5
|
+
const githubConnectPathPattern = /^\/v1\/workspaces\/[^/]+\/github\/connect$/;
|
|
6
|
+
|
|
7
|
+
export function requireAccessKey(settings: Settings): MiddlewareHandler {
|
|
8
|
+
return async (c, next) => {
|
|
9
|
+
// §7.2 P1: requireAccessKey is the coarse NETWORK perimeter, not the
|
|
10
|
+
// per-tenant identity gate (that is resolveAccessContext). When
|
|
11
|
+
// `authRequired:false` it is a NO-OP — the embedded (Path 2) case where the
|
|
12
|
+
// host's own auth is the sole human gate and OpenGeni is mounted behind it.
|
|
13
|
+
// Standalone/separate deployments set `authRequired:true` to keep this ON as
|
|
14
|
+
// the shared-deployment-key perimeter.
|
|
15
|
+
if (!settings.authRequired || isAuthExempt(c, settings)) {
|
|
16
|
+
await next();
|
|
17
|
+
return;
|
|
18
|
+
}
|
|
19
|
+
if (isAuthorized(c, settings.accessKey)) {
|
|
20
|
+
await next();
|
|
21
|
+
return;
|
|
22
|
+
}
|
|
23
|
+
return c.json({ error: "unauthorized" }, 401);
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function isAuthExempt(c: Context, settings: Settings): boolean {
|
|
28
|
+
if (c.req.method === "OPTIONS") {
|
|
29
|
+
return true;
|
|
30
|
+
}
|
|
31
|
+
const path = new URL(c.req.url).pathname;
|
|
32
|
+
if (path === "/v1/config/client") {
|
|
33
|
+
return true;
|
|
34
|
+
}
|
|
35
|
+
if (path === "/v1/auth" || path.startsWith("/v1/auth/")) {
|
|
36
|
+
return true;
|
|
37
|
+
}
|
|
38
|
+
if (path === "/v1/webhooks/stripe") {
|
|
39
|
+
return true;
|
|
40
|
+
}
|
|
41
|
+
if (
|
|
42
|
+
path === "/v1/github/setup" ||
|
|
43
|
+
path === "/v1/github/install/callback" ||
|
|
44
|
+
path === "/v1/github/oauth/callback" ||
|
|
45
|
+
path === "/v1/github/app-manifest/callback"
|
|
46
|
+
) {
|
|
47
|
+
return true;
|
|
48
|
+
}
|
|
49
|
+
// Browser entry for MCP-issued GitHub install links: opened in a browser
|
|
50
|
+
// that holds no API credentials, like the callbacks above. The route itself
|
|
51
|
+
// verifies the signed workspace-bound state before doing anything.
|
|
52
|
+
if (githubConnectPathPattern.test(path)) {
|
|
53
|
+
return true;
|
|
54
|
+
}
|
|
55
|
+
// The get.<domain> install-serving routes (install.sh/.ps1/uninstall.sh/
|
|
56
|
+
// minisign pub + the release-binary redirects). Reached by a fresh machine
|
|
57
|
+
// with no credentials; the bodies carry no secrets (dossier §23.1).
|
|
58
|
+
if (installExactPaths.has(path) || isInstallRedirectPath(path)) {
|
|
59
|
+
return true;
|
|
60
|
+
}
|
|
61
|
+
if (settings.authAllowHealth && path === "/healthz") {
|
|
62
|
+
return true;
|
|
63
|
+
}
|
|
64
|
+
if (settings.authAllowMetrics && path === "/metrics") {
|
|
65
|
+
return true;
|
|
66
|
+
}
|
|
67
|
+
return false;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function isAuthorized(c: Context, expected: string | undefined): boolean {
|
|
71
|
+
if (!expected) {
|
|
72
|
+
return false;
|
|
73
|
+
}
|
|
74
|
+
const explicit = c.req.header("x-opengeni-access-key");
|
|
75
|
+
return constantTimeEqual(explicit, expected);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function constantTimeEqual(actual: string | undefined, expected: string): boolean {
|
|
79
|
+
if (typeof actual !== "string") {
|
|
80
|
+
return false;
|
|
81
|
+
}
|
|
82
|
+
const actualBytes = new TextEncoder().encode(actual);
|
|
83
|
+
const expectedBytes = new TextEncoder().encode(expected);
|
|
84
|
+
if (actualBytes.length !== expectedBytes.length) {
|
|
85
|
+
return false;
|
|
86
|
+
}
|
|
87
|
+
let diff = 0;
|
|
88
|
+
for (let index = 0; index < actualBytes.length; index += 1) {
|
|
89
|
+
diff |= actualBytes[index]! ^ expectedBytes[index]!;
|
|
90
|
+
}
|
|
91
|
+
return diff === 0;
|
|
92
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { getSession, type Database } from "@opengeni/db";
|
|
2
|
+
import { HTTPException } from "hono/http-exception";
|
|
3
|
+
|
|
4
|
+
export function boundedLimit(raw: string | undefined): number {
|
|
5
|
+
const limit = Number(raw ?? 100);
|
|
6
|
+
if (!Number.isFinite(limit)) {
|
|
7
|
+
return 100;
|
|
8
|
+
}
|
|
9
|
+
return Math.min(500, Math.max(1, Math.floor(limit)));
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export async function assertSessionExists(db: Database, workspaceId: string, sessionId: string): Promise<void> {
|
|
13
|
+
if (!await getSession(db, workspaceId, sessionId)) {
|
|
14
|
+
throw new HTTPException(404, { message: "session not found" });
|
|
15
|
+
}
|
|
16
|
+
}
|
package/src/http/sse.ts
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import type { SessionEvent } from "@opengeni/contracts";
|
|
2
|
+
import { listSessionEvents, type Database } from "@opengeni/db";
|
|
3
|
+
import { formatSse, type EventBus } from "@opengeni/events";
|
|
4
|
+
|
|
5
|
+
export async function sseSessionStream(db: Database, bus: EventBus, workspaceId: string, sessionId: string, after: number, signal: AbortSignal): Promise<Response> {
|
|
6
|
+
const encoder = new TextEncoder();
|
|
7
|
+
let controller: ReadableStreamDefaultController<Uint8Array>;
|
|
8
|
+
let lastSent = after;
|
|
9
|
+
let replaying = true;
|
|
10
|
+
const buffered: SessionEvent[] = [];
|
|
11
|
+
let unsubscribe: (() => void) | null = null;
|
|
12
|
+
|
|
13
|
+
const stream = new ReadableStream<Uint8Array>({
|
|
14
|
+
start: async (rawController) => {
|
|
15
|
+
controller = rawController;
|
|
16
|
+
const send = async (event: SessionEvent) => {
|
|
17
|
+
if (event.sequence <= lastSent) {
|
|
18
|
+
return;
|
|
19
|
+
}
|
|
20
|
+
if (event.sequence > lastSent + 1) {
|
|
21
|
+
const missing = await listSessionEvents(db, workspaceId, sessionId, lastSent, event.sequence - lastSent - 1);
|
|
22
|
+
for (const missed of missing) {
|
|
23
|
+
if (missed.sequence > lastSent) {
|
|
24
|
+
controller.enqueue(encoder.encode(formatSse(missed)));
|
|
25
|
+
lastSent = missed.sequence;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
controller.enqueue(encoder.encode(formatSse(event)));
|
|
30
|
+
lastSent = event.sequence;
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
unsubscribe = await bus.subscribe(workspaceId, sessionId, async (events) => {
|
|
34
|
+
if (replaying) {
|
|
35
|
+
buffered.push(...events);
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
for (const event of events.sort((a, b) => a.sequence - b.sequence)) {
|
|
39
|
+
await send(event);
|
|
40
|
+
}
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
await replaySessionEvents((cursor, limit) => listSessionEvents(db, workspaceId, sessionId, cursor, limit), send, after);
|
|
44
|
+
replaying = false;
|
|
45
|
+
for (const event of buffered.sort((a, b) => a.sequence - b.sequence)) {
|
|
46
|
+
await send(event);
|
|
47
|
+
}
|
|
48
|
+
buffered.length = 0;
|
|
49
|
+
controller.enqueue(encoder.encode(": connected\n\n"));
|
|
50
|
+
},
|
|
51
|
+
cancel: () => {
|
|
52
|
+
unsubscribe?.();
|
|
53
|
+
},
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
signal.addEventListener("abort", () => {
|
|
57
|
+
unsubscribe?.();
|
|
58
|
+
}, { once: true });
|
|
59
|
+
|
|
60
|
+
return new Response(stream, {
|
|
61
|
+
headers: {
|
|
62
|
+
"Content-Type": "text/event-stream; charset=utf-8",
|
|
63
|
+
"Cache-Control": "no-cache, no-transform",
|
|
64
|
+
Connection: "keep-alive",
|
|
65
|
+
},
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export async function replaySessionEvents(
|
|
70
|
+
loadPage: (after: number, limit: number) => Promise<SessionEvent[]>,
|
|
71
|
+
send: (event: SessionEvent) => Promise<void>,
|
|
72
|
+
after: number,
|
|
73
|
+
pageSize = 1000,
|
|
74
|
+
): Promise<void> {
|
|
75
|
+
let cursor = after;
|
|
76
|
+
while (true) {
|
|
77
|
+
const page = await loadPage(cursor, pageSize);
|
|
78
|
+
if (page.length === 0) {
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
for (const event of page.sort((a, b) => a.sequence - b.sequence)) {
|
|
82
|
+
await send(event);
|
|
83
|
+
cursor = Math.max(cursor, event.sequence);
|
|
84
|
+
}
|
|
85
|
+
if (page.length < pageSize) {
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
}
|