@opengeni/api-router 2.5.0 → 2.6.4
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 +5 -1
- package/dist/auth/managed-auth.d.ts +24 -1
- package/dist/{chunk-QESX7HDK.js → chunk-XTLI3CBH.js} +2236 -387
- package/dist/chunk-XTLI3CBH.js.map +1 -0
- package/dist/http/sse.d.ts +9 -0
- package/dist/index.js +104 -4
- package/dist/index.js.map +1 -1
- package/dist/interaction-metrics.d.ts +2 -0
- package/dist/mcp/company-brain-governed-writes.d.ts +1 -1
- package/dist/mcp/company-profile-agent-admin.d.ts +6 -6
- package/dist/mcp/remember.d.ts +2 -2
- package/dist/mcp/server.d.ts +1 -0
- package/dist/mcp/session-view.d.ts +1 -0
- package/dist/mcp/session-wait.d.ts +19 -0
- package/dist/routes/api-keys.d.ts +2 -0
- package/dist/routes/browser-sessions.d.ts +1 -0
- package/dist/routes/computer-sessions.d.ts +12 -0
- package/dist/routes/managed-auth-session-sets.d.ts +7 -0
- package/dist/routes/workspaces.d.ts +1 -1
- package/dist/sandbox/metrics-ingestion.d.ts +16 -0
- package/dist/workspace-delete-observability.d.ts +10 -0
- package/package.json +15 -15
- package/src/app.ts +172 -20
- package/src/auth/managed-auth-attempt-context.ts +40 -3
- package/src/auth/managed-auth-session-adapter.ts +1 -0
- package/src/auth/managed-auth.ts +164 -4
- package/src/http/sse.ts +279 -45
- package/src/integrations/oauth-client.ts +8 -1
- package/src/integrations/provider-oauth.ts +12 -2
- package/src/interaction-metrics.ts +30 -0
- package/src/mcp/company-brain-governed-writes.ts +38 -23
- package/src/mcp/company-profile-agent-admin.ts +7 -7
- package/src/mcp/remember.ts +19 -8
- package/src/mcp/server.ts +318 -26
- package/src/mcp/session-wait.ts +56 -8
- package/src/routes/api-integrations.ts +2 -2
- package/src/routes/api-keys.ts +149 -7
- package/src/routes/browser-sessions.ts +10 -2
- package/src/routes/capabilities.ts +3 -3
- package/src/routes/codex.ts +483 -74
- package/src/routes/company-profile.ts +64 -0
- package/src/routes/computer-sessions.ts +103 -1
- package/src/routes/integration-facets.ts +8 -5
- package/src/routes/interaction-resources.ts +7 -1
- package/src/routes/managed-auth-session-sets.ts +199 -2
- package/src/routes/organization-memberships.ts +28 -8
- package/src/routes/packs.ts +5 -5
- package/src/routes/plugins.ts +2 -2
- package/src/routes/scheduled-tasks.ts +9 -0
- package/src/routes/sessions.ts +3 -6
- package/src/routes/skills.ts +3 -3
- package/src/routes/workspaces.ts +293 -54
- package/src/sandbox/channel-a.ts +10 -4
- package/src/sandbox/machines.ts +13 -6
- package/src/sandbox/metrics-ingestion.ts +157 -3
- package/src/sandbox/viewer.ts +20 -1
- package/src/workspace-delete-observability.ts +75 -0
- package/dist/chunk-QESX7HDK.js.map +0 -1
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { randomUUID } from "node:crypto";
|
|
2
2
|
import {
|
|
3
3
|
ActivateCompanyProfileRevisionRequest,
|
|
4
|
+
CompanyProfileAgentPolicy,
|
|
4
5
|
CompanyProfileConflictResponse,
|
|
5
6
|
CompanyProfileDiffRequest,
|
|
6
7
|
CompanyProfileDiffResponse,
|
|
@@ -11,6 +12,7 @@ import {
|
|
|
11
12
|
CompanyProfileRevision,
|
|
12
13
|
RollbackCompanyProfileRequest,
|
|
13
14
|
UpdateCompanyProfileRequest,
|
|
15
|
+
UpdateCompanyProfileAgentPolicyRequest,
|
|
14
16
|
} from "@opengeni/contracts";
|
|
15
17
|
import {
|
|
16
18
|
requireAccessGrant,
|
|
@@ -20,15 +22,18 @@ import {
|
|
|
20
22
|
} from "@opengeni/core";
|
|
21
23
|
import {
|
|
22
24
|
activateCompanyProfileRevision,
|
|
25
|
+
CompanyProfileAgentPolicyError,
|
|
23
26
|
CompanyProfileConflictError,
|
|
24
27
|
CompanyProfileInvalidOperationError,
|
|
25
28
|
CompanyProfileNotFoundError,
|
|
26
29
|
CompanyProfileOperationReuseError,
|
|
27
30
|
diffCompanyProfileRevisions,
|
|
28
31
|
getCompanyProfileRevision,
|
|
32
|
+
getCompanyProfileAgentPolicy,
|
|
29
33
|
listCompanyProfile,
|
|
30
34
|
rollbackCompanyProfileRevision,
|
|
31
35
|
updateCompanyProfile,
|
|
36
|
+
updateCompanyProfileAgentPolicy,
|
|
32
37
|
} from "@opengeni/db";
|
|
33
38
|
import type { Context, Hono } from "hono";
|
|
34
39
|
import { HTTPException } from "hono/http-exception";
|
|
@@ -84,6 +89,15 @@ function profileError(context: Context, error: unknown): Response {
|
|
|
84
89
|
if (error instanceof CompanyProfileInvalidOperationError) {
|
|
85
90
|
return context.json({ code: "INVALID_COMPANY_PROFILE_OPERATION", message: error.message }, 422);
|
|
86
91
|
}
|
|
92
|
+
if (error instanceof CompanyProfileAgentPolicyError) {
|
|
93
|
+
if (error.code === "authority_unavailable") {
|
|
94
|
+
throw new HTTPException(403, { message: error.message });
|
|
95
|
+
}
|
|
96
|
+
if (error.code === "policy_conflict" || error.code === "operation_reused") {
|
|
97
|
+
throw new HTTPException(409, { message: error.message });
|
|
98
|
+
}
|
|
99
|
+
throw new HTTPException(422, { message: error.message });
|
|
100
|
+
}
|
|
87
101
|
throw error;
|
|
88
102
|
}
|
|
89
103
|
|
|
@@ -97,6 +111,56 @@ function revisionId(context: Context): string {
|
|
|
97
111
|
export function registerCompanyProfileRoutes(app: Hono, deps: ApiRouteDeps): void {
|
|
98
112
|
const base = "/v1/workspaces/:workspaceId/company-profile";
|
|
99
113
|
|
|
114
|
+
app.get(`${base}/agent-policy`, async (context) => {
|
|
115
|
+
const workspaceId = context.req.param("workspaceId");
|
|
116
|
+
const access = await requireAccessGrantAuthorization(
|
|
117
|
+
context,
|
|
118
|
+
deps,
|
|
119
|
+
workspaceId,
|
|
120
|
+
"workspace:read",
|
|
121
|
+
);
|
|
122
|
+
requireDirectAccountAdmin(access);
|
|
123
|
+
try {
|
|
124
|
+
return context.json(
|
|
125
|
+
CompanyProfileAgentPolicy.parse(
|
|
126
|
+
await getCompanyProfileAgentPolicy(deps.db, {
|
|
127
|
+
accountId: access.grant.accountId,
|
|
128
|
+
workspaceId,
|
|
129
|
+
actorSubjectId: access.grant.subjectId,
|
|
130
|
+
}),
|
|
131
|
+
),
|
|
132
|
+
);
|
|
133
|
+
} catch (error) {
|
|
134
|
+
return profileError(context, error);
|
|
135
|
+
}
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
app.patch(`${base}/agent-policy`, async (context) => {
|
|
139
|
+
const workspaceId = context.req.param("workspaceId");
|
|
140
|
+
const access = await requireAccessGrantAuthorization(
|
|
141
|
+
context,
|
|
142
|
+
deps,
|
|
143
|
+
workspaceId,
|
|
144
|
+
"workspace:read",
|
|
145
|
+
);
|
|
146
|
+
requireDirectAccountAdmin(access);
|
|
147
|
+
const request = await parseBody(context, UpdateCompanyProfileAgentPolicyRequest);
|
|
148
|
+
try {
|
|
149
|
+
return context.json(
|
|
150
|
+
CompanyProfileAgentPolicy.parse(
|
|
151
|
+
await updateCompanyProfileAgentPolicy(deps.db, {
|
|
152
|
+
accountId: access.grant.accountId,
|
|
153
|
+
workspaceId,
|
|
154
|
+
actorSubjectId: access.grant.subjectId,
|
|
155
|
+
...request,
|
|
156
|
+
}),
|
|
157
|
+
),
|
|
158
|
+
);
|
|
159
|
+
} catch (error) {
|
|
160
|
+
return profileError(context, error);
|
|
161
|
+
}
|
|
162
|
+
});
|
|
163
|
+
|
|
100
164
|
app.get(base, async (context) => {
|
|
101
165
|
const workspaceId = context.req.param("workspaceId");
|
|
102
166
|
const grant = await requireAccessGrant(context, deps, workspaceId, "workspace:read");
|
|
@@ -68,6 +68,7 @@ import {
|
|
|
68
68
|
BrowserControlServerUnsupportedError,
|
|
69
69
|
BrowserControlTransportError,
|
|
70
70
|
BrowserControlUnsupportedError,
|
|
71
|
+
ComputerFrameEvidenceMismatchError,
|
|
71
72
|
buildSelfhostedBackendSession,
|
|
72
73
|
buildStreamUrl,
|
|
73
74
|
exposedPortEndpointFromUrl,
|
|
@@ -75,8 +76,11 @@ import {
|
|
|
75
76
|
NatsControlRpc,
|
|
76
77
|
NatsOpStreamTransport,
|
|
77
78
|
provisionBrowserControlClient,
|
|
79
|
+
validateComputerControlFrameEvidence,
|
|
78
80
|
renewSandboxProviderExpiration,
|
|
79
81
|
type BrowserControlPlacementSession,
|
|
82
|
+
type ComputerControlFrame,
|
|
83
|
+
type ExpectedComputerFrameEvidence,
|
|
80
84
|
} from "@opengeni/runtime/sandbox";
|
|
81
85
|
import type { Context, Hono } from "hono";
|
|
82
86
|
import { HTTPException } from "hono/http-exception";
|
|
@@ -101,7 +105,11 @@ import {
|
|
|
101
105
|
createInteractionFrameProxyAttachment,
|
|
102
106
|
placementUsesInteractionFrameProxy,
|
|
103
107
|
} from "../interaction-frame-proxy";
|
|
104
|
-
import {
|
|
108
|
+
import {
|
|
109
|
+
observeComputerActionResult,
|
|
110
|
+
observeComputerFrameEvidenceMismatch,
|
|
111
|
+
observeLifecycleResult,
|
|
112
|
+
} from "../interaction-metrics";
|
|
105
113
|
import { withChannelA, withChannelARead, type ChannelAOperation } from "../sandbox/channel-a";
|
|
106
114
|
|
|
107
115
|
type ComputerPlacement = {
|
|
@@ -111,6 +119,59 @@ type ComputerPlacement = {
|
|
|
111
119
|
lease: LeaseSnapshot | null;
|
|
112
120
|
};
|
|
113
121
|
|
|
122
|
+
const MODEL_COMPUTER_FRAME_MAX_BYTES = 256 * 1024;
|
|
123
|
+
|
|
124
|
+
type ComputerFrameCaptureClient = {
|
|
125
|
+
capture(
|
|
126
|
+
targetId: string,
|
|
127
|
+
options: {
|
|
128
|
+
format?: "jpeg" | "png";
|
|
129
|
+
quality?: number;
|
|
130
|
+
maxWidth?: number;
|
|
131
|
+
maxHeight?: number;
|
|
132
|
+
},
|
|
133
|
+
): Promise<ComputerControlFrame>;
|
|
134
|
+
};
|
|
135
|
+
|
|
136
|
+
export function validateComputerFrameForApi(
|
|
137
|
+
frame: Pick<ComputerControlFrame, "data" | "mediaType" | "metadataHeader">,
|
|
138
|
+
expected: ExpectedComputerFrameEvidence,
|
|
139
|
+
): ComputerControlFrame {
|
|
140
|
+
return validateComputerControlFrameEvidence(frame, expected);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
export async function captureModelComputerFrame(
|
|
144
|
+
sessionClient: ComputerFrameCaptureClient,
|
|
145
|
+
expected: ExpectedComputerFrameEvidence,
|
|
146
|
+
): Promise<ComputerControlFrame> {
|
|
147
|
+
let captured = validateComputerFrameForApi(
|
|
148
|
+
await sessionClient.capture(expected.targetId, {
|
|
149
|
+
format: "jpeg",
|
|
150
|
+
quality: 55,
|
|
151
|
+
maxWidth: 1_024,
|
|
152
|
+
maxHeight: 768,
|
|
153
|
+
}),
|
|
154
|
+
expected,
|
|
155
|
+
);
|
|
156
|
+
if (captured.data.byteLength > MODEL_COMPUTER_FRAME_MAX_BYTES) {
|
|
157
|
+
captured = validateComputerFrameForApi(
|
|
158
|
+
await sessionClient.capture(expected.targetId, {
|
|
159
|
+
format: "jpeg",
|
|
160
|
+
quality: 30,
|
|
161
|
+
maxWidth: 640,
|
|
162
|
+
maxHeight: 480,
|
|
163
|
+
}),
|
|
164
|
+
expected,
|
|
165
|
+
);
|
|
166
|
+
}
|
|
167
|
+
if (captured.data.byteLength > MODEL_COMPUTER_FRAME_MAX_BYTES) {
|
|
168
|
+
throw new BrowserControlProtocolError(
|
|
169
|
+
"computer screenshot could not honor the model image byte bound",
|
|
170
|
+
);
|
|
171
|
+
}
|
|
172
|
+
return captured;
|
|
173
|
+
}
|
|
174
|
+
|
|
114
175
|
/** Public ComputerSession resource surface. Physical app/window authority stays
|
|
115
176
|
* in the same placement controller used by BrowserSession; this route owns only
|
|
116
177
|
* durable authorization, placement fencing, lifecycle receipts, and routing. */
|
|
@@ -389,6 +450,47 @@ export function registerComputerSessionRoutes(app: Hono, deps: ApiRouteDeps): vo
|
|
|
389
450
|
},
|
|
390
451
|
);
|
|
391
452
|
|
|
453
|
+
app.get(
|
|
454
|
+
"/v1/workspaces/:workspaceId/computer-sessions/:computerSessionId/targets/:targetId/screenshot",
|
|
455
|
+
async (context) => {
|
|
456
|
+
const { workspaceId, grant, computerSessionId } = await routePreamble(
|
|
457
|
+
context,
|
|
458
|
+
"sessions:read",
|
|
459
|
+
);
|
|
460
|
+
const targetId = requireOpaqueParam(context, "targetId");
|
|
461
|
+
const frame = await withActiveComputerController(
|
|
462
|
+
context,
|
|
463
|
+
grant,
|
|
464
|
+
workspaceId,
|
|
465
|
+
computerSessionId,
|
|
466
|
+
"session.read",
|
|
467
|
+
"computer.read",
|
|
468
|
+
async ({ sessionClient, binding }) => {
|
|
469
|
+
try {
|
|
470
|
+
return await captureModelComputerFrame(sessionClient, {
|
|
471
|
+
computerSessionId,
|
|
472
|
+
controllerGeneration: binding.controllerGeneration,
|
|
473
|
+
targetId,
|
|
474
|
+
});
|
|
475
|
+
} catch (error) {
|
|
476
|
+
if (error instanceof ComputerFrameEvidenceMismatchError) {
|
|
477
|
+
observeComputerFrameEvidenceMismatch(deps.observability, error.reason);
|
|
478
|
+
}
|
|
479
|
+
throw error;
|
|
480
|
+
}
|
|
481
|
+
},
|
|
482
|
+
);
|
|
483
|
+
return new Response(frame.data.slice().buffer, {
|
|
484
|
+
status: 200,
|
|
485
|
+
headers: {
|
|
486
|
+
"cache-control": "no-store",
|
|
487
|
+
"content-type": frame.mediaType,
|
|
488
|
+
"x-opengeni-computer-frame": frame.metadataHeader,
|
|
489
|
+
},
|
|
490
|
+
});
|
|
491
|
+
},
|
|
492
|
+
);
|
|
493
|
+
|
|
392
494
|
app.get(
|
|
393
495
|
"/v1/workspaces/:workspaceId/computer-sessions/:computerSessionId/clipboard",
|
|
394
496
|
async (context) => {
|
|
@@ -66,7 +66,7 @@ export function registerIntegrationFacetRoutes(app: Hono, deps: ApiRouteDeps): v
|
|
|
66
66
|
c,
|
|
67
67
|
deps,
|
|
68
68
|
workspaceId,
|
|
69
|
-
"
|
|
69
|
+
"capabilities:manage",
|
|
70
70
|
);
|
|
71
71
|
const { grant } = authorization;
|
|
72
72
|
try {
|
|
@@ -82,7 +82,10 @@ export function registerIntegrationFacetRoutes(app: Hono, deps: ApiRouteDeps): v
|
|
|
82
82
|
payload: await c.req.json(),
|
|
83
83
|
canManageOrganizationDestination:
|
|
84
84
|
authorization.accountGrant?.permissions.includes("account:admin") === true,
|
|
85
|
-
canManageWorkspaceDestination: hasPermission(
|
|
85
|
+
canManageWorkspaceDestination: hasPermission(
|
|
86
|
+
grant.permissions,
|
|
87
|
+
"capabilities:manage",
|
|
88
|
+
),
|
|
86
89
|
canManagePersonalDestination:
|
|
87
90
|
authorization.contextIntegrity &&
|
|
88
91
|
authorization.authenticatedSubjectId === grant.subjectId,
|
|
@@ -122,7 +125,7 @@ export function registerIntegrationFacetRoutes(app: Hono, deps: ApiRouteDeps): v
|
|
|
122
125
|
"/v1/workspaces/:workspaceId/integrations/:capabilityId/instances/:instanceKey/facets/:facetKey",
|
|
123
126
|
async (c) => {
|
|
124
127
|
const workspaceId = c.req.param("workspaceId");
|
|
125
|
-
const grant = await requireAccessGrant(c, deps, workspaceId, "
|
|
128
|
+
const grant = await requireAccessGrant(c, deps, workspaceId, "capabilities:manage");
|
|
126
129
|
const payload = UpsertIntegrationFacetRequest.parse(await c.req.json());
|
|
127
130
|
const capabilityId = decoded(c.req.param("capabilityId"));
|
|
128
131
|
const instanceKey = decoded(c.req.param("instanceKey"));
|
|
@@ -204,7 +207,7 @@ export function registerIntegrationFacetRoutes(app: Hono, deps: ApiRouteDeps): v
|
|
|
204
207
|
`/v1/workspaces/:workspaceId/integrations/:capabilityId/instances/:instanceKey/facets/:facetKey/${action}`,
|
|
205
208
|
async (c) => {
|
|
206
209
|
const workspaceId = c.req.param("workspaceId");
|
|
207
|
-
const grant = await requireAccessGrant(c, deps, workspaceId, "
|
|
210
|
+
const grant = await requireAccessGrant(c, deps, workspaceId, "capabilities:manage");
|
|
208
211
|
const payload = MutateIntegrationFacetRequest.parse(await c.req.json());
|
|
209
212
|
try {
|
|
210
213
|
return c.json(
|
|
@@ -233,7 +236,7 @@ export function registerIntegrationFacetRoutes(app: Hono, deps: ApiRouteDeps): v
|
|
|
233
236
|
"/v1/workspaces/:workspaceId/integrations/:capabilityId/instances/:instanceKey/facets/:facetKey",
|
|
234
237
|
async (c) => {
|
|
235
238
|
const workspaceId = c.req.param("workspaceId");
|
|
236
|
-
const grant = await requireAccessGrant(c, deps, workspaceId, "
|
|
239
|
+
const grant = await requireAccessGrant(c, deps, workspaceId, "capabilities:manage");
|
|
237
240
|
const payload = MutateIntegrationFacetRequest.parse(await c.req.json());
|
|
238
241
|
try {
|
|
239
242
|
return c.json(
|
|
@@ -58,7 +58,11 @@ import {
|
|
|
58
58
|
import { publishDurableSessionEvents } from "@opengeni/events";
|
|
59
59
|
import type { Context, Hono } from "hono";
|
|
60
60
|
import { HTTPException } from "hono/http-exception";
|
|
61
|
-
import {
|
|
61
|
+
import {
|
|
62
|
+
browserSseDeliveryOptions,
|
|
63
|
+
sseWorkspaceInteractionRevisionStream,
|
|
64
|
+
sseWorkspaceLiveStream,
|
|
65
|
+
} from "../http/sse";
|
|
62
66
|
import { observeInterventionMutation } from "../interaction-metrics";
|
|
63
67
|
|
|
64
68
|
export function registerInteractionResourceRoutes(app: Hono, deps: ApiRouteDeps): void {
|
|
@@ -74,6 +78,7 @@ export function registerInteractionResourceRoutes(app: Hono, deps: ApiRouteDeps)
|
|
|
74
78
|
nonnegativeSafeIntegerQuery(context, "interactionAfter", 0),
|
|
75
79
|
context.req.raw.signal,
|
|
76
80
|
{
|
|
81
|
+
...browserSseDeliveryOptions(context.req.query("transport")),
|
|
77
82
|
observability: deps.observability,
|
|
78
83
|
actorEpoch: getManagedAuthRequestActorEpoch(context.req.raw) ?? undefined,
|
|
79
84
|
reauthorize: async () => {
|
|
@@ -99,6 +104,7 @@ export function registerInteractionResourceRoutes(app: Hono, deps: ApiRouteDeps)
|
|
|
99
104
|
after,
|
|
100
105
|
context.req.raw.signal,
|
|
101
106
|
{
|
|
107
|
+
...browserSseDeliveryOptions(context.req.query("transport")),
|
|
102
108
|
observability: deps.observability,
|
|
103
109
|
actorEpoch: getManagedAuthRequestActorEpoch(context.req.raw) ?? undefined,
|
|
104
110
|
reauthorize: async () => {
|
|
@@ -7,6 +7,7 @@ import {
|
|
|
7
7
|
CompleteManagedAuthLoginTransactionResponse,
|
|
8
8
|
LogoutManagedAuthLoginSlotRequest,
|
|
9
9
|
LogoutManagedAuthSessionSetRequest,
|
|
10
|
+
MANAGED_AUTH_TRANSACTION_TTL_SECONDS,
|
|
10
11
|
MANAGED_AUTH_SESSION_SET_API_CONTRACT_HEADER,
|
|
11
12
|
MANAGED_AUTH_SESSION_SET_API_CONTRACT_REVISION,
|
|
12
13
|
ManagedAuthDeepLinkResolution,
|
|
@@ -15,6 +16,8 @@ import {
|
|
|
15
16
|
ManagedAuthSessionSetErrorCode,
|
|
16
17
|
ResolveManagedAuthDeepLinkRequest,
|
|
17
18
|
SelectManagedAuthLoginSlotRequest,
|
|
19
|
+
StartManagedAuthSocialTransactionRequest,
|
|
20
|
+
StartManagedAuthSocialTransactionResponse,
|
|
18
21
|
type ManagedAuthSessionSetProjection as ManagedAuthSessionSetProjectionType,
|
|
19
22
|
type ManagedAuthSessionSetErrorCode as ManagedAuthSessionSetErrorCodeType,
|
|
20
23
|
} from "@opengeni/contracts/managed-auth-session-sets";
|
|
@@ -57,11 +60,21 @@ import {
|
|
|
57
60
|
mutateManagedAuthSessionSet,
|
|
58
61
|
} from "@opengeni/db/managed-auth-session-sets";
|
|
59
62
|
import { ensureManagedAccessForUser, getSession } from "@opengeni/db";
|
|
63
|
+
import { sql } from "drizzle-orm";
|
|
60
64
|
import type { Context, Hono } from "hono";
|
|
61
65
|
import { deleteCookie, getCookie, setCookie } from "hono/cookie";
|
|
62
66
|
import { HTTPException } from "hono/http-exception";
|
|
63
67
|
import { ApiHttpError } from "../http/api-error";
|
|
64
|
-
import
|
|
68
|
+
import { z } from "zod";
|
|
69
|
+
|
|
70
|
+
const ManagedAuthSocialStartReceipt = z
|
|
71
|
+
.object({
|
|
72
|
+
version: z.literal(1),
|
|
73
|
+
requestDigest: z.string().regex(/^[0-9a-f]{64}$/u),
|
|
74
|
+
url: z.string().url().max(4_096),
|
|
75
|
+
stateCookie: z.string().min(1).max(4_096),
|
|
76
|
+
})
|
|
77
|
+
.strict();
|
|
65
78
|
|
|
66
79
|
export function registerManagedAuthSessionSetRoutes(app: Hono, deps: ApiRouteDeps): void {
|
|
67
80
|
const noStore = async (context: Context, next: () => Promise<void>) => {
|
|
@@ -304,6 +317,129 @@ export function registerManagedAuthSessionSetRoutes(app: Hono, deps: ApiRouteDep
|
|
|
304
317
|
}
|
|
305
318
|
});
|
|
306
319
|
|
|
320
|
+
app.post("/v1/auth/session-set/transactions/social", async (context) => {
|
|
321
|
+
const body = await bodyAs(context, StartManagedAuthSocialTransactionRequest);
|
|
322
|
+
const { authority, actorEpoch } = await requireMutation(context, deps, body.expectedGeneration);
|
|
323
|
+
const available = requireAvailable(deps);
|
|
324
|
+
if (!managedAuthSocialProviderConfigured(deps, body.provider)) {
|
|
325
|
+
throw managedAuthApiError(404, "managed_authentication_unavailable");
|
|
326
|
+
}
|
|
327
|
+
const transactionSecret = requireTransactionSecret(context, body.transactionId);
|
|
328
|
+
const publicBaseUrl = deps.settings.publicBaseUrl;
|
|
329
|
+
if (!publicBaseUrl) {
|
|
330
|
+
throw managedAuthApiError(503, "managed_authentication_unavailable", {
|
|
331
|
+
retryable: true,
|
|
332
|
+
});
|
|
333
|
+
}
|
|
334
|
+
const callbackURL = new URL("/account-auth", publicBaseUrl);
|
|
335
|
+
callbackURL.searchParams.set("transaction", body.transactionId);
|
|
336
|
+
callbackURL.searchParams.set("social", "complete");
|
|
337
|
+
const errorCallbackURL = new URL(callbackURL);
|
|
338
|
+
errorCallbackURL.searchParams.set("social", "error");
|
|
339
|
+
try {
|
|
340
|
+
const authorityHash = managedAuthSha256(authority);
|
|
341
|
+
const transactionSecretHash = managedAuthSha256(transactionSecret);
|
|
342
|
+
const requestDigest = digest(deps, {
|
|
343
|
+
operation: "social_start",
|
|
344
|
+
authorityHash,
|
|
345
|
+
actorEpoch,
|
|
346
|
+
transactionSecretHash,
|
|
347
|
+
request: body,
|
|
348
|
+
});
|
|
349
|
+
const receiptIdentifier = `opengeni-managed-social-start:${authorityHash}:${body.operationId}`;
|
|
350
|
+
const receipt = await deps.db.transaction(async (transaction) => {
|
|
351
|
+
await transaction.execute(
|
|
352
|
+
sql`select pg_advisory_xact_lock(hashtextextended(${receiptIdentifier}, 0))`,
|
|
353
|
+
);
|
|
354
|
+
const authContext = await available.managedAuth.$context;
|
|
355
|
+
const stored = await authContext.internalAdapter.findVerificationValue(receiptIdentifier);
|
|
356
|
+
if (stored && new Date(stored.expiresAt).getTime() > Date.now()) {
|
|
357
|
+
let parsed: ReturnType<typeof ManagedAuthSocialStartReceipt.safeParse>;
|
|
358
|
+
try {
|
|
359
|
+
parsed = ManagedAuthSocialStartReceipt.safeParse(JSON.parse(stored.value));
|
|
360
|
+
} catch (error) {
|
|
361
|
+
throw new ManagedAuthCompletionOutcomeUnknownError({
|
|
362
|
+
cause: error,
|
|
363
|
+
});
|
|
364
|
+
}
|
|
365
|
+
if (!parsed.success) {
|
|
366
|
+
throw new ManagedAuthCompletionOutcomeUnknownError({
|
|
367
|
+
cause: parsed.error,
|
|
368
|
+
});
|
|
369
|
+
}
|
|
370
|
+
if (parsed.data.requestDigest !== requestDigest) {
|
|
371
|
+
throw new ManagedAuthSessionSetOperationReuseError();
|
|
372
|
+
}
|
|
373
|
+
return parsed.data;
|
|
374
|
+
}
|
|
375
|
+
if (stored) {
|
|
376
|
+
await authContext.internalAdapter.deleteVerificationByIdentifier(receiptIdentifier);
|
|
377
|
+
}
|
|
378
|
+
const result = await available.managedAuth.api.signInSocial({
|
|
379
|
+
body: {
|
|
380
|
+
provider: body.provider,
|
|
381
|
+
disableRedirect: true,
|
|
382
|
+
callbackURL: callbackURL.toString(),
|
|
383
|
+
errorCallbackURL: errorCallbackURL.toString(),
|
|
384
|
+
additionalData: {
|
|
385
|
+
opengeniManagedAuth: {
|
|
386
|
+
version: 1,
|
|
387
|
+
operationId: body.operationId,
|
|
388
|
+
provider: body.provider,
|
|
389
|
+
transactionId: body.transactionId,
|
|
390
|
+
authorityHash,
|
|
391
|
+
transactionSecretHash,
|
|
392
|
+
expectedGeneration: body.expectedGeneration,
|
|
393
|
+
expectedActorEpoch: actorEpoch,
|
|
394
|
+
},
|
|
395
|
+
},
|
|
396
|
+
},
|
|
397
|
+
headers: isolatedManagedAuthHeaders(context.req.raw),
|
|
398
|
+
returnHeaders: true,
|
|
399
|
+
});
|
|
400
|
+
const url = validatedManagedAuthSocialAuthorizationUrl({
|
|
401
|
+
provider: body.provider,
|
|
402
|
+
rawUrl: result.response?.url,
|
|
403
|
+
expectedClientId:
|
|
404
|
+
body.provider === "google"
|
|
405
|
+
? deps.settings.managedAuthGoogleClientId!
|
|
406
|
+
: deps.settings.managedAuthGithubClientId!,
|
|
407
|
+
expectedCallbackUrl: new URL(
|
|
408
|
+
`/v1/auth/callback/${body.provider}`,
|
|
409
|
+
publicBaseUrl,
|
|
410
|
+
).toString(),
|
|
411
|
+
});
|
|
412
|
+
const stateCookieName = authContext.createAuthCookie("state").name;
|
|
413
|
+
const stateCookie = setCookieHeaders(result.headers).find((cookie) =>
|
|
414
|
+
cookie.startsWith(`${stateCookieName}=`),
|
|
415
|
+
);
|
|
416
|
+
const parsed = ManagedAuthSocialStartReceipt.parse({
|
|
417
|
+
version: 1,
|
|
418
|
+
requestDigest,
|
|
419
|
+
url,
|
|
420
|
+
stateCookie,
|
|
421
|
+
});
|
|
422
|
+
const created = await authContext.internalAdapter.createVerificationValue({
|
|
423
|
+
identifier: receiptIdentifier,
|
|
424
|
+
value: JSON.stringify(parsed),
|
|
425
|
+
expiresAt: new Date(Date.now() + MANAGED_AUTH_TRANSACTION_TTL_SECONDS * 1_000),
|
|
426
|
+
});
|
|
427
|
+
if (!created) {
|
|
428
|
+
throw new ManagedAuthCompletionOutcomeUnknownError();
|
|
429
|
+
}
|
|
430
|
+
return parsed;
|
|
431
|
+
});
|
|
432
|
+
context.header("set-cookie", receipt.stateCookie, { append: true });
|
|
433
|
+
return jsonWithActorEpoch(
|
|
434
|
+
context,
|
|
435
|
+
actorEpoch,
|
|
436
|
+
StartManagedAuthSocialTransactionResponse.parse({ url: receipt.url }),
|
|
437
|
+
);
|
|
438
|
+
} catch (error) {
|
|
439
|
+
throwHttp(error);
|
|
440
|
+
}
|
|
441
|
+
});
|
|
442
|
+
|
|
307
443
|
app.delete("/v1/auth/session-set/transactions/:transactionId", async (context) => {
|
|
308
444
|
const body = await bodyAs(context, CancelManagedAuthLoginTransactionRequest);
|
|
309
445
|
if (body.transactionId !== context.req.param("transactionId")) invalid();
|
|
@@ -449,6 +585,46 @@ export function registerManagedAuthSessionSetRoutes(app: Hono, deps: ApiRouteDep
|
|
|
449
585
|
});
|
|
450
586
|
}
|
|
451
587
|
|
|
588
|
+
export function validatedManagedAuthSocialAuthorizationUrl(input: {
|
|
589
|
+
provider: "google" | "github";
|
|
590
|
+
rawUrl: unknown;
|
|
591
|
+
expectedClientId: string;
|
|
592
|
+
expectedCallbackUrl: string;
|
|
593
|
+
}): string {
|
|
594
|
+
if (typeof input.rawUrl !== "string") {
|
|
595
|
+
throw new ManagedAuthCompletionOutcomeUnknownError();
|
|
596
|
+
}
|
|
597
|
+
let url: URL;
|
|
598
|
+
try {
|
|
599
|
+
url = new URL(input.rawUrl);
|
|
600
|
+
} catch (error) {
|
|
601
|
+
throw new ManagedAuthCompletionOutcomeUnknownError({ cause: error });
|
|
602
|
+
}
|
|
603
|
+
const expectedEndpoint =
|
|
604
|
+
input.provider === "google"
|
|
605
|
+
? { origin: "https://accounts.google.com", pathname: "/o/oauth2/v2/auth" }
|
|
606
|
+
: { origin: "https://github.com", pathname: "/login/oauth/authorize" };
|
|
607
|
+
const exactlyOne = (name: string, expected?: string): boolean => {
|
|
608
|
+
const values = url.searchParams.getAll(name);
|
|
609
|
+
return (
|
|
610
|
+
values.length === 1 && values[0] !== "" && (expected === undefined || values[0] === expected)
|
|
611
|
+
);
|
|
612
|
+
};
|
|
613
|
+
if (
|
|
614
|
+
url.origin !== expectedEndpoint.origin ||
|
|
615
|
+
url.pathname !== expectedEndpoint.pathname ||
|
|
616
|
+
url.username ||
|
|
617
|
+
url.password ||
|
|
618
|
+
url.hash ||
|
|
619
|
+
!exactlyOne("client_id", input.expectedClientId) ||
|
|
620
|
+
!exactlyOne("redirect_uri", input.expectedCallbackUrl) ||
|
|
621
|
+
!exactlyOne("state")
|
|
622
|
+
) {
|
|
623
|
+
throw new ManagedAuthCompletionOutcomeUnknownError();
|
|
624
|
+
}
|
|
625
|
+
return input.rawUrl;
|
|
626
|
+
}
|
|
627
|
+
|
|
452
628
|
/**
|
|
453
629
|
* Better Auth 1.6.26 provider lifecycle routes retained while session-set mode
|
|
454
630
|
* owns all selected-session reads, enumeration, revocation, and user mutation.
|
|
@@ -470,21 +646,42 @@ export function requireManagedAuthProviderRouteAllowed(method: string, pathname:
|
|
|
470
646
|
(pathname === "/v1/auth/verify-email" ||
|
|
471
647
|
pathname === "/v1/auth/error" ||
|
|
472
648
|
pathname === "/v1/auth/ok" ||
|
|
649
|
+
/^\/v1\/auth\/callback\/(?:google|github)$/.test(pathname) ||
|
|
473
650
|
/^\/v1\/auth\/reset-password\/[^/]+$/.test(pathname)));
|
|
474
651
|
if (!allowed) throw managedAuthApiError(409, "provider_route_blocked");
|
|
475
652
|
}
|
|
476
653
|
|
|
654
|
+
function managedAuthSocialProviderConfigured(
|
|
655
|
+
deps: ApiRouteDeps,
|
|
656
|
+
provider: "google" | "github",
|
|
657
|
+
): boolean {
|
|
658
|
+
return provider === "google"
|
|
659
|
+
? Boolean(
|
|
660
|
+
deps.settings.managedAuthGoogleClientId && deps.settings.managedAuthGoogleClientSecret,
|
|
661
|
+
)
|
|
662
|
+
: Boolean(
|
|
663
|
+
deps.settings.managedAuthGithubClientId && deps.settings.managedAuthGithubClientSecret,
|
|
664
|
+
);
|
|
665
|
+
}
|
|
666
|
+
|
|
477
667
|
/** Remove provider bearer/session material and enforce browser-auth cache/cookie policy. */
|
|
478
668
|
export async function scrubManagedAuthProviderResponse(
|
|
479
669
|
response: Response,
|
|
480
|
-
options: {
|
|
670
|
+
options: {
|
|
671
|
+
replacementCookies?: readonly string[] | undefined;
|
|
672
|
+
preserveCookieNames?: readonly string[] | undefined;
|
|
673
|
+
} = {},
|
|
481
674
|
): Promise<Response> {
|
|
482
675
|
const contentType = response.headers.get("content-type")?.toLowerCase() ?? "";
|
|
483
676
|
const headers = new Headers(response.headers);
|
|
484
677
|
headers.set("cache-control", "no-store");
|
|
485
678
|
headers.set("pragma", "no-cache");
|
|
486
679
|
if (options.replacementCookies !== undefined) {
|
|
680
|
+
const preserved = setCookieHeaders(response.headers).filter((cookie) =>
|
|
681
|
+
options.preserveCookieNames?.some((name) => cookie.startsWith(`${name}=`)),
|
|
682
|
+
);
|
|
487
683
|
headers.delete("set-cookie");
|
|
684
|
+
for (const cookie of preserved) headers.append("set-cookie", cookie);
|
|
488
685
|
for (const cookie of options.replacementCookies) headers.append("set-cookie", cookie);
|
|
489
686
|
}
|
|
490
687
|
let body: BodyInit | null = response.body;
|